Skip to content

Commit e08ebdd

Browse files
committed
add benchmarking script
1 parent 04d9592 commit e08ebdd

File tree

5 files changed

+162
-3
lines changed

5 files changed

+162
-3
lines changed

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ ignore = [
6161
"RET505", # superfluous-else-return
6262
"S101", # assert
6363
"S301", # suspicious-pickle-usage
64+
"S311", # suspicious-non-cryptographic-random-usage
6465
"S324", # hashlib-insecure-hash-function
6566
"S603", # subprocess-without-shell-equals-true
6667
"S607", # start-process-with-partial-path

tests/README.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,19 @@ Connect to the debugger, eg [using vscode](https://code.visualstudio.com/docs/py
6666
Note: set `CLEAR_WORKSPACE = False` in `common.py` if you want to prevent the temporary files generated during the test
6767
from being cleared.
6868

69+
### Benchmarking
70+
71+
The `create_benchmark_data.py` script creates a directory with many python packages to represent a worst case scenario.
72+
Run the script then run `venv/bin/python run.py` from the created directory.
73+
74+
One way of obtaining profiling information is to run:
75+
76+
```sh
77+
venv/bin/python -m cProfile -o profile.prof run.py
78+
pyprof2calltree -i profile.prof -o profile.log
79+
kcachegrind profile.log
80+
```
81+
6982
### Caching
7083

7184
sccache is a tool for caching build artifacts to speed up compilation. Unfortunately, it is currently useless for these

tests/__init__.py

Whitespace-only changes.

tests/create_benchmark_data.py

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import argparse
2+
import logging
3+
import random
4+
import string
5+
import sys
6+
import textwrap
7+
from dataclasses import dataclass
8+
from pathlib import Path
9+
10+
from runner import VirtualEnv
11+
12+
script_dir = Path(__file__).resolve().parent
13+
repo_root = script_dir.parent
14+
15+
log = logging.getLogger("runner")
16+
logging.basicConfig(format="[%(name)s] [%(levelname)s] %(message)s", level=logging.DEBUG)
17+
18+
19+
@dataclass
20+
class BenchmarkConfig:
21+
seed: int
22+
filename_length: int
23+
depth: int
24+
num_python_editable_packages: int
25+
26+
@staticmethod
27+
def default() -> "BenchmarkConfig":
28+
return BenchmarkConfig(
29+
seed=0,
30+
filename_length=10,
31+
depth=10,
32+
num_python_editable_packages=100,
33+
)
34+
35+
36+
def random_name(rng: random.Random, length: int) -> str:
37+
return "".join(rng.choices(string.ascii_lowercase, k=length))
38+
39+
40+
def random_path(rng: random.Random, root: Path, depth: int, name_length: int) -> Path:
41+
path = root
42+
for _ in range(depth):
43+
path = path / random_name(rng, name_length)
44+
return path
45+
46+
47+
def create_python_package(root: Path) -> tuple[str, Path]:
48+
root.mkdir(parents=True, exist_ok=False)
49+
src_dir = root / "src" / root.name
50+
src_dir.mkdir(parents=True)
51+
(src_dir / "__init__.py").write_text(
52+
textwrap.dedent(f"""\
53+
def get_name():
54+
return "{root.name}"
55+
""")
56+
)
57+
(root / "pyproject.toml").write_text(
58+
textwrap.dedent(f"""\
59+
[project]
60+
name = "{root.name}"
61+
version = "0.1.0"
62+
63+
[tool.setuptools.packages.find]
64+
where = ["src"]
65+
66+
[build-system]
67+
requires = ["setuptools", "wheel"]
68+
build-backend = "setuptools.build_meta"
69+
""")
70+
)
71+
return root.name, src_dir
72+
73+
74+
def create_benchmark_environment(root: Path, config: BenchmarkConfig) -> None:
75+
rng = random.Random(config.seed)
76+
77+
log.info("creating benchmark environment at %s", root)
78+
root.mkdir(parents=True, exist_ok=False)
79+
venv = VirtualEnv.create(root / "venv", Path(sys.executable))
80+
81+
venv.install_editable_package(repo_root)
82+
83+
python_package_names = []
84+
python_package_paths = []
85+
86+
packages_root = random_path(rng, root, config.depth, config.filename_length)
87+
name, src_dir = create_python_package(packages_root)
88+
python_package_names.append(name)
89+
python_package_paths.append(src_dir)
90+
91+
for _ in range(config.num_python_editable_packages):
92+
path = random_path(rng, packages_root, config.depth, config.filename_length)
93+
name, src_dir = create_python_package(path)
94+
python_package_names.append(name)
95+
python_package_paths.append(src_dir)
96+
97+
python_package_paths_str = ", ".join(f'"{path.parent}"' for path in python_package_paths)
98+
import_python_packages = "\n".join(f"import {name}" for name in python_package_names)
99+
(root / "run.py").write_text(f"""\
100+
import time
101+
import logging
102+
import sys
103+
import maturin_import_hook
104+
105+
sys.path.extend([{python_package_paths_str}])
106+
107+
# logging.basicConfig(format='%(asctime)s %(name)s [%(levelname)s] %(message)s', level=logging.DEBUG)
108+
# maturin_import_hook.reset_logger()
109+
110+
maturin_import_hook.install()
111+
112+
start = time.perf_counter()
113+
114+
{import_python_packages}
115+
116+
end = time.perf_counter()
117+
print(f'took {{end - start:.6f}}s')
118+
""")
119+
120+
121+
def main() -> None:
122+
parser = argparse.ArgumentParser()
123+
parser.add_argument("root", type=Path, help="the location to write the benchmark data to")
124+
args = parser.parse_args()
125+
126+
config = BenchmarkConfig.default()
127+
create_benchmark_environment(args.root, config)
128+
129+
130+
if __name__ == "__main__":
131+
main()

tests/runner.py

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ def _pip_install_command(interpreter_path: Path) -> list[str]:
122122

123123

124124
def _create_test_venv(python: Path, venv_dir: Path) -> VirtualEnv:
125-
venv = VirtualEnv.new(venv_dir, python)
125+
venv = VirtualEnv.create(venv_dir, python)
126126
log.info("installing test requirements into virtualenv")
127127
proc = subprocess.run(
128128
[
@@ -156,13 +156,22 @@ def _create_virtual_env_command(interpreter_path: Path, venv_path: Path) -> list
156156
return [str(interpreter_path), "-m", "venv", str(venv_path)]
157157

158158

159+
def _install_into_virtual_env_command(interpreter_path: Path, package_path: Path) -> list[str]:
160+
if shutil.which("uv") is not None:
161+
log.info("using uv to install package as editable")
162+
return ["uv", "pip", "install", "--python", str(interpreter_path), "--editable", str(package_path)]
163+
else:
164+
log.info("using pip to install package as editable")
165+
return [str(interpreter_path), "-m", "pip", "install", "--editable", str(package_path)]
166+
167+
159168
class VirtualEnv:
160169
def __init__(self, root: Path) -> None:
161170
self._root = root.resolve()
162171
self._is_windows = platform.system() == "Windows"
163172

164173
@staticmethod
165-
def new(root: Path, interpreter_path: Path) -> VirtualEnv:
174+
def create(root: Path, interpreter_path: Path) -> VirtualEnv:
166175
if root.exists():
167176
log.info("removing virtualenv at %s", root)
168177
shutil.rmtree(root)
@@ -194,6 +203,11 @@ def interpreter_path(self) -> Path:
194203
assert interpreter.exists()
195204
return interpreter
196205

206+
def install_editable_package(self, package_path: Path) -> None:
207+
cmd = _install_into_virtual_env_command(self.interpreter_path, package_path)
208+
proc = subprocess.run(cmd, capture_output=True, check=True)
209+
log.debug("%s", proc.stdout.decode())
210+
197211
def activate(self, env: dict[str, str]) -> None:
198212
"""set the environment as-if venv/bin/activate was run"""
199213
path = env.get("PATH", "").split(os.pathsep)
@@ -254,7 +268,7 @@ def main() -> None:
254268
parser.add_argument(
255269
"--name",
256270
default="Tests",
257-
help="the name for the suite of tests this run (use to distinguish between OS/python version)",
271+
help="the name to assign for the suite of tests this run (use to distinguish between OS/python version)",
258272
)
259273

260274
parser.add_argument(

0 commit comments

Comments
 (0)