From 0afe9b5c9065f2210375a40e7cb45f9dce4cda2e Mon Sep 17 00:00:00 2001 From: MK Date: Tue, 22 Sep 2026 22:08:33 +0800 Subject: [PATCH 1/3] perf(cli): read literal config metadata without loading Vite --- bench/config-performance/README.md | 2 + .../check_config_no_lint/snapshots.toml | 13 ++ .../static_metadata_and_runtime_fallback.md | 65 +++++++ crates/vp_static_config/README.md | 10 + crates/vp_static_config/src/lib.rs | 3 + crates/vp_static_config/src/metadata.rs | 184 ++++++++++++++++++ packages/cli/binding/src/cli/resolver.rs | 109 +++++++++++ 7 files changed, 386 insertions(+) create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/check_config_no_lint/snapshots/static_metadata_and_runtime_fallback.md create mode 100644 crates/vp_static_config/src/metadata.rs diff --git a/bench/config-performance/README.md b/bench/config-performance/README.md index 8167347b97..b7ebbf2577 100644 --- a/bench/config-performance/README.md +++ b/bench/config-performance/README.md @@ -18,6 +18,8 @@ The cases cover root and package-directory `vp check --fix`, missing and minimal Config evaluations are measured in separate runs. Synchronous log writes do not affect the timing samples. The current ceilings are four evaluations for a root or package check and five for staged checks. Each process may evaluate its config at most once. The check process reuses its resolved config when it selects the config for each tool phase. +Timing samples for plain metadata exports exercise the Rust static path in the check process. The `defineConfig` case retains JavaScript resolution. Evaluation probes add an executable log statement, which deliberately requires runtime resolution; their counts describe that instrumented config, not the static timing fixture. Rust resolver tests verify that eligible configs do not call the JavaScript resolver and that later calls observe file changes. Tool processes still resolve their own configs. + The benchmark uses temporary projects outside the checkout so ancestor config discovery cannot find the repository's config. It runs the same Node executable in staged tasks and tool children. It uses a separate Node compile-cache directory and removes fixtures after completion. Warmups make this a measurement of fresh-process startup with warm filesystem and compile caches, not a cold-disk benchmark. The [Config Performance workflow](../../.github/workflows/config-performance.yml) runs only when a relevant PR opens, updates, or reopens. Draft PRs run normally. diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/check_config_no_lint/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/check_config_no_lint/snapshots.toml index a595563e6f..292062f23b 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/check_config_no_lint/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/check_config_no_lint/snapshots.toml @@ -4,3 +4,16 @@ vp = "local" steps = [ { argv = ["vp", "check"], continue-on-failure = true }, ] + +[[case]] +name = "static_metadata_and_runtime_fallback" +vp = "local" +steps = [ + { argv = ["vp", "check"], comment = "A literal metadata export preserves the configured check phases." }, + { argv = ["vpt", "write-file", "vite.config.ts", "export default { check: { fmt: false, lint: false } };\n"], snapshot = false }, + { argv = ["vp", "check"], comment = "The next command observes the changed metadata.", continue-on-failure = true }, + { argv = ["vpt", "write-file", "vite.config.ts", "export default async () => ({ plugins: [{ name: 'metadata', config() { return { check: { fmt: false, lint: false } }; } }] });\n"], snapshot = false }, + { argv = ["vp", "check"], comment = "Config functions and plugin hooks still run through Vite.", continue-on-failure = true }, + { argv = ["vpt", "write-file", "vite.config.ts", "console.log('config side effect'); export default { check: { fmt: false, lint: false } };\n"], snapshot = false }, + { argv = ["vp", "check"], comment = "A module with executable statements must retain its side effects.", continue-on-failure = true }, +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/check_config_no_lint/snapshots/static_metadata_and_runtime_fallback.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/check_config_no_lint/snapshots/static_metadata_and_runtime_fallback.md new file mode 100644 index 0000000000..b815e16a2c --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/check_config_no_lint/snapshots/static_metadata_and_runtime_fallback.md @@ -0,0 +1,65 @@ +# static_metadata_and_runtime_fallback + +## `vp check` + +A literal metadata export preserves the configured check phases. + +``` +note: Lint skipped (check.lint: false in vite.config.ts) +pass: All 3 files are correctly formatted (, threads) +``` + +## `vpt write-file vite.config.ts 'export default { check: { fmt: false, lint: false } }; +'` + + +## `vp check` + +The next command observes the changed metadata. + +**Exit code:** 1 + +``` +note: Format skipped (check.fmt: false in vite.config.ts) +note: Lint skipped (check.lint: false in vite.config.ts) +error: No checks enabled + +Enable `lint.options.typeCheck` in vite.config.ts for type-check only, drop a `--no-fmt`/`--no-lint` flag, or re-enable `check.fmt`/`check.lint` in vite.config.ts. +``` + +## `vpt write-file vite.config.ts 'export default async () => ({ plugins: [{ name: '\''metadata'\'', config() { return { check: { fmt: false, lint: false } }; } }] }); +'` + + +## `vp check` + +Config functions and plugin hooks still run through Vite. + +**Exit code:** 1 + +``` +note: Format skipped (check.fmt: false in vite.config.ts) +note: Lint skipped (check.lint: false in vite.config.ts) +error: No checks enabled + +Enable `lint.options.typeCheck` in vite.config.ts for type-check only, drop a `--no-fmt`/`--no-lint` flag, or re-enable `check.fmt`/`check.lint` in vite.config.ts. +``` + +## `vpt write-file vite.config.ts 'console.log('\''config side effect'\''); export default { check: { fmt: false, lint: false } }; +'` + + +## `vp check` + +A module with executable statements must retain its side effects. + +**Exit code:** 1 + +``` +config side effect +note: Format skipped (check.fmt: false in vite.config.ts) +note: Lint skipped (check.lint: false in vite.config.ts) +error: No checks enabled + +Enable `lint.options.typeCheck` in vite.config.ts for type-check only, drop a `--no-fmt`/`--no-lint` flag, or re-enable `check.fmt`/`check.lint` in vite.config.ts. +``` diff --git a/crates/vp_static_config/README.md b/crates/vp_static_config/README.md index 950649567d..a9caab747f 100644 --- a/crates/vp_static_config/README.md +++ b/crates/vp_static_config/README.md @@ -2,6 +2,16 @@ Statically extracts configuration from `vite.config.*` files without executing JavaScript. +`resolve_static_metadata` provides a stricter path for the CLI's universal config resolver. +It accepts only a single `export default` object containing literal `lint`, `fmt`, `check`, +`run`, or `staged` fields. It rejects imports, executable statements, Vite settings, and +values with dynamic behavior. Unsupported, missing, or unreadable configs return `None` +so the CLI uses its existing JavaScript resolver. Each call reads the file again. + +This differs from field extraction: a statically extractable field does not prove that +the rest of the module can be skipped. Prototype setters and unsupported string values +also require runtime resolution. + ## What it does Parses vite config files using [oxc_parser](https://crates.io/crates/oxc_parser) and extracts diff --git a/crates/vp_static_config/src/lib.rs b/crates/vp_static_config/src/lib.rs index 2af9103163..418f855e5c 100644 --- a/crates/vp_static_config/src/lib.rs +++ b/crates/vp_static_config/src/lib.rs @@ -14,6 +14,9 @@ use oxc_span::SourceType; use rustc_hash::FxHashMap; use vt_path::AbsolutePath; +mod metadata; +pub use metadata::{StaticConfigMetadata, resolve_static_metadata}; + /// Packages whose `defineConfig` helpers preserve top-level config fields. const TRUSTED_DEFINE_CONFIG_PACKAGES: &[&str] = &["vite-plus", "vite"]; /// The name of the config helper static extraction trusts. diff --git a/crates/vp_static_config/src/metadata.rs b/crates/vp_static_config/src/metadata.rs new file mode 100644 index 0000000000..18a55f0363 --- /dev/null +++ b/crates/vp_static_config/src/metadata.rs @@ -0,0 +1,184 @@ +use oxc_allocator::Allocator; +use oxc_ast::ast::{Expression, ObjectPropertyKind, PropertyKey, PropertyKind, Statement}; +use oxc_parser::Parser; +use oxc_span::SourceType; +use vt_path::{AbsolutePath, AbsolutePathBuf}; + +use super::{expr_to_json, resolve_config_path}; + +/// Metadata from a config whose entire module can be read without execution. +pub struct StaticConfigMetadata { + pub config_file: AbsolutePathBuf, + pub metadata: serde_json::Value, +} + +/// Read a plain metadata export without initializing Vite. +/// +/// Unlike field extraction, this requires the whole module to be a single +/// literal export. Vite settings, imports, executable code, and unsupported +/// syntax return `None`, so the caller retains runtime validation and effects. +/// Missing or unreadable files also use the caller's existing resolution path. +pub fn resolve_static_metadata(dir: &AbsolutePath) -> Option { + let config_file = resolve_config_path(dir)?; + let source = std::fs::read_to_string(&config_file).ok()?; + let source_type = SourceType::from_path(config_file.as_path()).ok()?; + let metadata = parse_metadata(&source, source_type)?; + Some(StaticConfigMetadata { config_file, metadata }) +} + +fn parse_metadata(source: &str, source_type: SourceType) -> Option { + let allocator = Allocator::default(); + let parsed = Parser::new(&allocator, source, source_type).parse(); + if parsed.fatal_error || !parsed.diagnostics.is_empty() || !parsed.program.directives.is_empty() + { + return None; + } + let [Statement::ExportDefaultDeclaration(export)] = parsed.program.body.as_slice() else { + return None; + }; + let expression = export.declaration.as_expression()?; + if !is_json_literal(expression) { + return None; + } + let metadata = expr_to_json(expression)?; + let fields = metadata.as_object()?; + // Even literal Vite settings can trigger validation, environment changes, + // or plugin integration. Only these unmodified metadata fields are safe. + if fields.keys().any(|key| !matches!(key.as_str(), "lint" | "fmt" | "check" | "run" | "staged")) + { + return None; + } + Some(metadata) +} + +fn is_json_literal(expression: &Expression<'_>) -> bool { + match expression.get_inner_expression() { + Expression::NullLiteral(_) | Expression::BooleanLiteral(_) => true, + Expression::NumericLiteral(value) => value.value.is_finite(), + Expression::StringLiteral(value) => !value.lone_surrogates, + Expression::UnaryExpression(value) => { + value.operator == oxc_ast::ast::UnaryOperator::UnaryNegation + && matches!(&value.argument, Expression::NumericLiteral(number) if number.value.is_finite()) + } + Expression::ArrayExpression(array) => array + .elements + .iter() + .all(|element| element.as_expression().is_some_and(is_json_literal)), + Expression::ObjectExpression(object) => object.properties.iter().all(|property| { + let ObjectPropertyKind::ObjectProperty(property) = property else { + return false; + }; + let key = match &property.key { + PropertyKey::StaticIdentifier(key) => key.name.as_str(), + PropertyKey::StringLiteral(key) if !key.lone_surrogates => key.value.as_str(), + _ => return false, + }; + // __proto__ changes inheritance instead of creating a JSON field. + key != "__proto__" + && property.kind == PropertyKind::Init + && !property.computed + && !property.method + && !property.shorthand + && is_json_literal(&property.value) + }), + _ => false, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn literal_metadata_preserves_values_and_last_duplicate_key() { + let fields = parse_metadata( + r#"// A plain, typed metadata export. + export default ({ + lint: { rules: { 'no-debugger': 'error' }, options: { typeAware: true } }, + fmt: { singleQuote: true, printWidth: 90 }, + check: { lint: false }, check: { fmt: false }, + run: { tasks: { build: { command: 'echo ok', values: [null, -2, 1.5] } } }, + staged: { '*.ts': 'vp check --fix' } + } as const);"#, + SourceType::ts(), + ) + .unwrap(); + assert_eq!( + fields, + serde_json::json!({ + "lint": { "rules": { "no-debugger": "error" }, "options": { "typeAware": true } }, + "fmt": { "singleQuote": true, "printWidth": 90 }, + "check": { "fmt": false }, + "run": { "tasks": { "build": { "command": "echo ok", "values": [null, -2, 1.5] } } }, + "staged": { "*.ts": "vp check --fix" } + }) + ); + } + + #[test] + fn runtime_features_and_vite_settings_require_fallback() { + for source in [ + "export default {};\nthrow new Error('must run');", + "throw new Error('must run'); export default {};", + "import './side-effect.js'; export default {};", + "import { defineConfig } from 'vite-plus'; export default defineConfig({ lint: {} });", + "import type { UserConfig } from 'vite'; export default {} satisfies UserConfig;", + "export default () => ({ lint: {} });", + "export default async () => ({ lint: {} });", + "const config = {}; export default config;", + "export default { plugins: [] };", + "export default { root: 'app', lint: {} };", + "export default { envPrefix: '', lint: {} };", + "export default { devtools: true };", + "export default { configFile: false };", + "export default { ...base, lint: {} };", + "export default { ['lint']: {} };", + "export default { lint: { get rules() { return {}; } } };", + "export default { lint: { rules() {} } };", + "export default { lint };", + "export default { lint: JSON.parse('{}') };", + "export default { lint: { __proto__: { options: { typeAware: true } } } };", + "export default { __proto__: { check: { fmt: false } } };", + "export default { lint: { ['__proto__']: {} } };", + "export default { lint: { rules: { '\\ud800': 'error' } } };", + "export default { fmt: { value: '\\ud800' } };", + "export default { fmt: { value: 1e400 } };", + "export default { fmt: { value: [, 1] } };", + "export default { fmt: { value: [...items] } };", + "export default { fmt: { value: /pattern/ } };", + "export default { fmt: { value: undefined } };", + "export default { fmt: { value: 1n } };", + "module.exports = { lint: {} };", + "export default null;", + "export default [];", + "export default { lint:", + "export default {}; export default {};", + ] { + assert!(parse_metadata(source, SourceType::ts()).is_none(), "{source}"); + } + } + + #[test] + fn respects_extensions_precedence_and_file_changes() { + let temp = tempfile::tempdir().unwrap(); + let root = AbsolutePathBuf::new(temp.path().to_path_buf()).unwrap(); + assert!(resolve_static_metadata(&root).is_none()); + for name in super::super::CONFIG_FILE_NAMES { + let file = root.join(name); + std::fs::write(&file, "export default { lint: {} };").unwrap(); + let metadata = resolve_static_metadata(&root).unwrap(); + assert_eq!(metadata.config_file, file); + assert_eq!(metadata.metadata["lint"], serde_json::json!({})); + std::fs::write(&file, "export default { check: { fmt: false } };").unwrap(); + assert!(resolve_static_metadata(&root).unwrap().metadata.get("lint").is_none()); + std::fs::remove_file(&file).unwrap(); + assert!(resolve_static_metadata(&root).is_none()); + } + std::fs::write(root.join("vite.config.ts"), "export default {};").unwrap(); + std::fs::write(root.join("vite.config.js"), "throw new Error('higher priority');").unwrap(); + assert!(resolve_static_metadata(&root).is_none()); + std::fs::remove_file(root.join("vite.config.js")).unwrap(); + std::fs::create_dir(root.join("vite.config.js")).unwrap(); + assert!(resolve_static_metadata(&root).is_none()); + } +} diff --git a/packages/cli/binding/src/cli/resolver.rs b/packages/cli/binding/src/cli/resolver.rs index a0c43d723f..1eebda14af 100644 --- a/packages/cli/binding/src/cli/resolver.rs +++ b/packages/cli/binding/src/cli/resolver.rs @@ -1,5 +1,6 @@ use std::{borrow::Cow, env, ffi::OsStr, iter, sync::Arc}; +use cow_utils::CowUtils; use rustc_hash::FxHashMap; use vt::config::user::{ AutoTracking, EnabledCacheConfig, GlobWithBase, InputBase, UserCacheConfig, UserInputEntry, @@ -53,6 +54,14 @@ impl SubcommandResolver { .as_path() .to_str() .ok_or_else(|| anyhow::anyhow!("workspace path is not valid UTF-8"))?; + if let Some(config) = vp_static_config::resolve_static_metadata(&self.workspace_path) + && let Some(config_file) = config.config_file.as_path().to_str() + { + let mut resolved: ResolvedUniversalViteConfig = + serde_json::from_value(config.metadata)?; + resolved.config_file = Some(config_file.cow_replace('\\', "/").into_owned()); + return Ok(resolved); + } let vite_config_json = (cli_options.resolve_universal_vite_config)(workspace_path_str.to_string()).await?; @@ -544,6 +553,106 @@ mod tests { assert_eq!(resolved.args.as_ref(), &[Str::from("tool.js")]); } + #[tokio::test] + async fn literal_metadata_skips_javascript_and_observes_changes() { + let temp = tempfile::tempdir().unwrap(); + let root = AbsolutePathBuf::new(temp.path().to_path_buf()).unwrap(); + let file = root.join("vite.config.ts"); + let resolver = SubcommandResolver::new(root.clone().into()) + .with_cli_options(cli_options(Arc::from(OsStr::new("node")))); + std::fs::write(&file, "export default { lint: {}, fmt: {}, check: { fmt: false } };") + .unwrap(); + + // The callback in cli_options fails if JavaScript resolution is used. + let config = resolver.resolve_universal_vite_config().await.unwrap(); + assert_eq!(config.lint, Some(serde_json::json!({}))); + assert_eq!(config.fmt, Some(serde_json::json!({}))); + assert_eq!(config.check, Some(serde_json::json!({ "fmt": false }))); + assert_eq!( + config.config_file.as_deref(), + Some(file.as_path().to_str().unwrap().cow_replace('\\', "/").as_ref()) + ); + + let envs = Arc::new(FxHashMap::default()); + let cwd = root.join("packages/app"); + let resolved = resolver + .resolve( + SynthesizableSubcommand::Fmt { args: vec!["src".to_string()] }, + &envs, + &cwd, + Some(&config), + ) + .await + .unwrap(); + assert!(resolved.args.ends_with(&[ + Str::from("-c"), + Str::from(config.config_file.unwrap()), + Str::from("src") + ])); + + std::fs::write(&file, "export default { check: { lint: false } };").unwrap(); + let config = resolver.resolve_universal_vite_config().await.unwrap(); + assert!(config.lint.is_none()); + assert!(config.fmt.is_none()); + assert_eq!(config.check, Some(serde_json::json!({ "lint": false }))); + + std::fs::remove_file(file).unwrap(); + // Removal must return to the existing JavaScript no-config path. + assert!(resolver.resolve_universal_vite_config().await.is_err()); + } + + #[tokio::test] + async fn dynamic_metadata_and_vite_options_use_runtime_resolution() { + use std::sync::atomic::{AtomicUsize, Ordering}; + + let temp = tempfile::tempdir().unwrap(); + let root = AbsolutePathBuf::new(temp.path().to_path_buf()).unwrap(); + let file = root.join("vite.config.ts"); + let loads = Arc::new(AtomicUsize::new(0)); + let mut options = cli_options(Arc::from(OsStr::new("node"))); + options.resolve_universal_vite_config = { + let loads = Arc::clone(&loads); + Arc::new(move |_| { + loads.fetch_add(1, Ordering::SeqCst); + Box::pin(async { Ok(r#"{"check":{"fmt":false}}"#.to_string()) }) + }) + }; + let resolver = SubcommandResolver::new(root.into()).with_cli_options(options); + for (index, source) in [ + "export default async () => ({ check: { fmt: false } });", + "export default { plugins: [{ name: 'metadata', config() { return { check: { fmt: false } }; } }] };", + "import { defineConfig } from 'vite-plus'; export default defineConfig({});", + "export default {}; console.log('side effect');", + "export default { envPrefix: '' };", + "export default { __proto__: { check: { fmt: false } } };", + "export default {", + ].iter().enumerate() { + std::fs::write(&file, source).unwrap(); + let config = resolver.resolve_universal_vite_config().await.unwrap(); + assert_eq!(config.check, Some(serde_json::json!({ "fmt": false }))); + assert_eq!(loads.load(Ordering::SeqCst), index + 1); + } + } + + #[tokio::test] + async fn runtime_errors_are_not_hidden_by_static_extraction() { + let temp = tempfile::tempdir().unwrap(); + let root = AbsolutePathBuf::new(temp.path().to_path_buf()).unwrap(); + std::fs::write( + root.join("vite.config.ts"), + "export default {}; throw new Error('bad config');", + ) + .unwrap(); + let mut options = cli_options(Arc::from(OsStr::new("node"))); + options.resolve_universal_vite_config = + Arc::new(|_| Box::pin(async { anyhow::bail!("bad config") })); + let resolver = SubcommandResolver::new(root.into()).with_cli_options(options); + assert_eq!( + resolver.resolve_universal_vite_config().await.unwrap_err().to_string(), + "bad config" + ); + } + #[tokio::test] async fn explicit_lint_and_fmt_config_from_subdirectory_skips_root_config_loading() { let temp = tempfile::tempdir().unwrap(); From d87053e8c77c3386d232d49d0e16f922b936d8f8 Mon Sep 17 00:00:00 2001 From: MK Date: Tue, 22 Sep 2026 22:54:49 +0800 Subject: [PATCH 2/3] ci: diagnose config performance on one runner --- .github/workflows/config-performance.yml | 22 +- .../config-performance/native-diagnostic.mjs | 316 ++++++++++++++++++ 2 files changed, 337 insertions(+), 1 deletion(-) create mode 100644 bench/config-performance/native-diagnostic.mjs diff --git a/.github/workflows/config-performance.yml b/.github/workflows/config-performance.yml index 28327834f1..aa5f8994df 100644 --- a/.github/workflows/config-performance.yml +++ b/.github/workflows/config-performance.yml @@ -33,7 +33,7 @@ jobs: benchmark: name: Config loading benchmark runs-on: namespace-profile-linux-x64-default - timeout-minutes: 30 + timeout-minutes: 40 outputs: report-ready: ${{ steps.measure.outputs.report-ready }} steps: @@ -111,6 +111,26 @@ jobs: fi node bench/config-performance/run.ts --output "$RUNNER_TEMP/config-performance" "${args[@]}" + # Temporary diagnostic for #2787: compare native revisions on one runner. + - name: Compare native revisions on the same runner + if: github.event.pull_request.number == 2787 + env: + BASE_NATIVE_REVISION: 76ce17ced5f46d54ac16ad0627e714d0a8270bf5 + HEAD_NATIVE_REVISION: 0afe9b5c9065f2210375a40e7cb45f9dce4cda2e + TARGET_CC: clang + run: | + git fetch origin "$BASE_NATIVE_REVISION" "$HEAD_NATIVE_REVISION" + # Sharing JavaScript is valid only when these inputs are identical. + git diff --exit-code "$BASE_NATIVE_REVISION" "$HEAD_NATIVE_REVISION" -- packages/cli/src packages/cli/bin packages/cli/build.ts packages/cli/tsdown.config.ts packages/cli/package.json packages/core packages/tools/.upstream-versions.json pnpm-lock.yaml Cargo.lock rust-toolchain.toml .cargo + mkdir -p "$RUNNER_TEMP/native-comparison" + cp packages/cli/binding/vite-plus.linux-x64-gnu.node "$RUNNER_TEMP/native-comparison/after.node" + git restore --source "$BASE_NATIVE_REVISION" -- crates/vp_static_config/src packages/cli/binding/src/cli/resolver.rs + pnpm --filter=vite-plus build-native --skip-format --target x86_64-unknown-linux-gnu --use-napi-cross + cp packages/cli/binding/vite-plus.linux-x64-gnu.node "$RUNNER_TEMP/native-comparison/before.node" + git restore --source HEAD -- crates/vp_static_config/src packages/cli/binding/src/cli/resolver.rs + cp "$RUNNER_TEMP/native-comparison/after.node" packages/cli/binding/vite-plus.linux-x64-gnu.node + node bench/config-performance/native-diagnostic.mjs --before "$RUNNER_TEMP/native-comparison/before.node" --after "$RUNNER_TEMP/native-comparison/after.node" --output "$RUNNER_TEMP/config-performance/native-comparison" + - name: Upload measurements if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 diff --git a/bench/config-performance/native-diagnostic.mjs b/bench/config-performance/native-diagnostic.mjs new file mode 100644 index 0000000000..a7d5b472f4 --- /dev/null +++ b/bench/config-performance/native-diagnostic.mjs @@ -0,0 +1,316 @@ +import { spawnSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { + appendFileSync, + cpSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; +import { cpus, hostname, release, tmpdir } from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { parseArgs } from 'node:util'; + +const { values } = parseArgs({ + options: { + before: { type: 'string' }, + after: { type: 'string' }, + output: { type: 'string' }, + samples: { type: 'string', default: '21' }, + warmup: { type: 'string', default: '3' }, + }, +}); +if (!values.before || !values.after || !values.output) { + throw new Error('Pass --before, --after and --output'); +} +const samples = Number(values.samples); +const warmup = Number(values.warmup); +const output = path.resolve(values.output); +mkdirSync(output, { recursive: true }); +const temporary = realpathSync(mkdtempSync(path.join(tmpdir(), 'vp-native-diagnostic-'))); +const cliPackage = fileURLToPath(new URL('../../packages/cli/', import.meta.url)); +const fixture = path.join(temporary, 'project'); +const app = path.join(fixture, 'packages/app'); +const bin = path.join(temporary, 'bin'); +const files = [0, 1, 2].map((i) => `packages/app/src/f${i}.ts`); +const configs = { + none: undefined, + minimal: "export default { staged: { '*.ts': 'vp check --fix' } };\n", + defined: + "import { defineConfig } from 'vite-plus';\nexport default defineConfig({ staged: { '*.ts': 'vp check --fix' } });\n", + blocks: "export default { staged: { '*.ts': 'vp check --fix' }, lint: {}, fmt: {} };\n", + noop: "export default { staged: { '*.ts': 'node -e \"process.exit(0)\"' } };\n", +}; +const cases = [ + { id: 'root/check/no-config', config: 'none', command: 'check' }, + { id: 'root/check/minimal', config: 'minimal', command: 'check' }, + { id: 'root/check/defineConfig', config: 'defined', command: 'check' }, + { id: 'package/check/minimal', config: 'minimal', command: 'check', package: true }, + { id: 'package/check/blocks', config: 'blocks', command: 'check', package: true }, + { id: 'root/fmt/minimal', config: 'minimal', command: 'fmt' }, + { id: 'root/lint/minimal', config: 'minimal', command: 'lint' }, + { id: 'root/staged/minimal', config: 'minimal', command: 'staged' }, + { id: 'root/staged/noop', config: 'noop', command: 'staged' }, +]; +const variants = [ + { label: 'before', binding: path.resolve(values.before) }, + { label: 'after', binding: path.resolve(values.after) }, + { label: 'control', binding: path.resolve(values.after) }, +]; +const env = { ...process.env }; +for (const key of Object.keys(env)) { + if ( + key.startsWith('VP_') || + key.startsWith('GIT_') || + ['DEBUG', 'NODE_OPTIONS', 'NODE_DISABLE_COMPILE_CACHE', 'NAPI_RS_NATIVE_LIBRARY_PATH'].includes( + key, + ) + ) { + delete env[key]; + } +} +Object.assign(env, { PATH: `${bin}${path.delimiter}${env.PATH}`, NO_COLOR: '1', FORCE_COLOR: '0' }); +function run(program, args, cwd = fixture, extraEnv = {}) { + const start = performance.now(); + const child = spawnSync(program, args, { + cwd, + env: { ...env, ...extraEnv }, + encoding: 'utf8', + timeout: 60000, + }); + const elapsed = performance.now() - start; + if (child.error || child.status !== 0) { + throw new Error( + `${program} ${args.join(' ')}: ${child.error ?? ''}\n${child.stdout}\n${child.stderr}`, + ); + } + return { elapsed, stdout: child.stdout, stderr: child.stderr }; +} +const git = (...args) => run('git', ['-c', 'core.hooksPath=/dev/null', ...args]).stdout; +function telemetry() { + return Object.fromEntries( + [ + '/proc/cpuinfo', + '/proc/stat', + '/proc/loadavg', + '/sys/fs/cgroup/cpu.stat', + '/sys/fs/cgroup/cpu.max', + ] + .filter(existsSync) + .map((file) => [file, readFileSync(file, 'utf8')]), + ); +} +const report = { + node: process.version, + platform: process.platform, + arch: process.arch, + cpus: cpus(), + hostname: hostname(), + kernel: release(), + beforeRevision: process.env.BASE_NATIVE_REVISION, + afterRevision: process.env.HEAD_NATIVE_REVISION, + nativeProfile: process.env.CI ? 'release' : 'debug', + samples, + warmup, + configs, + variants: variants.map((v) => ({ + label: v.label, + sha256: createHash('sha256').update(readFileSync(v.binding)).digest('hex'), + })), + telemetryBefore: telemetry(), + measurements: [], +}; +try { + mkdirSync(path.join(app, 'src'), { recursive: true }); + mkdirSync(path.join(fixture, 'node_modules')); + mkdirSync(bin); + symlinkSync(process.execPath, path.join(bin, 'node')); + writeFileSync( + path.join(fixture, 'package.json'), + JSON.stringify({ private: true, type: 'module', workspaces: ['packages/*'] }), + ); + writeFileSync(path.join(fixture, 'pnpm-workspace.yaml'), 'packages:\n - packages/*\n'); + writeFileSync( + path.join(app, 'package.json'), + JSON.stringify({ name: 'config-perf-app', private: true, type: 'module' }), + ); + writeFileSync(path.join(fixture, '.gitignore'), 'node_modules\nvite.config.ts\n'); + for (const [i, file] of files.entries()) { + writeFileSync(path.join(fixture, file), `export const f${i} = ${i};\n`); + } + git('init', '--quiet'); + git('add', '.'); + git( + '-c', + 'user.name=Benchmark', + '-c', + 'user.email=benchmark@example.invalid', + '-c', + 'commit.gpgsign=false', + 'commit', + '--quiet', + '-m', + 'fixture', + ); + for (const variant of variants) { + variant.root = path.join(temporary, variant.label); + mkdirSync(path.join(variant.root, 'binding'), { recursive: true }); + for (const file of ['bin', 'dist', 'package.json']) { + cpSync(path.join(cliPackage, file), path.join(variant.root, file), { recursive: true }); + } + for (const file of ['index.js', 'index.cjs']) { + cpSync(path.join(cliPackage, 'binding', file), path.join(variant.root, 'binding', file)); + } + // Only this CLI's loader changes. A global NAPI override would also affect Oxc. + const loader = path.join(variant.root, 'binding/index.cjs'); + let code = readFileSync(loader, 'utf8'); + const needle = 'function requireNative() {'; + if (!code.includes(needle)) { + throw new Error('Unrecognized NAPI loader'); + } + code = code.replace(needle, `${needle}\n return require(${JSON.stringify(variant.binding)});`); + writeFileSync(loader, code); + symlinkSync( + path.join(cliPackage, 'node_modules'), + path.join(variant.root, 'node_modules'), + 'dir', + ); + } + for (let round = 0; round < warmup + samples; round++) { + for (let offset = 0; offset < cases.length; offset++) { + const item = cases[(round + offset) % cases.length]; + for (let v = 0; v < variants.length; v++) { + const variant = variants[(round + offset + v) % variants.length]; + for (const [link, target] of [ + [path.join(fixture, 'node_modules/vite-plus'), variant.root], + [path.join(bin, 'vp'), path.join(variant.root, 'bin/vp')], + ]) { + rmSync(link, { force: true }); + symlinkSync(target, link); + } + const configPath = path.join(fixture, 'vite.config.ts'); + rmSync(configPath, { force: true }); + if (configs[item.config] !== undefined) { + writeFileSync(configPath, configs[item.config]); + } + for (const [i, file] of files.entries()) { + writeFileSync( + path.join(fixture, file), + `export const f${i}={message:"fixture",value:${i}}\n`, + ); + } + if (item.command === 'staged') { + git('add', '--', ...files); + } + const args = ['-p', process.execPath, path.join(variant.root, 'bin/vp'), item.command]; + if (item.command === 'check' || item.command === 'lint') { + args.push('--fix'); + } + if (item.command !== 'staged') { + args.push( + ...files.map((file) => (item.package ? path.relative('packages/app', file) : file)), + ); + } + const measured = run('/usr/bin/time', args, item.package ? app : fixture, { + NODE_COMPILE_CACHE: path.join(temporary, `${variant.label}-compile-cache`), + }); + const times = Object.fromEntries( + ['real', 'user', 'sys'].map((key) => { + const value = measured.stderr.match( + new RegExp(`^${key}\\s+(\\d+(?:\\.\\d+)?)$`, 'm'), + )?.[1]; + if (!value) { + throw new Error(`Missing ${key}: ${measured.stderr}`); + } + return [key, Number(value)]; + }), + ); + if (item.command !== 'lint' && item.config !== 'noop') { + for (const file of files) { + const formatted = + item.command === 'staged' + ? git('show', `:${file}`) + : readFileSync(path.join(fixture, file), 'utf8'); + if (!formatted.includes('= {')) { + throw new Error(`${item.id}/${variant.label} did not format ${file}`); + } + } + } + if (round >= warmup) { + report.measurements.push({ + case: item.id, + variant: variant.label, + round: round - warmup, + order: v, + elapsed: measured.elapsed, + real: times.real, + user: times.user, + sys: times.sys, + }); + } + } + } + process.stdout.write(`Completed native diagnostic round ${round + 1}/${warmup + samples}\n`); + } +} finally { + report.telemetryAfter = telemetry(); + writeFileSync(path.join(output, 'results.json'), JSON.stringify(report, null, 2) + '\n'); + rmSync(temporary, { recursive: true, force: true }); +} +const median = (values) => values.toSorted((a, b) => a - b)[Math.floor(values.length / 2)]; +const rows = cases.map((item) => { + const times = Object.fromEntries( + variants.map((v) => [ + v.label, + median( + report.measurements + .filter((m) => m.case === item.id && m.variant === v.label) + .map((m) => m.elapsed), + ), + ]), + ); + const cpu = Object.fromEntries( + variants.map((v) => [ + v.label, + median( + report.measurements + .filter((m) => m.case === item.id && m.variant === v.label) + .map((m) => m.user + m.sys), + ), + ]), + ); + return { + case: item.id, + before: times.before, + after: times.after, + control: times.control, + change: (times.after / times.before - 1) * 100, + controlChange: (times.control / times.after - 1) * 100, + cpu, + }; +}); +writeFileSync(path.join(output, 'summary.json'), JSON.stringify(rows, null, 2) + '\n'); +const markdown = [ + '## Same-runner native diagnostic', + '', + `Linux CI uses release binaries; ${samples} samples and ${warmup} warmups per version/case. Cases and version order rotate. JavaScript, dependencies and fixture content are identical. Each version has its own warm compile cache. The control loads the exact same binary as after.`, + '', + '| Case | Before (ms) | After (ms) | Change | Identical binary control change | CPU before → after (s) |', + '| --- | ---: | ---: | ---: | ---: | ---: |', + ...rows.map( + (r) => + `| ${r.case} | ${r.before.toFixed(1)} | ${r.after.toFixed(1)} | ${r.change.toFixed(1)}% | ${r.controlChange.toFixed(1)}% | ${r.cpu.before.toFixed(2)} → ${r.cpu.after.toFixed(2)} |`, + ), + '', +].join('\n'); +writeFileSync(path.join(output, 'summary.md'), markdown); +if (process.env.GITHUB_STEP_SUMMARY) { + appendFileSync(process.env.GITHUB_STEP_SUMMARY, markdown); +} +process.stdout.write(markdown); From 68fe5afb7c22d2bd6adaee9bbd4762f393ad6f99 Mon Sep 17 00:00:00 2001 From: MK Date: Tue, 22 Sep 2026 23:10:17 +0800 Subject: [PATCH 3/3] Revert "ci: diagnose config performance on one runner" This reverts commit d87053e8c77c3386d232d49d0e16f922b936d8f8. --- .github/workflows/config-performance.yml | 22 +- .../config-performance/native-diagnostic.mjs | 316 ------------------ 2 files changed, 1 insertion(+), 337 deletions(-) delete mode 100644 bench/config-performance/native-diagnostic.mjs diff --git a/.github/workflows/config-performance.yml b/.github/workflows/config-performance.yml index aa5f8994df..28327834f1 100644 --- a/.github/workflows/config-performance.yml +++ b/.github/workflows/config-performance.yml @@ -33,7 +33,7 @@ jobs: benchmark: name: Config loading benchmark runs-on: namespace-profile-linux-x64-default - timeout-minutes: 40 + timeout-minutes: 30 outputs: report-ready: ${{ steps.measure.outputs.report-ready }} steps: @@ -111,26 +111,6 @@ jobs: fi node bench/config-performance/run.ts --output "$RUNNER_TEMP/config-performance" "${args[@]}" - # Temporary diagnostic for #2787: compare native revisions on one runner. - - name: Compare native revisions on the same runner - if: github.event.pull_request.number == 2787 - env: - BASE_NATIVE_REVISION: 76ce17ced5f46d54ac16ad0627e714d0a8270bf5 - HEAD_NATIVE_REVISION: 0afe9b5c9065f2210375a40e7cb45f9dce4cda2e - TARGET_CC: clang - run: | - git fetch origin "$BASE_NATIVE_REVISION" "$HEAD_NATIVE_REVISION" - # Sharing JavaScript is valid only when these inputs are identical. - git diff --exit-code "$BASE_NATIVE_REVISION" "$HEAD_NATIVE_REVISION" -- packages/cli/src packages/cli/bin packages/cli/build.ts packages/cli/tsdown.config.ts packages/cli/package.json packages/core packages/tools/.upstream-versions.json pnpm-lock.yaml Cargo.lock rust-toolchain.toml .cargo - mkdir -p "$RUNNER_TEMP/native-comparison" - cp packages/cli/binding/vite-plus.linux-x64-gnu.node "$RUNNER_TEMP/native-comparison/after.node" - git restore --source "$BASE_NATIVE_REVISION" -- crates/vp_static_config/src packages/cli/binding/src/cli/resolver.rs - pnpm --filter=vite-plus build-native --skip-format --target x86_64-unknown-linux-gnu --use-napi-cross - cp packages/cli/binding/vite-plus.linux-x64-gnu.node "$RUNNER_TEMP/native-comparison/before.node" - git restore --source HEAD -- crates/vp_static_config/src packages/cli/binding/src/cli/resolver.rs - cp "$RUNNER_TEMP/native-comparison/after.node" packages/cli/binding/vite-plus.linux-x64-gnu.node - node bench/config-performance/native-diagnostic.mjs --before "$RUNNER_TEMP/native-comparison/before.node" --after "$RUNNER_TEMP/native-comparison/after.node" --output "$RUNNER_TEMP/config-performance/native-comparison" - - name: Upload measurements if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 diff --git a/bench/config-performance/native-diagnostic.mjs b/bench/config-performance/native-diagnostic.mjs deleted file mode 100644 index a7d5b472f4..0000000000 --- a/bench/config-performance/native-diagnostic.mjs +++ /dev/null @@ -1,316 +0,0 @@ -import { spawnSync } from 'node:child_process'; -import { createHash } from 'node:crypto'; -import { - appendFileSync, - cpSync, - existsSync, - mkdirSync, - mkdtempSync, - readFileSync, - realpathSync, - rmSync, - symlinkSync, - writeFileSync, -} from 'node:fs'; -import { cpus, hostname, release, tmpdir } from 'node:os'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { parseArgs } from 'node:util'; - -const { values } = parseArgs({ - options: { - before: { type: 'string' }, - after: { type: 'string' }, - output: { type: 'string' }, - samples: { type: 'string', default: '21' }, - warmup: { type: 'string', default: '3' }, - }, -}); -if (!values.before || !values.after || !values.output) { - throw new Error('Pass --before, --after and --output'); -} -const samples = Number(values.samples); -const warmup = Number(values.warmup); -const output = path.resolve(values.output); -mkdirSync(output, { recursive: true }); -const temporary = realpathSync(mkdtempSync(path.join(tmpdir(), 'vp-native-diagnostic-'))); -const cliPackage = fileURLToPath(new URL('../../packages/cli/', import.meta.url)); -const fixture = path.join(temporary, 'project'); -const app = path.join(fixture, 'packages/app'); -const bin = path.join(temporary, 'bin'); -const files = [0, 1, 2].map((i) => `packages/app/src/f${i}.ts`); -const configs = { - none: undefined, - minimal: "export default { staged: { '*.ts': 'vp check --fix' } };\n", - defined: - "import { defineConfig } from 'vite-plus';\nexport default defineConfig({ staged: { '*.ts': 'vp check --fix' } });\n", - blocks: "export default { staged: { '*.ts': 'vp check --fix' }, lint: {}, fmt: {} };\n", - noop: "export default { staged: { '*.ts': 'node -e \"process.exit(0)\"' } };\n", -}; -const cases = [ - { id: 'root/check/no-config', config: 'none', command: 'check' }, - { id: 'root/check/minimal', config: 'minimal', command: 'check' }, - { id: 'root/check/defineConfig', config: 'defined', command: 'check' }, - { id: 'package/check/minimal', config: 'minimal', command: 'check', package: true }, - { id: 'package/check/blocks', config: 'blocks', command: 'check', package: true }, - { id: 'root/fmt/minimal', config: 'minimal', command: 'fmt' }, - { id: 'root/lint/minimal', config: 'minimal', command: 'lint' }, - { id: 'root/staged/minimal', config: 'minimal', command: 'staged' }, - { id: 'root/staged/noop', config: 'noop', command: 'staged' }, -]; -const variants = [ - { label: 'before', binding: path.resolve(values.before) }, - { label: 'after', binding: path.resolve(values.after) }, - { label: 'control', binding: path.resolve(values.after) }, -]; -const env = { ...process.env }; -for (const key of Object.keys(env)) { - if ( - key.startsWith('VP_') || - key.startsWith('GIT_') || - ['DEBUG', 'NODE_OPTIONS', 'NODE_DISABLE_COMPILE_CACHE', 'NAPI_RS_NATIVE_LIBRARY_PATH'].includes( - key, - ) - ) { - delete env[key]; - } -} -Object.assign(env, { PATH: `${bin}${path.delimiter}${env.PATH}`, NO_COLOR: '1', FORCE_COLOR: '0' }); -function run(program, args, cwd = fixture, extraEnv = {}) { - const start = performance.now(); - const child = spawnSync(program, args, { - cwd, - env: { ...env, ...extraEnv }, - encoding: 'utf8', - timeout: 60000, - }); - const elapsed = performance.now() - start; - if (child.error || child.status !== 0) { - throw new Error( - `${program} ${args.join(' ')}: ${child.error ?? ''}\n${child.stdout}\n${child.stderr}`, - ); - } - return { elapsed, stdout: child.stdout, stderr: child.stderr }; -} -const git = (...args) => run('git', ['-c', 'core.hooksPath=/dev/null', ...args]).stdout; -function telemetry() { - return Object.fromEntries( - [ - '/proc/cpuinfo', - '/proc/stat', - '/proc/loadavg', - '/sys/fs/cgroup/cpu.stat', - '/sys/fs/cgroup/cpu.max', - ] - .filter(existsSync) - .map((file) => [file, readFileSync(file, 'utf8')]), - ); -} -const report = { - node: process.version, - platform: process.platform, - arch: process.arch, - cpus: cpus(), - hostname: hostname(), - kernel: release(), - beforeRevision: process.env.BASE_NATIVE_REVISION, - afterRevision: process.env.HEAD_NATIVE_REVISION, - nativeProfile: process.env.CI ? 'release' : 'debug', - samples, - warmup, - configs, - variants: variants.map((v) => ({ - label: v.label, - sha256: createHash('sha256').update(readFileSync(v.binding)).digest('hex'), - })), - telemetryBefore: telemetry(), - measurements: [], -}; -try { - mkdirSync(path.join(app, 'src'), { recursive: true }); - mkdirSync(path.join(fixture, 'node_modules')); - mkdirSync(bin); - symlinkSync(process.execPath, path.join(bin, 'node')); - writeFileSync( - path.join(fixture, 'package.json'), - JSON.stringify({ private: true, type: 'module', workspaces: ['packages/*'] }), - ); - writeFileSync(path.join(fixture, 'pnpm-workspace.yaml'), 'packages:\n - packages/*\n'); - writeFileSync( - path.join(app, 'package.json'), - JSON.stringify({ name: 'config-perf-app', private: true, type: 'module' }), - ); - writeFileSync(path.join(fixture, '.gitignore'), 'node_modules\nvite.config.ts\n'); - for (const [i, file] of files.entries()) { - writeFileSync(path.join(fixture, file), `export const f${i} = ${i};\n`); - } - git('init', '--quiet'); - git('add', '.'); - git( - '-c', - 'user.name=Benchmark', - '-c', - 'user.email=benchmark@example.invalid', - '-c', - 'commit.gpgsign=false', - 'commit', - '--quiet', - '-m', - 'fixture', - ); - for (const variant of variants) { - variant.root = path.join(temporary, variant.label); - mkdirSync(path.join(variant.root, 'binding'), { recursive: true }); - for (const file of ['bin', 'dist', 'package.json']) { - cpSync(path.join(cliPackage, file), path.join(variant.root, file), { recursive: true }); - } - for (const file of ['index.js', 'index.cjs']) { - cpSync(path.join(cliPackage, 'binding', file), path.join(variant.root, 'binding', file)); - } - // Only this CLI's loader changes. A global NAPI override would also affect Oxc. - const loader = path.join(variant.root, 'binding/index.cjs'); - let code = readFileSync(loader, 'utf8'); - const needle = 'function requireNative() {'; - if (!code.includes(needle)) { - throw new Error('Unrecognized NAPI loader'); - } - code = code.replace(needle, `${needle}\n return require(${JSON.stringify(variant.binding)});`); - writeFileSync(loader, code); - symlinkSync( - path.join(cliPackage, 'node_modules'), - path.join(variant.root, 'node_modules'), - 'dir', - ); - } - for (let round = 0; round < warmup + samples; round++) { - for (let offset = 0; offset < cases.length; offset++) { - const item = cases[(round + offset) % cases.length]; - for (let v = 0; v < variants.length; v++) { - const variant = variants[(round + offset + v) % variants.length]; - for (const [link, target] of [ - [path.join(fixture, 'node_modules/vite-plus'), variant.root], - [path.join(bin, 'vp'), path.join(variant.root, 'bin/vp')], - ]) { - rmSync(link, { force: true }); - symlinkSync(target, link); - } - const configPath = path.join(fixture, 'vite.config.ts'); - rmSync(configPath, { force: true }); - if (configs[item.config] !== undefined) { - writeFileSync(configPath, configs[item.config]); - } - for (const [i, file] of files.entries()) { - writeFileSync( - path.join(fixture, file), - `export const f${i}={message:"fixture",value:${i}}\n`, - ); - } - if (item.command === 'staged') { - git('add', '--', ...files); - } - const args = ['-p', process.execPath, path.join(variant.root, 'bin/vp'), item.command]; - if (item.command === 'check' || item.command === 'lint') { - args.push('--fix'); - } - if (item.command !== 'staged') { - args.push( - ...files.map((file) => (item.package ? path.relative('packages/app', file) : file)), - ); - } - const measured = run('/usr/bin/time', args, item.package ? app : fixture, { - NODE_COMPILE_CACHE: path.join(temporary, `${variant.label}-compile-cache`), - }); - const times = Object.fromEntries( - ['real', 'user', 'sys'].map((key) => { - const value = measured.stderr.match( - new RegExp(`^${key}\\s+(\\d+(?:\\.\\d+)?)$`, 'm'), - )?.[1]; - if (!value) { - throw new Error(`Missing ${key}: ${measured.stderr}`); - } - return [key, Number(value)]; - }), - ); - if (item.command !== 'lint' && item.config !== 'noop') { - for (const file of files) { - const formatted = - item.command === 'staged' - ? git('show', `:${file}`) - : readFileSync(path.join(fixture, file), 'utf8'); - if (!formatted.includes('= {')) { - throw new Error(`${item.id}/${variant.label} did not format ${file}`); - } - } - } - if (round >= warmup) { - report.measurements.push({ - case: item.id, - variant: variant.label, - round: round - warmup, - order: v, - elapsed: measured.elapsed, - real: times.real, - user: times.user, - sys: times.sys, - }); - } - } - } - process.stdout.write(`Completed native diagnostic round ${round + 1}/${warmup + samples}\n`); - } -} finally { - report.telemetryAfter = telemetry(); - writeFileSync(path.join(output, 'results.json'), JSON.stringify(report, null, 2) + '\n'); - rmSync(temporary, { recursive: true, force: true }); -} -const median = (values) => values.toSorted((a, b) => a - b)[Math.floor(values.length / 2)]; -const rows = cases.map((item) => { - const times = Object.fromEntries( - variants.map((v) => [ - v.label, - median( - report.measurements - .filter((m) => m.case === item.id && m.variant === v.label) - .map((m) => m.elapsed), - ), - ]), - ); - const cpu = Object.fromEntries( - variants.map((v) => [ - v.label, - median( - report.measurements - .filter((m) => m.case === item.id && m.variant === v.label) - .map((m) => m.user + m.sys), - ), - ]), - ); - return { - case: item.id, - before: times.before, - after: times.after, - control: times.control, - change: (times.after / times.before - 1) * 100, - controlChange: (times.control / times.after - 1) * 100, - cpu, - }; -}); -writeFileSync(path.join(output, 'summary.json'), JSON.stringify(rows, null, 2) + '\n'); -const markdown = [ - '## Same-runner native diagnostic', - '', - `Linux CI uses release binaries; ${samples} samples and ${warmup} warmups per version/case. Cases and version order rotate. JavaScript, dependencies and fixture content are identical. Each version has its own warm compile cache. The control loads the exact same binary as after.`, - '', - '| Case | Before (ms) | After (ms) | Change | Identical binary control change | CPU before → after (s) |', - '| --- | ---: | ---: | ---: | ---: | ---: |', - ...rows.map( - (r) => - `| ${r.case} | ${r.before.toFixed(1)} | ${r.after.toFixed(1)} | ${r.change.toFixed(1)}% | ${r.controlChange.toFixed(1)}% | ${r.cpu.before.toFixed(2)} → ${r.cpu.after.toFixed(2)} |`, - ), - '', -].join('\n'); -writeFileSync(path.join(output, 'summary.md'), markdown); -if (process.env.GITHUB_STEP_SUMMARY) { - appendFileSync(process.env.GITHUB_STEP_SUMMARY, markdown); -} -process.stdout.write(markdown);