feat: take Bun profile (#8515)

**Motivation**

- to be able to take profile in Bun

**Description**

- implement `profileBun` api using `console.profile()` apis
- as tested, it only supported up to 3s or Bun will crash so I have to
do a for loop
- cannot take the whole epoch, or `debug.bun.sh` will take forever to
load
- refactor: implement `profileThread` as wrapper of either
`profileNodeJS` or `profileBun`
- note that NodeJS and Bun works a bit differently:
  - NodeJS: we can persist to a file, log into server and copy it
- Bun: need to launch `debug.bun.sh` web page as the inspector, profile
will be flushed from node to to the inspected and rendered live there

**Steps to take profile**
- start beacon node with `--inspect` and look for `debug.bun.sh` log
- launch the specified url, for example
`https://debug.bun.sh/#127.0.0.1:9229/0qoflywrwso`
- (optional) the UI does not show if the inspector is connected to app
or not, so normally I wait for the sources to be launched
- `curl -X POST
http://localhost:9596/eth/v1/lodestar/write_profile?thread=main`
- look into `Timelines` tab in `https://debug.bun.sh/`, check `Call
tree` there
- (optional) export Timeline to share it

**Sample Profile**
[Timeline%20Recording%201
(4).json.zip](https://github.com/user-attachments/files/22788370/Timeline.20Recording.201.4.json.zip)

---------

Co-authored-by: Tuyen Nguyen <twoeths@users.noreply.github.com>
This commit is contained in:
twoeths
2025-10-20 20:13:57 +07:00
committed by GitHub
parent e88fbeb6b6
commit 6f46a8bd20
5 changed files with 56 additions and 27 deletions

View File

@@ -117,7 +117,7 @@ export type Endpoints = {
dirpath?: string;
},
{query: {thread?: LodestarThreadType; duration?: number; dirpath?: string}},
{filepath: string},
{result: string},
EmptyMeta
>;
/** TODO: description */

View File

@@ -1,5 +1,3 @@
import fs from "node:fs";
import path from "node:path";
import {Tree} from "@chainsafe/persistent-merkle-tree";
import {routes} from "@lodestar/api";
import {ApplicationMethods} from "@lodestar/api/server";
@@ -13,7 +11,7 @@ import {BeaconChain} from "../../../chain/index.js";
import {QueuedStateRegenerator, RegenRequest} from "../../../chain/regen/index.js";
import {IBeaconDb} from "../../../db/interface.js";
import {GossipType} from "../../../network/index.js";
import {profileNodeJS, writeHeapSnapshot} from "../../../util/profile.js";
import {ProfileThread, profileThread, writeHeapSnapshot} from "../../../util/profile.js";
import {getStateResponseWithRegen} from "../beacon/state/utils.js";
import {ApiModules} from "../types.js";
@@ -26,7 +24,9 @@ export function getLodestarApi({
}: Pick<ApiModules, "chain" | "config" | "db" | "network" | "sync">): ApplicationMethods<routes.lodestar.Endpoints> {
let writingHeapdump = false;
let writingProfile = false;
const defaultProfileMs = SLOTS_PER_EPOCH * config.SLOT_DURATION_MS;
// for NodeJS, profile the whole epoch
// for Bun, profile 1 slot. Otherwise it will either crash the app, and/or inspector cannot render the profile
const defaultProfileMs = globalThis.Bun ? config.SLOT_DURATION_MS : SLOTS_PER_EPOCH * config.SLOT_DURATION_MS;
return {
async writeHeapdump({thread = "main", dirpath = "."}) {
@@ -63,7 +63,6 @@ export function getLodestarApi({
try {
let filepath: string;
let profile: string;
switch (thread) {
case "network":
filepath = await network.writeNetworkThreadProfile(duration, dirpath);
@@ -73,12 +72,10 @@ export function getLodestarApi({
break;
default:
// main thread
profile = await profileNodeJS(duration);
filepath = path.join(dirpath, `main_thread_${new Date().toISOString()}.cpuprofile`);
fs.writeFileSync(filepath, profile);
filepath = await profileThread(ProfileThread.MAIN, duration, dirpath);
break;
}
return {data: {filepath}};
return {data: {result: filepath}};
} finally {
writingProfile = false;
}

View File

@@ -1,5 +1,3 @@
import fs from "node:fs";
import path from "node:path";
import worker from "node:worker_threads";
import {privateKeyFromProtobuf} from "@libp2p/crypto/keys";
import {peerIdFromPrivateKey} from "@libp2p/peer-id";
@@ -11,7 +9,7 @@ import {RegistryMetricCreator, collectNodeJSMetrics} from "../../metrics/index.j
import {AsyncIterableBridgeCaller, AsyncIterableBridgeHandler} from "../../util/asyncIterableToEvents.js";
import {Clock} from "../../util/clock.js";
import {peerIdToString} from "../../util/peerId.js";
import {profileNodeJS, writeHeapSnapshot} from "../../util/profile.js";
import {ProfileThread, profileThread, writeHeapSnapshot} from "../../util/profile.js";
import {wireEventsOnWorkerThread} from "../../util/workerEvents.js";
import {NetworkEventBus, NetworkEventData, networkEventDirection} from "../events.js";
import {
@@ -157,10 +155,7 @@ const libp2pWorkerApi: NetworkWorkerApi = {
dumpDiscv5KadValues: () => core.dumpDiscv5KadValues(),
dumpMeshPeers: () => core.dumpMeshPeers(),
writeProfile: async (durationMs: number, dirpath: string) => {
const profile = await profileNodeJS(durationMs);
const filePath = path.join(dirpath, `network_thread_${new Date().toISOString()}.cpuprofile`);
fs.writeFileSync(filePath, profile);
return filePath;
return profileThread(ProfileThread.NETWORK, durationMs, dirpath);
},
writeDiscv5Profile: async (durationMs: number, dirpath: string) => {
return core.writeDiscv5Profile(durationMs, dirpath);

View File

@@ -1,5 +1,3 @@
import fs from "node:fs";
import path from "node:path";
import worker from "node:worker_threads";
import {privateKeyFromProtobuf} from "@libp2p/crypto/keys";
import {peerIdFromPrivateKey} from "@libp2p/peer-id";
@@ -14,7 +12,7 @@ import {Gauge} from "@lodestar/utils";
import {RegistryMetricCreator} from "../../metrics/index.js";
import {collectNodeJSMetrics} from "../../metrics/nodeJsMetrics.js";
import {Clock} from "../../util/clock.js";
import {profileNodeJS, writeHeapSnapshot} from "../../util/profile.js";
import {ProfileThread, profileThread, writeHeapSnapshot} from "../../util/profile.js";
import {Discv5WorkerApi, Discv5WorkerData} from "./types.js";
import {ENRRelevance, enrRelevance} from "./utils.js";
@@ -108,10 +106,7 @@ const module: Discv5WorkerApi = {
return (await metricsRegistry?.metrics()) ?? "";
},
writeProfile: async (durationMs: number, dirpath: string) => {
const profile = await profileNodeJS(durationMs);
const filePath = path.join(dirpath, `discv5_thread_${new Date().toISOString()}.cpuprofile`);
fs.writeFileSync(filePath, profile);
return filePath;
return profileThread(ProfileThread.DISC5, durationMs, dirpath);
},
writeHeapSnapshot: async (prefix: string, dirpath: string) => {
return writeHeapSnapshot(prefix, dirpath);

View File

@@ -1,13 +1,32 @@
import fs from "node:fs";
import path from "node:path";
import {sleep} from "@lodestar/utils";
export enum ProfileThread {
MAIN = "main",
NETWORK = "network",
DISC5 = "discv5",
}
/**
* Take 10m profile of the current thread without promise tracking.
* The time to take a Bun profile.
* If we increase this time it'll potentiall cause the app to crash.
* If we decrease this time, profile recorded will be fragmented and hard to analyze.
*/
export async function profileNodeJS(durationMs: number): Promise<string> {
const BUN_PROFILE_MS = 3 * 1000;
export async function profileThread(thread: ProfileThread, durationMs: number, dirpath: string): Promise<string> {
return globalThis.Bun ? profileBun(thread, durationMs) : profileNodeJS(thread, durationMs, dirpath);
}
/**
* Take `durationMs` profile of the current thread and return the persisted file path.
*/
async function profileNodeJS(thread: ProfileThread, durationMs: number, dirpath: string): Promise<string> {
const inspector = await import("node:inspector");
// due to some typing issues, not able to use promisify here
return new Promise<string>((resolve, reject) => {
const profile = await new Promise<string>((resolve, reject) => {
// Start the inspector and connect to it
const session = new inspector.Session();
session.connect();
@@ -29,6 +48,29 @@ export async function profileNodeJS(durationMs: number): Promise<string> {
});
});
});
const filePath = path.join(dirpath, `${thread}_thread_${new Date().toISOString()}.cpuprofile`);
fs.writeFileSync(filePath, profile);
return filePath;
}
/**
* Unlike NodeJS, Bun console.profile() api flush data to the inspector,
* so this api returns ms taken of this profile instead of file path.
*/
async function profileBun(thread: ProfileThread, durationMs: number): Promise<string> {
const start = Date.now();
let now = Date.now();
while (now - start < durationMs) {
// biome-ignore lint/suspicious/noConsole: need to use console api to profile in Bun
console.profile(String(now));
await sleep(BUN_PROFILE_MS);
// biome-ignore lint/suspicious/noConsole: need to use console api to profile in Bun
console.profileEnd(String(now));
now = Date.now();
}
return `Successfully take Bun ${thread} thread profile in ${now - start}ms. Check your inspector to see the profile.`;
}
/**