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

[cpu-profile-summarizer] Add new CLI tool for summarizing many V8 CPU Profiles #5098

Merged
merged 2 commits into from
Feb 7, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ These GitHub repositories provide supplementary resources for Rush Stack:
| ------ | ------- | --------- | ------- |
| [/apps/api-documenter](./apps/api-documenter/) | [![npm version](https://badge.fury.io/js/%40microsoft%2Fapi-documenter.svg)](https://badge.fury.io/js/%40microsoft%2Fapi-documenter) | [changelog](./apps/api-documenter/CHANGELOG.md) | [@microsoft/api-documenter](https://www.npmjs.com/package/@microsoft/api-documenter) |
| [/apps/api-extractor](./apps/api-extractor/) | [![npm version](https://badge.fury.io/js/%40microsoft%2Fapi-extractor.svg)](https://badge.fury.io/js/%40microsoft%2Fapi-extractor) | [changelog](./apps/api-extractor/CHANGELOG.md) | [@microsoft/api-extractor](https://www.npmjs.com/package/@microsoft/api-extractor) |
| [/apps/cpu-profile-summarizer](./apps/cpu-profile-summarizer/) | [![npm version](https://badge.fury.io/js/%40rushstack%2Fcpu-profile-summarizer.svg)](https://badge.fury.io/js/%40rushstack%2Fcpu-profile-summarizer) | [changelog](./apps/cpu-profile-summarizer/CHANGELOG.md) | [@rushstack/cpu-profile-summarizer](https://www.npmjs.com/package/@rushstack/cpu-profile-summarizer) |
| [/apps/heft](./apps/heft/) | [![npm version](https://badge.fury.io/js/%40rushstack%2Fheft.svg)](https://badge.fury.io/js/%40rushstack%2Fheft) | [changelog](./apps/heft/CHANGELOG.md) | [@rushstack/heft](https://www.npmjs.com/package/@rushstack/heft) |
| [/apps/lockfile-explorer](./apps/lockfile-explorer/) | [![npm version](https://badge.fury.io/js/%40rushstack%2Flockfile-explorer.svg)](https://badge.fury.io/js/%40rushstack%2Flockfile-explorer) | [changelog](./apps/lockfile-explorer/CHANGELOG.md) | [@rushstack/lockfile-explorer](https://www.npmjs.com/package/@rushstack/lockfile-explorer) |
| [/apps/rundown](./apps/rundown/) | [![npm version](https://badge.fury.io/js/%40rushstack%2Frundown.svg)](https://badge.fury.io/js/%40rushstack%2Frundown) | [changelog](./apps/rundown/CHANGELOG.md) | [@rushstack/rundown](https://www.npmjs.com/package/@rushstack/rundown) |
Expand Down
21 changes: 21 additions & 0 deletions apps/cpu-profile-summarizer/.eslintrc.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// This is a workaround for https://github.com/eslint/eslint/issues/3458
require('local-node-rig/profiles/default/includes/eslint/patch/modern-module-resolution');
// This is a workaround for https://github.com/microsoft/rushstack/issues/3021
require('local-node-rig/profiles/default/includes/eslint/patch/custom-config-package-names');

module.exports = {
extends: [
'local-node-rig/profiles/default/includes/eslint/profile/node-trusted-tool',
'local-node-rig/profiles/default/includes/eslint/mixins/friendly-locals'
],
parserOptions: { tsconfigRootDir: __dirname },

overrides: [
{
files: ['*.ts', '*.tsx'],
rules: {
'no-console': 'off'
}
}
]
};
32 changes: 32 additions & 0 deletions apps/cpu-profile-summarizer/.npmignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# THIS IS A STANDARD TEMPLATE FOR .npmignore FILES IN THIS REPO.

# Ignore all files by default, to avoid accidentally publishing unintended files.
*

# Use negative patterns to bring back the specific things we want to publish.
!/bin/**
!/lib/**
!/lib-*/**
!/dist/**

!CHANGELOG.md
!CHANGELOG.json
!heft-plugin.json
!rush-plugin-manifest.json
!ThirdPartyNotice.txt

# Ignore certain patterns that should not get published.
/dist/*.stats.*
/lib/**/test/
/lib-*/**/test/
*.test.js

# NOTE: These don't need to be specified, because NPM includes them automatically.
#
# package.json
# README.md
# LICENSE

# ---------------------------------------------------------------------------
# DO NOT MODIFY ABOVE THIS LINE! Add any project-specific overrides below.
# ---------------------------------------------------------------------------
24 changes: 24 additions & 0 deletions apps/cpu-profile-summarizer/LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
@rushstack/cpu-profile-summarizer

Copyright (c) Microsoft Corporation. All rights reserved.

MIT License

Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:

The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
26 changes: 26 additions & 0 deletions apps/cpu-profile-summarizer/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# @rushstack/cpu-profile-summarizer

> 🚨 _EARLY PREVIEW RELEASE_ 🚨
>
> Not all features are implemented yet. To provide suggestions, please
> [create a GitHub issue](https://github.com/microsoft/rushstack/issues/new/choose).
> If you have questions, see the [Rush Stack Help page](https://rushstack.io/pages/help/support/)
> for support resources.

The `cpu-profile-summarizer` command line tool helps you:

- Collate self/total CPU usage statistics for an entire monorepo worth of V8 .cpuprofile files

## Usage

It's recommended to install this package globally:

```
# Install the NPM package
npm install -g @rushstack/cpu-profile-summarizer

# Process a folder of cpuprofile files into a summary tsv file
cpu-profile-summarizer --input FOLDER --output FILE.tsv
```

The output file is in the tab-separated values (tsv) format.
2 changes: 2 additions & 0 deletions apps/cpu-profile-summarizer/bin/cpu-profile-aggregator
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
#!/usr/bin/env node
require('../lib/start.js');
3 changes: 3 additions & 0 deletions apps/cpu-profile-summarizer/config/jest.config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"extends": "local-node-rig/profiles/default/config/jest.config.json"
}
7 changes: 7 additions & 0 deletions apps/cpu-profile-summarizer/config/rig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
// The "rig.json" file directs tools to look for their config files in an external package.
// Documentation for this system: https://www.npmjs.com/package/@rushstack/rig-package
"$schema": "https://developer.microsoft.com/json-schemas/rig-package/rig.schema.json",

"rigPackageName": "local-node-rig"
}
29 changes: 29 additions & 0 deletions apps/cpu-profile-summarizer/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
{
"name": "@rushstack/cpu-profile-summarizer",
"version": "0.0.0",
"description": "CLI tool for running analytics on multiple V8 .cpuprofile files",
"repository": {
"type": "git",
"url": "https://github.com/microsoft/rushstack.git",
"directory": "apps/cpu-profile-summarizer"
},
"bin": {
"cpu-profile-summarizer": "./bin/cpu-profile-summarizer"
},
"license": "MIT",
"scripts": {
"start": "node lib/start",
"build": "heft build --clean",
"_phase:build": "heft run --only build -- --clean",
"_phase:test": "heft run --only test -- --clean"
},
"dependencies": {
"@rushstack/ts-command-line": "workspace:*",
"@rushstack/worker-pool": "workspace:*"
},
"devDependencies": {
"@rushstack/heft": "workspace:*",
"local-node-rig": "workspace:*",
"typescript": "~5.4.2"
}
}
44 changes: 44 additions & 0 deletions apps/cpu-profile-summarizer/src/protocol.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
// See LICENSE in the project root for license information.

import type { IProfileSummary } from './types';

/**
* A message sent to a worker to process a file (or shutdown).
*/
export type IMessageToWorker = string | false;

/**
* A message sent from a worker to the main thread on success.
*/
export interface IWorkerSuccessMessage {
type: 'success';
/**
* The file requested to be processed.
*/
file: string;
/**
* The summary of the profile data.
*/
data: IProfileSummary;
}

/**
* A message sent from a worker to the main thread on error.
*/
export interface IWorkerErrorMessage {
type: 'error';
/**
* The file requested to be processed.
*/
file: string;
/**
* The error stack trace or message.
*/
data: string;
}

/**
* A message sent from a worker to the main thread.
*/
export type IMessageFromWorker = IWorkerSuccessMessage | IWorkerErrorMessage;
191 changes: 191 additions & 0 deletions apps/cpu-profile-summarizer/src/start.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
// See LICENSE in the project root for license information.

import { once } from 'node:events';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import type { Worker } from 'node:worker_threads';

import {
type CommandLineStringListParameter,
type IRequiredCommandLineStringParameter,
CommandLineParser
} from '@rushstack/ts-command-line';
import { WorkerPool } from '@rushstack/worker-pool';

import type { IMessageFromWorker } from './protocol';
import type { INodeSummary, IProfileSummary } from './types';

/**
* Merges summarized information from multiple profiles into a single collection.
* @param accumulator - The collection to merge the nodes into
* @param values - The nodes to merge
*/
function mergeProfileSummaries(
accumulator: Map<string, INodeSummary>,
values: Iterable<[string, INodeSummary]>
): void {
for (const [nodeId, node] of values) {
const existing: INodeSummary | undefined = accumulator.get(nodeId);
if (!existing) {
accumulator.set(nodeId, node);
} else {
existing.selfTime += node.selfTime;
existing.totalTime += node.totalTime;
}
}
}

/**
* Scans a directory and its subdirectories for CPU profiles.
* @param baseDir - The directory to recursively search for CPU profiles
* @returns All .cpuprofile files found in the directory and its subdirectories
*/
function findProfiles(baseDir: string): string[] {
baseDir = path.resolve(baseDir);

const files: string[] = [];
const directories: string[] = [baseDir];

for (const dir of directories) {
const entries: fs.Dirent[] = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isFile() && entry.name.endsWith('.cpuprofile')) {
files.push(`${dir}/${entry.name}`);
} else if (entry.isDirectory()) {
directories.push(`${dir}/${entry.name}`);
}
}
}

return files;
}

/**
* Processes a set of CPU profiles and aggregates the results.
* Uses a worker pool.
* @param profiles - The set of .cpuprofile files to process
* @returns A summary of the profiles
*/
async function processProfilesAsync(profiles: Set<string>): Promise<IProfileSummary> {
const maxWorkers: number = Math.min(profiles.size, os.availableParallelism());
console.log(`Processing ${profiles.size} profiles using ${maxWorkers} workers...`);
const workerPool: WorkerPool = new WorkerPool({
id: 'cpu-profile-summarizer',
maxWorkers,
workerScriptPath: path.resolve(__dirname, 'worker.js')
});

const summary: IProfileSummary = new Map();

let processed: number = 0;
await Promise.all(
Array.from(profiles, async (profile: string) => {
const worker: Worker = await workerPool.checkoutWorkerAsync(true);
const responsePromise: Promise<IMessageFromWorker[]> = once(worker, 'message');
worker.postMessage(profile);
const { 0: messageFromWorker } = await responsePromise;
if (messageFromWorker.type === 'error') {
console.error(`Error processing ${profile}: ${messageFromWorker.data}`);
} else {
++processed;
console.log(`Processed ${profile} (${processed}/${profiles.size})`);
mergeProfileSummaries(summary, messageFromWorker.data);
}
workerPool.checkinWorker(worker);
})
);

await workerPool.finishAsync();

return summary;
}

function writeSummaryToTsv(tsvPath: string, summary: IProfileSummary): void {
const dir: string = path.dirname(tsvPath);
fs.mkdirSync(dir, { recursive: true });

let tsv: string = `Self Time (seconds)\tTotal Time (seconds)\tFunction Name\tURL\tLine\tColumn`;
for (const { selfTime, totalTime, functionName, url, lineNumber, columnNumber } of summary.values()) {
const selfSeconds: string = (selfTime / 1e6).toFixed(3);
const totalSeconds: string = (totalTime / 1e6).toFixed(3);

tsv += `\n${selfSeconds}\t${totalSeconds}\t${functionName}\t${url}\t${lineNumber}\t${columnNumber}`;
}

fs.writeFileSync(tsvPath, tsv, 'utf8');
console.log(`Wrote summary to ${tsvPath}`);
}

class CpuProfileSummarizerCommandLineParser extends CommandLineParser {
private readonly _inputParameter: CommandLineStringListParameter;
private readonly _outputParameter: IRequiredCommandLineStringParameter;

public constructor() {
super({
toolFilename: 'cpu-profile-summarizer',
toolDescription:
'This tool summarizes the contents of multiple V8 .cpuprofile reports. ' +
'For example, those generated by running `node --cpu-prof`.'
});

this._inputParameter = this.defineStringListParameter({
parameterLongName: '--input',
parameterShortName: '-i',
description: 'The directory containing .cpuprofile files to summarize',
argumentName: 'DIR',
required: true
});

this._outputParameter = this.defineStringParameter({
parameterLongName: '--output',
parameterShortName: '-o',
description: 'The output file to write the summary to',
argumentName: 'TSV_FILE',
required: true
});
}

protected async onExecute(): Promise<void> {
const input: readonly string[] = this._inputParameter.values;
const output: string = this._outputParameter.value;

if (input.length === 0) {
throw new Error('No input directories provided');
}

const allProfiles: Set<string> = new Set();
for (const dir of input) {
const resolvedDir: string = path.resolve(dir);
console.log(`Collating CPU profiles from ${resolvedDir}...`);
const profiles: string[] = findProfiles(resolvedDir);
console.log(`Found ${profiles.length} profiles`);
for (const profile of profiles) {
allProfiles.add(profile);
}
}

if (allProfiles.size === 0) {
throw new Error(`No profiles found`);
}

const summary: IProfileSummary = await processProfilesAsync(allProfiles);

writeSummaryToTsv(output, summary);
}
}

process.exitCode = 1;
const parser: CpuProfileSummarizerCommandLineParser = new CpuProfileSummarizerCommandLineParser();

parser
.executeAsync()
.then((success: boolean) => {
if (success) {
process.exitCode = 0;
}
})
.catch((error: Error) => {
console.error(error);
});
Loading
Loading