Skip to content
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
78 changes: 74 additions & 4 deletions bindings/profilers/wall.cc
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,45 @@ class PersistentContextPtr {
void WallProfiler::MarkDeadPersistentContextPtr(PersistentContextPtr* ptr) {
deadContextPtrs_.push_back(ptr);
liveContextPtrs_.erase(ptr);
// Cap freelist growth by a dynamic byte budget based on live async contexts.
constexpr size_t kMinDeadContextPtrBudgetBytes = 512 * 1024; // 512 KiB
constexpr size_t kMaxDeadContextPtrBudgetBytes = 16 * 1024 * 1024; // 16 MiB
constexpr size_t kDeadContextPtrMultiplier = 2;
const size_t perPtrBytes = sizeof(PersistentContextPtr);
size_t maxDeadContextPtrs = kMaxDeadContextPtrBudgetBytes / perPtrBytes;
size_t minDeadContextPtrs = kMinDeadContextPtrBudgetBytes / perPtrBytes;
if (minDeadContextPtrs > maxDeadContextPtrs) {
minDeadContextPtrs = maxDeadContextPtrs;
}

const size_t liveCount = liveContextPtrs_.size();
size_t targetDeadContextPtrs;
if (liveCount >= maxDeadContextPtrs / kDeadContextPtrMultiplier) {
targetDeadContextPtrs = maxDeadContextPtrs;
} else {
targetDeadContextPtrs = liveCount * kDeadContextPtrMultiplier;
if (targetDeadContextPtrs < minDeadContextPtrs) {
targetDeadContextPtrs = minDeadContextPtrs;
}
}

const size_t shrinkThreshold =
targetDeadContextPtrs + targetDeadContextPtrs / 2; // 1.5x hysteresis
if (deadContextPtrs_.size() <= shrinkThreshold) {
return;
}

const size_t emergencyThreshold = maxDeadContextPtrs * 2; // 2x max
size_t toTrim = deadContextPtrs_.size() - targetDeadContextPtrs;
if (deadContextPtrs_.size() <= emergencyThreshold && toTrim > trimBatch_) {
toTrim = trimBatch_;
}
while (toTrim > 0) {
auto* toDelete = deadContextPtrs_.front();
deadContextPtrs_.pop_front();
delete toDelete;
--toTrim;
}
}

// Maximum number of rounds in the GetV8ToEpochOffset
Expand Down Expand Up @@ -1504,10 +1543,41 @@ void WallProfiler::OnGCStart(v8::Isolate* isolate) {

void WallProfiler::OnGCEnd() {
auto oldCount = gcCount.fetch_sub(1, std::memory_order_relaxed);
if (oldCount == 1 && useCPED_) {
// Not strictly necessary, as we'll reset it to something else on next GC,
// but why retain it longer than needed?
gcContext_.reset();
if (oldCount != 1 || !useCPED_) {
return;
}

// Not strictly necessary, as we'll reset it to something else on next GC,
// but why retain it longer than needed?
gcContext_.reset();

const size_t deadCount = deadContextPtrs_.size();
deadCountAtPrevGc_ = deadCountAtLastGc_;
deadCountAtLastGc_ = deadCount;
if (deadCountAtLastGc_ > deadCountAtPrevGc_) {
deadStableCycles_ = 0;
if (trimBatch_ < kTrimBatchMax) {
if (++deadGrowthCycles_ >= 2) {
const size_t doubled = trimBatch_ * 2;
trimBatch_ = doubled > kTrimBatchMax ? kTrimBatchMax : doubled;
deadGrowthCycles_ = 0;
}
} else {
deadGrowthCycles_ = 0;
}
} else {
deadGrowthCycles_ = 0;
if (trimBatch_ > kTrimBatchMin) {
if (++deadStableCycles_ >= 3) {
trimBatch_ = trimBatch_ / 2;
if (trimBatch_ < kTrimBatchMin) {
trimBatch_ = kTrimBatchMin;
}
deadStableCycles_ = 0;
}
} else {
deadStableCycles_ = 0;
}
}
}

Expand Down
7 changes: 7 additions & 0 deletions bindings/profilers/wall.hh
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,13 @@ class WallProfiler : public Nan::ObjectWrap {
// Context pointers belonging to GC'd CPED objects register themselves here.
// They will be reused.
std::deque<PersistentContextPtr*> deadContextPtrs_;
static constexpr size_t kTrimBatchMin = 32;
static constexpr size_t kTrimBatchMax = 1024;
size_t trimBatch_ = kTrimBatchMin;
size_t deadCountAtLastGc_ = 0;
size_t deadCountAtPrevGc_ = 0;
unsigned int deadGrowthCycles_ = 0;
unsigned int deadStableCycles_ = 0;

std::atomic<int> gcCount = 0;
std::atomic<bool> setInProgress_ = false;
Expand Down
125 changes: 125 additions & 0 deletions ts/test/cped-freelist-regression-child.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
/**
* Child process entrypoint for CPED freelist trimming regression test.
*
* This file is intentionally not named `test-*.ts` so mocha won't execute it
* directly. It is executed as a standalone Node.js script from the test suite.
*/

import assert from 'assert';
import {AsyncLocalStorage} from 'async_hooks';
import {satisfies} from 'semver';

// Require from the built output to match how tests run in CI (out/test/*).
// eslint-disable-next-line @typescript-eslint/no-var-requires
const {time} = require('../src');

function isUseCPEDEnabled(): boolean {
return (
(satisfies(process.versions.node, '>=24.0.0') &&
!process.execArgv.includes('--no-async-context-frame')) ||
(satisfies(process.versions.node, '>=22.7.0') &&
process.execArgv.includes('--experimental-async-context-frame'))
);
}

async function main() {
if (process.platform !== 'darwin' && process.platform !== 'linux') {
return; // unsupported in this repo's time profiler tests
}

// This regression targets the CPED path.
const useCPED = isUseCPEDEnabled();
if (!useCPED) return;

const gc = global.gc;
if (typeof gc !== 'function') {
throw new Error('expected --expose-gc');
}
const runGc = gc as () => void;

// Ensure an async context frame exists to hold the profiler context.
new AsyncLocalStorage().enterWith(1);

time.start({
intervalMicros: 1000,
durationMillis: 10_000,
withContexts: true,
lineNumbers: false,
useCPED: true,
});

const als = new AsyncLocalStorage<number>();

const waveSize = 20_000;
const maxWaves = 6;
const minDelta = 5_000;
const minTotalBeforeGc = 40_000;
const debug = process.env.DEBUG_CPED_TEST === '1';
const log = (...args: unknown[]) => {
if (debug) {
// eslint-disable-next-line no-console
console.error(...args);
}
};

async function gcAndYield(times = 3) {
for (let i = 0; i < times; i++) {
runGc();
await new Promise(resolve => setImmediate(resolve));
}
}

async function runWave(count: number): Promise<void> {
const tasks: Array<Promise<void>> = [];
for (let i = 0; i < count; i++) {
const value = i;
tasks.push(
als.run(value, async () => {
await new Promise(resolve => setTimeout(resolve, 0));
time.setContext({v: value});
})
);
}
await Promise.all(tasks);
}

const baseline = time.getMetrics().totalAsyncContextCount;
let totalBeforeGc = baseline;
let wavesRun = 0;
while (wavesRun < maxWaves && totalBeforeGc < minTotalBeforeGc) {
await runWave(waveSize);
totalBeforeGc = time.getMetrics().totalAsyncContextCount;
wavesRun++;
log('wave', wavesRun, 'totalBeforeGc', totalBeforeGc);
}
const metricsBeforeGc = time.getMetrics();
log('baseline', baseline, 'metricsBeforeGc', metricsBeforeGc);
assert(
totalBeforeGc - baseline >= minDelta,
`test did not create enough async contexts (baseline=${baseline}, total=${totalBeforeGc})`
);
assert(
totalBeforeGc >= minTotalBeforeGc,
`test did not reach target async context count (total=${totalBeforeGc})`
);

await gcAndYield(6);
const metricsAfterGc = time.getMetrics();
const totalAfterGc = metricsAfterGc.totalAsyncContextCount;
log('metricsAfterGc', metricsAfterGc);
const maxAllowed = Math.floor(totalBeforeGc * 0.75);
assert(
totalAfterGc <= maxAllowed,
`expected trimming; before=${totalBeforeGc}, after=${totalAfterGc}, max=${maxAllowed}`
);

time.stop(false);
}

main().catch(err => {
// Ensure the child exits non-zero on failure.
// eslint-disable-next-line no-console
console.error(err);
// eslint-disable-next-line no-process-exit
process.exit(1);
});
58 changes: 58 additions & 0 deletions ts/test/test-cped-freelist-trimming.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/**
* Regression test for CPED context pointer freelist growth.
*
* Runs the actual workload in a separate Node.js process launched with
* `--expose-gc` so we can force GC deterministically.
*/

import assert from 'assert';
import {spawnSync} from 'child_process';
import path from 'path';
import {satisfies} from 'semver';

describe('CPED freelist trimming (regression)', () => {
it('should plateau total async context pointers after enough churn', function () {
this.timeout(120_000);

if (process.platform !== 'darwin' && process.platform !== 'linux') {
this.skip();
}

const supportsCPED =
satisfies(process.versions.node, '>=24.0.0') ||
satisfies(process.versions.node, '>=22.7.0');
if (!supportsCPED) {
this.skip();
}

const gcCheck = spawnSync(
process.execPath,
[
'--expose-gc',
'-e',
"process.exit(typeof global.gc === 'function' ? 0 : 1)",
],
{stdio: 'pipe'}
);
if (gcCheck.status !== 0) {
this.skip();
}

const child = path.join(__dirname, 'cped-freelist-regression-child.js');
const args = ['--expose-gc', '--max-old-space-size=4096'];
if (
satisfies(process.versions.node, '>=22.7.0') &&
satisfies(process.versions.node, '<24.0.0')
) {
args.push('--experimental-async-context-frame');
}
args.push(child);
const res = spawnSync(process.execPath, args, {
stdio: 'inherit',
});

// If the child process exits non-zero, fail with a helpful message.
assert.strictEqual(res.error, undefined);
assert.strictEqual(res.status, 0, `child exited with status ${res.status}`);
});
});