Repository: jlfwong/speedscope
Pull request #453 (2023-12-21 03:22:12)

Support the chrome JSON trace format (allows viewing of hermes traces)
Add support for the chrome [JSON object trace format](https://docs.google.com/document/d/1CvAClvFfyA5R-PhYUmn5OOQtYMH4h6I0nSsKchNAySU/preview#heading=h.q8di1j2nawlp). I have tested this with hermes profiles, which spit out a version of the spec with additional information in the stack frame. Mainly inspiration was taken from two sources:

* [React native profiling docs](https://reactnative.dev/docs/profile-hermes#how-does-the-hermes-profile-transformer-work)
* [hermes-profile-transformer package](https://github.com/react-native-community/hermes-profile-transformer)

[Example profiles](https://drive.google.com/drive/u/0/folders/1--E9sLk-4Iti_ddRozXkrYw4VH8auEPa)

Specifically I pulled some code from [this file](https://github.com/react-native-community/hermes-profile-transformer/blob/master/src/profiler/cpuProfilerModel.ts). While this works great for raw profiles, the `hermes-profile-transformer` package also applies sourcemaps, which is something that I would like to by applied for hermes profiles as well. It's a little awkward because in the hermes profile file format, there isn't actually a field from the file path where a given function in a frame originated - instead it is in parentheses after the function name. Also I will need to update the implementations in three places:

1. https://github.com/react-native-community/hermes-profile-transformer
2. https://github.com/react-native-community/cli
3. Bump version in [react](https://github.com/facebook/react-native/blob/main/packages/react-native/package.json#L98)

Which is a little overwhelming. Given time constraints I might not get a chance to land all of this for a while. I think in the shorter term it would be nice to just add some specific support for hermes-generated trace profiles in the trace-event file. Discussion on this is [here](https://github.com/jlfwong/speedscope/pull/451)

## Example

| Trace event format | Trace event object format |
|------- | ------- |
|  <img width="1727" alt="Screenshot 2023-12-20 at 10 21 29 PM" src="https://github.com/jlfwong/speedscope/assets/9957046/a084723d-8cb4-4308-9c88-ba62789d4338"> | <img width="1728" alt="Screenshot 2023-12-20 at 10 21 55 PM" src="https://github.com/jlfwong/speedscope/assets/9957046/9675fc04-7711-49f8-a687-9b06f8be3f07"> |

coveralls: 
[![Coverage Status](https://coveralls.io/builds/64700638/badge)](https://coveralls.io/builds/64700638)

coverage: 43.998% (+1.0%) from 43.041%
when pulling **e8b78e88a1c405f08c6be3e7bba43e150b861bab on zacharyfmarion:zac/hermes-format**
into **dfd3a0dfb3b35d73b75023a1447604ebd721af12 on jlfwong:main**.

zacharyfmarion: Actually after looking at this some more it looks like spec is actually part of the [chrome trace event spec](https://docs.google.com/document/d/1CvAClvFfyA5R-PhYUmn5OOQtYMH4h6I0nSsKchNAySU/preview#bookmark=id.end1pppx3rl)? But it doesn't currently seem to be implemented...at least hermes profiles did not work before. @jlfwong should i name this something more generic?
jlfwong: Going to continue discussion of both https://github.com/jlfwong/speedscope/pull/451#issuecomment-1865423405 and this PR here, since they're intertwined.

Given that this is an extension of the chrome trace event spec, this should IMO live inside of `import/trace-event.ts`. That file already contains handling of both a `TraceEvent[]` format and a `{traceEvents: TraceEvent[]}` format, so this seems like a fairly natural extension. And that unfortunately means it similarly should be generic w.r.t. handling of `args`.

That said, if there are highly reliable ways to see from analysis of a profile that it was specifically generated by Hermes, whether it be a `{traceEvents, stackFrames, samples}` profile as in this PR, or one of the existing supported trace event formats, I'm open to accepting a patch to `trace-event.ts` which ignores args in that case because they're known to be useless when coming from Hermes.

This could be through structural analysis of the JSON, or it could be from filename matching if Hermes spits out files with a specific naming convention.

Does that sound tenable?
zacharyfmarion: @jlfwong Okay sounds good - I will refactor trace-event to support the json object format - are you okay with me keeping the main json import code in a separate file? The trace-event file is already pretty large. Not sure if you're opposed to me creating a folder in the imports directory called `traceEvent` that has a `types` file and then two files, one for each import type. I can put it all in one file if you prefer.

Re. the hermes format - I should be able to reliably detect if the args match what hermes exports - thanks for understanding. I will do that after this lands and we can iterate. I definitely don't want to merge anything you're not comfortable with.
zacharyfmarion: Went ahead and pushed it up, can clean up a bit later
jlfwong: @zacharyfmarion thanks for being so responsive to feedback! is this ready to review now, or should I hold off for further changes?
zacharyfmarion: @jlfwong ready for review!
zacharyfmarion: @jlfwong Thank you for the thorough review - I will likely take a look at this after Christmas - I totally missed `StackListProfileBuilder` 🤦 , will read through that code and update!
jlfwong: @zacharyfmarion looks like you couldn't wait until after Christmas 😄

High level this looks great! Thanks for taking all the feedback in stride. It looks like there are tests failing now, so it seems like the refactor changed the output. Let me know when that's resolved and this is ready for another look
zacharyfmarion: @jlfwong hahaha yep got antsy! Tests were correctly failing as I thought "weight" represented the time diff between the previous frame and current frame, instead of the current frame and the next one. Updated and it looks like the profiles look the same as before the refactor. Should be ready for another round of feedback whenever you get a chance!
zacharyfmarion: @jlfwong awesome - attempted to address feedback with [this commit](https://github.com/jlfwong/speedscope/pull/453/commits/0a5633af0a416e2477c8a66389a8e7d5eb088a6e) and left a couple of questions

Patch:
diff --git a/sample/profiles/trace-event/multi-thread-with-samples.json b/sample/profiles/trace-event/multi-thread-with-samples.json
new file mode 100644
index 0000000..11d832b
--- /dev/null
+++ b/sample/profiles/trace-event/multi-thread-with-samples.json
@@ -0,0 +1,150 @@
+{
+    "traceEvents": [
+      {
+        "name": "process_name",
+        "ph": "M",
+        "cat": "__metadata",
+        "pid": 7512,
+        "ts": "11550183666",
+        "tid": "-1",
+        "args": {
+          "name": "hermes"
+        }
+      },
+      {
+        "name": "thread_name",
+        "ph": "M",
+        "cat": "__metadata",
+        "pid": 7512,
+        "ts": "11550183666",
+        "tid": "1",
+        "args": {
+          "name": "mqt_js"
+        }
+      },
+      {
+        "name": "mqt_js",
+        "cat": "mqt_js",
+        "ph": "X",
+        "dur": 0,
+        "pid": 7512,
+        "ts": "11550183666",
+        "tid": "1",
+        "args": {}
+      },
+      {
+        "name": "mqt_js",
+        "cat": "mqt_js",
+        "ph": "X",
+        "dur": 0,
+        "pid": 7512,
+        "ts": "11550183666",
+        "tid": "2",
+        "args": {}
+      }
+    ],
+    "samples": [
+      {
+        "cpu": "-1",
+        "name": "",
+        "ts": "11552125241",
+        "pid": 7512,
+        "tid": "1",
+        "weight": "1",
+        "sf": 1
+      },
+      {
+        "cpu": "-1",
+        "name": "",
+        "ts": "11552125241",
+        "pid": 7512,
+        "tid": "2",
+        "weight": "1",
+        "sf": 4
+      },
+      {
+        "cpu": "-1",
+        "name": "",
+        "ts": "11552146011",
+        "pid": 7512,
+        "tid": "1",
+        "weight": "1",
+        "sf": 2
+      },
+      {
+        "cpu": "-1",
+        "name": "",
+        "ts": "11552146011",
+        "pid": 7512,
+        "tid": "2",
+        "weight": "1",
+        "sf": 5
+      },
+      {
+        "cpu": "-1",
+        "name": "",
+        "ts": "11552159337",
+        "pid": 7512,
+        "tid": "1",
+        "weight": "1",
+        "sf": 3
+      },
+      {
+        "cpu": "-1",
+        "name": "",
+        "ts": "11552159337",
+        "pid": 7512,
+        "tid": "2",
+        "weight": "1",
+        "sf": 6
+      },
+      {
+        "cpu": "-1",
+        "name": "",
+        "ts": "11552169337",
+        "pid": 7512,
+        "tid": "2",
+        "weight": "1",
+        "sf": 4
+      },
+      {
+        "cpu": "-1",
+        "name": "",
+        "ts": "11552179337",
+        "pid": 7512,
+        "tid": "2",
+        "weight": "1",
+        "sf": 4
+      }
+    ],
+    "stackFrames": {
+      "1": {
+        "name": "[root]",
+        "category": "root"
+      },
+      "2": {
+        "name": "function1",
+        "category": "JavaScript",
+        "parent": 1
+      },
+      "3": {
+        "name": "[GC Young Gen]",
+        "category": "Metadata",
+        "parent": 1
+      },
+      "4": {
+        "name": "[root thread 2]",
+        "category": "root"
+      },
+      "5": {
+        "name": "function3",
+        "category": "JavaScript",
+        "parent": 4
+      },
+      "6": {
+        "name": "function4",
+        "category": "Metadata",
+        "parent": 4
+      } 
+    }
+  }
\ No newline at end of file
diff --git a/sample/profiles/trace-event/simple-with-samples.json b/sample/profiles/trace-event/simple-with-samples.json
new file mode 100644
index 0000000..ae8456a
--- /dev/null
+++ b/sample/profiles/trace-event/simple-with-samples.json
@@ -0,0 +1,127 @@
+{
+  "traceEvents": [
+    {
+      "name": "process_name",
+      "ph": "M",
+      "cat": "__metadata",
+      "pid": 7512,
+      "ts": "11550183666",
+      "tid": "-1",
+      "args": {
+        "name": "hermes"
+      }
+    },
+    {
+      "name": "thread_name",
+      "ph": "M",
+      "cat": "__metadata",
+      "pid": 7512,
+      "ts": "11550183666",
+      "tid": "7695",
+      "args": {
+        "name": "mqt_js"
+      }
+    },
+    {
+      "name": "mqt_js",
+      "cat": "mqt_js",
+      "ph": "X",
+      "dur": 0,
+      "pid": 7512,
+      "ts": "11550183666",
+      "tid": "7695",
+      "args": {}
+    }
+  ],
+  "samples": [
+    {
+      "cpu": "-1",
+      "name": "",
+      "ts": "11552125241",
+      "pid": 7512,
+      "tid": "7695",
+      "weight": "1",
+      "sf": 1
+    },
+    {
+      "cpu": "-1",
+      "name": "",
+      "ts": "11552129681",
+      "pid": 7512,
+      "tid": "7695",
+      "weight": "1",
+      "sf": 1
+    },
+    {
+      "cpu": "-1",
+      "name": "",
+      "ts": "11552146011",
+      "pid": 7512,
+      "tid": "7695",
+      "weight": "1",
+      "sf": 2
+    },
+    {
+      "cpu": "-1",
+      "name": "",
+      "ts": "11552159337",
+      "pid": 7512,
+      "tid": "7695",
+      "weight": "1",
+      "sf": 4
+    },
+    {
+      "cpu": "-1",
+      "name": "",
+      "ts": "11552169942",
+      "pid": 7512,
+      "tid": "7695",
+      "weight": "1",
+      "sf": 1
+    },
+    {
+      "cpu": "-1",
+      "name": "",
+      "ts": "11552179951",
+      "pid": 7512,
+      "tid": "7695",
+      "weight": "1",
+      "sf": 5
+    },
+    {
+      "cpu": "-1",
+      "name": "",
+      "ts": "11552189951",
+      "pid": 7512,
+      "tid": "7695",
+      "weight": "1",
+      "sf": 5
+    }
+  ],
+  "stackFrames": {
+    "1": {
+      "name": "[root]",
+      "category": "root"
+    },
+    "2": {
+      "name": "function1(http://localhost:8081/index.bundle?platform=android&dev=false&minify=false&app=org.toshi&modulesOnly=false&runModule=true:1:1)",
+      "category": "JavaScript",
+      "parent": 1
+    },
+    "3": {
+      "name": "function2(http://localhost:8081/index.bundle?platform=android&dev=false&minify=false&app=org.toshi&modulesOnly=false&runModule=true:418874:4)",
+      "category": "JavaScript",
+      "parent": 1
+    },
+    "4": {
+      "name": "function3(http://localhost:8081/index.bundle?platform=android&dev=false&minify=false&app=org.toshi&modulesOnly=false&runModule=true:17:3)",
+      "category": "JavaScript",
+      "parent": 3
+    },
+    "5": {
+      "name": "[GC Young Gen]",
+      "category": "Metadata",
+      "parent": 1
+    }
+  }
+}
\ No newline at end of file
diff --git a/src/import/index.ts b/src/import/index.ts
index 155ff07..29d5e09 100644
--- a/src/import/index.ts
+++ b/src/import/index.ts
@@ -23,7 +23,7 @@ import {decodeBase64} from '../lib/utils'
 import {importFromChromeHeapProfile} from './v8heapalloc'
 import {isTraceEventFormatted, importTraceEvents} from './trace-event'
 import {importFromCallgrind} from './callgrind'
-import {importFromPapyrus} from "./papyrus";
+import {importFromPapyrus} from './papyrus'
 
 export async function importProfileGroupFromText(
   fileName: string,
@@ -189,8 +189,8 @@ async function _importProfileGroup(dataSource: ProfileDataSource): Promise<Profi
       return toGroup(importFromInstrumentsDeepCopy(contents))
     }
 
-    if (/^(Stack_|Script_|Obj_)\S+ log opened \(PC\)\n/.exec(contents.firstChunk())){
-      console.log("Importing as Papyrus profile")
+    if (/^(Stack_|Script_|Obj_)\S+ log opened \(PC\)\n/.exec(contents.firstChunk())) {
+      console.log('Importing as Papyrus profile')
       return toGroup(importFromPapyrus(contents))
     }
 
diff --git a/src/import/stackprof.ts b/src/import/stackprof.ts
index c6c23e5..90d2e6c 100644
--- a/src/import/stackprof.ts
+++ b/src/import/stackprof.ts
@@ -33,9 +33,9 @@ export function importFromStackprof(stackprofProfile: StackprofProfile): Profile
     let stack: FrameInfo[] = []
     for (let j = 0; j < stackHeight; j++) {
       const id = raw[i++]
-      let frameName = frames[id].name;
+      let frameName = frames[id].name
       if (frameName == null) {
-        frameName = '(unknown)';
+        frameName = '(unknown)'
       }
       const frame = {
         key: id,
diff --git a/src/import/trace-event.ts b/src/import/trace-event.ts
index 6427070..3396d8c 100644
--- a/src/import/trace-event.ts
+++ b/src/import/trace-event.ts
@@ -1,5 +1,11 @@
 import {sortBy, zeroPad, getOrInsert, lastOf} from '../lib/utils'
-import {ProfileGroup, CallTreeProfileBuilder, FrameInfo, Profile} from '../lib/profile'
+import {
+  ProfileGroup,
+  CallTreeProfileBuilder,
+  FrameInfo,
+  Profile,
+  StackListProfileBuilder,
+} from '../lib/profile'
 import {TimeFormatter} from '../lib/value-formatters'
 
 // This file concerns import from the "Trace Event Format", authored by Google
@@ -65,16 +71,53 @@ interface XTraceEvent extends TraceEvent {
 // The trace format supports a number of event types that we ignore.
 type ImportableTraceEvent = BTraceEvent | ETraceEvent | XTraceEvent
 
+interface StackFrame {
+  line: string
+  column: string
+  funcLine: string
+  funcColumn: string
+  name: string
+  category: string
+  // A parent function may or may not exist
+  parent?: number
+}
+
+interface Sample {
+  cpu: string
+  name: string
+  ts: string
+  pid: number
+  tid: string
+  weight: string
+  // Will refer to an element in the stackFrames object
+  sf: number
+  stackFrameData?: StackFrame
+}
+
+interface TraceWithSamples {
+  traceEvents: TraceEvent[]
+  samples: Sample[]
+  stackFrames: {[key: string]: StackFrame}
+}
+
+interface TraceEventObject {
+  traceEvents: TraceEvent[]
+}
+
+type Trace = TraceEvent[] | TraceEventObject | TraceWithSamples
+
 function pidTidKey(pid: number, tid: number): string {
   // We zero-pad the PID and TID to make sorting them by pid/tid pair later easier.
   return `${zeroPad('' + pid, 10)}:${zeroPad('' + tid, 10)}`
 }
 
-function partitionByPidTid(events: ImportableTraceEvent[]): Map<string, ImportableTraceEvent[]> {
-  const map = new Map<string, ImportableTraceEvent[]>()
+function partitionByPidTid<T extends {tid: number | string; pid: number | string}>(
+  events: T[],
+): Map<string, T[]> {
+  const map = new Map<string, T[]>()
 
   for (let ev of events) {
-    const list = getOrInsert(map, pidTidKey(ev.pid, ev.tid), () => [])
+    const list = getOrInsert(map, pidTidKey(Number(ev.pid), Number(ev.tid)), () => [])
     list.push(ev)
   }
 
@@ -244,110 +287,150 @@ function frameInfoForEvent(event: TraceEvent): FrameInfo {
   }
 }
 
-function eventListToProfileGroup(events: TraceEvent[]): ProfileGroup {
-  const importableEvents = filterIgnoredEventTypes(events)
-  const partitioned = partitionByPidTid(importableEvents)
-
+/**
+ * Constructs an array mapping pid-tid keys to profile builders. Both the traceEvent[]
+ * format and the sample + stack frame based object format specify the process and thread
+ * names based on metadata so we share this logic.
+ *
+ * See https://docs.google.com/document/d/1CvAClvFfyA5R-PhYUmn5OOQtYMH4h6I0nSsKchNAySU/preview#heading=h.xqopa5m0e28f
+ */
+function getProfileNameByPidTid(
+  events: TraceEvent[],
+  partitionedTraceEvents: Map<string, TraceEvent[]>,
+): Map<string, string> {
   const processNamesByPid = getProcessNamesByPid(events)
   const threadNamesByPidTid = getThreadNamesByPidTid(events)
 
-  const profilePairs: [string, Profile][] = []
-
-  partitioned.forEach(eventsForThread => {
-    if (eventsForThread.length === 0) return
+  const profileNamesByPidTid = new Map<string, string>()
 
-    const {pid, tid} = eventsForThread[0]
+  partitionedTraceEvents.forEach(importableEvents => {
+    if (importableEvents.length === 0) return
 
-    const profile = new CallTreeProfileBuilder()
-    profile.setValueFormatter(new TimeFormatter('microseconds'))
+    const {pid, tid} = importableEvents[0]
 
+    const profileKey = pidTidKey(pid, tid)
     const processName = processNamesByPid.get(pid)
-    const threadName = threadNamesByPidTid.get(pidTidKey(pid, tid))
+    const threadName = threadNamesByPidTid.get(profileKey)
 
     if (processName != null && threadName != null) {
-      profile.setName(`${processName} (pid ${pid}), ${threadName} (tid ${tid})`)
+      profileNamesByPidTid.set(
+        profileKey,
+        `${processName} (pid ${pid}), ${threadName} (tid ${tid})`,
+      )
     } else if (processName != null) {
-      profile.setName(`${processName} (pid ${pid}, tid ${tid})`)
+      profileNamesByPidTid.set(profileKey, `${processName} (pid ${pid}, tid ${tid})`)
     } else if (threadName != null) {
-      profile.setName(`${threadName} (pid ${pid}, tid ${tid})`)
+      profileNamesByPidTid.set(profileKey, `${threadName} (pid ${pid}, tid ${tid})`)
     } else {
-      profile.setName(`pid ${pid}, tid ${tid}`)
+      profileNamesByPidTid.set(profileKey, `pid ${pid}, tid ${tid}`)
     }
+  })
 
-    // The trace event format is hard to deal with because it specifically
-    // allows events to be recorded out of order, *but* event ordering is still
-    // important for events with the same timestamp. Because of this, rather
-    // than thinking about the entire event stream as a single queue of events,
-    // we're going to first construct two time-ordered lists of events:
-    //
-    // 1. ts ordered queue of 'B' events
-    // 2. ts ordered queue of 'E' events
-    //
-    // We deal with 'X' events by converting them to one entry in the 'B' event
-    // queue and one entry in the 'E' event queue.
-    //
-    // The high level goal is to deal with 'B' events in 'ts' order, breaking
-    // ties by the order the events occurred in the file, and deal with 'E'
-    // events in 'ts' order, breaking ties in whatever order causes the 'E'
-    // events to match whatever is on the top of the stack.
-    const [bEventQueue, eEventQueue] = convertToEventQueues(eventsForThread)
-
-    const frameStack: BTraceEvent[] = []
-    const enterFrame = (b: BTraceEvent) => {
-      frameStack.push(b)
-      profile.enterFrame(frameInfoForEvent(b), b.ts)
-    }
+  return profileNamesByPidTid
+}
 
-    const tryToLeaveFrame = (e: ETraceEvent) => {
-      const b = lastOf(frameStack)
+function eventListToProfile(
+  importableEvents: ImportableTraceEvent[],
+  name: string,
+): Profile {
+  // The trace event format is hard to deal with because it specifically
+  // allows events to be recorded out of order, *but* event ordering is still
+  // important for events with the same timestamp. Because of this, rather
+  // than thinking about the entire event stream as a single queue of events,
+  // we're going to first construct two time-ordered lists of events:
+  //
+  // 1. ts ordered queue of 'B' events
+  // 2. ts ordered queue of 'E' events
+  //
+  // We deal with 'X' events by converting them to one entry in the 'B' event
+  // queue and one entry in the 'E' event queue.
+  //
+  // The high level goal is to deal with 'B' events in 'ts' order, breaking
+  // ties by the order the events occurred in the file, and deal with 'E'
+  // events in 'ts' order, breaking ties in whatever order causes the 'E'
+  // events to match whatever is on the top of the stack.
+  const [bEventQueue, eEventQueue] = convertToEventQueues(importableEvents)
+
+  const profileBuilder = new CallTreeProfileBuilder()
+  profileBuilder.setValueFormatter(new TimeFormatter('microseconds'))
+  profileBuilder.setName(name)
+
+  const frameStack: BTraceEvent[] = []
+  const enterFrame = (b: BTraceEvent) => {
+    frameStack.push(b)
+    profileBuilder.enterFrame(frameInfoForEvent(b), b.ts)
+  }
 
-      if (b == null) {
-        console.warn(
-          `Tried to end frame "${
-            frameInfoForEvent(e).key
-          }", but the stack was empty. Doing nothing instead.`,
-        )
-        return
-      }
+  const tryToLeaveFrame = (e: ETraceEvent) => {
+    const b = lastOf(frameStack)
 
-      const eFrameInfo = frameInfoForEvent(e)
-      const bFrameInfo = frameInfoForEvent(b)
+    if (b == null) {
+      console.warn(
+        `Tried to end frame "${
+          frameInfoForEvent(e).key
+        }", but the stack was empty. Doing nothing instead.`,
+      )
+      return
+    }
 
-      if (e.name !== b.name) {
-        console.warn(
-          `ts=${e.ts}: Tried to end "${eFrameInfo.key}" when "${bFrameInfo.key}" was on the top of the stack. Doing nothing instead.`,
-        )
-        return
-      }
+    const eFrameInfo = frameInfoForEvent(e)
+    const bFrameInfo = frameInfoForEvent(b)
 
-      if (eFrameInfo.key !== bFrameInfo.key) {
-        console.warn(
-          `ts=${e.ts}: Tried to end "${eFrameInfo.key}" when "${bFrameInfo.key}" was on the top of the stack. Ending ${bFrameInfo.key} instead.`,
-        )
-      }
+    if (e.name !== b.name) {
+      console.warn(
+        `ts=${e.ts}: Tried to end "${eFrameInfo.key}" when "${bFrameInfo.key}" was on the top of the stack. Doing nothing instead.`,
+      )
+      return
+    }
 
-      frameStack.pop()
-      profile.leaveFrame(bFrameInfo, e.ts)
+    if (eFrameInfo.key !== bFrameInfo.key) {
+      console.warn(
+        `ts=${e.ts}: Tried to end "${eFrameInfo.key}" when "${bFrameInfo.key}" was on the top of the stack. Ending ${bFrameInfo.key} instead.`,
+      )
     }
 
-    while (bEventQueue.length > 0 || eEventQueue.length > 0) {
-      const queueName = selectQueueToTakeFromNext(bEventQueue, eEventQueue)
-      switch (queueName) {
-        case 'B': {
-          enterFrame(bEventQueue.shift()!)
-          break
-        }
-        case 'E': {
-          // Before we take the first event in the 'E' queue, let's first see if
-          // there are any e events that exactly match the top of the stack.
-          // We'll prioritize first by key, then by name if we can't find a key
-          // match.
-          const stackTop = lastOf(frameStack)
-          if (stackTop != null) {
-            const bFrameInfo = frameInfoForEvent(stackTop)
+    frameStack.pop()
+    profileBuilder.leaveFrame(bFrameInfo, e.ts)
+  }
+
+  while (bEventQueue.length > 0 || eEventQueue.length > 0) {
+    const queueName = selectQueueToTakeFromNext(bEventQueue, eEventQueue)
+    switch (queueName) {
+      case 'B': {
+        enterFrame(bEventQueue.shift()!)
+        break
+      }
+      case 'E': {
+        // Before we take the first event in the 'E' queue, let's first see if
+        // there are any e events that exactly match the top of the stack.
+        // We'll prioritize first by key, then by name if we can't find a key
+        // match.
+        const stackTop = lastOf(frameStack)
+        if (stackTop != null) {
+          const bFrameInfo = frameInfoForEvent(stackTop)
+
+          let swapped: boolean = false
+
+          for (let i = 1; i < eEventQueue.length; i++) {
+            const eEvent = eEventQueue[i]
+            if (eEvent.ts > eEventQueue[0].ts) {
+              // Only consider 'E' events with the same ts as the front of the queue.
+              break
+            }
 
-            let swapped: boolean = false
+            const eFrameInfo = frameInfoForEvent(eEvent)
+            if (bFrameInfo.key === eFrameInfo.key) {
+              // We have a match! Process this one first.
+              const temp = eEventQueue[0]
+              eEventQueue[0] = eEventQueue[i]
+              eEventQueue[i] = temp
+              swapped = true
+              break
+            }
+          }
 
+          if (!swapped) {
+            // There was no key match, let's see if we can find a name match
             for (let i = 1; i < eEventQueue.length; i++) {
               const eEvent = eEventQueue[i]
               if (eEvent.ts > eEventQueue[0].ts) {
@@ -355,8 +438,7 @@ function eventListToProfileGroup(events: TraceEvent[]): ProfileGroup {
                 break
               }
 
-              const eFrameInfo = frameInfoForEvent(eEvent)
-              if (bFrameInfo.key === eFrameInfo.key) {
+              if (eEvent.name === stackTop.name) {
                 // We have a match! Process this one first.
                 const temp = eEventQueue[0]
                 eEventQueue[0] = eEventQueue[i]
@@ -365,50 +447,166 @@ function eventListToProfileGroup(events: TraceEvent[]): ProfileGroup {
                 break
               }
             }
-
-            if (!swapped) {
-              // There was no key match, let's see if we can find a name match
-              for (let i = 1; i < eEventQueue.length; i++) {
-                const eEvent = eEventQueue[i]
-                if (eEvent.ts > eEventQueue[0].ts) {
-                  // Only consider 'E' events with the same ts as the front of the queue.
-                  break
-                }
-
-                if (eEvent.name === stackTop.name) {
-                  // We have a match! Process this one first.
-                  const temp = eEventQueue[0]
-                  eEventQueue[0] = eEventQueue[i]
-                  eEventQueue[i] = temp
-                  swapped = true
-                  break
-                }
-              }
-            }
-
-            // If swapped is still false at this point, it means we're about to
-            // pop a stack frame that doesn't even match by name. Bummer.
           }
 
-          const e = eEventQueue.shift()!
-
-          tryToLeaveFrame(e)
-          break
+          // If swapped is still false at this point, it means we're about to
+          // pop a stack frame that doesn't even match by name. Bummer.
         }
 
-        default:
-          const _exhaustiveCheck: never = queueName
-          return _exhaustiveCheck
+        const e = eEventQueue.shift()!
+
+        tryToLeaveFrame(e)
+        break
       }
+
+      default:
+        const _exhaustiveCheck: never = queueName
+        return _exhaustiveCheck
     }
+  }
+
+  for (let i = frameStack.length - 1; i >= 0; i--) {
+    const frame = frameInfoForEvent(frameStack[i])
+    console.warn(`Frame "${frame.key}" was still open at end of profile. Closing automatically.`)
+    profileBuilder.leaveFrame(frame, profileBuilder.getTotalWeight())
+  }
+
+  return profileBuilder.build()
+}
+
+/**
+ * Returns an array containing the time difference in microseconds between the current
+ * sample and the next sample
+ */
+function getTimeDeltasForSamples(samples: Sample[]): number[] {
+  const timeDeltas: number[] = []
+  let lastTimeStamp = Number(samples[0].ts)
+
+  samples.forEach((sample: Sample, idx: number) => {
+    if (idx === 0) return
+
+    const timeDiff = Number(sample.ts) - lastTimeStamp
+    lastTimeStamp = Number(sample.ts)
+    timeDeltas.push(timeDiff)
+  })
+
+  timeDeltas.push(0)
 
-    for (let i = frameStack.length - 1; i >= 0; i--) {
-      const frame = frameInfoForEvent(frameStack[i])
-      console.warn(`Frame "${frame.key}" was still open at end of profile. Closing automatically.`)
-      profile.leaveFrame(frame, profile.getTotalWeight())
+  return timeDeltas
+}
+
+/**
+ * The chrome json trace event spec only specifies name and category
+ * as required stack frame properties
+ *
+ * https://docs.google.com/document/d/1CvAClvFfyA5R-PhYUmn5OOQtYMH4h6I0nSsKchNAySU/preview#heading=h.b4y98p32171
+ */
+function frameInfoForSampleFrame({name, category}: StackFrame): FrameInfo {
+  return {
+    key: `${name}:${category}`,
+    name: name,
+  }
+}
+
+function getActiveFramesForSample(
+  stackFrames: {[key: string]: StackFrame},
+  frameId: number,
+): FrameInfo[] {
+  const frames = []
+  let parent: number | undefined = frameId
+
+  while (parent) {
+    const frame: StackFrame = stackFrames[parent]
+
+    if (!frame) {
+      throw new Error(`Could not find frame for id ${parent}`)
+    }
+
+    frames.push(frameInfoForSampleFrame(frame))
+    parent = frame.parent
+  }
+
+  return frames.reverse()
+}
+
+function sampleListToProfile(
+  contents: TraceWithSamples,
+  samples: Sample[],
+  name: string,
+): Profile {
+  const profileBuilder = new StackListProfileBuilder()
+
+  profileBuilder.setValueFormatter(new TimeFormatter('microseconds'))
+  profileBuilder.setName(name)
+
+  const timeDeltas = getTimeDeltasForSamples(samples)
+
+  samples.forEach((sample, index) => {
+    const timeDelta = timeDeltas[index]
+    const activeFrames = getActiveFramesForSample(contents.stackFrames, sample.sf)
+
+    profileBuilder.appendSampleWithWeight(activeFrames, timeDelta)
+  })
+
+  return profileBuilder.build()
+}
+
+function eventListToProfileGroup(events: TraceEvent[]): ProfileGroup {
+  const importableEvents = filterIgnoredEventTypes(events)
+  const partitionedTraceEvents = partitionByPidTid(importableEvents)
+  const profileNamesByPidTid = getProfileNameByPidTid(events, partitionedTraceEvents)
+
+  const profilePairs: [string, Profile][] = []
+
+  profileNamesByPidTid.forEach((name, profileKey) => {
+    const importableEventsForPidTid = partitionedTraceEvents.get(profileKey)
+
+    if (!importableEventsForPidTid) {
+      throw new Error(`Could not find events for key: ${importableEventsForPidTid}`)
     }
 
-    profilePairs.push([pidTidKey(pid, tid), profile.build()])
+    profilePairs.push([
+      profileKey,
+      eventListToProfile(importableEventsForPidTid, name),
+    ])
+  })
+
+  // For now, we just sort processes by pid & tid.
+  // TODO: The standard specifies that metadata events with the name
+  // "process_sort_index" and "thread_sort_index" can be used to influence the
+  // order, but for simplicity we'll ignore that until someone complains :)
+  sortBy(profilePairs, p => p[0])
+
+  return {
+    name: '',
+    indexToView: 0,
+    profiles: profilePairs.map(p => p[1]),
+  }
+}
+
+function sampleListToProfileGroup(contents: TraceWithSamples): ProfileGroup {
+  const importableEvents = filterIgnoredEventTypes(contents.traceEvents)
+  const partitionedTraceEvents = partitionByPidTid(importableEvents)
+  const partitionedSamples = partitionByPidTid(contents.samples)
+  const profileNamesByPidTid = getProfileNameByPidTid(contents.traceEvents, partitionedTraceEvents)
+
+  const profilePairs: [string, Profile][] = []
+
+  profileNamesByPidTid.forEach((name, profileKey) => {
+    const samplesForPidTid = partitionedSamples.get(profileKey)
+
+    if (!samplesForPidTid) {
+      throw new Error(`Could not find samples for key: ${samplesForPidTid}`)
+    }
+
+    if (samplesForPidTid.length === 0) {
+      return
+    }
+
+    profilePairs.push([
+      profileKey,
+      sampleListToProfile(contents, samplesForPidTid, name),
+    ])
   })
 
   // For now, we just sort processes by pid & tid.
@@ -456,26 +654,33 @@ function isTraceEventList(maybeEventList: any): maybeEventList is TraceEvent[] {
   return true
 }
 
-function isTraceEventObject(
-  maybeTraceEventObject: any,
-): maybeTraceEventObject is {traceEvents: TraceEvent[]} {
+function isTraceEventObject(maybeTraceEventObject: any): maybeTraceEventObject is TraceEventObject {
   if (!('traceEvents' in maybeTraceEventObject)) return false
   return isTraceEventList(maybeTraceEventObject['traceEvents'])
 }
 
-export function isTraceEventFormatted(
-  rawProfile: any,
-): rawProfile is {traceEvents: TraceEvent[]} | TraceEvent[] {
+function isTraceEventWithSamples(
+  maybeTraceEventObject: any,
+): maybeTraceEventObject is TraceWithSamples {
+  return (
+    'traceEvents' in maybeTraceEventObject &&
+    'stackFrames' in maybeTraceEventObject &&
+    'samples' in maybeTraceEventObject &&
+    isTraceEventList(maybeTraceEventObject['traceEvents'])
+  )
+}
+
+export function isTraceEventFormatted(rawProfile: any): rawProfile is Trace {
   // We're only going to support the JSON formatted profiles for now.
   // The spec also discusses support for data embedded in ftrace supported data: https://lwn.net/Articles/365835/.
 
   return isTraceEventObject(rawProfile) || isTraceEventList(rawProfile)
 }
 
-export function importTraceEvents(
-  rawProfile: {traceEvents: TraceEvent[]} | TraceEvent[],
-): ProfileGroup {
-  if (isTraceEventObject(rawProfile)) {
+export function importTraceEvents(rawProfile: Trace): ProfileGroup {
+  if (isTraceEventWithSamples(rawProfile)) {
+    return sampleListToProfileGroup(rawProfile)
+  } else if (isTraceEventObject(rawProfile)) {
     return eventListToProfileGroup(rawProfile.traceEvents)
   } else if (isTraceEventList(rawProfile)) {
     return eventListToProfileGroup(rawProfile)
diff --git a/src/views/application.tsx b/src/views/application.tsx
index ee1c225..cf7ec9e 100644
--- a/src/views/application.tsx
+++ b/src/views/application.tsx
@@ -416,7 +416,7 @@ export class Application extends StatelessComponent<ApplicationProps> {
   }
 
   async maybeLoadHashParamProfile() {
-    const {profileURL} = this.props.hashParams;
+    const {profileURL} = this.props.hashParams
     if (profileURL) {
       if (!canUseXHR) {
         alert(
diff --git a/src/views/flamechart-pan-zoom-view.tsx b/src/views/flamechart-pan-zoom-view.tsx
index 2cf5c08..9734051 100644
--- a/src/views/flamechart-pan-zoom-view.tsx
+++ b/src/views/flamechart-pan-zoom-view.tsx
@@ -239,10 +239,7 @@ export class FlamechartPanZoomView extends Component<FlamechartPanZoomViewProps,
           )
 
           if (match) {
-            const rangesToHighlightInTrimmedText = remapRangesToTrimmedText(
-              trimmedText,
-              match
-            )
+            const rangesToHighlightInTrimmedText = remapRangesToTrimmedText(trimmedText, match)
 
             // Once we have the character ranges to highlight, we need to
             // actually do the highlighting.
diff --git a/src/views/hovertip.tsx b/src/views/hovertip.tsx
index e32d1b7..67e5cc4 100644
--- a/src/views/hovertip.tsx
+++ b/src/views/hovertip.tsx
@@ -3,7 +3,7 @@ import {Sizes, FontSize, FontFamily, ZIndex} from './style'
 import {css, StyleSheet} from 'aphrodite'
 import {ComponentChildren, h} from 'preact'
 import {useTheme, withTheme} from './themes/theme'
-import { useCallback } from 'preact/hooks'
+import {useCallback} from 'preact/hooks'
 
 interface HovertipProps {
   containerSize: Vec2
@@ -20,42 +20,48 @@ export function Hovertip(props: HovertipProps) {
 
   const OFFSET_FROM_MOUSE = 7
 
-  const updateLocation = useCallback((el: HTMLDivElement | null) => {
-    if (!el) return
-
-    const clientRect = el.getBoundingClientRect()
-
-    // Place the hovertip to the right of the cursor.
-    let leftEdgeX = offset.x + OFFSET_FROM_MOUSE
-
-    // If this would cause it to overflow the container, align the right
-    // edge of the hovertip with the right edge of the container.
-    if (leftEdgeX + clientRect.width > containerWidth - 1) {
-      leftEdgeX = containerWidth - clientRect.width - 1
-
-      // If aligning the right edge overflows the container, align the left edge
-      // of the hovertip with the left edge of the container.
-      if (leftEdgeX < 1) { leftEdgeX = 1 }
-    }
-    el.style.left = `${leftEdgeX}px`
-
-    // Place the tooltip below the cursor
-    let topEdgeY = offset.y + OFFSET_FROM_MOUSE
-
-    // If this would cause it to overflow the container, place the hovertip
-    // above the cursor instead. This intentionally differs from the horizontal
-    // axis logic to avoid the cursor being in the middle of a hovertip when
-    // possible.
-    if (topEdgeY + clientRect.height > containerHeight - 1) {
-      topEdgeY = offset.y - clientRect.height - 1
-
-      // If placing the hovertip above the cursor overflows the container, align
-      // the top edge of the hovertip with the top edge of the container.
-      if (topEdgeY < 1) { topEdgeY = 1 }
-    }
-    el.style.top = `${topEdgeY}px`
-
-  }, [containerWidth, containerHeight, offset.x, offset.y])
+  const updateLocation = useCallback(
+    (el: HTMLDivElement | null) => {
+      if (!el) return
+
+      const clientRect = el.getBoundingClientRect()
+
+      // Place the hovertip to the right of the cursor.
+      let leftEdgeX = offset.x + OFFSET_FROM_MOUSE
+
+      // If this would cause it to overflow the container, align the right
+      // edge of the hovertip with the right edge of the container.
+      if (leftEdgeX + clientRect.width > containerWidth - 1) {
+        leftEdgeX = containerWidth - clientRect.width - 1
+
+        // If aligning the right edge overflows the container, align the left edge
+        // of the hovertip with the left edge of the container.
+        if (leftEdgeX < 1) {
+          leftEdgeX = 1
+        }
+      }
+      el.style.left = `${leftEdgeX}px`
+
+      // Place the tooltip below the cursor
+      let topEdgeY = offset.y + OFFSET_FROM_MOUSE
+
+      // If this would cause it to overflow the container, place the hovertip
+      // above the cursor instead. This intentionally differs from the horizontal
+      // axis logic to avoid the cursor being in the middle of a hovertip when
+      // possible.
+      if (topEdgeY + clientRect.height > containerHeight - 1) {
+        topEdgeY = offset.y - clientRect.height - 1
+
+        // If placing the hovertip above the cursor overflows the container, align
+        // the top edge of the hovertip with the top edge of the container.
+        if (topEdgeY < 1) {
+          topEdgeY = 1
+        }
+      }
+      el.style.top = `${topEdgeY}px`
+    },
+    [containerWidth, containerHeight, offset.x, offset.y],
+  )
 
   return (
     <div className={css(style.hoverTip)} ref={updateLocation}>


Test patch:
diff --git a/src/import/__snapshots__/trace-event.test.ts.snap b/src/import/__snapshots__/trace-event.test.ts.snap
index efc317a..e135030 100644
--- a/src/import/__snapshots__/trace-event.test.ts.snap
+++ b/src/import/__snapshots__/trace-event.test.ts.snap
@@ -530,6 +530,81 @@ exports[`importTraceEvents mismatched name: indexToView 1`] = `0`;
 
 exports[`importTraceEvents mismatched name: profileGroup.name 1`] = `"mismatched-name.json"`;
 
+exports[`importTraceEvents multi-thread profile with samples 1`] = `
+Object {
+  "frames": Array [
+    Frame {
+      "col": undefined,
+      "file": undefined,
+      "key": "[root]:root",
+      "line": undefined,
+      "name": "[root]",
+      "selfWeight": 20770,
+      "totalWeight": 34096,
+    },
+    Frame {
+      "col": undefined,
+      "file": undefined,
+      "key": "function1:JavaScript",
+      "line": undefined,
+      "name": "function1",
+      "selfWeight": 13326,
+      "totalWeight": 13326,
+    },
+  ],
+  "name": "hermes (pid 7512), mqt_js (tid 1)",
+  "stacks": Array [
+    "[root] 20.77ms",
+    "[root];function1 13.33ms",
+  ],
+}
+`;
+
+exports[`importTraceEvents multi-thread profile with samples 2`] = `
+Object {
+  "frames": Array [
+    Frame {
+      "col": undefined,
+      "file": undefined,
+      "key": "[root thread 2]:root",
+      "line": undefined,
+      "name": "[root thread 2]",
+      "selfWeight": 30770,
+      "totalWeight": 54096,
+    },
+    Frame {
+      "col": undefined,
+      "file": undefined,
+      "key": "function3:JavaScript",
+      "line": undefined,
+      "name": "function3",
+      "selfWeight": 13326,
+      "totalWeight": 13326,
+    },
+    Frame {
+      "col": undefined,
+      "file": undefined,
+      "key": "function4:Metadata",
+      "line": undefined,
+      "name": "function4",
+      "selfWeight": 10000,
+      "totalWeight": 10000,
+    },
+  ],
+  "name": "hermes (pid 7512, tid 2)",
+  "stacks": Array [
+    "[root thread 2] 20.77ms",
+    "[root thread 2];function3 13.33ms",
+    "[root thread 2];function4 10.00ms",
+    "[root thread 2] 10.00ms",
+  ],
+}
+`;
+
+exports[`importTraceEvents multi-thread profile with samples: indexToView 1`] = `0`;
+
+exports[`importTraceEvents multi-thread profile with samples: profileGroup.name 1`] = `"multi-thread-with-samples.json"`;
+
 exports[`importTraceEvents multiprocess 1`] = `
 Object {
   "frames": Array [
@@ -1286,6 +1361,70 @@ exports[`importTraceEvents simple object: indexToView 1`] = `0`;
 
 exports[`importTraceEvents simple object: profileGroup.name 1`] = `"simple-object.json"`;
 
+exports[`importTraceEvents simple profile with samples 1`] = `
+Object {
+  "frames": Array [
+    Frame {
+      "col": undefined,
+      "file": undefined,
+      "key": "[root]:root",
+      "line": undefined,
+      "name": "[root]",
+      "selfWeight": 30779,
+      "totalWeight": 64710,
+    },
+    Frame {
+      "col": undefined,
+      "file": undefined,
+      "key": "function1(http://localhost:8081/index.bundle?platform=android&dev=false&minify=false&app=org.toshi&modulesOnly=false&runModule=true:1:1):JavaScript",
+      "line": undefined,
+      "name": "function1(http://localhost:8081/index.bundle?platform=android&dev=false&minify=false&app=org.toshi&modulesOnly=false&runModule=true:1:1)",
+      "selfWeight": 13326,
+      "totalWeight": 13326,
+    },
+    Frame {
+      "col": undefined,
+      "file": undefined,
+      "key": "function2(http://localhost:8081/index.bundle?platform=android&dev=false&minify=false&app=org.toshi&modulesOnly=false&runModule=true:418874:4):JavaScript",
+      "line": undefined,
+      "name": "function2(http://localhost:8081/index.bundle?platform=android&dev=false&minify=false&app=org.toshi&modulesOnly=false&runModule=true:418874:4)",
+      "selfWeight": 0,
+      "totalWeight": 10605,
+    },
+    Frame {
+      "col": undefined,
+      "file": undefined,
+      "key": "function3(http://localhost:8081/index.bundle?platform=android&dev=false&minify=false&app=org.toshi&modulesOnly=false&runModule=true:17:3):JavaScript",
+      "line": undefined,
+      "name": "function3(http://localhost:8081/index.bundle?platform=android&dev=false&minify=false&app=org.toshi&modulesOnly=false&runModule=true:17:3)",
+      "selfWeight": 10605,
+      "totalWeight": 10605,
+    },
+    Frame {
+      "col": undefined,
+      "file": undefined,
+      "key": "[GC Young Gen]:Metadata",
+      "line": undefined,
+      "name": "[GC Young Gen]",
+      "selfWeight": 10000,
+      "totalWeight": 10000,
+    },
+  ],
+  "name": "hermes (pid 7512), mqt_js (tid 7695)",
+  "stacks": Array [
+    "[root] 20.77ms",
+    "[root];function1(http://localhost:8081/index.bundle?platform=android&dev=false&minify=false&app=org.toshi&modulesOnly=false&runModule=true:1:1) 13.33ms",
+    "[root];function2(http://localhost:8081/index.bundle?platform=android&dev=false&minify=false&app=org.toshi&modulesOnly=false&runModule=true:418874:4);function3(http://localhost:8081/index.bundle?platform=android&dev=false&minify=false&app=org.toshi&modulesOnly=false&runModule=true:17:3) 10.61ms",
+    "[root] 10.01ms",
+    "[root];[GC Young Gen] 10.00ms",
+  ],
+}
+`;
+
+exports[`importTraceEvents simple profile with samples: indexToView 1`] = `0`;
+
+exports[`importTraceEvents simple profile with samples: profileGroup.name 1`] = `"simple-with-samples.json"`;
+
 exports[`importTraceEvents simple: indexToView 1`] = `0`;
 
 exports[`importTraceEvents simple: profileGroup.name 1`] = `"simple.json"`;
diff --git a/src/import/stackprof.test.ts b/src/import/stackprof.test.ts
index 46b9f36..d0c7a43 100644
--- a/src/import/stackprof.test.ts
+++ b/src/import/stackprof.test.ts
@@ -1,5 +1,5 @@
-import { readFileSync } from 'fs';
-import { importProfileGroupFromText } from '.'
+import {readFileSync} from 'fs'
+import {importProfileGroupFromText} from '.'
 import {checkProfileSnapshot} from '../lib/test-utils'
 
 test('importFromStackprof', async () => {
@@ -20,9 +20,11 @@ describe('importCpuProfileWithProperWeights', () => {
   })
 
   test('uses samples count for weight when importing cpu profile', async () => {
-
     const profileFile = readFileSync('./sample/profiles/stackprof/simple-cpu-stackprof.json')
-    const profileGroup = await importProfileGroupFromText('simple-cpu-stackprof.json', profileFile.toString())
+    const profileGroup = await importProfileGroupFromText(
+      'simple-cpu-stackprof.json',
+      profileFile.toString(),
+    )
     expect(profileGroup).not.toBeNull()
 
     if (profileGroup) {
diff --git a/src/import/trace-event.test.ts b/src/import/trace-event.test.ts
index 6a8e956..75fe547 100644
--- a/src/import/trace-event.test.ts
+++ b/src/import/trace-event.test.ts
@@ -122,3 +122,11 @@ test('importTraceEvents invalid x nesting', async () => {
 test('importTraceEvents event reordering name match', async () => {
   await checkProfileSnapshot('./sample/profiles/trace-event/event-reordering-name-match.json')
 })
+
+test('importTraceEvents simple profile with samples', async () => {
+  await checkProfileSnapshot('./sample/profiles/trace-event/simple-with-samples.json')
+})
+
+test('importTraceEvents multi-thread profile with samples', async () => {
+  await checkProfileSnapshot('./sample/profiles/trace-event/multi-thread-with-samples.json')
+})