Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,18 @@ page.

### Added

- **`pyrer.parse_static_packages_py(paths) -> list[PackageData | None]`** —
batched, Rayon-parallel variant of `parse_static_package_py`
that opens and parses every path in one Rust call across a
thread pool. Output is positionally aligned with the input;
missing files, unreadable bytes, and parser-bails all map to
`None`. The GIL is released for the whole batch via
`Python::allow_threads`. Pool size follows
`RAYON_NUM_THREADS` (default: logical core count). Targets
issue #94's profile finding that serial Python `open()` was
the top of the resolve flamegraph (3.20 s of 9.12 s, 35% of
wall time) after the per-file static parser landed.
Closes #94.
- **`version_range` hint on the `load_family` callback** (issue #92) —
`pyrer.solve(..., load_family=cb)` now invokes `cb` as
`cb(name, version_range="2+<3")` when the callback's signature can
Expand Down
11 changes: 7 additions & 4 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ members = ["crates/*"]
resolver = "2"

[workspace.package]
version = "1.0.0-rc.2"
version = "1.0.0-rc.3"
authors = [
"Lorenzo Montant <lo.montant.pro@gmail.com>",
"Maxim Doucet <maxim.doucet@gmail.com>",
Expand All @@ -23,9 +23,12 @@ lazy_static = "1.5.0"
rand = "0.8.5"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
rer-version = { path = "crates/rer-version", version = "1.0.0-rc.2" }
rer-resolver = { path = "crates/rer-resolver", version = "1.0.0-rc.2" }
rer-package = { path = "crates/rer-package", version = "1.0.0-rc.2" }
rer-version = { path = "crates/rer-version", version = "1.0.0-rc.3" }
rer-resolver = { path = "crates/rer-resolver", version = "1.0.0-rc.3" }
rer-package = { path = "crates/rer-package", version = "1.0.0-rc.3" }
# Rayon powers the batched `parse_static_packages_py` API (#94). Honours
# `RAYON_NUM_THREADS` for environments that need to cap the pool size.
rayon = "1"
pyo3 = { version = "0.23.5", features = ["extension-module"] }
# `mimalloc` is wired into the bench binary as a `#[global_allocator]`.
# Callgrind shows ~33 % of cycles in libc malloc/free; mimalloc has measurably
Expand Down
6 changes: 6 additions & 0 deletions crates/rer-package/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,9 @@ name = "rer_package"
# Hand-rolled lexer — no AST library dependency. Stage 3 measurement
# showed `rustpython-parser` was ~2 ms/file because it built the
# whole module AST when we only needed four top-level fields.
#
# Rayon powers the batched `parse_static_packages_py` API (issue #94)
# that reads + parses many `package.py` files in parallel, replacing
# the rez shim's serial Python file-open loop (~3 s on a typical
# 132-package Fortiche resolve).
rayon = { workspace = true }
132 changes: 132 additions & 0 deletions crates/rer-package/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,40 @@ pub fn parse_static_package_py(source: &str) -> Option<PackageInfo> {
p.parse_module()
}

/// Batched, parallel variant of [`parse_static_package_py`]: open and
/// parse every path on a Rayon thread pool, returning a `Vec` aligned
/// with `paths`.
///
/// Each entry in the returned `Vec` is independent — a file that
/// doesn't exist, can't be read, or contains dynamic content all
/// produce `None` at the same index as the input path. No error
/// path: the function never panics on per-file failures.
///
/// Issue #94: the rez integration shim's bottleneck after the static
/// parser landed was the serial Python loop of `open()` calls
/// (~3 s on a typical 132-package Fortiche resolve, 91% of the
/// `_load_family` budget). This call replaces that loop with one
/// `Python::allow_threads`-released batch, so the I/O overlaps
/// across cores.
///
/// Pool size follows Rayon's default (`RAYON_NUM_THREADS` env var or
/// logical core count). Order is preserved regardless of completion
/// order — callers can `zip(paths, results)` after.
pub fn parse_static_packages_py<P>(paths: &[P]) -> Vec<Option<PackageInfo>>
where
P: AsRef<std::path::Path> + Sync,
{
use rayon::prelude::*;

paths
.par_iter()
.map(|p| {
let source = std::fs::read_to_string(p.as_ref()).ok()?;
parse_static_package_py(&source)
})
.collect()
}

// ===========================================================================
// Parser
// ===========================================================================
Expand Down Expand Up @@ -925,6 +959,104 @@ fn line_assigns_to_solver_field(line: &[u8]) -> bool {
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use std::path::PathBuf;

/// Write `content` to a fresh tempfile and return its path. The
/// `tempfile` crate isn't a dep — keep tests dep-free with a
/// hand-rolled scratch path under `std::env::temp_dir()`.
fn write_temp(name: &str, content: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"rer-package-test-{}-{}",
std::process::id(),
name
));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("package.py");
let mut f = std::fs::File::create(&path).unwrap();
f.write_all(content.as_bytes()).unwrap();
path
}

#[test]
fn batch_empty_returns_empty() {
let result: Vec<Option<PackageInfo>> =
parse_static_packages_py::<&std::path::Path>(&[]);
assert!(result.is_empty());
}

#[test]
fn batch_parses_each_file_independently() {
let static_path = write_temp(
"static-1",
"name = \"app\"\nversion = \"1.0.0\"\nrequires = [\"lib-2\"]\n",
);
let dynamic_path = write_temp(
"dynamic-1",
"import os\nname = \"foo\"\nversion = \"1.0\"\n",
);
let static_path_2 = write_temp(
"static-2",
"name = \"lib\"\nversion = \"2.0.0\"\n",
);

let paths = vec![&static_path, &dynamic_path, &static_path_2];
let results = parse_static_packages_py(&paths);

assert_eq!(results.len(), 3);
// [0] static → Some
let r0 = results[0].as_ref().expect("static-1 should parse");
assert_eq!(r0.name, "app");
assert_eq!(r0.version, "1.0.0");
// [1] dynamic (top-level import) → None
assert!(results[1].is_none(), "dynamic file should bail to None");
// [2] static → Some
let r2 = results[2].as_ref().expect("static-2 should parse");
assert_eq!(r2.name, "lib");
}

#[test]
fn batch_missing_file_becomes_none() {
// Build a path that doesn't exist; the batched call should map
// it to None at the matching index, never raise.
let phantom = std::env::temp_dir().join("rer-package-test-this-does-not-exist/package.py");
let real = write_temp(
"real-static",
"name = \"x\"\nversion = \"1.0\"\n",
);
let paths = vec![&phantom, &real];
let results = parse_static_packages_py(&paths);
assert_eq!(results.len(), 2);
assert!(results[0].is_none(), "phantom path must be None");
assert!(results[1].is_some(), "real path should parse");
}

#[test]
fn batch_preserves_input_order() {
// Write 16 files alternating valid/dynamic content. The batched
// call uses par_iter which can complete out of order; the
// returned Vec must still match the input positions exactly.
let mut paths: Vec<PathBuf> = Vec::with_capacity(16);
for i in 0..16 {
let content = if i % 2 == 0 {
format!("name = \"pkg{i}\"\nversion = \"1.0\"\n")
} else {
// dynamic — top-level if always bails
format!("name = \"pkg{i}\"\nversion = \"1.0\"\nif True:\n pass\n")
};
paths.push(write_temp(&format!("order-{i}"), &content));
}
let results = parse_static_packages_py(&paths);
assert_eq!(results.len(), 16);
for (i, r) in results.iter().enumerate() {
if i % 2 == 0 {
let r = r.as_ref().expect("even index should be Some");
assert_eq!(r.name, format!("pkg{i}"));
} else {
assert!(r.is_none(), "odd index should be None (dynamic)");
}
}
}

#[test]
fn parses_minimal_static() {
Expand Down
53 changes: 53 additions & 0 deletions crates/rer-python/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -718,11 +718,64 @@ fn parse_static_package_py(source: &str) -> Option<PackageData> {
})
}

/// Batched variant of [`parse_static_package_py`]: open and parse
/// every path on a Rayon thread pool, returning a list aligned with
/// `paths`. Closes issue #94.
///
/// ```python
/// import pyrer
///
/// paths = [pkg.filepath for pkg in iter_packages(...)]
/// pds = pyrer.parse_static_packages_py(paths)
/// for pd, pkg in zip(pds, pkgs):
/// if pd is None:
/// pd = pyrer.PackageData.from_rez(pkg) # dynamic / unreadable
/// ...
/// ```
///
/// - **Output is positionally aligned** with the input. A missing
/// file, a parser bail on dynamic content, and an unreadable file
/// all become `None` at the same index.
/// - **No exceptions escape.** Per-file failures map to `None`.
/// - **GIL is released for the batch** via `Python::allow_threads` so
/// other Python threads run during the I/O.
/// - **Pool size** follows Rayon's default (`RAYON_NUM_THREADS` or
/// logical core count). No per-call knob — set the env var to
/// constrain on shared CI hosts.
///
/// Replaces the rez shim's serial Python loop of `open()` calls —
/// ~3 s on a typical 132-package Fortiche resolve, 91% of the
/// `_load_family` budget when all other pyrer wins are stacked.
#[pyfunction]
fn parse_static_packages_py(
py: Python<'_>,
paths: Vec<std::path::PathBuf>,
) -> Vec<Option<PackageData>> {
// Release the GIL while reading + parsing. The closure produces a
// `Vec<Option<rer_package::PackageInfo>>`; the conversion to the
// PyO3-managed `PackageData` happens after the GIL is reacquired.
let infos: Vec<Option<rer_package::PackageInfo>> =
py.allow_threads(|| rer_package::parse_static_packages_py(&paths));

infos
.into_iter()
.map(|maybe| {
maybe.map(|info| PackageData {
name: info.name,
version: info.version,
requires: info.requires,
variants: info.variants,
})
})
.collect()
}

/// The `pyrer` Python module — Rez-compatible package resolver.
#[pymodule]
fn pyrer(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_function(wrap_pyfunction!(solve, m)?)?;
m.add_function(wrap_pyfunction!(parse_static_package_py, m)?)?;
m.add_function(wrap_pyfunction!(parse_static_packages_py, m)?)?;
m.add_class::<PackageData>()?;
m.add_class::<ResolvedVariant>()?;
m.add_class::<SolveResult>()?;
Expand Down
2 changes: 1 addition & 1 deletion docs/config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ weight = 10
name = "GitHub"
pre = '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-github"><path d="M9 19c-5 1.5-5-2.5-7-3m14 6v-3.87a3.37 3.37 0 0 0-.94-2.61c3.14-.35 6.44-1.54 6.44-7A5.44 5.44 0 0 0 20 4.77 5.07 5.07 0 0 0 19.91 1S18.73.65 16 2.48a13.38 13.38 0 0 0-7 0C6.27.65 5.09 1 5.09 1A5.07 5.07 0 0 0 5 4.77a5.44 5.44 0 0 0-1.5 3.78c0 5.42 3.3 6.61 6.44 7A3.37 3.37 0 0 0 9 18.13V22"></path></svg>'
url = "https://github.com/doubleailes/rer"
post = "v1.0.0-rc.2"
post = "v1.0.0-rc.3"
weight = 20

# Footer contents
Expand Down
2 changes: 1 addition & 1 deletion docs/content/_index.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ title = "rer — Rez En Rust"
lead = "A faithful Rust port of <a href=\"https://github.com/AcademySoftwareFoundation/rez\">rez</a>'s package solver — callable from Python via PyO3, resolves match rez 1:1."
url = "/docs/getting-started/introduction/"
url_button = "Get started"
repo_version = "GitHub v1.0.0-rc.2"
repo_version = "GitHub v1.0.0-rc.3"
repo_license = "MIT-licensed."
repo_url = "https://github.com/doubleailes/rer"

Expand Down
97 changes: 94 additions & 3 deletions docs/content/docs/engineering/fast-package-py-parser.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,17 @@ toc = true
top = false
+++

> **Status: Stage 1 complete, Stage 2 scaffolded.** Survey tool at
> **Status: Stages 1–4 shipped.** Survey tool at
> `scripts/survey_package_py.py`; Rust parser crate at
> `crates/rer-package/`. Stage 1 numbers (Fortiche, May 2026) inline
> below.
> `crates/rer-package/`; PyO3 bindings (`pyrer.parse_static_package_py`
> and the batched `parse_static_packages_py`) at
> `crates/rer-python/src/lib.rs`; differential safety net at
> `scripts/diff_against_rez.py`; perf benches at
> `scripts/bench_package_py_parser.py` and
> `scripts/bench_batched_parser.py`. Stage 1 numbers, Stage 3
> per-file timing, Stage 2 differential (0 mismatches on 5,979
> files), and Stage 4 batched speedup (2.81× on 2,000 files) are
> all inline below.

## Stage 1 result — Fortiche, May 2026

Expand Down Expand Up @@ -409,6 +416,90 @@ If the differential test ever needs to be tightened, the path is to
also configure `package_definition_python_path` in the dev venv —
but that's CI infrastructure, not a parser change.

## Stage 4 — Batched parallel parse (issue #94)

After Stages 1–3 landed, `cProfile` of a real Fortiche resolve
showed the static parser itself was no longer in the top of the
flamegraph. The cost had moved one layer up: the shim's serial
Python loop of `open()` calls feeding the parser. On a 132-package
resolve that was 3.20 s of pure I/O (35% of total wall time), one
file at a time while seven cores idled.

`parse_static_packages_py(paths)` is the response: open + parse
every path in one Rust call across a Rayon thread pool, with the
GIL released for the whole batch. Same per-file semantics as
`parse_static_package_py` — accept rate, output shape, differential
correctness all carry over.

### Result on Fortiche

`scripts/bench_batched_parser.py` against `/thierry/rez/pkg` over
CIFS, best-of-3:

| Sample | Serial `open` + parse | Batched | Speedup |
|---:|---:|---:|---:|
| 500 files (warm cache) | 56.71 ms | 40.76 ms | **1.39×** |
| 2,000 files | 4,234 ms | 1,508 ms | **2.81×** |

Per-file saving on the 2,000-file run: **~1.36 ms**. Extrapolated
to the issue's target workload (132-package resolve, ~2,600
`package.py` files): **~3.5 s saved per resolve**.

Both paths produce identical accepts (1,864/2,000 → static-parseable
fraction matches the per-file parser) — zero correctness drift.

The 500-file bench is bottlenecked on warm-page-cache parsing CPU;
the Rayon dispatch overhead amortises less on small batches. On
cold-cache or larger batches the parallel-I/O overlap shows
through. The 2.81× is a lower bound on warm hardware; on cold CIFS
(Windows production) it should grow.

### Design choices

- **Output is positionally aligned with input.** Missing files,
unreadable bytes, and parser bails all become `None` at the
matching index. The shim's `zip(pkgs, result)` is then trivially
correct.
- **No exception escapes.** Per-file failures map to `None`. The
function only raises if the input type is wrong.
- **Pool size = Rayon default** (`RAYON_NUM_THREADS` env var or
logical core count). No per-call knob initially; capacity
control is environmental.
- **Pure addition.** The single-file API stays. Shims feature-detect
with `hasattr(pyrer, "parse_static_packages_py")` and fall back
to the per-file loop on older pyrer.

### Safety net

Reused from Stage 2. The same `from_rez(pkg)` comparison can be
shadow-checked at production runtime, gated on
`REZ_PYRER_VALIDATE_BATCHED`. The integration page in the
[rez integration docs](../../getting-started/rez-integration/#shadow-validation-mode-1)
has the recipe.

The offline Stage 2 differential — 5,813 / 5,813 matched on the
Fortiche corpus — covers the per-file semantics. Stage 4's batched
call uses the exact same `parse_static_package_py` per file, so the
existing safety net carries over byte-for-byte; the only Stage 4
specific risk is around ordering / completion which the
positional-alignment contract handles explicitly.

### What's next after this

Stage 4 takes us to:

- ~93 % of `package.py` files served by the Rust fast path
- ~75 µs per file via the static parser (Stage 3)
- ~1/3 the wall-time on the open+parse phase via the batched call (Stage 4)

The remaining cost on `_load_family` is now real I/O (CIFS round-
trips for files Rayon's pool can't overlap further) plus the
dynamic-7 % rez evaluator path. Both are architectural —
addressing them needs a layer outside this RFC (memcache caching
of parsed `PackageData` across invocations is the obvious next
move, as called out in the "Considered alternatives" section
below).

## Considered alternatives

### Parsed-package cache on top of the shared memcache
Expand Down
Loading
Loading