From 3d18971eaae7c054a3a8a12a414771353313e41c Mon Sep 17 00:00:00 2001 From: Kyle Into Date: Wed, 22 Jul 2026 18:17:23 -0700 Subject: [PATCH 1/8] Run benchmarks on CodSpeed in CI Add a CodSpeed GitHub Actions workflow so the landed criterion benchmarks run continuously: instruction-count (simulation) mode for the deterministic microbenchmarks, walltime mode for the heavy real-world PyTorch benchmarks (which must not run under Valgrind). Swap the `criterion` dev-dependency to `codspeed-criterion-compat` (aliased back to `criterion`) so the existing bench harness emits CodSpeed measurements under `cargo codspeed` while behaving like plain criterion otherwise, and add a `[profile.bench]` that drops LTO/single-codegen-unit to keep the CI build within runner memory limits. --- .github/workflows/codspeed.yml | 68 ++++++++++++++++++++++++++ Cargo.lock | 89 +++++++++++++++++++++++----------- Cargo.toml | 9 ++++ pyrefly/Cargo.toml | 2 +- 4 files changed, 140 insertions(+), 28 deletions(-) create mode 100644 .github/workflows/codspeed.yml diff --git a/.github/workflows/codspeed.yml b/.github/workflows/codspeed.yml new file mode 100644 index 0000000000..d2e6bb2d95 --- /dev/null +++ b/.github/workflows/codspeed.yml @@ -0,0 +1,68 @@ +name: CodSpeed +on: + push: + branches: [ main ] + pull_request: + # workflow_dispatch lets CodSpeed trigger backtest runs to seed baseline data. + workflow_dispatch: +permissions: + contents: read + id-token: write # OpenID Connect auth with CodSpeed (no token secret required) +jobs: + # Instruction-count (Valgrind) measurement of the small, deterministic + # microbenchmarks. They are single-threaded and fast, so callgrind's overhead + # is bounded and the instruction counts are stable run-to-run. + simulation: + name: Microbenchmarks (simulation) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: dtolnay/rust-toolchain@master + with: + toolchain: stable + - name: set up rust cache + uses: Swatinem/rust-cache@v2 + with: + prefix-key: pyrefly-codspeed + - name: install cargo-codspeed + uses: taiki-e/install-action@v2 + with: + tool: cargo-codspeed + - name: build benchmarks + run: cargo codspeed build -p pyrefly --bench micro + - name: run benchmarks + uses: CodSpeedHQ/action@v4 + with: + mode: simulation + run: cargo codspeed run -p pyrefly --bench micro + # Wall-clock measurement of the heavy, real-world PyTorch benchmarks. They drive + # the full LSP server over all cores against the pinned PyTorch checkout, so they + # must not run under Valgrind (which serializes threads and inflates the ~GB cold + # start past any reasonable timeout). Walltime mode tolerates threads, I/O, and + # long cold starts. CodSpeed's dedicated `codspeed-macro` runners give the lowest + # variance for walltime; `ubuntu-latest` also works if they aren't provisioned. + walltime: + name: PyTorch benchmarks (walltime) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + # No submodule checkout: in OSS the benchmark clones the pinned PyTorch itself + # (rev from benches/pytorch_pin.bzl) on first run. The runner has github egress. + - uses: dtolnay/rust-toolchain@master + with: + toolchain: stable + - name: set up rust cache + uses: Swatinem/rust-cache@v2 + with: + prefix-key: pyrefly-codspeed-walltime + - name: install cargo-codspeed + uses: taiki-e/install-action@v2 + with: + tool: cargo-codspeed + - name: build benchmarks + run: cargo codspeed build -p pyrefly --bench pytorch + - name: run benchmarks + uses: CodSpeedHQ/action@v4 + with: + mode: walltime + run: cargo codspeed run -p pyrefly --bench pytorch diff --git a/Cargo.lock b/Cargo.lock index 8a38e418f6..0ac9247718 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -461,6 +461,57 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +[[package]] +name = "codspeed" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93f4cce9c27c49c4f101fffeebb1826f41a9df2e7498b7cd4d95c0658b796c6c" +dependencies = [ + "colored", + "libc", + "serde", + "serde_json", + "uuid", +] + +[[package]] +name = "codspeed-criterion-compat" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3c23d880a28a2aab52d38ca8481dd7a3187157d0a952196b6db1db3c8499725" +dependencies = [ + "codspeed", + "codspeed-criterion-compat-walltime", + "colored", +] + +[[package]] +name = "codspeed-criterion-compat-walltime" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b0a2f7365e347f4f22a67e9ea689bf7bc89900a354e22e26cf8a531a42c8fbb" +dependencies = [ + "anes", + "cast", + "ciborium", + "clap", + "codspeed", + "criterion-plot", + "is-terminal", + "itertools 0.10.5", + "num-traits", + "once_cell", + "oorandom", + "plotters", + "rayon", + "regex", + "serde", + "serde_derive", + "serde_json", + "tinytemplate", + "walkdir", +] + [[package]] name = "collection_literals" version = "1.0.3" @@ -473,6 +524,16 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "colored" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c" +dependencies = [ + "lazy_static", + "windows-sys 0.59.0", +] + [[package]] name = "compact_str" version = "0.9.1" @@ -552,32 +613,6 @@ dependencies = [ "libc", ] -[[package]] -name = "criterion" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f" -dependencies = [ - "anes", - "cast", - "ciborium", - "clap", - "criterion-plot", - "is-terminal", - "itertools 0.10.5", - "num-traits", - "once_cell", - "oorandom", - "plotters", - "rayon", - "regex", - "serde", - "serde_derive", - "serde_json", - "tinytemplate", - "walkdir", -] - [[package]] name = "criterion-plot" version = "0.5.0" @@ -2086,7 +2121,7 @@ dependencies = [ "blake3", "capnp", "clap", - "criterion", + "codspeed-criterion-compat", "crossbeam-channel", "dashmap", "dupe", diff --git a/Cargo.toml b/Cargo.toml index 038ea4797d..36ee1da7ad 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,15 @@ strip = "debuginfo" [profile.dev] debug = 1 +# Benchmarks don't need release's whole-program LTO with a single codegen unit. +# Disabling LTO and using multiple codegen units keeps the CodSpeed build within +# the memory budget of standard CI runners (and builds much faster) without +# affecting CodSpeed's deterministic instruction-count measurements. +[profile.bench] +inherits = "release" +lto = false +codegen-units = 16 + [profile.dbg] debug = true inherits = "dev" diff --git a/pyrefly/Cargo.toml b/pyrefly/Cargo.toml index 34525ee75d..5e10c62675 100644 --- a/pyrefly/Cargo.toml +++ b/pyrefly/Cargo.toml @@ -85,7 +85,7 @@ xxhash-rust = { version = "0.8.15", features = ["xxh64"] } yansi = { version = "1.0.0-rc.1", features = ["hyperlink"] } [dev-dependencies] -criterion = { version = "0.5", features = ["html_reports"] } +criterion = { package = "codspeed-criterion-compat", version = "2" } pretty_assertions = { version = "1.4.1", features = ["alloc"], default-features = false } pyrefly_lsp_test = { path = "../crates/pyrefly_lsp_test" } From 715a49276bc18563baa33dc510ca7a1628894832 Mon Sep 17 00:00:00 2001 From: Kyle Into Date: Thu, 23 Jul 2026 12:46:31 -0700 Subject: [PATCH 2/8] [pyrefly] Skip interpreter discovery in micro benchmarks configure() otherwise spawns python3 subprocesses to probe the interpreter and site-packages during state init. Under CodSpeed's instrumented (Valgrind) instrument those syscalls (~65 execve, ~300ms) land in the measured region, triggering the "cannot instrument system calls" warning and polluting instruction counts. The snippets only use stdlib, served from bundled in-memory typeshed, so no interpreter or site-packages are needed. --- pyrefly/benches/micro.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/pyrefly/benches/micro.rs b/pyrefly/benches/micro.rs index 122146f8f3..60a2de8a40 100644 --- a/pyrefly/benches/micro.rs +++ b/pyrefly/benches/micro.rs @@ -57,6 +57,12 @@ static SHARED_STATE: LazyLock = LazyLock::new(|| { let mut c = ConfigFile::default(); c.python_environment.python_version = Some(PythonVersion::default()); c.python_environment.python_platform = Some(PythonPlatform::default()); + // Skip interpreter discovery: `configure()` otherwise spawns `python3` + // subprocesses to probe site-packages, whose syscalls land in the measured + // region under CodSpeed's instrumented instrument. The snippets only use + // stdlib, served from bundled in-memory typeshed, so no site-packages exist. + c.interpreters.skip_interpreter_query = true; + c.python_environment.site_package_path = Some(Vec::new()); c.configure(); ArcId::new(c) }; From bf07cb3a477b4c670bea0cedfb4aa8cfeaf66d1b Mon Sep 17 00:00:00 2001 From: Kyle Into Date: Thu, 23 Jul 2026 13:03:01 -0700 Subject: [PATCH 3/8] Build PyTorch benchmarks in walltime mode for CodSpeed The walltime job ran `cargo codspeed run --mode walltime` but built with `cargo codspeed build` (default instrumentation mode), so no walltime-built benchmarks existed and the run failed with "No benchmarks found for the walltime mode". Pass `-m walltime` to the build so it matches the run mode. --- .github/workflows/codspeed.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/codspeed.yml b/.github/workflows/codspeed.yml index d2e6bb2d95..656032e902 100644 --- a/.github/workflows/codspeed.yml +++ b/.github/workflows/codspeed.yml @@ -60,7 +60,7 @@ jobs: with: tool: cargo-codspeed - name: build benchmarks - run: cargo codspeed build -p pyrefly --bench pytorch + run: cargo codspeed build -m walltime -p pyrefly --bench pytorch - name: run benchmarks uses: CodSpeedHQ/action@v4 with: From 720f7ff09efc97baf9be6035430a32bac667bb18 Mon Sep 17 00:00:00 2001 From: Kyle Into Date: Thu, 23 Jul 2026 13:54:15 -0700 Subject: [PATCH 4/8] Bump codspeed-criterion-compat to v5 to match cargo-codspeed CodSpeed CI installs the latest cargo-codspeed (v5.0.1), but the compat crate was pinned to major v2. Walltime result collection requires the compat layer and the cargo-codspeed binary to share a major version, so the walltime job failed with a version-incompatibility error. Align the compat crate (and its transitive codspeed runtime) to v5. --- Cargo.lock | 67 +++++++++++++++++++++++++++++++++++++--------- pyrefly/Cargo.toml | 2 +- 2 files changed, 56 insertions(+), 13 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0ac9247718..8685b3f50a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -146,6 +146,15 @@ version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2114736faba96bcd79595c700d03183f61357b9fbce14852515e59f3bee4ed4a" +[[package]] +name = "approx" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] + [[package]] name = "arc-swap" version = "1.9.2" @@ -241,7 +250,7 @@ checksum = "7fd2d70527f3737a1ad17355e260706c1badebabd1fa06a7a053407380df841b" dependencies = [ "backtrace", "libc", - "nix", + "nix 0.23.1", ] [[package]] @@ -370,6 +379,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + [[package]] name = "chacha20" version = "0.10.1" @@ -463,33 +478,40 @@ checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] name = "codspeed" -version = "2.10.1" +version = "5.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93f4cce9c27c49c4f101fffeebb1826f41a9df2e7498b7cd4d95c0658b796c6c" +checksum = "7083f253260bcb4aaa3b4aa4c52973703dabc1a85c2f193997e2689aafa8a919" dependencies = [ + "anyhow", + "cc", "colored", + "getrandom 0.4.2", + "glob", "libc", + "nix 0.31.3", "serde", "serde_json", - "uuid", + "statrs", ] [[package]] name = "codspeed-criterion-compat" -version = "2.10.1" +version = "5.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3c23d880a28a2aab52d38ca8481dd7a3187157d0a952196b6db1db3c8499725" +checksum = "d9f24251445188c69d50f10179d795424bd71b533b7e3f84fc03f26893b01af0" dependencies = [ + "clap", "codspeed", "codspeed-criterion-compat-walltime", "colored", + "regex", ] [[package]] name = "codspeed-criterion-compat-walltime" -version = "2.10.1" +version = "5.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b0a2f7365e347f4f22a67e9ea689bf7bc89900a354e22e26cf8a531a42c8fbb" +checksum = "5c38205d56e2cb4fe04b708de7f9653a3f1b89edbe3a20b28f21e9e525e9e061" dependencies = [ "anes", "cast", @@ -526,12 +548,11 @@ checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] name = "colored" -version = "2.2.0" +version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c" +checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "lazy_static", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1750,6 +1771,18 @@ dependencies = [ "memoffset", ] +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags 2.13.1", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "notify" version = "8.2.0" @@ -3027,6 +3060,16 @@ dependencies = [ "lock_free_hashtable", ] +[[package]] +name = "statrs" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a3fe7c28c6512e766b0874335db33c94ad7b8f9054228ae1c2abd47ce7d335e" +dependencies = [ + "approx", + "num-traits", +] + [[package]] name = "strong_hash" version = "0.1.0" diff --git a/pyrefly/Cargo.toml b/pyrefly/Cargo.toml index 5e10c62675..5831cd3a65 100644 --- a/pyrefly/Cargo.toml +++ b/pyrefly/Cargo.toml @@ -85,7 +85,7 @@ xxhash-rust = { version = "0.8.15", features = ["xxh64"] } yansi = { version = "1.0.0-rc.1", features = ["hyperlink"] } [dev-dependencies] -criterion = { package = "codspeed-criterion-compat", version = "2" } +criterion = { package = "codspeed-criterion-compat", version = "5" } pretty_assertions = { version = "1.4.1", features = ["alloc"], default-features = false } pyrefly_lsp_test = { path = "../crates/pyrefly_lsp_test" } From ca8d9296401768370cc0576467c03975d70e494f Mon Sep 17 00:00:00 2001 From: Kyle Into Date: Thu, 23 Jul 2026 14:54:41 -0700 Subject: [PATCH 5/8] [pyrefly] Run micro benchmarks inline to avoid uninstrumentable syscalls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Under CodSpeed's instrumented (Valgrind) instrument, dispatching the check to a rayon pool — even a single-thread one — parks the measured thread on a futex for the full check while a worker runs it. That blocking cross-thread syscall can't be instrumented, so CodSpeed drops it from the measure and warns ("46 system calls, 304.7ms"). Add a ThreadCount::Inline mode that builds no rayon pool, so spawn_many runs the work directly on the calling thread, and use it for the micro benchmarks. All work then stays on the measured thread with no handoff. --- crates/pyrefly_util/src/thread_pool.rs | 14 ++++++++++++-- pyrefly/benches/micro.rs | 7 +++++-- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/crates/pyrefly_util/src/thread_pool.rs b/crates/pyrefly_util/src/thread_pool.rs index 619ecf62a1..74ef327878 100644 --- a/crates/pyrefly_util/src/thread_pool.rs +++ b/crates/pyrefly_util/src/thread_pool.rs @@ -27,6 +27,13 @@ pub enum ThreadCount { #[default] AllThreads, NumThreads(NonZeroUsize), + /// Run all work inline on the calling thread with no rayon pool. A rayon + /// pool — even one sized to a single thread — dispatches work to a worker + /// and blocks the caller on a futex for the whole duration. That futex + /// shows up under CodSpeed's instrumented instrument as an uninstrumentable + /// system call spanning the entire check. Running inline keeps all work on + /// the measured thread. + Inline, } /// Thread count used by tests. Enough threads to see parallelism bugs, but not too many to debug through. @@ -72,8 +79,10 @@ impl ThreadPool { } pub fn new(count: ThreadCount) -> Self { - if cfg!(target_arch = "wasm32") { - // ThreadPool doesn't work on WASM + if cfg!(target_arch = "wasm32") || count == ThreadCount::Inline { + // No pool: WASM can't spawn threads, and `Inline` deliberately runs + // work on the calling thread. `spawn_many`/`install` then invoke their + // closures directly, with no worker handoff. return Self(None); } @@ -89,6 +98,7 @@ impl ThreadPool { .unwrap_or(1); builder = builder.num_threads(max_threads); } + ThreadCount::Inline => unreachable!("handled by the early return above"), } let pool = builder.build().expect("To be able to build a thread pool"); // Only print the message once diff --git a/pyrefly/benches/micro.rs b/pyrefly/benches/micro.rs index 60a2de8a40..b3d381d0ab 100644 --- a/pyrefly/benches/micro.rs +++ b/pyrefly/benches/micro.rs @@ -25,7 +25,6 @@ //! Run with buck: `buck run @fbcode//mode/opt fbcode//pyrefly/pyrefly:micro_bench -- --bench` use std::fmt::Write as _; -use std::num::NonZeroUsize; use std::path::PathBuf; use std::sync::Arc; use std::sync::LazyLock; @@ -67,7 +66,11 @@ static SHARED_STATE: LazyLock = LazyLock::new(|| { ArcId::new(c) }; let finder = ConfigFinder::new_constant(config); - let state = State::new(finder, ThreadCount::NumThreads(NonZeroUsize::MIN)); + // Inline (no rayon pool): a pooled run blocks the measured thread on a futex + // while a worker does the check, which CodSpeed's instrumented instrument + // reports as an uninstrumentable ~300ms system call. Running inline keeps the + // whole check on the measured thread. + let state = State::new(finder, ThreadCount::Inline); // Force stdlib init by running an empty module. let h = Handle::new( ModuleName::from_str("_bench_init"), From f15176b25a283c83e582ed608b33d4e41abb7dce Mon Sep 17 00:00:00 2001 From: Kyle Into Date: Thu, 23 Jul 2026 17:11:39 -0700 Subject: [PATCH 6/8] Make wall-clock profiling timers globally disable-able MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The type checker times every step, run, and module load with Instant::now()/ elapsed() to populate telemetry counters. Instant::now() reads CLOCK_MONOTONIC via the vDSO, so it is nearly free normally — but under Valgrind (CodSpeed's instrumented instrument) the vDSO is bypassed and each reading becomes a real clock_gettime syscall. A single check makes ~34 of them; CodSpeed cannot instrument syscalls, so they are dropped from the measure and trigger a warning. Add a Timer type in pyrefly_util gated by a global flag (default enabled, so telemetry is unchanged) and route the hot-path timers through it. When disabled the timer never reads the clock, so no clock_gettime is issued. --- Cargo.lock | 1 + crates/pyrefly_util/Cargo.toml | 1 + crates/pyrefly_util/src/lib.rs | 1 + crates/pyrefly_util/src/timer.rs | 51 ++++++++++++++++++++++++++++++++ pyrefly/lib/state/state.rs | 21 ++++++------- 5 files changed, 65 insertions(+), 10 deletions(-) create mode 100644 crates/pyrefly_util/src/timer.rs diff --git a/Cargo.lock b/Cargo.lock index 8685b3f50a..ebc1322817 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2410,6 +2410,7 @@ dependencies = [ "uuid", "vec1", "watchman_client", + "web-time", "yansi", ] diff --git a/crates/pyrefly_util/Cargo.toml b/crates/pyrefly_util/Cargo.toml index e9ecfe5107..7e779fb79c 100644 --- a/crates/pyrefly_util/Cargo.toml +++ b/crates/pyrefly_util/Cargo.toml @@ -45,6 +45,7 @@ tracing = { version = "0.1.41", features = ["attributes", "valuable"] } tracing-subscriber = { version = "0.3.23", features = ["env-filter", "fmt", "registry"], default-features = false } uuid = { version = "1.17", features = ["js", "rng-getrandom", "v4"] } vec1 = { version = "1.12.1", features = ["serde"] } +web-time = "1.1.0" yansi = { version = "1.0.0-rc.1", features = ["hyperlink"] } [dev-dependencies] diff --git a/crates/pyrefly_util/src/lib.rs b/crates/pyrefly_util/src/lib.rs index cefa32a04f..93ff472ed9 100644 --- a/crates/pyrefly_util/src/lib.rs +++ b/crates/pyrefly_util/src/lib.rs @@ -57,6 +57,7 @@ pub mod task_heap; pub mod telemetry; pub mod test_path; pub mod thread_pool; +pub mod timer; pub mod trace; pub mod uniques; pub mod unix_path; diff --git a/crates/pyrefly_util/src/timer.rs b/crates/pyrefly_util/src/timer.rs new file mode 100644 index 0000000000..0dce30ef6a --- /dev/null +++ b/crates/pyrefly_util/src/timer.rs @@ -0,0 +1,51 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +//! A wall-clock timer for internal profiling counters that can be globally +//! disabled. +//! +//! `Instant::now()` reads `CLOCK_MONOTONIC` via the vDSO, so it is nearly free +//! at runtime. Under Valgrind (which CodSpeed's instrumented instrument uses), +//! the vDSO is bypassed and every reading becomes a real `clock_gettime` +//! syscall. A single type-check makes dozens of them, and CodSpeed cannot +//! instrument syscalls, so they pollute the measurement. Benchmarks call +//! [`set_timing_enabled(false)`] to make every [`Timer`] a no-op; production +//! leaves timing enabled so telemetry counters stay populated. + +use std::sync::atomic::AtomicBool; +use std::sync::atomic::Ordering; +use std::time::Duration; + +use web_time::Instant; + +static ENABLED: AtomicBool = AtomicBool::new(true); + +/// Enable or disable all [`Timer`]s process-wide. Enabled by default; a disabled +/// timer never calls `Instant::now()` and reports zero elapsed time. +pub fn set_timing_enabled(enabled: bool) { + ENABLED.store(enabled, Ordering::Relaxed); +} + +/// A profiling timer that captures a start instant only when timing is enabled. +/// When disabled, `elapsed*` return zero and no `clock_gettime` syscall is made. +#[derive(Debug, Clone, Copy)] +pub struct Timer(Option); + +impl Timer { + /// Start a timer, reading the clock only if timing is enabled. + pub fn start() -> Self { + Self(ENABLED.load(Ordering::Relaxed).then(Instant::now)) + } + + pub fn elapsed(&self) -> Duration { + self.0.map_or(Duration::ZERO, |start| start.elapsed()) + } + + pub fn elapsed_nanos(&self) -> u64 { + self.0.map_or(0, |start| start.elapsed().as_nanos() as u64) + } +} diff --git a/pyrefly/lib/state/state.rs b/pyrefly/lib/state/state.rs index 3ef864f309..327e2d85a1 100644 --- a/pyrefly/lib/state/state.rs +++ b/pyrefly/lib/state/state.rs @@ -60,6 +60,7 @@ use pyrefly_util::telemetry::TelemetryEventKind; use pyrefly_util::telemetry::TelemetryTransactionStats; use pyrefly_util::thread_pool::ThreadCount; use pyrefly_util::thread_pool::ThreadPool; +use pyrefly_util::timer::Timer; use pyrefly_util::uniques::UniqueFactory; use ruff_python_ast::name::Name; use ruff_text_size::TextRange; @@ -667,7 +668,7 @@ impl<'a> TransactionData<'a> { /// underlying state is unchanged, otherwise the transaction data might make inconsistent /// assumptions, in particular about deps/rdeps. pub(crate) fn restore(self) -> Result, Duration> { - let start = Instant::now(); + let start = Timer::start(); let readable = self.state.state.read(); let state_lock_blocked = start.elapsed(); if self.base == readable.now { @@ -1370,7 +1371,7 @@ impl<'a> Transaction<'a> { if !module_data.state.is_checked(self.data.now) && let Some(guard) = module_data.state.try_start_clean(self.data.now) { - let clean_start = Instant::now(); + let clean_start = Timer::start(); self.clean(module_data, guard); self.timing .clean_ns @@ -1387,7 +1388,7 @@ impl<'a> Transaction<'a> { } // Try to acquire exclusive compute access for the next step. - let wait_start = Instant::now(); + let wait_start = Timer::start(); let result = module_data.state.try_start_compute(step); let wait_ns = wait_start.elapsed().as_nanos() as u64; if wait_ns > 1000 { @@ -1460,7 +1461,7 @@ impl<'a> Transaction<'a> { // then releases the computing flag and notifies waiting threads. // Post-compute work (diffing, invalidation, eviction) runs without // the flag held. - let compute_start = Instant::now(); + let compute_start = Timer::start(); let post = guard.compute(&ctx); let elapsed_ns = compute_start.elapsed().as_nanos() as u64; let (ns_counter, count_counter) = match todo { @@ -1985,7 +1986,7 @@ impl<'a> Transaction<'a> { require: Require, custom_thread_pool: Option<&ThreadPool>, ) -> Result<(), Cancelled> { - let run_start = Instant::now(); + let run_start = Timer::start(); self.data.now.next(); @@ -2008,7 +2009,7 @@ impl<'a> Transaction<'a> { } } - let work_start = Instant::now(); + let work_start = Timer::start(); let cancelled = AtomicBool::new(false); // When the todo queue is empty, run `work()` on the calling thread instead of // dispatching to the shared thread pool. `spawn_many` uses rayon `scope` which @@ -2087,7 +2088,7 @@ impl<'a> Transaction<'a> { let run_number = self.data.state.run_count.fetch_add(1, Ordering::SeqCst); // Compute stdlib once before the epoch loop. Stdlib is deterministic for a // given SysInfo and does not depend on user code, so it only needs to run once. - let stdlib_start = Instant::now(); + let stdlib_start = Timer::start(); let stdlib_cached = self.compute_stdlib(handles); let compute_stdlib_time = stdlib_start.elapsed(); { @@ -2653,7 +2654,7 @@ impl<'a> TransactionHandle<'a> { Some(path) => path.dupe(), None => { drop(imports_read); - let fi_start = Instant::now(); + let fi_start = Timer::start(); let finding = self .transaction .get_cached_loader(&self.module_data.config.read()) @@ -3289,7 +3290,7 @@ impl State { default_require: Require, subscriber: Option>, ) -> Transaction<'a> { - let start = Instant::now(); + let start = Timer::start(); let readable = self.state.read(); let state_lock_blocked = start.elapsed(); let now = readable.now; @@ -3415,7 +3416,7 @@ impl State { ); assert!(dirty.into_inner().is_empty(), "Transaction is dirty"); - let state_lock_start = Instant::now(); + let state_lock_start = Timer::start(); let mut state = self.state.write(); stats.state_lock_blocked += state_lock_start.elapsed(); From c8cb4bd6e43c12e2d727adbb0d13e2448bbf378a Mon Sep 17 00:00:00 2001 From: Kyle Into Date: Thu, 23 Jul 2026 17:11:46 -0700 Subject: [PATCH 7/8] Skip condvar wakes when no thread is waiting TaskHeap and per-module step computation call Condvar::notify_all() after every work item / step completes. notify_all() issues a futex wake syscall even when no thread is parked, which under CodSpeed's Valgrind instrument cannot be instrumented (~7 futex per check in the single-threaded case). Track the parked-waiter count under the same lock that guards the wait, and only wake when it is non-zero. The count is read and mutated under the lock, so skipping the wake is race-free: a thread that has not yet parked cannot have incremented the count, and it re-checks the predicate under the lock before waiting. --- crates/pyrefly_util/src/task_heap.rs | 10 ++++++++-- pyrefly/lib/state/module.rs | 19 ++++++++++++++++++- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/crates/pyrefly_util/src/task_heap.rs b/crates/pyrefly_util/src/task_heap.rs index a9fb60339b..2f032d09bd 100644 --- a/crates/pyrefly_util/src/task_heap.rs +++ b/crates/pyrefly_util/src/task_heap.rs @@ -173,7 +173,9 @@ impl TaskHeap { fn drop(&mut self) { let mut lock = self.0.inner.lock(); lock.active_workers -= 1; - if lock.active_workers == 0 && lock.heap.is_empty() { + // Only wake if a worker is actually parked. `notify_all` otherwise + // still issues a `futex` syscall, which CodSpeed cannot instrument. + if lock.active_workers == 0 && lock.heap.is_empty() && lock.paused_workers > 0 { self.0.condition.notify_all(); } } @@ -200,7 +202,11 @@ impl TaskHeap { } None => { if lock.active_workers == 0 { - self.condition.notify_all(); + // Only wake if a worker is actually parked; a bare + // `notify_all` still issues an uninstrumentable `futex`. + if lock.paused_workers > 0 { + self.condition.notify_all(); + } break; } lock.paused_workers += 1; diff --git a/pyrefly/lib/state/module.rs b/pyrefly/lib/state/module.rs index cbd142a604..7cdc6954cd 100644 --- a/pyrefly/lib/state/module.rs +++ b/pyrefly/lib/state/module.rs @@ -34,6 +34,8 @@ //! this condition remains true for the duration of the read. use std::sync::Arc; +use std::sync::atomic::AtomicUsize; +use std::sync::atomic::Ordering; use arc_swap::Guard; use dupe::Dupe; @@ -91,6 +93,7 @@ impl ModuleState { require: AtomicRequire::new(self.require), computing: Mutex::new(false), computing_condvar: Condvar::new(), + computing_waiters: AtomicUsize::new(0), } } } @@ -109,6 +112,10 @@ pub struct ModuleStateMut { computing: Mutex, /// Signaled when `computing` becomes false. computing_condvar: Condvar, + /// Number of threads currently parked in `computing_condvar.wait`, maintained + /// under the `computing` lock. Lets us skip `notify_all` (an uninstrumentable + /// `futex` syscall) when no thread is waiting — the common single-threaded case. + computing_waiters: AtomicUsize, } impl ModuleStateMut { @@ -120,6 +127,7 @@ impl ModuleStateMut { require: AtomicRequire::new(require), computing: Mutex::new(false), computing_condvar: Condvar::new(), + computing_waiters: AtomicUsize::new(0), } } @@ -205,7 +213,9 @@ impl ModuleStateMut { } else { return None; } + self.computing_waiters.fetch_add(1, Ordering::Relaxed); computing = self.computing_condvar.wait(computing); + self.computing_waiters.fetch_sub(1, Ordering::Relaxed); } } @@ -226,7 +236,9 @@ impl ModuleStateMut { _computing: ComputingFlag { state: self }, }); } + self.computing_waiters.fetch_add(1, Ordering::Relaxed); computing = self.computing_condvar.wait(computing); + self.computing_waiters.fetch_sub(1, Ordering::Relaxed); } } @@ -292,7 +304,12 @@ impl Drop for ComputingFlag<'_> { fn drop(&mut self) { let mut computing = self.state.computing.lock(); *computing = false; - self.state.computing_condvar.notify_all(); + // Both the waiter count and this check happen under the `computing` lock, + // so skipping the wake when no thread is parked is race-free and avoids an + // uninstrumentable `futex` syscall in the common single-threaded case. + if self.state.computing_waiters.load(Ordering::Relaxed) > 0 { + self.state.computing_condvar.notify_all(); + } } } From 7037c9f52208da65cfd69821bbca59b91efe9403 Mon Sep 17 00:00:00 2001 From: Kyle Into Date: Thu, 23 Jul 2026 17:11:51 -0700 Subject: [PATCH 8/8] Disable profiling timers in micro benchmarks With timers gate-able, turn them off in the micro benchmark's shared state so CodSpeed's instrumented instrument sees no clock_gettime syscalls in the measured region. Combined with the condvar-wake guard, a measured check now issues zero syscalls (previously 41: ~34 clock_gettime + ~7 futex). --- pyrefly/benches/micro.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/pyrefly/benches/micro.rs b/pyrefly/benches/micro.rs index b3d381d0ab..173a73ce7a 100644 --- a/pyrefly/benches/micro.rs +++ b/pyrefly/benches/micro.rs @@ -46,11 +46,16 @@ use pyrefly_python::sys_info::PythonVersion; use pyrefly_python::sys_info::SysInfo; use pyrefly_util::arc_id::ArcId; use pyrefly_util::thread_pool::ThreadCount; +use pyrefly_util::timer::set_timing_enabled; const BENCH_FILE: &str = "bench.py"; /// Single-threaded state with stdlib pre-initialized. static SHARED_STATE: LazyLock = LazyLock::new(|| { + // Disable the type checker's wall-clock profiling timers. Each `Instant::now()` + // is a `clock_gettime` syscall under CodSpeed's Valgrind instrument, which + // cannot be instrumented and pollutes the measurement. + set_timing_enabled(false); let sys_info = SysInfo::new(PythonVersion::default(), PythonPlatform::default()); let config = { let mut c = ConfigFile::default();