Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add option to record and preview CPU profile of the app #989

Merged
merged 11 commits into from
Mar 3, 2025
12 changes: 11 additions & 1 deletion packages/vscode-extension/src/common/Project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,14 @@ export type DeviceSettings = {
showTouches: boolean;
};

export type ToolState = {
enabled: boolean;
panelAvailable: boolean;
label: string;
};

export type ToolsState = {
[key: string]: { enabled: boolean; panelAvailable: boolean; label: string };
[key: string]: ToolState;
};

export type ProjectState = {
Expand Down Expand Up @@ -125,6 +131,7 @@ export interface ProjectEventMap {
needsNativeRebuild: void;
replayDataCreated: MultimediaData;
isRecording: boolean;
isProfilingCPU: boolean;
}

export interface ProjectEventListener<T> {
Expand Down Expand Up @@ -175,6 +182,9 @@ export interface ProjectInterface {
captureReplay(): void;
captureScreenshot(): void;

startProfilingCPU(): void;
stopProfilingCPU(): void;

dispatchTouches(touches: Array<TouchPoint>, type: "Up" | "Move" | "Down"): void;
dispatchKeyPress(keyCode: number, direction: "Up" | "Down"): void;
dispatchWheel(point: TouchPoint, deltaX: number, deltaY: number): void;
Expand Down
11 changes: 11 additions & 0 deletions packages/vscode-extension/src/debugging/CDPSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { BreakpointsController } from "./BreakpointsController";
import { VariableStore } from "./variableStore";
import { CDPDebuggerScope, CDPRemoteObject } from "./cdp";
import { typeToCategory } from "./DebugAdapter";
import { annotateLocations } from "./cpuProfiler";

type ResolveType<T = unknown> = (result: T) => void;
type RejectType = (error: unknown) => void;
Expand Down Expand Up @@ -489,4 +490,14 @@ export class CDPSession {
}

//#endregion

public async startProfiling() {
await this.sendCDPMessage("Profiler.start", {});
}

public async stopProfiling() {
const result = await this.sendCDPMessage("Profiler.stop", {});
const annotatedProfile = annotateLocations(result.profile, this.sourceMapRegistry);
return annotatedProfile;
}
}
22 changes: 20 additions & 2 deletions packages/vscode-extension/src/debugging/DebugAdapter.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import fs from "fs";
import path from "path";
import os from "os";
import { DebugConfiguration } from "vscode";
import {
DebugSession,
Expand Down Expand Up @@ -369,19 +372,34 @@ export class DebugAdapter extends DebugSession implements CDPSessionDelegate {
this.sendResponse(response);
}

protected customRequest(
protected async customRequest(
command: string,
response: DebugProtocol.Response,
args: any,
request?: DebugProtocol.Request | undefined
): void {
) {
switch (command) {
case "RNIDE_connect_cdp_debugger":
this.connectJSDebugger(args);
break;
case "RNIDE_log_message":
this.logCustomMessage(args.message, args.type, args.source);
break;
case "RNIDE_startProfiling":
if (this.cdpSession) {
await this.cdpSession.startProfiling();
this.sendEvent(new Event("RNIDE_profilingCPUStarted"));
}
break;
case "RNIDE_stopProfiling":
if (this.cdpSession) {
const profile = await this.cdpSession.stopProfiling();
const fileName = `profile-${Date.now()}.cpuprofile`;
const filePath = path.join(os.tmpdir(), fileName);
await fs.promises.writeFile(filePath, JSON.stringify(profile));
this.sendEvent(new Event("RNIDE_profilingCPUStopped", { filePath }));
}
break;
default:
Logger.debug(`Custom req ${command} ${args}`);
}
Expand Down
20 changes: 18 additions & 2 deletions packages/vscode-extension/src/debugging/DebugSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ export type DebugSessionDelegate = {
onConsoleLog(event: DebugSessionCustomEvent): void;
onDebuggerPaused(event: DebugSessionCustomEvent): void;
onDebuggerResumed(event: DebugSessionCustomEvent): void;
onProfilingCPUStarted(event: DebugSessionCustomEvent): void;
onProfilingCPUStopped(event: DebugSessionCustomEvent): void;
};

export type DebugSource = { filename?: string; line1based?: number; column0based?: number };
Expand All @@ -32,6 +34,12 @@ export class DebugSession implements Disposable {
case "RNIDE_continued":
this.delegate.onDebuggerResumed(event);
break;
case "RNIDE_profilingCPUStarted":
this.delegate.onProfilingCPUStarted(event);
break;
case "RNIDE_profilingCPUStopped":
this.delegate.onProfilingCPUStopped(event);
break;
default:
// ignore other events
break;
Expand Down Expand Up @@ -85,9 +93,9 @@ export class DebugSession implements Disposable {
this.vscSession && (await debug.stopDebugging(this.vscSession));
}

/**
/**
This method is async to allow for awaiting it during restarts, please keep in mind tho that
build in vscode dispose system ignores async keyword and works synchronously.
build in vscode dispose system ignores async keyword and works synchronously.
*/
public async dispose() {
this.vscSession && (await debug.stopDebugging(this.vscSession));
Expand Down Expand Up @@ -135,6 +143,14 @@ export class DebugSession implements Disposable {
this.session.customRequest("next");
}

public async startProfilingCPU() {
await this.session.customRequest("RNIDE_startProfiling");
}

public async stopProfilingCPU() {
await this.session.customRequest("RNIDE_stopProfiling");
}

private async connectCDPDebugger(cdpConfiguration: CDPConfiguration) {
await this.session.customRequest("RNIDE_connect_cdp_debugger", cdpConfiguration);
}
Expand Down
30 changes: 30 additions & 0 deletions packages/vscode-extension/src/debugging/cdp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,36 @@ export type CDPDebuggerScope = {
object: CDPRemoteObject & { type: "object" };
};

export type CDPProfile = {
nodes: CDPProfileNode[];
startTime: number;
endTime: number;
samples?: number[];
timeDeltas?: number[];
};

export type CDPProfileNode = {
id: number;
callFrame: CDPCallFrame;
hitCount?: number;
children?: number[];
deoptReason?: string;
positionTicks?: CDPPositionTick[];
};

export type CDPPositionTick = {
line: number; // 1-based according to the spec, WTF!
ticks: number;
};

export type CDPCallFrame = {
functionName: string;
scriptId: string;
url: string;
lineNumber: number; // 0-based
columnNumber: number; // 0-based
};

export function inferDAPVariableValueForCDPRemoteObject(cdpValue: CDPRemoteObject): string {
switch (cdpValue.type) {
case "undefined":
Expand Down
84 changes: 84 additions & 0 deletions packages/vscode-extension/src/debugging/cpuProfiler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { Source } from "@vscode/debugadapter";
import { CDPCallFrame, CDPProfile } from "./cdp";
import { SourceMapsRegistry } from "./SourceMapsRegistry";

type FrameKey = string;

type DAPAnnotationLocation = {
callFrame: CDPCallFrame;
locations: DAPSourceLocation[];
};

type DAPSourceLocation = {
lineNumber: number;
columnNumber: number;
source: Source;
relativePath?: string;
};

function callFrameKey(callFrame: CDPCallFrame): FrameKey {
return [callFrame.scriptId, callFrame.lineNumber, callFrame.columnNumber].join(":") as FrameKey;
}

/**
* Expands the providing CPU Profile data with VSCode specific fields that
* are used by the VSCode CPU Profile Visualizer extension. The additional fields
* are used to provide the original source location for each node in the profile.
*/
export function annotateLocations(profile: CDPProfile, sourceMapsRegistry: SourceMapsRegistry) {
let locationIdCounter = 0;
const locationIds = new Map<FrameKey, number>();
const locations: DAPAnnotationLocation[] = [];

const nodes = profile.nodes.map((node) => {
const key = callFrameKey(node.callFrame);
let locationId = locationIds.get(key);

if (!locationId) {
locationId = locationIdCounter++;
locationIds.set(key, locationId);

const origPosition = sourceMapsRegistry.findOriginalPosition(
// Apparently, hermes seems to report a different scriptId here – for a fresh app that just started,
// we get scriptId "2" for the main bundle, but in profile node's callFrame, "0" is reported.
// for this reasone, we rely on the script URL rather than scriptIde here, but we should revisit this
// to investigate if this is a bug in hermes or something that can be addressed.
node.callFrame.url,
node.callFrame.lineNumber + 1,
node.callFrame.columnNumber
);

if (origPosition.sourceURL !== "__source__") {
// TODO: we should find a better way to communicate that the source location cannot be resolved
locations.push({
callFrame: node.callFrame,
locations: [
{
lineNumber: origPosition.lineNumber1Based - 1,
columnNumber: origPosition.columnNumber0Based,
source: new Source(origPosition.sourceURL, origPosition.sourceURL),
},
],
});
} else {
locations.push({
callFrame: node.callFrame,
locations: [],
});
}
}
return {
...node,
locationId, // VScode specific field: https://github.com/microsoft/vscode-js-profile-visualizer/blob/main/packages/vscode-js-profile-core/src/cpu/types.ts#L12
};
});

return {
...profile,
nodes,
$vscode: {
// VSCode especific fields: https://github.com/microsoft/vscode-js-profile-visualizer/blob/main/packages/vscode-js-profile-core/src/cpu/types.ts#L20
locations: locations,
},
};
}
16 changes: 16 additions & 0 deletions packages/vscode-extension/src/project/deviceSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,22 @@ export class DeviceSession implements Disposable {
return this.device.captureScreenshot();
}

public async startProfilingCPU() {
if (this.debugSession) {
await this.debugSession.startProfilingCPU();
} else {
throw new Error("Debug session not started");
}
}

public async stopProfilingCPU() {
if (this.debugSession) {
await this.debugSession.stopProfilingCPU();
} else {
throw new Error("Debug session not started");
}
}

public sendTouches(touches: Array<TouchPoint>, type: "Up" | "Move" | "Down") {
this.device.sendTouches(touches, type);
}
Expand Down
73 changes: 73 additions & 0 deletions packages/vscode-extension/src/project/project.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
import { EventEmitter } from "stream";
import os from "os";
import path from "path";
import fs from "fs";
import {
env,
Disposable,
commands,
workspace,
window,
DebugSessionCustomEvent,
Uri,
extensions,
ConfigurationChangeEvent,
} from "vscode";
import _ from "lodash";
Expand Down Expand Up @@ -277,6 +281,75 @@ export class Project
return this.deviceSession.captureAndStopRecording();
}

async startProfilingCPU() {
if (this.deviceSession) {
await this.deviceSession.startProfilingCPU();
} else {
throw new Error("No device session available");
}
}

async stopProfilingCPU() {
if (this.deviceSession) {
await this.deviceSession.stopProfilingCPU();
} else {
throw new Error("No device session available");
}
}

onProfilingCPUStarted(event: DebugSessionCustomEvent): void {
this.eventEmitter.emit("isProfilingCPU", true);
}

async onProfilingCPUStopped(event: DebugSessionCustomEvent) {
this.eventEmitter.emit("isProfilingCPU", false);

// Handle the profile file if a file path is provided
if (event.body && event.body.filePath) {
const tempFilePath = event.body.filePath;

// Show save dialog to save the profile file to the workspace folder:
let defaultUri = Uri.file(tempFilePath);
const workspaceFolder = workspace.workspaceFolders?.[0];
if (workspaceFolder) {
defaultUri = Uri.file(path.join(workspaceFolder.uri.fsPath, path.basename(tempFilePath)));
}

const saveDialog = await window.showSaveDialog({
defaultUri,
filters: {
"CPU Profile": ["cpuprofile"],
},
});

if (saveDialog) {
await fs.promises.copyFile(tempFilePath, saveDialog.fsPath);
commands.executeCommand("vscode.open", Uri.file(saveDialog.fsPath));

// verify whether flame chart visualizer extension is installed
// flame chart visualizer is not necessary to open the cpuprofile file, but when it is installed,
// the user can use the flame button from cpuprofile view to visualize it differently
const flameChartExtension = extensions.getExtension("ms-vscode.vscode-js-profile-flame");
if (!flameChartExtension) {
const GO_TO_EXTENSION_BUTTON = "Go to Extension";
window
.showInformationMessage(
"Flame Chart Visualizer extension is not installed. It is recommended to install it for better profiling insights.",
GO_TO_EXTENSION_BUTTON
)
.then((action) => {
if (action === GO_TO_EXTENSION_BUTTON) {
commands.executeCommand(
"workbench.extensions.search",
"ms-vscode.vscode-js-profile-flame"
);
}
});
}
}
}
}

async captureAndStopRecording() {
const recording = await this.stopRecording();
await this.utils.saveMultimedia(recording);
Expand Down
Loading