perfetto-ui: Moving heap profile flamegraph to details panel

Instead of having flamegraph for heap profiles in separate track, moving it
to the details panel which is now scrollable.

Test trace: ?s=25632898bab2c18347f2c2d5cd1c5ddc9e98f1278cb96e896eda5de3adbf3
Change-Id: I0cd5be4599474a98181a54551aa8eddf0e62e01f
diff --git a/ui/src/controller/heap_profile_controller.ts b/ui/src/controller/heap_profile_controller.ts
new file mode 100644
index 0000000..4a5d262
--- /dev/null
+++ b/ui/src/controller/heap_profile_controller.ts
@@ -0,0 +1,313 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import {Engine} from '../common/engine';
+import {
+  ALLOC_SPACE_MEMORY_ALLOCATED_KEY,
+  DEFAULT_VIEWING_OPTION,
+  expandCallsites,
+  findRootSize,
+  mergeCallsites,
+  OBJECTS_ALLOCATED_KEY,
+  OBJECTS_ALLOCATED_NOT_FREED_KEY,
+  SPACE_MEMORY_ALLOCATED_NOT_FREED_KEY
+} from '../common/flamegraph_util';
+import {CallsiteInfo, HeapProfileFlamegraph} from '../common/state';
+
+import {Controller} from './controller';
+import {globals} from './globals';
+
+export interface HeapProfileControllerArgs {
+  engine: Engine;
+}
+const MIN_PIXEL_DISPLAYED = 1;
+
+export class HeapProfileController extends Controller<'main'> {
+  private flamegraphDatasets: Map<string, CallsiteInfo[]> = new Map();
+  private lastSelectedHeapProfile?: HeapProfileFlamegraph;
+
+  constructor(private args: HeapProfileControllerArgs) {
+    super('main');
+  }
+
+  run() {
+    const selection = globals.state.currentHeapProfileFlamegraph;
+
+    if (!selection) return;
+
+    if (selection.kind === 'HEAP_PROFILE_FLAMEGRAPH') {
+      if (this.lastSelectedHeapProfile === undefined ||
+          (this.lastSelectedHeapProfile !== undefined &&
+           (this.lastSelectedHeapProfile.id !== selection.id ||
+            this.lastSelectedHeapProfile.ts !== selection.ts ||
+            this.lastSelectedHeapProfile.upid !== selection.upid ||
+            this.lastSelectedHeapProfile.viewingOption !==
+                selection.viewingOption ||
+            this.lastSelectedHeapProfile.expandedCallsite !==
+                selection.expandedCallsite))) {
+        const selectedId = selection.id;
+        const selectedUpid = selection.upid;
+        const selectedKind = selection.kind;
+        const selectedTs = selection.ts;
+        const selectedExpandedCallsite = selection.expandedCallsite;
+        const lastSelectedViewingOption = selection.viewingOption ?
+            selection.viewingOption :
+            DEFAULT_VIEWING_OPTION;
+
+        this.lastSelectedHeapProfile = {
+          kind: selectedKind,
+          id: selectedId,
+          upid: selectedUpid,
+          ts: selectedTs,
+          expandedCallsite: selectedExpandedCallsite,
+          viewingOption: lastSelectedViewingOption
+        };
+
+        const expandedId =
+            selectedExpandedCallsite ? selectedExpandedCallsite.id : -1;
+        const rootSize = selectedExpandedCallsite === undefined ?
+            undefined :
+            selectedExpandedCallsite.totalSize;
+
+        const key = `${selectedUpid};${selectedTs}`;
+
+        // TODO(tneda): Prevent lots of flamegraph queries being queued if a
+        // user clicks lots of the markers quickly.
+        this.getFlamegraphData(
+                key, lastSelectedViewingOption, selection.ts, selectedUpid)
+            .then(flamegraphData => {
+              if (flamegraphData !== undefined && selection &&
+                  selection.kind === selectedKind &&
+                  selection.id === selectedId && selection.ts === selectedTs) {
+                const expandedFlamegraphData =
+                    expandCallsites(flamegraphData, expandedId);
+                this.prepareAndMergeCallsites(
+                    expandedFlamegraphData,
+                    this.lastSelectedHeapProfile!.viewingOption,
+                    rootSize,
+                    this.lastSelectedHeapProfile!.expandedCallsite);
+              }
+            });
+      }
+    }
+  }
+
+  private prepareAndMergeCallsites(
+      flamegraphData: CallsiteInfo[],
+      viewingOption: string|undefined = DEFAULT_VIEWING_OPTION,
+      rootSize?: number, expandedCallsite?: CallsiteInfo) {
+    const mergedFlamegraphData = mergeCallsites(
+        flamegraphData, this.getMinSizeDisplayed(flamegraphData, rootSize));
+    globals.publish(
+        'HeapProfileFlamegraph',
+        {flamegraph: mergedFlamegraphData, expandedCallsite, viewingOption});
+  }
+
+
+  async getFlamegraphData(
+      baseKey: string, viewingOption: string, ts: number,
+      upid: number): Promise<CallsiteInfo[]> {
+    let currentData: CallsiteInfo[];
+    const key = `${baseKey}-${viewingOption}`;
+    if (this.flamegraphDatasets.has(key)) {
+      currentData = this.flamegraphDatasets.get(key)!;
+    } else {
+      // TODO(tneda): Show loading state.
+
+      // Collecting data for drawing flamegraph for selected heap profile.
+      // Data needs to be in following format:
+      // id, name, parent_id, depth, total_size
+      const tableName = await this.prepareViewsAndTables(ts, upid);
+      currentData =
+          await this.getFlamegraphDataFromTables(tableName, viewingOption);
+      this.flamegraphDatasets.set(key, currentData);
+    }
+    return currentData;
+  }
+
+  async getFlamegraphDataFromTables(
+      tableName: string, viewingOption = DEFAULT_VIEWING_OPTION) {
+    let orderBy = '';
+    let sizeIndex = 4;
+    switch (viewingOption) {
+      case SPACE_MEMORY_ALLOCATED_NOT_FREED_KEY:
+        orderBy = `where size > 0 order by depth, parent_hash, size desc, name`;
+        sizeIndex = 4;
+        break;
+      case ALLOC_SPACE_MEMORY_ALLOCATED_KEY:
+        orderBy =
+            `where alloc_size > 0 order by depth, parent_hash, alloc_size desc,
+            name`;
+        sizeIndex = 5;
+        break;
+      case OBJECTS_ALLOCATED_NOT_FREED_KEY:
+        orderBy =
+            `where count > 0 order by depth, parent_hash, count desc, name`;
+        sizeIndex = 6;
+        break;
+      case OBJECTS_ALLOCATED_KEY:
+        orderBy = `where alloc_count > 0 order by depth, parent_hash,
+            alloc_count desc, name`;
+        sizeIndex = 7;
+        break;
+      default:
+        break;
+    }
+
+    const callsites = await this.args.engine.query(
+        `SELECT hash, name, parent_hash, depth, size, alloc_size, count,
+        alloc_count, map_name, self_size from ${tableName} ${orderBy}`);
+
+    const flamegraphData: CallsiteInfo[] = new Array();
+    const hashToindex: Map<number, number> = new Map();
+    for (let i = 0; i < callsites.numRecords; i++) {
+      const hash = callsites.columns[0].longValues![i];
+      const name = callsites.columns[1].stringValues![i];
+      const parentHash = callsites.columns[2].longValues![i];
+      const depth = +callsites.columns[3].longValues![i];
+      const totalSize = +callsites.columns[sizeIndex].longValues![i];
+      const mapping = callsites.columns[8].stringValues![i];
+      const selfSize = +callsites.columns[9].longValues![i];
+      const parentId =
+          hashToindex.has(+parentHash) ? hashToindex.get(+parentHash)! : -1;
+      hashToindex.set(+hash, i);
+      // Instead of hash, we will store index of callsite in this original array
+      // as an id of callsite. That way, we have quicker access to parent and it
+      // will stay unique.
+      flamegraphData.push(
+          {id: i, totalSize, depth, parentId, name, selfSize, mapping});
+    }
+    return flamegraphData;
+  }
+
+  private async prepareViewsAndTables(ts: number, upid: number):
+      Promise<string> {
+    // Creating unique names for views so we can reuse and not delete them
+    // for each marker.
+    const tableNameCallsiteNameSize =
+        this.tableName(`callsite_with_name_and_size`);
+    const tableNameCallsiteHashNameSize =
+        this.tableName(`callsite_hash_name_size`);
+    const tableNameGroupedCallsitesForFlamegraph =
+        this.tableName(`grouped_callsites_for_flamegraph`);
+    // Joining the callsite table with frame table then with alloc table to get
+    // the size and name for each callsite.
+    // TODO(tneda): Make frame name nullable in the trace processor for
+    // consistency with the other columns.
+    await this.args.engine.query(
+        `create view if not exists ${tableNameCallsiteNameSize} as
+         select id, parent_id, depth, IFNULL(DEMANGLE(name), name) as name,
+            map_name, size, alloc_size, count, alloc_count from (
+         select cs.id as id, parent_id, depth,
+            coalesce(symbols.name,
+                case when fr.name != '' then fr.name else map.name end) as name,
+            map.name as map_name,
+            SUM(IFNULL(size, 0)) as size,
+            SUM(IFNULL(size, 0)) as size,
+            SUM(case when size > 0 then size else 0 end) as alloc_size,
+            SUM(IFNULL(count, 0)) as count,
+            SUM(case when count > 0 then count else 0 end) as alloc_count
+         from stack_profile_callsite cs
+         join stack_profile_frame fr on cs.frame_id = fr.id
+         join stack_profile_mapping map on fr.mapping = map.id
+         inner join (
+              select symbol_set_id, FIRST_VALUE(name) OVER(PARTITION BY
+                symbol_set_id) as name
+              from stack_profile_symbol GROUP BY symbol_set_id
+            ) as symbols using(symbol_set_id)
+         left join heap_profile_allocation alloc on alloc.callsite_id = cs.id
+         and alloc.ts <= ${ts} and alloc.upid = ${upid} group by cs.id)`);
+
+    // Recursive query to compute the hash for each callsite based on names
+    // rather than ids.
+    // We get all the children of the row in question and emit a row with hash
+    // equal hash(name, parent.hash). Roots without the parent will have -1 as
+    // hash.  Slices will be merged into a big slice.
+    await this.args.engine.query(
+        `create view if not exists ${tableNameCallsiteHashNameSize} as
+        with recursive callsite_table_names(
+          id, hash, name, map_name, size, alloc_size, count, alloc_count,
+          parent_hash, depth) AS (
+        select id, hash(name) as hash, name, map_name, size, alloc_size, count,
+          alloc_count, -1, depth
+        from ${tableNameCallsiteNameSize}
+        where depth = 0
+        union all
+        select cs.id, hash(cs.name, ctn.hash) as hash, cs.name, cs.map_name,
+          cs.size, cs.alloc_size, cs.count, cs.alloc_count, ctn.hash, cs.depth
+        from callsite_table_names ctn
+        inner join ${tableNameCallsiteNameSize} cs ON ctn.id = cs.parent_id
+        )
+        select hash, name, map_name, parent_hash, depth, SUM(size) as size,
+          SUM(case when alloc_size > 0 then alloc_size else 0 end)
+            as alloc_size, SUM(count) as count,
+          SUM(case when alloc_count > 0 then alloc_count else 0 end)
+            as alloc_count
+        from callsite_table_names
+        group by hash`);
+
+    // Recursive query to compute the cumulative size of each callsite.
+    // Base case: We get all the callsites where the size is non-zero.
+    // Recursive case: We get the callsite which is the parent of the current
+    //  callsite(in terms of hashes) and emit a row with the size of the current
+    //  callsite plus all the info of the parent.
+    // Grouping: For each callsite, our recursive table has n rows where n is
+    //  the number of descendents with a non-zero self size. We need to group on
+    //  the hash and sum all the sizes to get the cumulative size for each
+    //  callsite hash.
+    await this.args.engine.query(`create temp table if not exists ${
+        tableNameGroupedCallsitesForFlamegraph}
+        as with recursive callsite_children(
+          hash, name, map_name, parent_hash, depth, size, alloc_size, count,
+          alloc_count, self_size, self_alloc_size, self_count, self_alloc_count)
+        as (
+        select hash, name, map_name, parent_hash, depth, size, alloc_size,
+          count, alloc_count, size as self_size, alloc_size as self_alloc_size,
+          count as self_count, alloc_count as self_alloc_count
+        from ${tableNameCallsiteHashNameSize}
+        union all
+        select chns.hash, chns.name, chns.map_name, chns.parent_hash,
+          chns.depth, cc.size, cc.alloc_size, cc.count, cc.alloc_count,
+          chns.size, chns.alloc_size, chns.count, chns.alloc_count
+        from ${tableNameCallsiteHashNameSize} chns
+        inner join callsite_children cc on chns.hash = cc.parent_hash
+        )
+        select hash, name, map_name, parent_hash, depth, SUM(size) as size,
+          SUM(case when alloc_size > 0 then alloc_size else 0 end)
+            as alloc_size, SUM(count) as count,
+          SUM(case when alloc_count > 0 then alloc_count else 0 end) as
+            alloc_count,
+          self_size, self_alloc_size, self_count, self_alloc_count
+        from callsite_children
+        group by hash`);
+    return tableNameGroupedCallsitesForFlamegraph;
+  }
+
+  tableName(name: string): string {
+    const selection = globals.state.currentHeapProfileFlamegraph;
+    if (!selection) return name;
+    return `${name}_${selection.upid}_${selection.ts}`;
+  }
+
+  getMinSizeDisplayed(flamegraphData: CallsiteInfo[], rootSize?: number):
+      number {
+    const timeState = globals.state.frontendLocalState.visibleState;
+    const width =
+        (timeState.endSec - timeState.startSec) / timeState.resolution;
+    if (rootSize === undefined) {
+      rootSize = findRootSize(flamegraphData);
+    }
+    return MIN_PIXEL_DISPLAYED * rootSize / width;
+  }
+}