mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
* fix(bench): make the idle-CPU benchmark run and report CPU on Windows Three independent Windows defects in the idle-CPU benchmark (its report is what Windows performance work would cite): - `buildAppIfNeeded` spawned `npx`, which is `npx.cmd` on Windows, so execFileSync failed with ENOENT before any sampling. Build through `config/scripts/run-electron-vite-build.mjs` instead — the repo's own Windows-safe wrapper, which also runs the boot-graph verification. - `readWindowsProcesses` returned `cpuTimeSeconds: null`, so the sampler's per-process CPU delta (idle-cpu-process-sampling.mjs:198) fell back to the hardcoded `percentCpu: 0` and every CPU KPI in the report was zero. Read `UserModeTime`/`KernelModeTime` from `Win32_Process` (100 ns units) and keep `null` when a host does not return them. Measured on Windows, same options before and after the CPU fix: | class | mean CPU before | mean CPU after | | --- | ---: | ---: | | renderer | 0 | 2.20 | | gpu | 0 | 2.08 | | utility | 0 | 0.11 | | agent-or-node | 0 | 0.06 | | total | 0 | 4.51 (p95 8.40) | RSS is unchanged (934 MiB -> 937 MiB), so only the CPU reporting changed. Refs #25235 * fix(bench): reject a missing Win32_Process time instead of reading it as zero Review follow-up (CodeRabbit). `Number(null)` is `0`, which is also a real counter value, so an absent `UserModeTime`/`KernelModeTime` passed the finite check and the sampler would report a kernel-only delta instead of taking the fallback. Require an actual number from the CIM payload. Verified on Windows that the real payload still reports CPU once the check is tight: total mean 4.40%, p95 14.48%, renderer 3.51%. * test(bench): cover the Windows CIM CPU time mapping The mapping was only reachable through Get-CimInstance, so no test ran it. Lift it into cimCpuSeconds and assert the 100ns-to-seconds sum plus the missing / string / non-finite cases that must stay null. * fix(bench): read the Windows process table as UTF-8 so JSON.parse survives PowerShell writes to a redirected stdout in the OEM code page (437 here), which best-fits characters such as U+2022 and U+2192 in any running process's command line to raw 0x07 / 0x1A bytes. ConvertTo-Json had left them unescaped because they are not control characters in UTF-16, so the sampler's JSON.parse failed with "Bad control character in string literal" whenever such a process happened to be alive -- hence intermittent. Set [Console]::OutputEncoding to UTF-8, as the app's own CIM scan does, and cover it with a Windows-only test that fails without the fix. Fixes #25236 * fix(bench): pass the local runtime to fetchRepos in the idle-CPU fixture fetchRepos now requires a runtimeEnvironmentId, so the fixture's bare call threw "Cannot read properties of undefined (reading 'runtimeEnvironmentId')" before sampling on every platform. null selects this app's own catalog, as the e2e helpers already do. * ci(bench): run the Windows process-table test in the Windows lane The UTF-8 regression test self-skips off win32, so it must be registered in both Windows lists or it runs on no machine. --------- Co-authored-by: Paolo <paovalletta@hotmail.it> Co-authored-by: Orca Worker <orca-worker@localhost>
341 lines
10 KiB
JavaScript
341 lines
10 KiB
JavaScript
import { execFileSync, spawnSync } from 'node:child_process'
|
|
|
|
function parseCpuTimeSeconds(value) {
|
|
const trimmed = String(value || '').trim()
|
|
if (!trimmed) {
|
|
return null
|
|
}
|
|
const [dayOrTime, maybeTime] = trimmed.includes('-') ? trimmed.split('-', 2) : [null, trimmed]
|
|
const days = dayOrTime === null ? 0 : Number(dayOrTime)
|
|
const parts = maybeTime.split(':').map(Number)
|
|
if (!Number.isFinite(days) || parts.some((part) => !Number.isFinite(part))) {
|
|
return null
|
|
}
|
|
if (parts.length === 3) {
|
|
return days * 86400 + parts[0] * 3600 + parts[1] * 60 + parts[2]
|
|
}
|
|
if (parts.length === 2) {
|
|
return days * 86400 + parts[0] * 60 + parts[1]
|
|
}
|
|
if (parts.length === 1) {
|
|
return days * 86400 + parts[0]
|
|
}
|
|
return null
|
|
}
|
|
|
|
function parseUnixProcesses(stdout) {
|
|
const rows = []
|
|
for (const raw of stdout.split('\n')) {
|
|
const line = raw.trim()
|
|
if (!line) {
|
|
continue
|
|
}
|
|
const match = line.match(/^(\d+)\s+(\d+)\s+([\d.]+)\s+(\d+)\s+(\S+)\s+(.+)$/)
|
|
if (!match) {
|
|
continue
|
|
}
|
|
rows.push({
|
|
pid: Number(match[1]),
|
|
ppid: Number(match[2]),
|
|
percentCpu: Number(match[3]),
|
|
rssBytes: Number(match[4]) * 1024,
|
|
cpuTimeSeconds: parseCpuTimeSeconds(match[5]),
|
|
command: match[6]
|
|
})
|
|
}
|
|
return rows
|
|
}
|
|
|
|
function readUnixProcesses() {
|
|
const stdout = execFileSync('ps', ['-axo', 'pid=,ppid=,pcpu=,rss=,cputime=,command='], {
|
|
encoding: 'utf8',
|
|
env: { ...process.env, LC_ALL: 'C', LANG: 'C' },
|
|
maxBuffer: 20 * 1024 * 1024
|
|
})
|
|
return parseUnixProcesses(stdout)
|
|
}
|
|
|
|
// `Win32_Process` times are 100 ns counts. Not `Number(value)`: that turns a missing
|
|
// field into a real-looking 0, so the CPU delta would silently go kernel-only.
|
|
function cimTimeSeconds(value) {
|
|
return typeof value === 'number' && Number.isFinite(value) ? value / 10_000_000 : null
|
|
}
|
|
|
|
export function cimCpuSeconds(entry) {
|
|
const userModeSeconds = cimTimeSeconds(entry.UserModeTime)
|
|
const kernelModeSeconds = cimTimeSeconds(entry.KernelModeTime)
|
|
return userModeSeconds !== null && kernelModeSeconds !== null
|
|
? userModeSeconds + kernelModeSeconds
|
|
: null
|
|
}
|
|
|
|
function readWindowsProcesses() {
|
|
// Why UTF-8: the default OEM code page best-fits characters such as `•` or `→` in any
|
|
// process's command line to raw control bytes (0x07, 0x1A), which JSON.parse rejects.
|
|
const script =
|
|
'[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; ' +
|
|
'Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,WorkingSetSize,UserModeTime,KernelModeTime,CommandLine | ConvertTo-Json -Compress'
|
|
const result = spawnSync('powershell.exe', ['-NoProfile', '-Command', script], {
|
|
encoding: 'utf8',
|
|
maxBuffer: 20 * 1024 * 1024
|
|
})
|
|
if (result.status !== 0) {
|
|
throw new Error(result.stderr || 'PowerShell process enumeration failed')
|
|
}
|
|
const parsed = JSON.parse(result.stdout || '[]')
|
|
const entries = Array.isArray(parsed) ? parsed : [parsed]
|
|
return entries.map((entry) => ({
|
|
pid: Number(entry.ProcessId),
|
|
ppid: Number(entry.ParentProcessId),
|
|
percentCpu: 0,
|
|
cpuTimeSeconds: cimCpuSeconds(entry),
|
|
rssBytes: Number(entry.WorkingSetSize) || 0,
|
|
command: String(entry.CommandLine || '')
|
|
}))
|
|
}
|
|
|
|
export function readProcessRows() {
|
|
return process.platform === 'win32' ? readWindowsProcesses() : readUnixProcesses()
|
|
}
|
|
|
|
export function descendantsOf(rows, rootPid) {
|
|
const children = new Map()
|
|
for (const row of rows) {
|
|
const list = children.get(row.ppid) ?? []
|
|
list.push(row)
|
|
children.set(row.ppid, list)
|
|
}
|
|
const result = []
|
|
const stack = [rootPid]
|
|
const seen = new Set()
|
|
while (stack.length > 0) {
|
|
const pid = stack.pop()
|
|
if (seen.has(pid)) {
|
|
continue
|
|
}
|
|
seen.add(pid)
|
|
const row = rows.find((candidate) => candidate.pid === pid)
|
|
if (row) {
|
|
result.push(row)
|
|
}
|
|
for (const child of children.get(pid) ?? []) {
|
|
stack.push(child.pid)
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
export function classify(row, rootPid) {
|
|
const command = row.command.toLowerCase()
|
|
if (row.pid === rootPid) {
|
|
return 'main'
|
|
}
|
|
if (command.includes('daemon-entry')) {
|
|
return 'daemon'
|
|
}
|
|
if (command.includes('--type=gpu-process')) {
|
|
return 'gpu'
|
|
}
|
|
if (command.includes('--type=renderer')) {
|
|
return 'renderer'
|
|
}
|
|
if (command.includes('--type=utility')) {
|
|
return 'utility'
|
|
}
|
|
if (command.includes('--type=')) {
|
|
return 'electron-other'
|
|
}
|
|
if (command.includes('node') || command.includes('/pi') || command.endsWith(' pi')) {
|
|
return 'agent-or-node'
|
|
}
|
|
return 'other-descendant'
|
|
}
|
|
|
|
const DEFAULT_WORKLOAD_OVERRUN_LIMIT_MS = 120_000
|
|
|
|
export async function sampleProcessTreeUntilWorkloadsComplete({
|
|
rootPid,
|
|
requestedDurationMs,
|
|
intervalMs,
|
|
workloadPromise,
|
|
maxWorkloadOverrunMs = DEFAULT_WORKLOAD_OVERRUN_LIMIT_MS,
|
|
readRows = readProcessRows,
|
|
now = Date.now,
|
|
wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
}) {
|
|
const samplingStartedAt = now()
|
|
const requestedDeadline = samplingStartedAt + requestedDurationMs
|
|
const hardDeadline = requestedDeadline + maxWorkloadOverrunMs
|
|
let workloadSettled = false
|
|
let workloadResult
|
|
let workloadError
|
|
let workloadSettledAt = null
|
|
void workloadPromise.then(
|
|
(result) => {
|
|
workloadResult = result
|
|
workloadSettled = true
|
|
workloadSettledAt = now()
|
|
},
|
|
(error) => {
|
|
workloadError = error
|
|
workloadSettled = true
|
|
workloadSettledAt = now()
|
|
}
|
|
)
|
|
const samples = []
|
|
let previousSnapshot = null
|
|
const needsFinalWorkloadSample = () =>
|
|
workloadSettledAt !== null && (previousSnapshot?.at ?? -Infinity) < workloadSettledAt
|
|
while (
|
|
now() <= requestedDeadline ||
|
|
samples.length === 0 ||
|
|
!workloadSettled ||
|
|
needsFinalWorkloadSample()
|
|
) {
|
|
const sampledAt = now()
|
|
if (workloadError) {
|
|
throw workloadError
|
|
}
|
|
if (
|
|
(!workloadSettled && sampledAt >= hardDeadline) ||
|
|
(workloadSettledAt !== null && workloadSettledAt > hardDeadline)
|
|
) {
|
|
throw new Error(
|
|
`Benchmark workload exceeded the ${maxWorkloadOverrunMs}ms sampling overrun limit`
|
|
)
|
|
}
|
|
const processRows = descendantsOf(readRows(), rootPid)
|
|
const rawProcesses = processRows.map((row) => ({ ...row, kind: classify(row, rootPid) }))
|
|
if (previousSnapshot) {
|
|
const elapsedSeconds = Math.max(0.001, (sampledAt - previousSnapshot.at) / 1000)
|
|
const previousByPid = new Map(previousSnapshot.processes.map((proc) => [proc.pid, proc]))
|
|
const processes = rawProcesses.map((row) => {
|
|
const previous = previousByPid.get(row.pid)
|
|
const canComputeDelta =
|
|
typeof row.cpuTimeSeconds === 'number' && typeof previous?.cpuTimeSeconds === 'number'
|
|
const cpu = canComputeDelta
|
|
? Math.max(0, ((row.cpuTimeSeconds - previous.cpuTimeSeconds) / elapsedSeconds) * 100)
|
|
: row.percentCpu
|
|
return { ...row, cpu }
|
|
})
|
|
samples.push({
|
|
at: sampledAt,
|
|
elapsedMs: sampledAt - previousSnapshot.at,
|
|
totalCpuPercent: processes.reduce((sum, proc) => sum + proc.cpu, 0),
|
|
totalRssBytes: processes.reduce((sum, proc) => sum + proc.rssBytes, 0),
|
|
processes
|
|
})
|
|
}
|
|
previousSnapshot = { at: sampledAt, processes: rawProcesses }
|
|
await wait(intervalMs)
|
|
}
|
|
if (workloadError) {
|
|
throw workloadError
|
|
}
|
|
const measuredDurationMs = Math.max(
|
|
0,
|
|
(previousSnapshot?.at ?? samplingStartedAt) - samplingStartedAt
|
|
)
|
|
return {
|
|
samples,
|
|
workloadResult,
|
|
samplingWindow: {
|
|
requestedDurationMs,
|
|
measuredDurationMs,
|
|
maxWorkloadOverrunMs,
|
|
extendedForWorkload: workloadSettledAt !== null && workloadSettledAt > requestedDeadline,
|
|
workloadSettledElapsedMs:
|
|
workloadSettledAt === null ? null : Math.max(0, workloadSettledAt - samplingStartedAt),
|
|
workloadSettledBeforeStop: workloadSettled
|
|
}
|
|
}
|
|
}
|
|
|
|
function mean(values) {
|
|
return values.length === 0 ? 0 : values.reduce((sum, value) => sum + value, 0) / values.length
|
|
}
|
|
|
|
function percentile(sorted, fraction) {
|
|
if (sorted.length === 0) {
|
|
return 0
|
|
}
|
|
const index = Math.min(sorted.length - 1, Math.ceil(sorted.length * fraction) - 1)
|
|
return sorted[index]
|
|
}
|
|
|
|
export function summarizeSamples(samples) {
|
|
const byKind = new Map()
|
|
for (const sample of samples) {
|
|
for (const proc of sample.processes) {
|
|
const bucket = byKind.get(proc.kind) ?? { cpuValues: [], rssValues: [], maxProcessCount: 0 }
|
|
bucket.cpuValues.push(proc.cpu)
|
|
bucket.rssValues.push(proc.rssBytes)
|
|
byKind.set(proc.kind, bucket)
|
|
}
|
|
const counts = new Map()
|
|
for (const proc of sample.processes) {
|
|
counts.set(proc.kind, (counts.get(proc.kind) ?? 0) + 1)
|
|
}
|
|
for (const [kind, count] of counts) {
|
|
byKind.get(kind).maxProcessCount = Math.max(byKind.get(kind).maxProcessCount, count)
|
|
}
|
|
}
|
|
const summary = {}
|
|
for (const [kind, values] of byKind) {
|
|
const cpuSorted = [...values.cpuValues].sort((a, b) => a - b)
|
|
const rssSumBySample = samples.map((sample) =>
|
|
sample.processes
|
|
.filter((proc) => proc.kind === kind)
|
|
.reduce((sum, proc) => sum + proc.rssBytes, 0)
|
|
)
|
|
summary[kind] = {
|
|
meanCpuPercent: mean(values.cpuValues),
|
|
p95CpuPercent: percentile(cpuSorted, 0.95),
|
|
maxCpuPercent: Math.max(0, ...values.cpuValues),
|
|
meanRssBytes: mean(rssSumBySample),
|
|
maxProcessCount: values.maxProcessCount
|
|
}
|
|
}
|
|
summary.total = {
|
|
meanCpuPercent: mean(samples.map((sample) => sample.totalCpuPercent)),
|
|
p95CpuPercent: percentile(
|
|
samples.map((sample) => sample.totalCpuPercent).sort((a, b) => a - b),
|
|
0.95
|
|
),
|
|
meanRssBytes: mean(samples.map((sample) => sample.totalRssBytes))
|
|
}
|
|
return summary
|
|
}
|
|
|
|
export function summarizeProcessInventory(samples) {
|
|
const inventory = {}
|
|
for (const sample of samples) {
|
|
const counts = new Map()
|
|
for (const proc of sample.processes) {
|
|
counts.set(proc.kind, (counts.get(proc.kind) ?? 0) + 1)
|
|
const entry = inventory[proc.kind] ?? {
|
|
maxProcessCount: 0,
|
|
maxCpuPercent: 0,
|
|
commandSamples: []
|
|
}
|
|
entry.maxCpuPercent = Math.max(entry.maxCpuPercent, proc.cpu)
|
|
if (!entry.commandSamples.includes(proc.command) && entry.commandSamples.length < 6) {
|
|
entry.commandSamples.push(proc.command)
|
|
}
|
|
inventory[proc.kind] = entry
|
|
}
|
|
for (const [kind, count] of counts) {
|
|
inventory[kind].maxProcessCount = Math.max(inventory[kind].maxProcessCount, count)
|
|
}
|
|
}
|
|
return inventory
|
|
}
|
|
|
|
export function terminateProcesses(processes) {
|
|
for (const proc of processes) {
|
|
try {
|
|
process.kill(proc.pid)
|
|
} catch {}
|
|
}
|
|
}
|