| // 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 {Duration, time} from '../base/time'; |
| import {exists} from '../base/utils'; |
| import {Actions} from '../common/actions'; |
| import { |
| defaultViewingOption, |
| expandCallsites, |
| findRootSize, |
| mergeCallsites, |
| } from '../common/flamegraph_util'; |
| import { |
| CallsiteInfo, |
| FlamegraphState, |
| FlamegraphStateViewingOption, |
| isHeapGraphDominatorTreeViewingOption, |
| ProfileType, |
| } from '../common/state'; |
| import {FlamegraphDetails, globals} from '../frontend/globals'; |
| import {publishFlamegraphDetails} from '../frontend/publish'; |
| import {Engine} from '../trace_processor/engine'; |
| import {NUM, STR} from '../trace_processor/query_result'; |
| import {PERF_SAMPLES_PROFILE_TRACK_KIND} from '../tracks/perf_samples_profile'; |
| |
| import {AreaSelectionHandler} from './area_selection_handler'; |
| import {Controller} from './controller'; |
| |
| export function profileType(s: string): ProfileType { |
| if (isProfileType(s)) { |
| return s; |
| } |
| if (s.startsWith('heap_profile')) { |
| return ProfileType.HEAP_PROFILE; |
| } |
| throw new Error('Unknown type ${s}'); |
| } |
| |
| function isProfileType(s: string): s is ProfileType { |
| return Object.values(ProfileType).includes(s as ProfileType); |
| } |
| |
| function getFlamegraphType(type: ProfileType) { |
| switch (type) { |
| case ProfileType.HEAP_PROFILE: |
| case ProfileType.MIXED_HEAP_PROFILE: |
| case ProfileType.NATIVE_HEAP_PROFILE: |
| case ProfileType.JAVA_HEAP_SAMPLES: |
| return 'native'; |
| case ProfileType.JAVA_HEAP_GRAPH: |
| return 'graph'; |
| case ProfileType.PERF_SAMPLE: |
| return 'perf'; |
| default: |
| const exhaustiveCheck: never = type; |
| throw new Error(`Unhandled case: ${exhaustiveCheck}`); |
| } |
| } |
| |
| export interface FlamegraphControllerArgs { |
| engine: Engine; |
| } |
| const MIN_PIXEL_DISPLAYED = 1; |
| |
| class TablesCache { |
| private engine: Engine; |
| private cache: Map<string, string>; |
| private prefix: string; |
| private tableId: number; |
| private cacheSizeLimit: number; |
| |
| constructor(engine: Engine, prefix: string) { |
| this.engine = engine; |
| this.cache = new Map<string, string>(); |
| this.prefix = prefix; |
| this.tableId = 0; |
| this.cacheSizeLimit = 10; |
| } |
| |
| async getTableName(query: string): Promise<string> { |
| let tableName = this.cache.get(query); |
| if (tableName === undefined) { |
| // TODO(hjd): This should be LRU. |
| if (this.cache.size > this.cacheSizeLimit) { |
| for (const name of this.cache.values()) { |
| await this.engine.query(`drop table ${name}`); |
| } |
| this.cache.clear(); |
| } |
| tableName = `${this.prefix}_${this.tableId++}`; |
| await this.engine.query( |
| `create temp table if not exists ${tableName} as ${query}`, |
| ); |
| this.cache.set(query, tableName); |
| } |
| return tableName; |
| } |
| |
| hasQuery(query: string): boolean { |
| return this.cache.get(query) !== undefined; |
| } |
| } |
| |
| export class FlamegraphController extends Controller<'main'> { |
| private flamegraphDatasets: Map<string, CallsiteInfo[]> = new Map(); |
| private lastSelectedFlamegraphState?: FlamegraphState; |
| private requestingData = false; |
| private queuedRequest = false; |
| private flamegraphDetails: FlamegraphDetails = {}; |
| private areaSelectionHandler: AreaSelectionHandler; |
| private cache: TablesCache; |
| |
| constructor(private args: FlamegraphControllerArgs) { |
| super('main'); |
| this.cache = new TablesCache(args.engine, 'grouped_callsites'); |
| this.areaSelectionHandler = new AreaSelectionHandler(); |
| } |
| |
| run() { |
| const [hasAreaChanged, area] = this.areaSelectionHandler.getAreaChange(); |
| if (hasAreaChanged) { |
| const upids = []; |
| if (!area) { |
| this.checkCompletionAndPublishFlamegraph({ |
| ...globals.flamegraphDetails, |
| isInAreaSelection: false, |
| }); |
| return; |
| } |
| for (const trackId of area.tracks) { |
| const track = globals.state.tracks[trackId]; |
| if (track?.uri) { |
| const trackInfo = globals.trackManager.resolveTrackInfo(track.uri); |
| if (trackInfo?.kind === PERF_SAMPLES_PROFILE_TRACK_KIND) { |
| exists(trackInfo.upid) && upids.push(trackInfo.upid); |
| } |
| } |
| } |
| if (upids.length === 0) { |
| this.checkCompletionAndPublishFlamegraph({ |
| ...globals.flamegraphDetails, |
| isInAreaSelection: false, |
| }); |
| return; |
| } |
| globals.dispatch( |
| Actions.openFlamegraph({ |
| upids, |
| start: area.start, |
| end: area.end, |
| type: ProfileType.PERF_SAMPLE, |
| viewingOption: defaultViewingOption(ProfileType.PERF_SAMPLE), |
| }), |
| ); |
| } |
| const selection = globals.state.currentFlamegraphState; |
| if (!selection || !this.shouldRequestData(selection)) { |
| return; |
| } |
| if (this.requestingData) { |
| this.queuedRequest = true; |
| return; |
| } |
| this.requestingData = true; |
| |
| this.assembleFlamegraphDetails(selection, area !== undefined); |
| } |
| |
| private async assembleFlamegraphDetails( |
| selection: FlamegraphState, |
| isInAreaSelection: boolean, |
| ) { |
| const selectedFlamegraphState = {...selection}; |
| const flamegraphMetadata = await this.getFlamegraphMetadata( |
| selection.type, |
| selectedFlamegraphState.start, |
| selectedFlamegraphState.end, |
| selectedFlamegraphState.upids, |
| ); |
| if (flamegraphMetadata !== undefined) { |
| Object.assign(this.flamegraphDetails, flamegraphMetadata); |
| } |
| |
| // TODO(hjd): Clean this up. |
| if ( |
| this.lastSelectedFlamegraphState && |
| this.lastSelectedFlamegraphState.focusRegex !== selection.focusRegex |
| ) { |
| this.flamegraphDatasets.clear(); |
| } |
| |
| this.lastSelectedFlamegraphState = {...selection}; |
| |
| const expandedCallsite = |
| selectedFlamegraphState.expandedCallsiteByViewingOption[ |
| selectedFlamegraphState.viewingOption |
| ]; |
| const expandedId = expandedCallsite ? expandedCallsite.id : -1; |
| const rootSize = expandedCallsite?.totalSize; |
| |
| const key = `${selectedFlamegraphState.upids};${selectedFlamegraphState.start};${selectedFlamegraphState.end}`; |
| |
| try { |
| const flamegraphData = await this.getFlamegraphData( |
| key, |
| /* eslint-disable @typescript-eslint/strict-boolean-expressions */ |
| selectedFlamegraphState.viewingOption /* eslint-enable */ |
| ? selectedFlamegraphState.viewingOption |
| : defaultViewingOption(selectedFlamegraphState.type), |
| selection.start, |
| selection.end, |
| selectedFlamegraphState.upids, |
| selectedFlamegraphState.type, |
| selectedFlamegraphState.focusRegex, |
| ); |
| if ( |
| flamegraphData !== undefined && |
| // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions |
| selection && |
| selection.kind === selectedFlamegraphState.kind && |
| selection.start === selectedFlamegraphState.start && |
| selection.end === selectedFlamegraphState.end |
| ) { |
| const expandedFlamegraphData = expandCallsites( |
| flamegraphData, |
| expandedId, |
| ); |
| this.prepareAndMergeCallsites( |
| expandedFlamegraphData, |
| this.lastSelectedFlamegraphState.viewingOption, |
| isInAreaSelection, |
| rootSize, |
| expandedCallsite, |
| ); |
| } |
| } finally { |
| this.requestingData = false; |
| if (this.queuedRequest) { |
| this.queuedRequest = false; |
| this.run(); |
| } |
| } |
| } |
| |
| private shouldRequestData(selection: FlamegraphState) { |
| return ( |
| selection.kind === 'FLAMEGRAPH_STATE' && |
| (this.lastSelectedFlamegraphState === undefined || |
| this.lastSelectedFlamegraphState.start !== selection.start || |
| this.lastSelectedFlamegraphState.end !== selection.end || |
| this.lastSelectedFlamegraphState.type !== selection.type || |
| !FlamegraphController.areArraysEqual( |
| this.lastSelectedFlamegraphState.upids, |
| selection.upids, |
| ) || |
| this.lastSelectedFlamegraphState.viewingOption !== |
| selection.viewingOption || |
| this.lastSelectedFlamegraphState.focusRegex !== selection.focusRegex || |
| this.lastSelectedFlamegraphState.expandedCallsiteByViewingOption[ |
| selection.viewingOption |
| ] !== |
| selection.expandedCallsiteByViewingOption[selection.viewingOption]) |
| ); |
| } |
| |
| private prepareAndMergeCallsites( |
| flamegraphData: CallsiteInfo[], |
| viewingOption: FlamegraphStateViewingOption, |
| isInAreaSelection: boolean, |
| rootSize?: number, |
| expandedCallsite?: CallsiteInfo, |
| ) { |
| this.flamegraphDetails.flamegraph = mergeCallsites( |
| flamegraphData, |
| this.getMinSizeDisplayed(flamegraphData, rootSize), |
| ); |
| this.flamegraphDetails.expandedCallsite = expandedCallsite; |
| this.flamegraphDetails.viewingOption = viewingOption; |
| this.flamegraphDetails.isInAreaSelection = isInAreaSelection; |
| this.checkCompletionAndPublishFlamegraph(this.flamegraphDetails); |
| } |
| |
| private async checkCompletionAndPublishFlamegraph( |
| flamegraphDetails: FlamegraphDetails, |
| ) { |
| flamegraphDetails.graphIncomplete = |
| ( |
| await this.args.engine.query(`select value from stats |
| where severity = 'error' and name = 'heap_graph_non_finalized_graph'`) |
| ).firstRow({value: NUM}).value > 0; |
| flamegraphDetails.graphLoading = false; |
| publishFlamegraphDetails(flamegraphDetails); |
| } |
| |
| async getFlamegraphData( |
| baseKey: string, |
| viewingOption: FlamegraphStateViewingOption, |
| start: time, |
| end: time, |
| upids: number[], |
| type: ProfileType, |
| focusRegex: string, |
| ): Promise<CallsiteInfo[]> { |
| let currentData: CallsiteInfo[]; |
| const key = `${baseKey}-${viewingOption}`; |
| if (this.flamegraphDatasets.has(key)) { |
| currentData = this.flamegraphDatasets.get(key)!; |
| } else { |
| publishFlamegraphDetails({ |
| ...globals.flamegraphDetails, |
| graphLoading: true, |
| }); |
| // Collecting data for drawing flamegraph for selected profile. |
| // Data needs to be in following format: |
| // id, name, parent_id, depth, total_size |
| const tableName = await this.prepareViewsAndTables( |
| start, |
| end, |
| upids, |
| type, |
| focusRegex, |
| viewingOption, |
| ); |
| currentData = await this.getFlamegraphDataFromTables( |
| tableName, |
| viewingOption, |
| focusRegex, |
| ); |
| this.flamegraphDatasets.set(key, currentData); |
| } |
| return currentData; |
| } |
| |
| async getFlamegraphDataFromTables( |
| tableName: string, |
| viewingOption: FlamegraphStateViewingOption, |
| focusRegex: string, |
| ) { |
| let orderBy = ''; |
| let totalColumnName: |
| | 'cumulativeSize' |
| | 'cumulativeAllocSize' |
| | 'cumulativeCount' |
| | 'cumulativeAllocCount' = 'cumulativeSize'; |
| let selfColumnName: 'size' | 'count' = 'size'; |
| // TODO(fmayer): Improve performance so this is no longer necessary. |
| // Alternatively consider collapsing frames of the same label. |
| const maxDepth = 100; |
| switch (viewingOption) { |
| case FlamegraphStateViewingOption.ALLOC_SPACE_MEMORY_ALLOCATED_KEY: |
| orderBy = `where cumulative_alloc_size > 0 and depth < ${maxDepth} order by depth, parent_id, |
| cumulative_alloc_size desc, name`; |
| totalColumnName = 'cumulativeAllocSize'; |
| selfColumnName = 'size'; |
| break; |
| case FlamegraphStateViewingOption.OBJECTS_ALLOCATED_NOT_FREED_KEY: |
| orderBy = `where cumulative_count > 0 and depth < ${maxDepth} order by depth, parent_id, |
| cumulative_count desc, name`; |
| totalColumnName = 'cumulativeCount'; |
| selfColumnName = 'count'; |
| break; |
| case FlamegraphStateViewingOption.OBJECTS_ALLOCATED_KEY: |
| orderBy = `where cumulative_alloc_count > 0 and depth < ${maxDepth} order by depth, parent_id, |
| cumulative_alloc_count desc, name`; |
| totalColumnName = 'cumulativeAllocCount'; |
| selfColumnName = 'count'; |
| break; |
| case FlamegraphStateViewingOption.PERF_SAMPLES_KEY: |
| case FlamegraphStateViewingOption.SPACE_MEMORY_ALLOCATED_NOT_FREED_KEY: |
| orderBy = `where cumulative_size > 0 and depth < ${maxDepth} order by depth, parent_id, |
| cumulative_size desc, name`; |
| totalColumnName = 'cumulativeSize'; |
| selfColumnName = 'size'; |
| break; |
| case FlamegraphStateViewingOption.DOMINATOR_TREE_OBJ_COUNT_KEY: |
| orderBy = `where depth < ${maxDepth} order by depth, |
| cumulativeCount desc, name`; |
| totalColumnName = 'cumulativeCount'; |
| selfColumnName = 'count'; |
| break; |
| case FlamegraphStateViewingOption.DOMINATOR_TREE_OBJ_SIZE_KEY: |
| orderBy = `where depth < ${maxDepth} order by depth, |
| cumulativeSize desc, name`; |
| totalColumnName = 'cumulativeSize'; |
| selfColumnName = 'size'; |
| break; |
| default: |
| const exhaustiveCheck: never = viewingOption; |
| throw new Error(`Unhandled case: ${exhaustiveCheck}`); |
| break; |
| } |
| |
| const callsites = await this.args.engine.query(` |
| SELECT |
| id as hash, |
| IFNULL(IFNULL(DEMANGLE(name), name), '[NULL]') as name, |
| IFNULL(parent_id, -1) as parentHash, |
| depth, |
| cumulative_size as cumulativeSize, |
| cumulative_alloc_size as cumulativeAllocSize, |
| cumulative_count as cumulativeCount, |
| cumulative_alloc_count as cumulativeAllocCount, |
| map_name as mapping, |
| size, |
| count, |
| IFNULL(source_file, '') as sourceFile, |
| IFNULL(line_number, -1) as lineNumber |
| from ${tableName} ${orderBy}`); |
| |
| const flamegraphData: CallsiteInfo[] = []; |
| const hashToindex: Map<number, number> = new Map(); |
| const it = callsites.iter({ |
| hash: NUM, |
| name: STR, |
| parentHash: NUM, |
| depth: NUM, |
| cumulativeSize: NUM, |
| cumulativeAllocSize: NUM, |
| cumulativeCount: NUM, |
| cumulativeAllocCount: NUM, |
| mapping: STR, |
| sourceFile: STR, |
| lineNumber: NUM, |
| size: NUM, |
| count: NUM, |
| }); |
| for (let i = 0; it.valid(); ++i, it.next()) { |
| const hash = it.hash; |
| let name = it.name; |
| const parentHash = it.parentHash; |
| const depth = it.depth; |
| const totalSize = it[totalColumnName]; |
| const selfSize = it[selfColumnName]; |
| const mapping = it.mapping; |
| const highlighted = |
| focusRegex !== '' && |
| name.toLocaleLowerCase().includes(focusRegex.toLocaleLowerCase()); |
| const parentId = hashToindex.has(+parentHash) |
| ? hashToindex.get(+parentHash)! |
| : -1; |
| |
| let location: string | undefined; |
| if (/[a-zA-Z]/i.test(it.sourceFile)) { |
| location = it.sourceFile; |
| if (it.lineNumber !== -1) { |
| location += `:${it.lineNumber}`; |
| } |
| } |
| |
| if (depth === maxDepth - 1) { |
| name += ' [tree truncated]'; |
| } |
| // 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: |
| hashToindex.set(hash, i); |
| |
| flamegraphData.push({ |
| id: i, |
| totalSize, |
| depth, |
| parentId, |
| name, |
| selfSize, |
| mapping, |
| merged: false, |
| highlighted, |
| location, |
| }); |
| } |
| return flamegraphData; |
| } |
| |
| private async prepareViewsAndTables( |
| start: time, |
| end: time, |
| upids: number[], |
| type: ProfileType, |
| focusRegex: string, |
| viewingOption: FlamegraphStateViewingOption, |
| ): Promise<string> { |
| const flamegraphType = getFlamegraphType(type); |
| if (type === ProfileType.PERF_SAMPLE) { |
| let upid: string; |
| let upidGroup: string; |
| if (upids.length > 1) { |
| upid = `NULL`; |
| upidGroup = `'${FlamegraphController.serializeUpidGroup(upids)}'`; |
| } else { |
| upid = `${upids[0]}`; |
| upidGroup = `NULL`; |
| } |
| return this.cache.getTableName( |
| `select id, name, map_name, parent_id, depth, cumulative_size, |
| cumulative_alloc_size, cumulative_count, cumulative_alloc_count, |
| size, alloc_size, count, alloc_count, source_file, line_number |
| from experimental_flamegraph( |
| '${flamegraphType}', |
| NULL, |
| '>=${start},<=${end}', |
| ${upid}, |
| ${upidGroup}, |
| '${focusRegex}' |
| )`, |
| ); |
| } |
| if ( |
| type === ProfileType.JAVA_HEAP_GRAPH && |
| isHeapGraphDominatorTreeViewingOption(viewingOption) |
| ) { |
| return this.cache.getTableName( |
| await this.loadHeapGraphDominatorTreeQuery(upids[0], end), |
| ); |
| } |
| return this.cache.getTableName( |
| `select id, name, map_name, parent_id, depth, cumulative_size, |
| cumulative_alloc_size, cumulative_count, cumulative_alloc_count, |
| size, alloc_size, count, alloc_count, source_file, line_number |
| from experimental_flamegraph( |
| '${flamegraphType}', |
| ${end}, |
| NULL, |
| ${upids[0]}, |
| NULL, |
| '${focusRegex}' |
| )`, |
| ); |
| } |
| |
| private async loadHeapGraphDominatorTreeQuery(upid: number, timestamp: time) { |
| const outputTableName = `heap_graph_type_dominated_${upid}_${timestamp}`; |
| const outputQuery = `SELECT * FROM ${outputTableName}`; |
| if (this.cache.hasQuery(outputQuery)) { |
| return outputQuery; |
| } |
| |
| this.args.engine.query(` |
| INCLUDE PERFETTO MODULE memory.heap_graph_dominator_tree; |
| |
| -- heap graph dominator tree with objects as nodes and all relavant |
| -- object self stats and dominated stats |
| CREATE PERFETTO TABLE _heap_graph_object_dominated AS |
| SELECT |
| node.id, |
| node.idom_id, |
| node.dominated_obj_count, |
| node.dominated_size_bytes + node.dominated_native_size_bytes AS dominated_size, |
| node.depth, |
| obj.type_id, |
| obj.root_type, |
| obj.self_size + obj.native_size AS self_size |
| FROM memory_heap_graph_dominator_tree node |
| JOIN heap_graph_object obj USING(id) |
| WHERE obj.upid = ${upid} AND obj.graph_sample_ts = ${timestamp} |
| -- required to accelerate the recursive cte below |
| ORDER BY idom_id; |
| |
| -- calculate for each object node in the dominator tree the |
| -- HASH(path of type_id's from the super root to the object) |
| CREATE PERFETTO TABLE _dominator_tree_path_hash AS |
| WITH RECURSIVE _tree_visitor(id, path_hash) AS ( |
| SELECT |
| id, |
| HASH( |
| CAST(type_id AS TEXT) || '-' || IFNULL(root_type, '') |
| ) AS path_hash |
| FROM _heap_graph_object_dominated |
| WHERE depth = 1 |
| UNION ALL |
| SELECT |
| child.id, |
| HASH(CAST(parent.path_hash AS TEXT) || '/' || CAST(type_id AS TEXT)) AS path_hash |
| FROM _heap_graph_object_dominated child |
| JOIN _tree_visitor parent ON child.idom_id = parent.id |
| ) |
| SELECT * from _tree_visitor |
| ORDER BY id; |
| |
| -- merge object nodes with the same path into one "class type node", so the |
| -- end result is a tree where nodes are identified by their types and the |
| -- dominator relationships are preserved. |
| CREATE PERFETTO TABLE ${outputTableName} AS |
| SELECT |
| map.path_hash as id, |
| COALESCE(cls.deobfuscated_name, cls.name, '[NULL]') || IIF( |
| node.root_type IS NOT NULL, |
| ' [' || node.root_type || ']', '' |
| ) AS name, |
| IFNULL(parent_map.path_hash, -1) AS parent_id, |
| node.depth - 1 AS depth, |
| sum(dominated_size) AS cumulative_size, |
| -1 AS cumulative_alloc_size, |
| sum(dominated_obj_count) AS cumulative_count, |
| -1 AS cumulative_alloc_count, |
| '' as map_name, |
| '' as source_file, |
| -1 as line_number, |
| sum(self_size) AS size, |
| count(*) AS count |
| FROM _heap_graph_object_dominated node |
| JOIN _dominator_tree_path_hash map USING(id) |
| LEFT JOIN _dominator_tree_path_hash parent_map ON node.idom_id = parent_map.id |
| JOIN heap_graph_class cls ON node.type_id = cls.id |
| GROUP BY map.path_hash, name, parent_id, depth, map_name, source_file, line_number; |
| |
| -- These are intermediates and not needed |
| DROP TABLE _heap_graph_object_dominated; |
| DROP TABLE _dominator_tree_path_hash; |
| `); |
| |
| return outputQuery; |
| } |
| |
| getMinSizeDisplayed( |
| flamegraphData: CallsiteInfo[], |
| rootSize?: number, |
| ): number { |
| const timeState = globals.state.frontendLocalState.visibleState; |
| const dur = globals.stateVisibleTime().duration; |
| // TODO(stevegolton): Does this actually do what we want??? |
| let width = Duration.toSeconds(dur / timeState.resolution); |
| // TODO(168048193): Remove screen size hack: |
| width = Math.max(width, 800); |
| if (rootSize === undefined) { |
| rootSize = findRootSize(flamegraphData); |
| } |
| return (MIN_PIXEL_DISPLAYED * rootSize) / width; |
| } |
| |
| async getFlamegraphMetadata( |
| type: ProfileType, |
| start: time, |
| end: time, |
| upids: number[], |
| ): Promise<FlamegraphDetails | undefined> { |
| // Don't do anything if selection of the marker stayed the same. |
| if ( |
| this.lastSelectedFlamegraphState !== undefined && |
| this.lastSelectedFlamegraphState.start === start && |
| this.lastSelectedFlamegraphState.end === end && |
| FlamegraphController.areArraysEqual( |
| this.lastSelectedFlamegraphState.upids, |
| upids, |
| ) |
| ) { |
| return undefined; |
| } |
| |
| // Collecting data for more information about profile, such as: |
| // total memory allocated, memory that is allocated and not freed. |
| const upidGroup = FlamegraphController.serializeUpidGroup(upids); |
| |
| const result = await this.args.engine.query( |
| `select pid from process where upid in (${upidGroup})`, |
| ); |
| const it = result.iter({pid: NUM}); |
| const pids = []; |
| for (let i = 0; it.valid(); ++i, it.next()) { |
| pids.push(it.pid); |
| } |
| return {start, dur: end - start, pids, upids, type}; |
| } |
| |
| private static areArraysEqual(a: number[], b: number[]) { |
| if (a.length !== b.length) { |
| return false; |
| } |
| for (let i = 0; i < a.length; i++) { |
| if (a[i] !== b[i]) { |
| return false; |
| } |
| } |
| return true; |
| } |
| |
| private static serializeUpidGroup(upids: number[]) { |
| return new Array(upids).join(); |
| } |
| } |