* Measure GC pressure for an async operation over multiple iterations. * Tracks total garbage collection time per operation using PerformanceObserver. * Using total GC time (sum of all pauses) rather than max single pause provides * much more stable metrics — it eliminates the variance from V8 ch
({ name, operation, iterations, skipWarmup = false })
| 212 | * @param {boolean} [options.skipWarmup=false] Skip warmup phase. |
| 213 | */ |
| 214 | async function measureMemoryOperation({ name, operation, iterations, skipWarmup = false }) { |
| 215 | const { PerformanceObserver } = require('node:perf_hooks'); |
| 216 | |
| 217 | // Override iterations if global ITERATIONS is set |
| 218 | iterations = ITERATIONS || iterations; |
| 219 | |
| 220 | // Determine warmup count (20% of iterations) |
| 221 | const warmupCount = skipWarmup ? 0 : Math.floor(iterations * 0.2); |
| 222 | const gcDurations = []; |
| 223 | |
| 224 | if (warmupCount > 0) { |
| 225 | logInfo(`Starting warmup phase of ${warmupCount} iterations...`); |
| 226 | for (let i = 0; i < warmupCount; i++) { |
| 227 | await operation(); |
| 228 | } |
| 229 | logInfo('Warmup complete.'); |
| 230 | } |
| 231 | |
| 232 | // Measurement phase |
| 233 | logInfo(`Starting measurement phase of ${iterations} iterations...`); |
| 234 | const progressInterval = Math.ceil(iterations / 10); |
| 235 | |
| 236 | for (let i = 0; i < iterations; i++) { |
| 237 | // Force GC before each iteration to start from a clean state |
| 238 | if (typeof global.gc === 'function') { |
| 239 | global.gc(); |
| 240 | } |
| 241 | |
| 242 | // Track GC events during this iteration; sum all GC pause durations to |
| 243 | // measure total GC work, which is stable regardless of whether V8 chooses |
| 244 | // one long pause or many short pauses |
| 245 | let totalGcTime = 0; |
| 246 | const obs = new PerformanceObserver((list) => { |
| 247 | for (const entry of list.getEntries()) { |
| 248 | totalGcTime += entry.duration; |
| 249 | } |
| 250 | }); |
| 251 | obs.observe({ type: 'gc', buffered: false }); |
| 252 | |
| 253 | await operation(); |
| 254 | |
| 255 | // Force GC after the operation to flush pending GC work into this |
| 256 | // iteration's measurement, preventing cross-iteration contamination |
| 257 | if (typeof global.gc === 'function') { |
| 258 | global.gc(); |
| 259 | } |
| 260 | |
| 261 | // Flush any buffered entries before disconnecting to avoid data loss |
| 262 | for (const entry of obs.takeRecords()) { |
| 263 | totalGcTime += entry.duration; |
| 264 | } |
| 265 | obs.disconnect(); |
| 266 | gcDurations.push(totalGcTime); |
| 267 | |
| 268 | if (LOG_ITERATIONS) { |
| 269 | logInfo(`Iteration ${i + 1}: ${totalGcTime.toFixed(2)} ms GC`); |
| 270 | } else if ((i + 1) % progressInterval === 0 || i + 1 === iterations) { |
| 271 | const progress = Math.round(((i + 1) / iterations) * 100); |
no test coverage detected