diff --git a/docs-developer/CHANGELOG-formats.md b/docs-developer/CHANGELOG-formats.md index e07d501aa6..b59d5fe0e3 100644 --- a/docs-developer/CHANGELOG-formats.md +++ b/docs-developer/CHANGELOG-formats.md @@ -6,6 +6,10 @@ Note that this is not an exhaustive list. Processed profile format upgraders can ## Processed profile format +### Version 65 + +The stack table's `frame` column (stored at `profile.shared.stackTable.frame`) can now optionally be stored as an `Int32Array`, for profiles loaded from [JsonSlabs](https://github.com/mstange/json-slabs/) files (.jslb, .jslb.gz). Regular JS / JSON arrays are still accepted. + ### Version 64 A new `SourceLocationTable` has been added to `profile.shared.sourceLocationTable`. It holds the original (pre-compilation) source positions produced by source map symbolication, paired with the generated `line`/`column` already on `FrameTable`. diff --git a/src/app-logic/constants.ts b/src/app-logic/constants.ts index 6cba739b59..8d39768163 100644 --- a/src/app-logic/constants.ts +++ b/src/app-logic/constants.ts @@ -12,7 +12,7 @@ export const GECKO_PROFILE_VERSION = 34; // The current version of the "processed" profile format. // Please don't forget to update the processed profile format changelog in // `docs-developer/CHANGELOG-formats.md`. -export const PROCESSED_PROFILE_VERSION = 64; +export const PROCESSED_PROFILE_VERSION = 65; // The following are the margin sizes for the left and right of the timeline. Independent // components need to share these values. diff --git a/src/profile-logic/data-structures.ts b/src/profile-logic/data-structures.ts index 18f8ab12fc..0599e2153e 100644 --- a/src/profile-logic/data-structures.ts +++ b/src/profile-logic/data-structures.ts @@ -27,6 +27,8 @@ import type { CallNodeTable, SourceTable, SourceLocationTable, + IndexIntoFrameTable, + IndexIntoStackTable, } from 'firefox-profiler/types'; /** @@ -47,7 +49,13 @@ export function getEmptySamplesTable(): RawSamplesTable { }; } -export function getEmptyRawStackTable(): RawStackTable { +export type RawStackTableBuilder = { + frame: IndexIntoFrameTable[]; + prefix: Array; + length: number; +}; + +export function getRawStackTableBuilder(): RawStackTableBuilder { return { // Important! // If modifying this structure, please update all callers of this function to ensure @@ -59,6 +67,27 @@ export function getEmptyRawStackTable(): RawStackTable { }; } +export function getRawStackTableBuilderWithExistingContents( + existing: RawStackTable +): RawStackTableBuilder { + return { + frame: [...existing.frame], + prefix: [...existing.prefix], + length: existing.length, + }; +} + +export function finishRawStackTableBuilder( + builder: RawStackTableBuilder +): RawStackTable { + const { frame, prefix, length } = builder; + return { + frame: new Int32Array(frame), + prefix, + length, + }; +} + /** * Returns an empty samples table with eventDelay field instead of responsiveness. * eventDelay is a new field and it replaced responsiveness. We should still @@ -393,7 +422,7 @@ export function getEmptyThread(overrides?: Partial): RawThread { export function getEmptySharedData(): RawProfileSharedData { return { - stackTable: getEmptyRawStackTable(), + stackTable: finishRawStackTableBuilder(getRawStackTableBuilder()), frameTable: getEmptyFrameTable(), funcTable: getEmptyFuncTable(), resourceTable: getEmptyResourceTable(), diff --git a/src/profile-logic/global-data-collector.ts b/src/profile-logic/global-data-collector.ts index 78b5b38543..c52e170293 100644 --- a/src/profile-logic/global-data-collector.ts +++ b/src/profile-logic/global-data-collector.ts @@ -4,13 +4,14 @@ import { StringTable } from '../utils/string-table'; import { + finishRawStackTableBuilder, getEmptyFrameTable, getEmptyFuncTable, getEmptyNativeSymbolTable, - getEmptyRawStackTable, getEmptyResourceTable, getEmptySourceTable, getEmptySourceLocationTable, + getRawStackTableBuilder, } from './data-structures'; import type { @@ -22,7 +23,6 @@ import type { RawProfileSharedData, SourceTable, FrameTable, - RawStackTable, FuncTable, ResourceTable, NativeSymbolTable, @@ -34,6 +34,7 @@ import type { Bytes, } from 'firefox-profiler/types'; import { ResourceType } from 'firefox-profiler/types'; +import type { RawStackTableBuilder } from './data-structures'; /** * GlobalDataCollector collects data which is global in the processed profile @@ -50,7 +51,7 @@ export class GlobalDataCollector { _stringTable: StringTable = StringTable.withBackingArray(this._stringArray); _sources: SourceTable = getEmptySourceTable(); _frameTable: FrameTable = getEmptyFrameTable(); - _stackTable: RawStackTable = getEmptyRawStackTable(); + _stackTableBuilder: RawStackTableBuilder = getRawStackTableBuilder(); _funcTable: FuncTable = getEmptyFuncTable(); _resourceTable: ResourceTable = getEmptyResourceTable(); _nativeSymbols: NativeSymbolTable = getEmptyNativeSymbolTable(); @@ -302,15 +303,15 @@ export class GlobalDataCollector { return this._frameTable; } - getStackTable(): RawStackTable { - return this._stackTable; + getStackTableBuilder(): RawStackTableBuilder { + return this._stackTableBuilder; } // Package up all de-duplicated global tables so that they can be embedded in // the profile. finish(): { libs: Lib[]; shared: RawProfileSharedData } { const shared: RawProfileSharedData = { - stackTable: this._stackTable, + stackTable: finishRawStackTableBuilder(this._stackTableBuilder), frameTable: this._frameTable, funcTable: this._funcTable, resourceTable: this._resourceTable, diff --git a/src/profile-logic/import/chrome.ts b/src/profile-logic/import/chrome.ts index 96add0a137..be4253f0ce 100644 --- a/src/profile-logic/import/chrome.ts +++ b/src/profile-logic/import/chrome.ts @@ -501,7 +501,7 @@ async function processTracingEvents( const stringTable = globalDataCollector.getStringTable(); const frameTable = globalDataCollector.getFrameTable(); - const stackTable = globalDataCollector.getStackTable(); + const stackTable = globalDataCollector.getStackTableBuilder(); let profileEvents: (ProfileEvent | CpuProfileEvent)[] = (eventsByName.get( 'Profile' diff --git a/src/profile-logic/import/dhat.ts b/src/profile-logic/import/dhat.ts index dc56c06f17..6f883c2e29 100644 --- a/src/profile-logic/import/dhat.ts +++ b/src/profile-logic/import/dhat.ts @@ -183,7 +183,7 @@ export function attemptToConvertDhat(json: unknown): Profile | null { profile.meta.product = dhat.cmd + ' (dhat)'; profile.meta.importedFrom = `dhat`; const globalDataCollector = new GlobalDataCollector(); - const stackTable = globalDataCollector.getStackTable(); + const stackTable = globalDataCollector.getStackTableBuilder(); const frameTable = globalDataCollector.getFrameTable(); const stringTable = globalDataCollector.getStringTable(); diff --git a/src/profile-logic/import/flame-graph.ts b/src/profile-logic/import/flame-graph.ts index 148a777bd2..b468d4d008 100644 --- a/src/profile-logic/import/flame-graph.ts +++ b/src/profile-logic/import/flame-graph.ts @@ -60,7 +60,7 @@ export function convertFlameGraphProfile(profileText: string): Profile { }); const frameTable = globalDataCollector.getFrameTable(); - const stackTable = globalDataCollector.getStackTable(); + const stackTable = globalDataCollector.getStackTableBuilder(); const { samples } = thread; // Maps to deduplicate stacks, frames, and functions. diff --git a/src/profile-logic/import/simpleperf.ts b/src/profile-logic/import/simpleperf.ts index 89b632cd85..83c38d8929 100644 --- a/src/profile-logic/import/simpleperf.ts +++ b/src/profile-logic/import/simpleperf.ts @@ -24,7 +24,9 @@ import { getEmptyFuncTable, getEmptyResourceTable, getEmptyFrameTable, - getEmptyRawStackTable, + getRawStackTableBuilder, + finishRawStackTableBuilder, + type RawStackTableBuilder, getEmptySamplesTable, getEmptyRawMarkerTable, getEmptyNativeSymbolTable, @@ -189,7 +191,7 @@ class FirefoxFrameTable { class FirefoxSampleTable { strings: StringTable; - stackTable: RawStackTable = getEmptyRawStackTable(); + stackTable: RawStackTableBuilder = getRawStackTableBuilder(); stackMap: Map = new Map(); constructor(strings: StringTable) { @@ -197,7 +199,7 @@ class FirefoxSampleTable { } toJson(): RawStackTable { - return this.stackTable; + return finishRawStackTableBuilder(this.stackTable); } findOrAddStack( diff --git a/src/profile-logic/js-tracer.ts b/src/profile-logic/js-tracer.ts index 8993ddfbca..e4664aee95 100644 --- a/src/profile-logic/js-tracer.ts +++ b/src/profile-logic/js-tracer.ts @@ -4,6 +4,8 @@ import { getEmptySamplesTableWithEventDelay, getEmptyRawMarkerTable, + finishRawStackTableBuilder, + getRawStackTableBuilderWithExistingContents, } from './data-structures'; import { StringTable } from '../utils/string-table'; import { ensureExists } from '../utils/types'; @@ -512,7 +514,10 @@ export function convertJsTracerToThreadWithoutSamples( samples, }; - const { funcTable, frameTable, stackTable } = shared; + const { funcTable, frameTable } = shared; + const stackTable = getRawStackTableBuilderWithExistingContents( + shared.stackTable + ); // Keep a stack of js tracer events, and end timings, that will be used to find // the stack prefixes. Once a JS tracer event starts past another event end, the @@ -620,6 +625,9 @@ export function convertJsTracerToThreadWithoutSamples( unmatchedEventEnds[unmatchedIndex] = end; } + // Write the augmented stackTable back to the shared data. + shared.stackTable = finishRawStackTableBuilder(stackTable); + return { thread, stackMap }; } diff --git a/src/profile-logic/merge-compare.ts b/src/profile-logic/merge-compare.ts index 5eeecb1705..6e9e424b1b 100644 --- a/src/profile-logic/merge-compare.ts +++ b/src/profile-logic/merge-compare.ts @@ -13,7 +13,8 @@ import { getEmptyNativeSymbolTable, getEmptyFrameTable, getEmptyFuncTable, - getEmptyRawStackTable, + getRawStackTableBuilder, + finishRawStackTableBuilder, getEmptyRawMarkerTable, getEmptySamplesTableWithEventDelay, shallowCloneRawMarkerTable, @@ -1117,7 +1118,7 @@ function mergeStackTables( translationMapsForFrames: TranslationMapForFrames[] ): { stackTable: RawStackTable; translationMaps: TranslationMapForStacks[] } { const translationMaps: TranslationMapForStacks[] = []; - const newStackTable = getEmptyRawStackTable(); + const newStackTable = getRawStackTableBuilder(); profiles.forEach((profile, profileIndex) => { const { stackTable } = profile.shared; @@ -1143,7 +1144,10 @@ function mergeStackTables( translationMaps.push(oldStackToNewStackPlusOne); }); - return { stackTable: newStackTable, translationMaps }; + return { + stackTable: finishRawStackTableBuilder(newStackTable), + translationMaps, + }; } /** diff --git a/src/profile-logic/process-profile.ts b/src/profile-logic/process-profile.ts index 712dda0c52..d1f96c4c90 100644 --- a/src/profile-logic/process-profile.ts +++ b/src/profile-logic/process-profile.ts @@ -16,6 +16,7 @@ import { getEmptyRawMarkerTable, getEmptyJsAllocationsTable, getEmptyUnbalancedNativeAllocationsTable, + type RawStackTableBuilder, } from './data-structures'; import { immutableUpdate, ensureExists } from '../utils/types'; import { verifyMagic, SIMPLEPERF as SIMPLEPERF_MAGIC } from '../utils/magic'; @@ -53,7 +54,6 @@ import type { FrameTable, RawCounterSamplesTable, RawSamplesTable, - RawStackTable, RawMarkerTable, LibMapping, IndexIntoStackTable, @@ -107,6 +107,7 @@ import type { CounterDisplayConfig, } from 'firefox-profiler/types'; import { decompress, isGzip } from 'firefox-profiler/utils/gz'; +import { jsonEncodeObjectWithTypedArraysAsRegularArrays } from 'firefox-profiler/utils/json-with-typed-arrays'; type RegExpResult = null | string[]; /** @@ -548,7 +549,7 @@ function _processFrameTable( */ function _processStackTable( geckoStackTable: GeckoStackStruct, - sharedStackTable: RawStackTable, + sharedStackTable: RawStackTableBuilder, frameIndexOffset: IndexIntoFrameTable ): IndexIntoStackTable { const stackIndexOffset = sharedStackTable.length; @@ -1336,7 +1337,7 @@ function _processThread( ); const stackIndexOffset = _processStackTable( geckoStackTable, - globalDataCollector.getStackTable(), + globalDataCollector.getStackTableBuilder(), frameIndexOffset ); @@ -2056,7 +2057,7 @@ export function processGeckoProfile(geckoProfile: GeckoProfile): Profile { * Take a processed profile and convert it to a string. */ export function serializeProfileToJsonString(profile: Profile): string { - return JSON.stringify(profile); + return jsonEncodeObjectWithTypedArraysAsRegularArrays(profile); } /** diff --git a/src/profile-logic/processed-profile-versioning.ts b/src/profile-logic/processed-profile-versioning.ts index bcf3931179..16ab69e409 100644 --- a/src/profile-logic/processed-profile-versioning.ts +++ b/src/profile-logic/processed-profile-versioning.ts @@ -3255,6 +3255,11 @@ const _upgraders: { }; sources.content = new Array(sources.length).fill(null); }, + [65]: (_profile: any) => { + // The type of `profile.shared.stackTable.frame` was changed from + // `IndexIntoFrameTable[]` to `IndexIntoFrameTable[] | Int32Array`. + // All valid v64 profiles are valid v65 profiles, so no upgrader is needed. + }, // If you add a new upgrader here, please document the change in // `docs-developer/CHANGELOG-formats.md`. }; diff --git a/src/profile-logic/profile-compacting.ts b/src/profile-logic/profile-compacting.ts index 492f914716..e7300a3551 100644 --- a/src/profile-logic/profile-compacting.ts +++ b/src/profile-logic/profile-compacting.ts @@ -48,15 +48,22 @@ type ColumnDescription = null extends ( | { type: 'INDEX_REF_OR_NULL'; referencedTable: TableCompactionState } | { type: 'SELF_INDEX_REF_OR_NULL' } | { type: 'NO_REF' } - : - | { type: 'INDEX_REF'; referencedTable: TableCompactionState } - | { type: 'INDEX_REF_OR_NEG_ONE'; referencedTable: TableCompactionState } - | { type: 'NO_REF' }; + : Int32Array extends TCol + ? + | { type: 'INDEX_REF_INT32'; referencedTable: TableCompactionState } + | { type: 'NO_REF' } + : + | { type: 'INDEX_REF'; referencedTable: TableCompactionState } + | { + type: 'INDEX_REF_OR_NEG_ONE'; + referencedTable: TableCompactionState; + } + | { type: 'NO_REF' }; type TableDescription = { - [K in keyof T as T[K] extends Array ? K : never]: ColumnDescription< - T[K] - >; + [K in keyof T as T[K] extends Array | Int32Array + ? K + : never]: ColumnDescription; }; const ColDesc = { @@ -64,6 +71,10 @@ const ColDesc = { type: 'INDEX_REF' as const, referencedTable, }), + indexRefInt32: (referencedTable: TableCompactionState) => ({ + type: 'INDEX_REF_INT32' as const, + referencedTable, + }), indexRefOrNull: (referencedTable: TableCompactionState) => ({ type: 'INDEX_REF_OR_NULL' as const, referencedTable, @@ -145,7 +156,7 @@ export function computeCompactedProfile( }; const stackTableDesc: TableDescription = { - frame: ColDesc.indexRef(tcs.frameTable), + frame: ColDesc.indexRefInt32(tcs.frameTable), prefix: ColDesc.selfIndexRefOrNull(), }; const frameTableDesc: TableDescription = { @@ -326,6 +337,7 @@ function _markTableAndComputeTranslation( const col = (table as any)[key]; switch (desc.type) { case 'INDEX_REF': + case 'INDEX_REF_INT32': markColumn(col, markBuffer, desc.referencedTable.markBuffer); break; case 'INDEX_REF_OR_NULL': @@ -354,7 +366,13 @@ function _markTableAndComputeTranslation( thisTableCompactionState.computeIndexTranslation(); } -function markColumn(col: Array, shouldMark: BitSet, markBuf: BitSet) { +function markColumn( + col: Array | Int32Array, + shouldMark: BitSet, + markBuf: BitSet +) { + // Polymorphic: indexing works the same on Int32Array as on number[], so the + // INDEX_REF and INDEX_REF_INT32 cases share this function. for (let i = 0; i < col.length; i++) { if (checkBit(shouldMark, i)) { const val = col[i]; @@ -499,6 +517,14 @@ function _compactTable( newLength ); break; + case 'INDEX_REF_INT32': + result[key] = _compactColIndexInt32( + oldCol, + markBuffer, + desc.referencedTable.oldIndexToNewIndexPlusOne, + newLength + ); + break; case 'INDEX_REF_OR_NULL': result[key] = _compactColIndexOrNull( oldCol, @@ -564,6 +590,22 @@ function _compactColIndex( return newCol; } +function _compactColIndexInt32( + oldCol: Int32Array, + markBuffer: BitSet, + oldIndexToNewIndexPlusOne: Int32Array, + newLength: number +): Int32Array { + const newCol = new Int32Array(newLength); + let newIndex = 0; + for (let i = 0; i < oldCol.length; i++) { + if (checkBit(markBuffer, i)) { + newCol[newIndex++] = oldIndexToNewIndexPlusOne[oldCol[i]] - 1; + } + } + return newCol; +} + function _compactColIndexOrNull( oldCol: (number | null)[], markBuffer: BitSet, diff --git a/src/profile-logic/profile-data.ts b/src/profile-logic/profile-data.ts index 787fdc7fda..3b6e424499 100644 --- a/src/profile-logic/profile-data.ts +++ b/src/profile-logic/profile-data.ts @@ -6,7 +6,8 @@ import memoize from 'memoize-immutable'; import MixedTupleMap from 'mixedtuplemap'; import { oneLine } from 'common-tags'; import { - getEmptyRawStackTable, + getRawStackTableBuilder, + finishRawStackTableBuilder, getEmptyCallNodeTable, shallowCloneFrameTable, shallowCloneFuncTable, @@ -1688,7 +1689,7 @@ export function createStackTableBySkippingDiscarded( keepStack: BitSet ): StackTable { const newStackCount = newPrefixCol.length; - const newFrameCol = new Array(newStackCount); + const newFrameCol = new Int32Array(newStackCount); const newCategoryCol = new Uint8Array(newStackCount); const newSubcategoryCol = stackTable.subcategory instanceof Uint16Array @@ -4343,7 +4344,7 @@ export function nudgeReturnAddresses(profile: Profile): Profile { // Now the frame table contains adjusted / "nudged" addresses. // Make a new stack table which refers to the adjusted frames. - const newStackTable = getEmptyRawStackTable(); + const newStackTable = getRawStackTableBuilder(); const mapForSamplingSelfStacks = new Map< null | IndexIntoStackTable, null | IndexIntoStackTable @@ -4385,7 +4386,7 @@ export function nudgeReturnAddresses(profile: Profile): Profile { const newShared: RawProfileSharedData = { ...profile.shared, frameTable: newFrameTable, - stackTable: newStackTable, + stackTable: finishRawStackTableBuilder(newStackTable), }; const newThreads = updateRawThreadStacksSeparate( @@ -4764,8 +4765,14 @@ export function computeStackTableFromRawStackTable( subcategoryColumn[stackIndex] = stackSubcategory; } + // The frame column is a typed array in the derived stack table. + const frame = + rawStackTable.frame instanceof Int32Array + ? rawStackTable.frame + : new Int32Array(rawStackTable.frame); + return { - frame: rawStackTable.frame, + frame, category: categoryColumn, subcategory: subcategoryColumn, prefix: rawStackTable.prefix, diff --git a/src/profile-logic/symbolication.ts b/src/profile-logic/symbolication.ts index a6370bc6fa..dfbfd345c2 100644 --- a/src/profile-logic/symbolication.ts +++ b/src/profile-logic/symbolication.ts @@ -2,7 +2,8 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import { - getEmptyRawStackTable, + getRawStackTableBuilder, + finishRawStackTableBuilder, shallowCloneFuncTable, shallowCloneNativeSymbolTable, shallowCloneFrameTable, @@ -420,7 +421,7 @@ function _computeStackTableWithAddedExpansionStacks( if (frameIndexToInlineExpansionFrames.size === 0) { return null; } - const newStackTable = getEmptyRawStackTable(); + const newStackTable = getRawStackTableBuilder(); const oldStackToNewStack = new Int32Array(stackTable.length); for (let stack = 0; stack < stackTable.length; stack++) { const oldFrame = stackTable.frame[stack]; @@ -452,7 +453,10 @@ function _computeStackTableWithAddedExpansionStacks( } oldStackToNewStack[stack] = prefix ?? -1; } - return { newStackTable, oldStackToNewStack }; + return { + newStackTable: finishRawStackTableBuilder(newStackTable), + oldStackToNewStack, + }; } /** diff --git a/src/test/components/Root-history.test.tsx b/src/test/components/Root-history.test.tsx index 13a130aa71..adacbd6510 100644 --- a/src/test/components/Root-history.test.tsx +++ b/src/test/components/Root-history.test.tsx @@ -13,6 +13,7 @@ import { autoMockCanvasContext } from '../fixtures/mocks/canvas-context'; import { fireFullClick } from '../fixtures/utils'; import { getProfileUrlForHash } from '../../utils/profile-fetch'; import { blankStore } from '../fixtures/stores'; +import { serializeProfileToJsonString } from '../../profile-logic/process-profile'; import { getProfileFromTextSamples } from '../fixtures/profiles/processed-profile'; import { autoMockFullNavigation, @@ -198,5 +199,5 @@ describe('Root with history', function () { function mockFetchProfileAtUrl(url: string, profile: Profile): void { window.fetchMock .catch(404) // catchall - .get(url, profile); + .get(url, serializeProfileToJsonString(profile)); } diff --git a/src/test/components/UrlManager.test.tsx b/src/test/components/UrlManager.test.tsx index 86d9f4ef3d..e5811d062a 100644 --- a/src/test/components/UrlManager.test.tsx +++ b/src/test/components/UrlManager.test.tsx @@ -18,6 +18,7 @@ import { waitUntilState } from '../fixtures/utils'; import { createGeckoProfile } from '../fixtures/profiles/gecko-profile'; import { getProfileFromTextSamples } from '../fixtures/profiles/processed-profile'; import { CURRENT_URL_VERSION } from '../../app-logic/url-handling'; +import { serializeProfileToJsonString } from '../../profile-logic/process-profile'; import { autoMockFullNavigation } from '../fixtures/mocks/window-navigation'; import { profilePublished } from 'firefox-profiler/actions/publish'; import { @@ -35,7 +36,7 @@ describe('UrlManager', function () { autoMockFullNavigation(); function getSerializableProfile() { - return getProfileFromTextSamples('A').profile; + return serializeProfileToJsonString(getProfileFromTextSamples('A').profile); } function setup(urlPath?: string) { diff --git a/src/test/fixtures/profiles/call-nodes.ts b/src/test/fixtures/profiles/call-nodes.ts index 909b48e7e7..0ffed2dd52 100644 --- a/src/test/fixtures/profiles/call-nodes.ts +++ b/src/test/fixtures/profiles/call-nodes.ts @@ -6,7 +6,8 @@ import type { FuncTable, FrameTable, Profile } from 'firefox-profiler/types'; import { getEmptyThread, getEmptyProfile, - getEmptyRawStackTable, + getRawStackTableBuilder, + finishRawStackTableBuilder, } from '../../../profile-logic/data-structures'; import { StringTable } from '../../../utils/string-table'; @@ -87,7 +88,7 @@ export default function getProfile(): Profile { length: frameFuncs.length, }; - const stackTable = getEmptyRawStackTable(); + const stackTable = getRawStackTableBuilder(); // Provide a utility function for readability. function addToStackTable(frame: any, prefix: any) { @@ -119,7 +120,12 @@ export default function getProfile(): Profile { length: 2, }; - profile.shared = { ...profile.shared, stackTable, funcTable, frameTable }; + profile.shared = { + ...profile.shared, + stackTable: finishRawStackTableBuilder(stackTable), + funcTable, + frameTable, + }; profile.threads.push(thread, { ...thread }, { ...thread }); diff --git a/src/test/fixtures/profiles/processed-profile.ts b/src/test/fixtures/profiles/processed-profile.ts index 07ef1d7bab..708018511f 100644 --- a/src/test/fixtures/profiles/processed-profile.ts +++ b/src/test/fixtures/profiles/processed-profile.ts @@ -8,6 +8,8 @@ import { getEmptyJsAllocationsTable, getEmptyUnbalancedNativeAllocationsTable, getEmptyBalancedNativeAllocationsTable, + getRawStackTableBuilderWithExistingContents, + finishRawStackTableBuilder, } from '../../../profile-logic/data-structures'; import { mergeProfilesForDiffing } from '../../../profile-logic/merge-compare'; import { computeReferenceCPUDeltaPerMs } from '../../../profile-logic/cpu'; @@ -983,7 +985,7 @@ function _buildThreadFromTextOnlyStacks( } // Attempt to find a stack that satisfies the given frameIndex and prefix. - const stackTable = globalDataCollector.getStackTable(); + const stackTable = globalDataCollector.getStackTableBuilder(); let stackIndex; for (let i = 0; i < stackTable.length; i++) { if ( @@ -1994,8 +1996,10 @@ function getStackIndexForCallNodePath( /** * Use this function to add window id information to frames, using call node * paths to point to frames using stacks. + * This mutates `shared`. * * @param thread The thread to mutate. + * @param shared The shared profile data to mutate. * @param listOfOperations A list of pairs { innerWindowID, callNodes } * indicating which call nodes this innerWindowID will * be assigned to. @@ -2037,6 +2041,9 @@ export function addInnerWindowIdToStacks( IndexIntoStackTable >(); + const stackTableBuilder = + getRawStackTableBuilderWithExistingContents(stackTable); + for (const callNode of callNodesToDupe) { const stackIndex = getStackIndexForCallNodePath(shared, callNode); const foundFrameIndex = stackTable.frame[stackIndex]; @@ -2062,14 +2069,16 @@ export function addInnerWindowIdToStacks( frameTable.innerWindowID.push(listOfOperations[1].innerWindowID); // Clone the stack - const newStackIndex = stackTable.length++; - stackTable.prefix.push(stackTable.prefix[stackIndex]); + const newStackIndex = stackTableBuilder.length++; + stackTableBuilder.prefix.push(stackTable.prefix[stackIndex]); // Using the cloned frame index. - stackTable.frame.push(newFrameIndex); + stackTableBuilder.frame.push(newFrameIndex); mapStackIndexToDupe.set(stackIndex, newStackIndex); } + shared.stackTable = finishRawStackTableBuilder(stackTableBuilder); + const sampleTimes = ensureExists(samples.time); for (let sampleIndex = samples.length; sampleIndex >= 0; sampleIndex--) { // We're looping from the end because we'll push some samples to the end diff --git a/src/test/integration/profiler-edit/__snapshots__/profiler-edit.test.ts.snap b/src/test/integration/profiler-edit/__snapshots__/profiler-edit.test.ts.snap index 9e919e59d0..8ffda5719f 100644 --- a/src/test/integration/profiler-edit/__snapshots__/profiler-edit.test.ts.snap +++ b/src/test/integration/profiler-edit/__snapshots__/profiler-edit.test.ts.snap @@ -87,7 +87,7 @@ Object { "markerSchema": Array [], "oscpu": "macOS 14.6.1", "pausedRanges": Array [], - "preprocessedProfileVersion": 64, + "preprocessedProfileVersion": 65, "processType": 0, "product": "a.out", "sampleUnits": Object { @@ -1452,7 +1452,7 @@ Object { "markerSchema": Array [], "oscpu": "macOS 14.6.1", "pausedRanges": Array [], - "preprocessedProfileVersion": 64, + "preprocessedProfileVersion": 65, "processType": 0, "product": "a.out", "sampleUnits": Object { @@ -2817,7 +2817,7 @@ Object { "markerSchema": Array [], "oscpu": "macOS 14.6.1", "pausedRanges": Array [], - "preprocessedProfileVersion": 64, + "preprocessedProfileVersion": 65, "processType": 0, "product": "a.out", "sampleUnits": Object { @@ -4182,7 +4182,7 @@ Object { "markerSchema": Array [], "oscpu": "macOS 14.6.1", "pausedRanges": Array [], - "preprocessedProfileVersion": 64, + "preprocessedProfileVersion": 65, "processType": 0, "product": "a.out", "sampleUnits": Object { diff --git a/src/test/store/__snapshots__/profile-view.test.ts.snap b/src/test/store/__snapshots__/profile-view.test.ts.snap index c342a59f5a..0e69b4671b 100644 --- a/src/test/store/__snapshots__/profile-view.test.ts.snap +++ b/src/test/store/__snapshots__/profile-view.test.ts.snap @@ -423,7 +423,7 @@ Object { "oscpu": "", "physicalCPUs": 0, "platform": "", - "preprocessedProfileVersion": 64, + "preprocessedProfileVersion": 65, "processType": 0, "product": "Firefox", "sourceURL": "", @@ -676,7 +676,7 @@ Object { "startLine": Array [], }, "stackTable": Object { - "frame": Array [ + "frame": Int32Array [ 0, 1, 2, @@ -2721,7 +2721,7 @@ CallTree { 0, 0, ], - "frame": Array [ + "frame": Int32Array [ 0, 1, 2, @@ -3109,7 +3109,7 @@ Object { 0, 0, ], - "frame": Array [ + "frame": Int32Array [ 0, 1, 2, @@ -3563,7 +3563,7 @@ Object { 0, 0, ], - "frame": Array [ + "frame": Int32Array [ 0, 1, 2, @@ -3949,7 +3949,7 @@ Object { 0, 0, ], - "frame": Array [ + "frame": Int32Array [ 0, 1, 2, @@ -4335,7 +4335,7 @@ Object { 0, 0, ], - "frame": Array [ + "frame": Int32Array [ 0, 1, 2, diff --git a/src/test/store/receive-profile.test.ts b/src/test/store/receive-profile.test.ts index f480f467c9..7a660fa865 100644 --- a/src/test/store/receive-profile.test.ts +++ b/src/test/store/receive-profile.test.ts @@ -879,7 +879,10 @@ describe('actions/receive-profile', function () { const hash = 'c5e53f9ab6aecef926d4be68c84f2de550e2ac2f'; const expectedUrl = `https://storage.googleapis.com/profile-store/${hash}`; - window.fetchMock.get(expectedUrl, _getSimpleProfile()); + window.fetchMock.get( + expectedUrl, + serializeProfileToJsonString(_getSimpleProfile()) + ); const store = blankStore(); await store.dispatch(retrieveProfileFromStore(hash)); @@ -939,7 +942,7 @@ describe('actions/receive-profile', function () { const expectedUrl = `https://storage.googleapis.com/profile-store/${hash}`; window.fetchMock .getOnce(expectedUrl, 403) - .get(expectedUrl, _getSimpleProfile()); + .get(expectedUrl, serializeProfileToJsonString(_getSimpleProfile())); const store = blankStore(); const views = ( @@ -1026,7 +1029,10 @@ describe('actions/receive-profile', function () { it('can retrieve a profile from the web and save it to state', async function () { const expectedUrl = 'https://profiles.club/shared.json'; - window.fetchMock.get(expectedUrl, _getSimpleProfile()); + window.fetchMock.get( + expectedUrl, + serializeProfileToJsonString(_getSimpleProfile()) + ); const store = blankStore(); await store.dispatch(retrieveProfileOrZipFromUrl(expectedUrl)); @@ -1063,7 +1069,7 @@ describe('actions/receive-profile', function () { // The first call will still be a 403 -- remember, it's the default return value. window.fetchMock .getOnce(expectedUrl, 403) - .get(expectedUrl, _getSimpleProfile()); + .get(expectedUrl, serializeProfileToJsonString(_getSimpleProfile())); const store = blankStore(); const views = ( @@ -1526,7 +1532,9 @@ describe('actions/receive-profile', function () { ]) ); } - window.fetchMock.getOnce('*', profile1).getOnce('*', profile2); + window.fetchMock + .getOnce('*', serializeProfileToJsonString(profile1)) + .getOnce('*', serializeProfileToJsonString(profile2)); const { dispatch, getState } = blankStore(); await dispatch(retrieveProfilesToCompare([url1, url2])); @@ -1793,7 +1801,9 @@ describe('actions/receive-profile', function () { it('gives a fatal error when the selected thread index is out of bounds', async function () { const { dispatch, getState } = blankStore(); const { profile1, profile2 } = getSomeProfiles(); - window.fetchMock.getOnce('*', profile1).getOnce('*', profile2); + window.fetchMock + .getOnce('*', serializeProfileToJsonString(profile1)) + .getOnce('*', serializeProfileToJsonString(profile2)); await dispatch( retrieveProfilesToCompare([ @@ -1813,7 +1823,10 @@ describe('actions/receive-profile', function () { location: Partial, requiredProfile: number = 1 ) { - const profile = _getSimpleProfile(); + const simpleProfile = _getSimpleProfile(); + // Create a profile object we can use with fetchMock, which uses JSON.stringify internally. + // `simpleProfile` may contain typed arrays which wouldn't survive JSON.stringfy. + const profile = JSON.parse(serializeProfileToJsonString(simpleProfile)); const geckoProfile = createGeckoProfile(); // Add mock fetch response for the required number of times. diff --git a/src/test/unit/__snapshots__/profile-conversion.test.ts.snap b/src/test/unit/__snapshots__/profile-conversion.test.ts.snap index 1ef1824e9b..de79c9b9c5 100644 --- a/src/test/unit/__snapshots__/profile-conversion.test.ts.snap +++ b/src/test/unit/__snapshots__/profile-conversion.test.ts.snap @@ -591,7 +591,7 @@ Object { "oscpu": undefined, "physicalCPUs": undefined, "platform": undefined, - "preprocessedProfileVersion": 64, + "preprocessedProfileVersion": 65, "processType": 0, "product": "ART Trace (Android)", "sampleUnits": undefined, @@ -43096,7 +43096,7 @@ Object { "startLine": Array [], }, "stackTable": Object { - "frame": Array [ + "frame": Int32Array [ 0, 1, 2, @@ -83833,7 +83833,7 @@ Object { "oscpu": undefined, "physicalCPUs": undefined, "platform": undefined, - "preprocessedProfileVersion": 64, + "preprocessedProfileVersion": 65, "processType": 0, "product": "ART Trace (Android)", "sampleUnits": undefined, @@ -167262,7 +167262,7 @@ Object { "startLine": Array [], }, "stackTable": Object { - "frame": Array [ + "frame": Int32Array [ 0, 1, 2, @@ -329278,7 +329278,7 @@ Object { "oscpu": "", "physicalCPUs": 0, "platform": "", - "preprocessedProfileVersion": 64, + "preprocessedProfileVersion": 65, "processType": 0, "product": "Chrome Trace", "profilingEndTime": 119159778.026, @@ -329817,7 +329817,7 @@ Object { ], }, "stackTable": Object { - "frame": Array [ + "frame": Int32Array [ 0, 1, 2, @@ -364259,7 +364259,7 @@ Object { "oscpu": "", "physicalCPUs": 0, "platform": "", - "preprocessedProfileVersion": 64, + "preprocessedProfileVersion": 65, "processType": 0, "product": "Chrome Trace", "profilingEndTime": 119159778.026, @@ -364798,7 +364798,7 @@ Object { ], }, "stackTable": Object { - "frame": Array [ + "frame": Int32Array [ 0, 1, 2, @@ -399219,7 +399219,7 @@ Object { "oscpu": "", "physicalCPUs": 0, "platform": "", - "preprocessedProfileVersion": 64, + "preprocessedProfileVersion": 65, "processType": 0, "product": "Chrome Trace", "profilingEndTime": 66155012.423, @@ -400087,7 +400087,7 @@ Object { ], }, "stackTable": Object { - "frame": Array [ + "frame": Int32Array [ 0, 1, 2, @@ -401927,7 +401927,7 @@ Object { "oscpu": "", "physicalCPUs": 0, "platform": "", - "preprocessedProfileVersion": 64, + "preprocessedProfileVersion": 65, "processType": 0, "product": "Chrome Trace", "sourceURL": "", @@ -401993,7 +401993,7 @@ Object { "startLine": Array [], }, "stackTable": Object { - "frame": Array [], + "frame": Int32Array [], "length": 0, "prefix": Array [], }, @@ -403765,7 +403765,7 @@ Object { "oscpu": "", "physicalCPUs": 0, "platform": "", - "preprocessedProfileVersion": 64, + "preprocessedProfileVersion": 65, "processType": 0, "product": "Chrome Trace", "profilingEndTime": 355035987.653, @@ -406722,7 +406722,7 @@ Object { ], }, "stackTable": Object { - "frame": Array [ + "frame": Int32Array [ 0, 1, 2, @@ -407776,7 +407776,7 @@ Object { "oscpu": "", "physicalCPUs": 0, "platform": "", - "preprocessedProfileVersion": 64, + "preprocessedProfileVersion": 65, "processType": 0, "product": "Chrome Trace", "sourceURL": "", @@ -411314,7 +411314,7 @@ Object { ], }, "stackTable": Object { - "frame": Array [ + "frame": Int32Array [ 0, 1, 2, @@ -413399,7 +413399,7 @@ Object { "oscpu": "", "physicalCPUs": 0, "platform": "", - "preprocessedProfileVersion": 64, + "preprocessedProfileVersion": 65, "processType": 0, "product": "Chrome Trace", "profilingEndTime": 66155012.423, @@ -414267,7 +414267,7 @@ Object { ], }, "stackTable": Object { - "frame": Array [ + "frame": Int32Array [ 0, 1, 2, @@ -414544,7 +414544,7 @@ Object { "oscpu": undefined, "physicalCPUs": undefined, "platform": undefined, - "preprocessedProfileVersion": 64, + "preprocessedProfileVersion": 65, "processType": 0, "product": "Firefox", "sampleUnits": undefined, @@ -417285,7 +417285,7 @@ Object { "startLine": Array [], }, "stackTable": Object { - "frame": Array [ + "frame": Int32Array [ 0, 1, 2, @@ -418704,7 +418704,7 @@ Object { "oscpu": undefined, "physicalCPUs": undefined, "platform": undefined, - "preprocessedProfileVersion": 64, + "preprocessedProfileVersion": 65, "processType": 0, "product": "Firefox", "sampleUnits": undefined, @@ -429901,7 +429901,7 @@ Object { "startLine": Array [], }, "stackTable": Object { - "frame": Array [ + "frame": Int32Array [ 0, 1, 2, @@ -434833,7 +434833,7 @@ Object { "oscpu": undefined, "physicalCPUs": undefined, "platform": undefined, - "preprocessedProfileVersion": 64, + "preprocessedProfileVersion": 65, "processType": 0, "product": "Firefox", "sampleUnits": undefined, @@ -439076,7 +439076,7 @@ Object { "startLine": Array [], }, "stackTable": Object { - "frame": Array [ + "frame": Int32Array [ 0, 1, 2, @@ -441509,7 +441509,7 @@ Object { "oscpu": undefined, "physicalCPUs": undefined, "platform": undefined, - "preprocessedProfileVersion": 64, + "preprocessedProfileVersion": 65, "processType": 0, "product": "Firefox", "sampleUnits": undefined, @@ -454858,7 +454858,7 @@ Object { "startLine": Array [], }, "stackTable": Object { - "frame": Array [ + "frame": Int32Array [ 0, 1, 2, @@ -459410,7 +459410,7 @@ Object { "keepProfileThreadOrder": true, "markerSchema": Array [], "platform": "Android", - "preprocessedProfileVersion": 64, + "preprocessedProfileVersion": 65, "processType": 0, "product": "com.example.sampleapplication", "sourceCodeIsNotOnSearchfox": true, @@ -499592,7 +499592,7 @@ Object { "startLine": Array [], }, "stackTable": Object { - "frame": Array [ + "frame": Int32Array [ 0, 1, 2, @@ -521502,7 +521502,7 @@ Object { "keepProfileThreadOrder": true, "markerSchema": Array [], "platform": "Android", - "preprocessedProfileVersion": 64, + "preprocessedProfileVersion": 65, "processType": 0, "product": "com.example.sampleapplication", "sourceCodeIsNotOnSearchfox": true, @@ -559132,7 +559132,7 @@ Object { "startLine": Array [], }, "stackTable": Object { - "frame": Array [ + "frame": Int32Array [ 0, 1, 2, @@ -587927,7 +587927,7 @@ Object { "oscpu": "", "physicalCPUs": 0, "platform": "", - "preprocessedProfileVersion": 64, + "preprocessedProfileVersion": 65, "processType": 0, "product": "target/debug/examples/work_log (dhat)", "sourceURL": "", @@ -594273,7 +594273,7 @@ Object { ], }, "stackTable": Object { - "frame": Array [ + "frame": Int32Array [ 0, 56, 55, @@ -597177,7 +597177,7 @@ Object { "oscpu": "", "physicalCPUs": 0, "platform": "", - "preprocessedProfileVersion": 64, + "preprocessedProfileVersion": 65, "processType": 0, "product": "Flamegraph", "sourceURL": "", @@ -600033,7 +600033,7 @@ Object { "startLine": Array [], }, "stackTable": Object { - "frame": Array [ + "frame": Int32Array [ 0, 1, 2, @@ -905382,7 +905382,7 @@ Object { "oscpu": "", "physicalCPUs": 0, "platform": "", - "preprocessedProfileVersion": 64, + "preprocessedProfileVersion": 65, "processType": 0, "product": "Flamegraph", "sourceURL": "", @@ -905556,7 +905556,7 @@ Object { "startLine": Array [], }, "stackTable": Object { - "frame": Array [ + "frame": Int32Array [ 0, 1, 2, diff --git a/src/test/unit/__snapshots__/profile-upgrading.test.ts.snap b/src/test/unit/__snapshots__/profile-upgrading.test.ts.snap index bd079be0b9..3715a6c4fa 100644 --- a/src/test/unit/__snapshots__/profile-upgrading.test.ts.snap +++ b/src/test/unit/__snapshots__/profile-upgrading.test.ts.snap @@ -40,7 +40,7 @@ Object { "oscpu": undefined, "physicalCPUs": undefined, "platform": undefined, - "preprocessedProfileVersion": 64, + "preprocessedProfileVersion": 65, "processType": 0, "product": "Firefox", "sampleUnits": undefined, @@ -2781,7 +2781,7 @@ Object { "startLine": Array [], }, "stackTable": Object { - "frame": Array [ + "frame": Int32Array [ 0, 1, 2, @@ -7643,7 +7643,7 @@ Object { "misc": "rv:48.0", "oscpu": "Intel Mac OS X 10.11", "platform": "Macintosh", - "preprocessedProfileVersion": 64, + "preprocessedProfileVersion": 65, "processType": 0, "product": "Firefox", "stackwalk": 1, @@ -9019,7 +9019,7 @@ Object { "misc": "rv:48.0", "oscpu": "Intel Mac OS X 10.11", "platform": "Macintosh", - "preprocessedProfileVersion": 64, + "preprocessedProfileVersion": 65, "processType": 0, "product": "Firefox", "stackwalk": 1, @@ -10563,7 +10563,7 @@ Object { "misc": "rv:48.0", "oscpu": "Intel Mac OS X 10.11", "platform": "Macintosh", - "preprocessedProfileVersion": 64, + "preprocessedProfileVersion": 65, "processType": 0, "product": "Firefox", "stackwalk": 1, diff --git a/src/test/unit/json-with-typed-arrays.test.ts b/src/test/unit/json-with-typed-arrays.test.ts new file mode 100644 index 0000000000..2ba9a2f070 --- /dev/null +++ b/src/test/unit/json-with-typed-arrays.test.ts @@ -0,0 +1,129 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { jsonEncodeObjectWithTypedArraysAsRegularArrays as encode } from '../../utils/json-with-typed-arrays'; + +describe('jsonEncodeObjectWithTypedArraysAsRegularArrays', function () { + it('encodes primitives the same as JSON.stringify', function () { + expect(encode(42)).toBe('42'); + expect(encode('hello')).toBe('"hello"'); + expect(encode(null)).toBe('null'); + expect(encode(true)).toBe('true'); + }); + + it('encodes a plain object the same as JSON.stringify', function () { + const obj = { a: 1, b: 'two', c: [1, 2, 3], d: { nested: true } }; + expect(encode(obj)).toBe(JSON.stringify(obj)); + }); + + it('encodes a top-level typed array as a regular array of numbers', function () { + const arr = new Int32Array([1, 2, 3, 4]); + expect(encode(arr)).toBe('[1,2,3,4]'); + }); + + it('encodes an empty typed array as an empty array', function () { + expect(encode(new Uint8Array())).toBe('[]'); + }); + + it('handles all typed array variants', function () { + expect(encode(new Int8Array([1, -2]))).toBe('[1,-2]'); + expect(encode(new Uint8Array([1, 2]))).toBe('[1,2]'); + expect(encode(new Uint8ClampedArray([1, 2]))).toBe('[1,2]'); + expect(encode(new Int16Array([1, -2]))).toBe('[1,-2]'); + expect(encode(new Uint16Array([1, 2]))).toBe('[1,2]'); + expect(encode(new Int32Array([1, -2]))).toBe('[1,-2]'); + expect(encode(new Uint32Array([1, 2]))).toBe('[1,2]'); + expect(encode(new Float32Array([1.5]))).toBe('[1.5]'); + expect(encode(new Float64Array([1.5]))).toBe('[1.5]'); + }); + + it('encodes typed arrays nested inside an object', function () { + const obj = { + name: 'data', + values: new Int32Array([10, 20, 30]), + }; + expect(encode(obj)).toBe('{"name":"data","values":[10,20,30]}'); + }); + + it('encodes typed arrays nested inside an array', function () { + const arr = [1, new Uint8Array([2, 3]), 4]; + expect(encode(arr)).toBe('[1,[2,3],4]'); + }); + + it('encodes deeply nested typed arrays', function () { + const obj = { + a: { + b: { + c: [ + { d: new Float32Array([1.5, 2.5]) }, + { e: new Int16Array([7, 8]) }, + ], + }, + }, + }; + expect(encode(obj)).toBe('{"a":{"b":{"c":[{"d":[1.5,2.5]},{"e":[7,8]}]}}}'); + }); + + it('does not mutate the original object', function () { + const typedArr = new Int32Array([1, 2, 3]); + const inner = { values: typedArr, label: 'x' }; + const obj = { inner, count: 2 }; + const originalKeys = Object.keys(obj); + const innerKeys = Object.keys(inner); + + encode(obj); + + expect(obj.inner).toBe(inner); + expect(inner.values).toBe(typedArr); + expect(inner.label).toBe('x'); + expect(obj.count).toBe(2); + expect(Object.keys(obj)).toEqual(originalKeys); + expect(Object.keys(inner)).toEqual(innerKeys); + }); + + it('does not mutate the original array', function () { + const typedArr = new Int32Array([1, 2, 3]); + const arr = [1, typedArr, 3]; + + encode(arr); + + expect(arr[1]).toBe(typedArr); + expect(arr).toEqual([1, typedArr, 3]); + }); + + it('leaves DataView alone (encoded as a regular object)', function () { + // DataView is excluded from typed-array handling and falls through + // to the regular object path. JSON.stringify on a DataView produces "{}". + const buffer = new ArrayBuffer(8); + const view = new DataView(buffer); + expect(encode(view)).toBe(JSON.stringify(view)); + }); + + it('handles null values inside objects and arrays', function () { + const obj = { a: null, b: [null, 1, null], c: new Int32Array([5]) }; + expect(encode(obj)).toBe('{"a":null,"b":[null,1,null],"c":[5]}'); + }); + + it('handles an empty object and an empty array', function () { + expect(encode({})).toBe('{}'); + expect(encode([])).toBe('[]'); + }); + + it('preserves array element order with typed arrays interleaved', function () { + const arr = [ + new Uint8Array([1]), + 'middle', + new Uint8Array([2]), + 42, + new Uint8Array([3]), + ]; + expect(encode(arr)).toBe('[[1],"middle",[2],42,[3]]'); + }); + + it('encodes the same shared typed array twice when it appears in two places', function () { + const shared = new Int32Array([7, 8, 9]); + const obj = { first: shared, second: shared }; + expect(encode(obj)).toBe('{"first":[7,8,9],"second":[7,8,9]}'); + }); +}); diff --git a/src/types/profile-derived.ts b/src/types/profile-derived.ts index 75325be75c..89e7cfab68 100644 --- a/src/types/profile-derived.ts +++ b/src/types/profile-derived.ts @@ -29,7 +29,6 @@ import type { JsTracerTable, IndexIntoStackTable, WeightType, - IndexIntoFrameTable, SourceTable, IndexIntoSourceTable, CounterDisplayConfig, @@ -200,7 +199,8 @@ export type Counter = { * The `StackTable` type of the derived thread. * * The only difference from the `RawStackTable` is that the `StackTable` has a - * `category` and a `subcategory` column. + * `category` and a `subcategory` column, and that the `StackTable` always uses + * a typed array for the `frame` column. * * The category of a stack node is always non-null and is derived from a stack's * frame and its prefix. Frames can have null categories, stacks cannot. If a @@ -225,8 +225,7 @@ export type Counter = { * nsAttrAndChildArray::InsertChildAt stack before transforms are applied. */ export type StackTable = { - // Same as in RawStackTable - frame: IndexIntoFrameTable[]; + frame: Int32Array; prefix: Array; length: number; diff --git a/src/types/profile.ts b/src/types/profile.ts index ad504ac933..fd1ce620f8 100644 --- a/src/types/profile.ts +++ b/src/types/profile.ts @@ -60,7 +60,7 @@ export type Pid = string; * to storing them as actual lists of frames. */ export type RawStackTable = { - frame: IndexIntoFrameTable[]; + frame: IndexIntoFrameTable[] | Int32Array; prefix: Array; length: number; }; diff --git a/src/utils/json-with-typed-arrays.ts b/src/utils/json-with-typed-arrays.ts new file mode 100644 index 0000000000..9ab283f35c --- /dev/null +++ b/src/utils/json-with-typed-arrays.ts @@ -0,0 +1,81 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * JSON.stringify an object which may contain typed arrays somewhere in + * its structure (nested arbitrarily deeply), with those typed arrays + * serialized as regular arrays of numbers. + * + * Calling JSON.stringify on a typed array would normally give you something + * like `{"0": 1, "1": 2}` which is not what you want. + * + * This function does not mutate rootObject. + */ +export function jsonEncodeObjectWithTypedArraysAsRegularArrays( + rootObject: unknown +): string { + // We could use JSON.stringify with a "replacer" here. + // But instead, we do a full traversal of the object first, possibly + // creating new objects (so that the original doesn't get mutated), + // and then a regular JSON.stringify with no replacer. This is 5x faster. + return JSON.stringify(rewriteTypedArrays(rootObject)); +} + +function rewriteTypedArrays(value: unknown): unknown { + if (value === null || typeof value !== 'object') { + return value; + } + if (ArrayBuffer.isView(value) && !(value instanceof DataView)) { + // Found a typed array! Use Array.from to convert it to a regular + // array of numbers. + return Array.from(value as unknown as Iterable); + } + if (Array.isArray(value)) { + return rewriteTypedArraysInArray(value); + } + return rewriteTypedArraysInObject(value as Record); +} + +function rewriteTypedArraysInArray(arr: readonly unknown[]): unknown[] { + let result: unknown[] | null = null; + for (let i = 0; i < arr.length; i++) { + const el = arr[i]; + // Inline fast-path for primitives: an array of 1000 numbers walks this + // loop without any function call. + if (el === null || typeof el !== 'object') { + continue; + } + const replaced = rewriteTypedArrays(el); + if (replaced !== el) { + if (result === null) { + result = arr.slice(); + } + result[i] = replaced; + } + } + return result ?? (arr as unknown[]); +} + +function rewriteTypedArraysInObject( + obj: Record +): Record { + let result: Record | null = null; + for (const key in obj) { + if (!Object.hasOwn(obj, key)) { + continue; + } + const el = obj[key]; + if (el === null || typeof el !== 'object') { + continue; + } + const replaced = rewriteTypedArrays(el); + if (replaced !== el) { + if (result === null) { + result = { ...obj }; + } + result[key] = replaced; + } + } + return result ?? obj; +}