Add support for benchmarking `uv sync` and `uv lock` (#5524)

## Summary

This PR adds support for `uv lock` and `uv sync` in the standardized
benchmarks script.

Part of: https://github.com/astral-sh/uv/issues/5263.

## Test Plan

For example:

```sh
python scripts/bench/__main__.py --uv-project --benchmark resolve-cold ./scripts/requirements/trio.in --verbose
```
This commit is contained in:
Charlie Marsh 2024-07-28 17:09:08 -04:00 committed by GitHub
parent 12e92b7718
commit efbc9fb78d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 285 additions and 16 deletions

View File

@ -1980,6 +1980,10 @@ pub struct SyncArgs {
/// - `/home/ferris/.local/bin/python3.10` uses the exact Python at the given path. /// - `/home/ferris/.local/bin/python3.10` uses the exact Python at the given path.
#[arg(long, short, env = "UV_PYTHON", verbatim_doc_comment)] #[arg(long, short, env = "UV_PYTHON", verbatim_doc_comment)]
pub python: Option<String>, pub python: Option<String>,
/// The path to the project. Defaults to the current working directory.
#[arg(long, hide = true)]
pub directory: Option<PathBuf>,
} }
#[derive(Args)] #[derive(Args)]
@ -2015,6 +2019,10 @@ pub struct LockArgs {
/// - `/home/ferris/.local/bin/python3.10` uses the exact Python at the given path. /// - `/home/ferris/.local/bin/python3.10` uses the exact Python at the given path.
#[arg(long, short, env = "UV_PYTHON", verbatim_doc_comment)] #[arg(long, short, env = "UV_PYTHON", verbatim_doc_comment)]
pub python: Option<String>, pub python: Option<String>,
/// The path to the project. Defaults to the current working directory.
#[arg(long, hide = true)]
pub directory: Option<PathBuf>,
} }
#[derive(Args)] #[derive(Args)]

View File

@ -2,6 +2,7 @@
use std::collections::BTreeSet; use std::collections::BTreeSet;
use std::fmt::Write; use std::fmt::Write;
use std::path::PathBuf;
use anstream::eprint; use anstream::eprint;
use owo_colors::OwoColorize; use owo_colors::OwoColorize;
@ -47,6 +48,7 @@ pub(crate) async fn lock(
frozen: bool, frozen: bool,
python: Option<String>, python: Option<String>,
settings: ResolverSettings, settings: ResolverSettings,
directory: Option<PathBuf>,
preview: PreviewMode, preview: PreviewMode,
python_preference: PythonPreference, python_preference: PythonPreference,
python_fetch: PythonFetch, python_fetch: PythonFetch,
@ -60,9 +62,14 @@ pub(crate) async fn lock(
warn_user_once!("`uv lock` is experimental and may change without warning"); warn_user_once!("`uv lock` is experimental and may change without warning");
} }
let directory = if let Some(directory) = directory {
directory
} else {
std::env::current_dir()?
};
// Find the project requirements. // Find the project requirements.
let workspace = let workspace = Workspace::discover(&directory, &DiscoveryOptions::default()).await?;
Workspace::discover(&std::env::current_dir()?, &DiscoveryOptions::default()).await?;
// Find an interpreter for the project // Find an interpreter for the project
let interpreter = FoundInterpreter::discover( let interpreter = FoundInterpreter::discover(

View File

@ -1,4 +1,5 @@
use anyhow::Result; use anyhow::Result;
use std::path::PathBuf;
use uv_auth::store_credentials_from_url; use uv_auth::store_credentials_from_url;
use uv_cache::Cache; use uv_cache::Cache;
use uv_client::{Connectivity, FlatIndexClient, RegistryClientBuilder}; use uv_client::{Connectivity, FlatIndexClient, RegistryClientBuilder};
@ -33,6 +34,7 @@ pub(crate) async fn sync(
python_preference: PythonPreference, python_preference: PythonPreference,
python_fetch: PythonFetch, python_fetch: PythonFetch,
settings: ResolverInstallerSettings, settings: ResolverInstallerSettings,
directory: Option<PathBuf>,
preview: PreviewMode, preview: PreviewMode,
connectivity: Connectivity, connectivity: Connectivity,
concurrency: Concurrency, concurrency: Concurrency,
@ -44,9 +46,14 @@ pub(crate) async fn sync(
warn_user_once!("`uv sync` is experimental and may change without warning"); warn_user_once!("`uv sync` is experimental and may change without warning");
} }
let directory = if let Some(directory) = directory {
directory
} else {
std::env::current_dir()?
};
// Identify the project // Identify the project
let project = let project = VirtualProject::discover(&directory, &DiscoveryOptions::default()).await?;
VirtualProject::discover(&std::env::current_dir()?, &DiscoveryOptions::default()).await?;
// Discover or create the virtual environment. // Discover or create the virtual environment.
let venv = project::get_or_init_environment( let venv = project::get_or_init_environment(

View File

@ -948,6 +948,7 @@ async fn run_project(
globals.python_preference, globals.python_preference,
globals.python_fetch, globals.python_fetch,
args.settings, args.settings,
args.directory,
globals.preview, globals.preview,
globals.connectivity, globals.connectivity,
Concurrency::default(), Concurrency::default(),
@ -970,6 +971,7 @@ async fn run_project(
args.frozen, args.frozen,
args.python, args.python,
args.settings, args.settings,
args.directory,
globals.preview, globals.preview,
globals.python_preference, globals.python_preference,
globals.python_fetch, globals.python_fetch,

View File

@ -527,6 +527,7 @@ pub(crate) struct SyncSettings {
pub(crate) python: Option<String>, pub(crate) python: Option<String>,
pub(crate) refresh: Refresh, pub(crate) refresh: Refresh,
pub(crate) settings: ResolverInstallerSettings, pub(crate) settings: ResolverInstallerSettings,
pub(crate) directory: Option<PathBuf>,
} }
impl SyncSettings { impl SyncSettings {
@ -546,6 +547,7 @@ impl SyncSettings {
build, build,
refresh, refresh,
python, python,
directory,
} = args; } = args;
let modifications = if no_clean { let modifications = if no_clean {
@ -569,6 +571,7 @@ impl SyncSettings {
resolver_installer_options(installer, build), resolver_installer_options(installer, build),
filesystem, filesystem,
), ),
directory,
} }
} }
} }
@ -582,6 +585,7 @@ pub(crate) struct LockSettings {
pub(crate) python: Option<String>, pub(crate) python: Option<String>,
pub(crate) refresh: Refresh, pub(crate) refresh: Refresh,
pub(crate) settings: ResolverSettings, pub(crate) settings: ResolverSettings,
pub(crate) directory: Option<PathBuf>,
} }
impl LockSettings { impl LockSettings {
@ -595,6 +599,7 @@ impl LockSettings {
build, build,
refresh, refresh,
python, python,
directory,
} = args; } = args;
Self { Self {
@ -603,6 +608,7 @@ impl LockSettings {
python, python,
refresh: Refresh::from(refresh), refresh: Refresh::from(refresh),
settings: ResolverSettings::combine(resolver_options(resolver, build), filesystem), settings: ResolverSettings::combine(resolver_options(resolver, build), filesystem),
directory,
} }
} }
} }

View File

@ -387,7 +387,7 @@ class Poetry(Suite):
"bench", "bench",
"--no-interaction", "--no-interaction",
"--python", "--python",
"3.12.1", "3.12.3",
], ],
cwd=cwd, cwd=cwd,
stdout=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
@ -508,7 +508,7 @@ class Poetry(Suite):
return Command( return Command(
name=f"{self.name} ({Benchmark.RESOLVE_INCREMENTAL.value})", name=f"{self.name} ({Benchmark.RESOLVE_INCREMENTAL.value})",
prepare=f"rm -f {poetry_lock} && cp {baseline} {poetry_lock}", prepare=f"rm {poetry_lock} && cp {baseline} {poetry_lock}",
command=[ command=[
f"POETRY_CONFIG_DIR={config_dir}", f"POETRY_CONFIG_DIR={config_dir}",
f"POETRY_CACHE_DIR={cache_dir}", f"POETRY_CACHE_DIR={cache_dir}",
@ -799,7 +799,7 @@ class Pdm(Suite):
) )
class uv(Suite): class UvPip(Suite):
def __init__(self, *, path: str | None = None) -> Command | None: def __init__(self, *, path: str | None = None) -> Command | None:
"""Initialize a uv benchmark.""" """Initialize a uv benchmark."""
self.name = path or "uv" self.name = path or "uv"
@ -818,7 +818,7 @@ class uv(Suite):
return Command( return Command(
name=f"{self.name} ({Benchmark.RESOLVE_COLD.value})", name=f"{self.name} ({Benchmark.RESOLVE_COLD.value})",
prepare=f"rm -rf {cwd} && rm -f {output_file}", prepare=f"rm -rf {cache_dir} && rm -f {output_file}",
command=[ command=[
self.path, self.path,
"pip", "pip",
@ -936,6 +936,229 @@ class uv(Suite):
) )
class UvProject(Suite):
def __init__(self, *, path: str | None = None) -> Command | None:
"""Initialize a uv benchmark."""
self.name = path or "uv"
self.path = path or os.path.join(
os.path.dirname(
os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
),
"target",
"release",
"uv",
)
def setup(self, requirements_file: str, *, cwd: str) -> None:
"""Initialize a uv project from a requirements file."""
import tomli
import tomli_w
from packaging.requirements import Requirement
# Parse all dependencies from the requirements file.
with open(requirements_file) as fp:
requirements = [
Requirement(line)
for line in fp
if not line.lstrip().startswith("#") and len(line.strip()) > 0
]
# Create a Poetry project.
subprocess.check_call(
[
self.path,
"init",
"--name",
"bench",
"--python",
"3.12.3",
],
cwd=cwd,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
# Parse the pyproject.toml.
with open(os.path.join(cwd, "pyproject.toml"), "rb") as fp:
pyproject = tomli.load(fp)
# Add the dependencies to the pyproject.toml.
pyproject["project"]["dependencies"] += [
str(requirement) for requirement in requirements
]
with open(os.path.join(cwd, "pyproject.toml"), "wb") as fp:
tomli_w.dump(pyproject, fp)
def resolve_cold(self, requirements_file: str, *, cwd: str) -> Command | None:
self.setup(requirements_file, cwd=cwd)
cache_dir = os.path.join(cwd, ".cache")
output_file = os.path.join(cwd, "uv.lock")
return Command(
name=f"{self.name} ({Benchmark.RESOLVE_COLD.value})",
prepare=f"rm -rf {cache_dir} && rm -f {output_file}",
command=[
self.path,
"lock",
"--cache-dir",
cache_dir,
"--directory",
cwd,
"--python",
"3.12.3",
],
)
def resolve_warm(self, requirements_file: str, *, cwd: str) -> Command | None:
self.setup(requirements_file, cwd=cwd)
cache_dir = os.path.join(cwd, ".cache")
output_file = os.path.join(cwd, "uv.lock")
return Command(
name=f"{self.name} ({Benchmark.RESOLVE_WARM.value})",
prepare=f"rm -f {output_file}",
command=[
self.path,
"lock",
"--cache-dir",
cache_dir,
"--directory",
cwd,
"--python",
"3.12.3",
],
)
def resolve_incremental(
self, requirements_file: str, *, cwd: str
) -> Command | None:
import tomli
import tomli_w
self.setup(requirements_file, cwd=cwd)
uv_lock = os.path.join(cwd, "uv.lock")
assert not os.path.exists(uv_lock), f"Lock file already exists at: {uv_lock}"
# Run a resolution, to ensure that the lockfile exists.
# TODO(charlie): Make this a `setup`.
subprocess.check_call(
[self.path, "lock"],
cwd=cwd,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
assert os.path.exists(uv_lock), f"Lock file doesn't exist at: {uv_lock}"
# Add a dependency to the requirements file.
with open(os.path.join(cwd, "pyproject.toml"), "rb") as fp:
pyproject = tomli.load(fp)
# Add the dependencies to the pyproject.toml.
pyproject["project"]["dependencies"] += [INCREMENTAL_REQUIREMENT]
with open(os.path.join(cwd, "pyproject.toml"), "wb") as fp:
tomli_w.dump(pyproject, fp)
# Store the baseline lockfile.
baseline = os.path.join(cwd, "baseline.lock")
shutil.copyfile(uv_lock, baseline)
uv_lock = os.path.join(cwd, "uv.lock")
cache_dir = os.path.join(cwd, ".cache")
return Command(
name=f"{self.name} ({Benchmark.RESOLVE_INCREMENTAL.value})",
prepare=f"rm -f {uv_lock} && cp {baseline} {uv_lock}",
command=[
self.path,
"lock",
"--cache-dir",
cache_dir,
"--directory",
cwd,
"--python",
"3.12.3",
],
)
def install_cold(self, requirements_file: str, *, cwd: str) -> Command | None:
self.setup(requirements_file, cwd=cwd)
uv_lock = os.path.join(cwd, "uv.lock")
assert not os.path.exists(uv_lock), f"Lock file already exists at: {uv_lock}"
# Run a resolution, to ensure that the lockfile exists.
# TODO(charlie): Make this a `setup`.
subprocess.check_call(
[self.path, "lock"],
cwd=cwd,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
assert os.path.exists(uv_lock), f"Lock file doesn't exist at: {uv_lock}"
cache_dir = os.path.join(cwd, ".cache")
venv_dir = os.path.join(cwd, ".venv")
return Command(
name=f"{self.name} ({Benchmark.INSTALL_COLD.value})",
prepare=(
f"rm -rf {cache_dir} && "
f"virtualenv --clear -p 3.12 {venv_dir} --no-seed"
),
command=[
f"VIRTUAL_ENV={venv_dir}",
self.path,
"sync",
"--cache-dir",
cache_dir,
"--directory",
cwd,
"--python",
"3.12.3",
],
)
def install_warm(self, requirements_file: str, *, cwd: str) -> Command | None:
self.setup(requirements_file, cwd=cwd)
uv_lock = os.path.join(cwd, "uv.lock")
assert not os.path.exists(uv_lock), f"Lock file already exists at: {uv_lock}"
# Run a resolution, to ensure that the lockfile exists.
# TODO(charlie): Make this a `setup`.
subprocess.check_call(
[self.path, "lock"],
cwd=cwd,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
assert os.path.exists(uv_lock), f"Lock file doesn't exist at: {uv_lock}"
cache_dir = os.path.join(cwd, ".cache")
venv_dir = os.path.join(cwd, ".venv")
return Command(
name=f"{self.name} ({Benchmark.INSTALL_COLD.value})",
prepare=(f"virtualenv --clear -p 3.12 {venv_dir} --no-seed"),
command=[
f"VIRTUAL_ENV={venv_dir}",
self.path,
"sync",
"--cache-dir",
cache_dir,
"--directory",
cwd,
"--python",
"3.12.3",
],
)
def main(): def main():
"""Run the benchmark.""" """Run the benchmark."""
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
@ -994,8 +1217,13 @@ def main():
action="store_true", action="store_true",
) )
parser.add_argument( parser.add_argument(
"--uv", "--uv-pip",
help="Whether to benchmark uv (assumes a uv binary exists at `./target/release/uv`).", help="Whether to benchmark uv's pip interface (assumes a uv binary exists at `./target/release/uv`).",
action="store_true",
)
parser.add_argument(
"--uv-project",
help="Whether to benchmark uv's project interface (assumes a uv binary exists at `./target/release/uv`).",
action="store_true", action="store_true",
) )
parser.add_argument( parser.add_argument(
@ -1023,7 +1251,13 @@ def main():
action="append", action="append",
) )
parser.add_argument( parser.add_argument(
"--uv-path", "--uv-pip-path",
type=str,
help="Path(s) to the uv binary to benchmark.",
action="append",
)
parser.add_argument(
"--uv-project-path",
type=str, type=str,
help="Path(s) to the uv binary to benchmark.", help="Path(s) to the uv binary to benchmark.",
action="append", action="append",
@ -1055,8 +1289,10 @@ def main():
suites.append(Poetry()) suites.append(Poetry())
if args.pdm: if args.pdm:
suites.append(Pdm()) suites.append(Pdm())
if args.uv: if args.uv_pip:
suites.append(uv()) suites.append(UvPip())
if args.uv_project:
suites.append(UvProject())
for path in args.pip_sync_path or []: for path in args.pip_sync_path or []:
suites.append(PipSync(path=path)) suites.append(PipSync(path=path))
for path in args.pip_compile_path or []: for path in args.pip_compile_path or []:
@ -1065,8 +1301,10 @@ def main():
suites.append(Poetry(path=path)) suites.append(Poetry(path=path))
for path in args.pdm_path or []: for path in args.pdm_path or []:
suites.append(Pdm(path=path)) suites.append(Pdm(path=path))
for path in args.uv_path or []: for path in args.uv_pip_path or []:
suites.append(uv(path=path)) suites.append(UvPip(path=path))
for path in args.uv_project_path or []:
suites.append(UvProject(path=path))
# If no tools were specified, benchmark all tools. # If no tools were specified, benchmark all tools.
if not suites: if not suites:
@ -1074,7 +1312,8 @@ def main():
PipSync(), PipSync(),
PipCompile(), PipCompile(),
Poetry(), Poetry(),
uv(), UvPip(),
UvProject(),
] ]
# Determine the benchmarks to run, based on user input. If no benchmarks were # Determine the benchmarks to run, based on user input. If no benchmarks were