ocalc 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ocalc-0.1.0/LICENSE +21 -0
- ocalc-0.1.0/PKG-INFO +61 -0
- ocalc-0.1.0/README.md +35 -0
- ocalc-0.1.0/pyproject.toml +40 -0
- ocalc-0.1.0/setup.cfg +4 -0
- ocalc-0.1.0/src/ocalc/__init__.py +216 -0
- ocalc-0.1.0/src/ocalc/bench.py +287 -0
- ocalc-0.1.0/src/ocalc/cli.py +86 -0
- ocalc-0.1.0/src/ocalc/comment.py +230 -0
- ocalc-0.1.0/src/ocalc/fit.py +260 -0
- ocalc-0.1.0/src/ocalc/generate.py +615 -0
- ocalc-0.1.0/src/ocalc/mcp_server.py +108 -0
- ocalc-0.1.0/src/ocalc/static.py +357 -0
- ocalc-0.1.0/src/ocalc.egg-info/PKG-INFO +61 -0
- ocalc-0.1.0/src/ocalc.egg-info/SOURCES.txt +22 -0
- ocalc-0.1.0/src/ocalc.egg-info/dependency_links.txt +1 -0
- ocalc-0.1.0/src/ocalc.egg-info/entry_points.txt +2 -0
- ocalc-0.1.0/src/ocalc.egg-info/requires.txt +11 -0
- ocalc-0.1.0/src/ocalc.egg-info/top_level.txt +1 -0
- ocalc-0.1.0/tests/test_bench.py +270 -0
- ocalc-0.1.0/tests/test_comment.py +115 -0
- ocalc-0.1.0/tests/test_fit.py +108 -0
- ocalc-0.1.0/tests/test_mcp.py +78 -0
- ocalc-0.1.0/tests/test_static.py +268 -0
ocalc-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ocalc contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
ocalc-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ocalc
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Measure a Python function and comment its time and space complexity.
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Project-URL: Homepage, https://github.com/Anuj-Attri/ocalc
|
|
7
|
+
Project-URL: Documentation, https://anuj-attri.github.io/ocalc/
|
|
8
|
+
Project-URL: Repository, https://github.com/Anuj-Attri/ocalc
|
|
9
|
+
Project-URL: Issues, https://github.com/Anuj-Attri/ocalc/issues
|
|
10
|
+
Keywords: complexity,benchmark,big-o,profiling
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Topic :: Software Development :: Testing
|
|
14
|
+
Requires-Python: >=3.10
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
License-File: LICENSE
|
|
17
|
+
Requires-Dist: numpy
|
|
18
|
+
Provides-Extra: mcp
|
|
19
|
+
Requires-Dist: mcp<3,>=2; extra == "mcp"
|
|
20
|
+
Provides-Extra: dev
|
|
21
|
+
Requires-Dist: pytest; extra == "dev"
|
|
22
|
+
Provides-Extra: docs
|
|
23
|
+
Requires-Dist: sphinx<8,>=7.2; extra == "docs"
|
|
24
|
+
Requires-Dist: pytorch-sphinx-theme2==0.4.13; extra == "docs"
|
|
25
|
+
Dynamic: license-file
|
|
26
|
+
|
|
27
|
+
# ocalc
|
|
28
|
+
|
|
29
|
+
`ocalc` measures a Python function and writes a one-line estimate of its time and space complexity.
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
# ocalc: Time: O(n); Space: O(1); Worst: 0.42 s at n=100000; Median: 0.31 s; Confidence: high; Protocol: min_n=100,max_n=100000,measures=8,budget=5,repeats=5
|
|
33
|
+
def two_sum(nums, target):
|
|
34
|
+
...
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Install
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
pip install ocalc
|
|
41
|
+
pip install "ocalc[mcp]"
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Use
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
ocalc measure FILE -f two_sum --sample "[1, 2, 3], 9"
|
|
48
|
+
ocalc annotate FILE -f two_sum --sample "[1, 2, 3], 9"
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+

|
|
52
|
+
|
|
53
|
+
Try a snippet on the [demo](https://anuj-attri.github.io/ocalc/demo.html). The page reports the worst case in the file. The guide and API reference are at [anuj-attri.github.io/ocalc](https://anuj-attri.github.io/ocalc/).
|
|
54
|
+
|
|
55
|
+
## Development
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
pip install -e ".[dev,docs]"
|
|
59
|
+
pytest
|
|
60
|
+
sphinx-build -b html docs docs/_build/html
|
|
61
|
+
```
|
ocalc-0.1.0/README.md
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# ocalc
|
|
2
|
+
|
|
3
|
+
`ocalc` measures a Python function and writes a one-line estimate of its time and space complexity.
|
|
4
|
+
|
|
5
|
+
```python
|
|
6
|
+
# ocalc: Time: O(n); Space: O(1); Worst: 0.42 s at n=100000; Median: 0.31 s; Confidence: high; Protocol: min_n=100,max_n=100000,measures=8,budget=5,repeats=5
|
|
7
|
+
def two_sum(nums, target):
|
|
8
|
+
...
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pip install ocalc
|
|
15
|
+
pip install "ocalc[mcp]"
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Use
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
ocalc measure FILE -f two_sum --sample "[1, 2, 3], 9"
|
|
22
|
+
ocalc annotate FILE -f two_sum --sample "[1, 2, 3], 9"
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+

|
|
26
|
+
|
|
27
|
+
Try a snippet on the [demo](https://anuj-attri.github.io/ocalc/demo.html). The page reports the worst case in the file. The guide and API reference are at [anuj-attri.github.io/ocalc](https://anuj-attri.github.io/ocalc/).
|
|
28
|
+
|
|
29
|
+
## Development
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pip install -e ".[dev,docs]"
|
|
33
|
+
pytest
|
|
34
|
+
sphinx-build -b html docs docs/_build/html
|
|
35
|
+
```
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "ocalc"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Measure a Python function and comment its time and space complexity."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
keywords = ["complexity", "benchmark", "big-o", "profiling"]
|
|
14
|
+
dependencies = ["numpy"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Operating System :: OS Independent",
|
|
18
|
+
"Topic :: Software Development :: Testing",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
[project.urls]
|
|
22
|
+
Homepage = "https://github.com/Anuj-Attri/ocalc"
|
|
23
|
+
Documentation = "https://anuj-attri.github.io/ocalc/"
|
|
24
|
+
Repository = "https://github.com/Anuj-Attri/ocalc"
|
|
25
|
+
Issues = "https://github.com/Anuj-Attri/ocalc/issues"
|
|
26
|
+
|
|
27
|
+
[project.optional-dependencies]
|
|
28
|
+
mcp = ["mcp>=2,<3"]
|
|
29
|
+
dev = ["pytest"]
|
|
30
|
+
docs = ["sphinx>=7.2,<8", "pytorch-sphinx-theme2==0.4.13"]
|
|
31
|
+
|
|
32
|
+
[project.scripts]
|
|
33
|
+
ocalc = "ocalc.cli:main"
|
|
34
|
+
|
|
35
|
+
[tool.setuptools.packages.find]
|
|
36
|
+
where = ["src"]
|
|
37
|
+
|
|
38
|
+
[tool.pytest.ini_options]
|
|
39
|
+
testpaths = ["tests"]
|
|
40
|
+
pythonpath = ["src"]
|
ocalc-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"""Measure a Python function and estimate its time and space complexity."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
|
|
10
|
+
from ocalc.bench import BenchResult, Point, benchmark
|
|
11
|
+
from ocalc.comment import (
|
|
12
|
+
Protocol,
|
|
13
|
+
apply_comment,
|
|
14
|
+
existing_comment,
|
|
15
|
+
format_comment,
|
|
16
|
+
format_table,
|
|
17
|
+
)
|
|
18
|
+
from ocalc.fit import FitResult, fit_curve
|
|
19
|
+
from ocalc.generate import (
|
|
20
|
+
FunctionNotFound,
|
|
21
|
+
NeedSample,
|
|
22
|
+
callable_location,
|
|
23
|
+
load_function,
|
|
24
|
+
parse_sample,
|
|
25
|
+
plan_inputs,
|
|
26
|
+
)
|
|
27
|
+
from ocalc.static import FunctionEstimate, SourceAnalysis, analyze_source
|
|
28
|
+
|
|
29
|
+
__version__ = "0.1.0"
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
"FunctionEstimate",
|
|
33
|
+
"FunctionNotFound",
|
|
34
|
+
"NeedSample",
|
|
35
|
+
"Report",
|
|
36
|
+
"SourceAnalysis",
|
|
37
|
+
"analyze_source",
|
|
38
|
+
"annotate_file",
|
|
39
|
+
"measure",
|
|
40
|
+
"parse_sample",
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class Report:
|
|
46
|
+
comment: str
|
|
47
|
+
points: list[Point]
|
|
48
|
+
time: FitResult
|
|
49
|
+
space: FitResult
|
|
50
|
+
worst_s: float
|
|
51
|
+
median_s: float
|
|
52
|
+
worst_n: int | None
|
|
53
|
+
protocol: Protocol
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def measure(
|
|
57
|
+
func,
|
|
58
|
+
*sample_args,
|
|
59
|
+
budget: float | None = None,
|
|
60
|
+
min_n: int | None = None,
|
|
61
|
+
max_n: int | None = None,
|
|
62
|
+
measures: int | None = None,
|
|
63
|
+
repeats: int | None = None,
|
|
64
|
+
file: str | None = None,
|
|
65
|
+
qualname: str | None = None,
|
|
66
|
+
protocol: Protocol | None = None,
|
|
67
|
+
previous_s: float | None = None,
|
|
68
|
+
**sample_kwargs,
|
|
69
|
+
) -> Report:
|
|
70
|
+
"""Measure a function and return its complexity report.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
func: The callable to measure.
|
|
74
|
+
sample_args: Example positional arguments. The first collection grows with ``n``.
|
|
75
|
+
budget: Seconds to spend measuring. The default is 5.
|
|
76
|
+
min_n: Smallest input size.
|
|
77
|
+
max_n: Largest input size.
|
|
78
|
+
measures: How many sizes to try.
|
|
79
|
+
repeats: Timed calls at each size.
|
|
80
|
+
file: Source file, when the callable should be re-imported for the timed run.
|
|
81
|
+
qualname: Qualname used to re-import ``file``.
|
|
82
|
+
protocol: Stored sizes and budget from an earlier comment.
|
|
83
|
+
previous_s: Worst time from that earlier comment.
|
|
84
|
+
sample_kwargs: Example keyword arguments.
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
The comment, the size table, and the fitted time and space classes.
|
|
88
|
+
"""
|
|
89
|
+
plan = plan_inputs(func, sample_args, sample_kwargs)
|
|
90
|
+
if protocol is not None and protocol.constant and plan.mode == "constant":
|
|
91
|
+
if budget is None:
|
|
92
|
+
budget = protocol.budget
|
|
93
|
+
if repeats is None:
|
|
94
|
+
repeats = protocol.repeats
|
|
95
|
+
elif protocol is not None and not protocol.constant and plan.mode != "constant":
|
|
96
|
+
if budget is None:
|
|
97
|
+
budget = protocol.budget
|
|
98
|
+
if min_n is None:
|
|
99
|
+
min_n = protocol.min_n
|
|
100
|
+
if max_n is None:
|
|
101
|
+
max_n = protocol.max_n
|
|
102
|
+
if measures is None:
|
|
103
|
+
measures = protocol.measures
|
|
104
|
+
if repeats is None:
|
|
105
|
+
repeats = protocol.repeats
|
|
106
|
+
|
|
107
|
+
located_file, located_name = callable_location(func)
|
|
108
|
+
file = file or located_file
|
|
109
|
+
qualname = qualname or located_name
|
|
110
|
+
result = benchmark(
|
|
111
|
+
func,
|
|
112
|
+
plan,
|
|
113
|
+
budget=5.0 if budget is None else budget,
|
|
114
|
+
min_n=min_n,
|
|
115
|
+
max_n=max_n,
|
|
116
|
+
measures=measures,
|
|
117
|
+
repeats=repeats,
|
|
118
|
+
file=file,
|
|
119
|
+
qualname=qualname,
|
|
120
|
+
)
|
|
121
|
+
time_fit, space_fit = _fits(result)
|
|
122
|
+
comment = format_comment(
|
|
123
|
+
time=time_fit,
|
|
124
|
+
space=space_fit,
|
|
125
|
+
worst_s=result.worst_s,
|
|
126
|
+
worst_n=result.worst_n,
|
|
127
|
+
median_s=result.median_s,
|
|
128
|
+
protocol=result.protocol,
|
|
129
|
+
previous_s=previous_s,
|
|
130
|
+
)
|
|
131
|
+
return Report(
|
|
132
|
+
comment=comment,
|
|
133
|
+
points=result.points,
|
|
134
|
+
time=time_fit,
|
|
135
|
+
space=space_fit,
|
|
136
|
+
worst_s=result.worst_s,
|
|
137
|
+
median_s=result.median_s,
|
|
138
|
+
worst_n=result.worst_n,
|
|
139
|
+
protocol=result.protocol,
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def annotate_file(
|
|
144
|
+
path,
|
|
145
|
+
qualname: str,
|
|
146
|
+
*sample_args,
|
|
147
|
+
budget: float | None = None,
|
|
148
|
+
min_n: int | None = None,
|
|
149
|
+
max_n: int | None = None,
|
|
150
|
+
measures: int | None = None,
|
|
151
|
+
repeats: int | None = None,
|
|
152
|
+
**sample_kwargs,
|
|
153
|
+
) -> Report:
|
|
154
|
+
"""Benchmark a function in a file and write the comment above it.
|
|
155
|
+
|
|
156
|
+
A later call reuses the protocol stored in the existing comment and records
|
|
157
|
+
the previous worst time.
|
|
158
|
+
|
|
159
|
+
Args:
|
|
160
|
+
path: Python file that defines the function.
|
|
161
|
+
qualname: Function qualname, such as ``two_sum`` or ``Box.run``.
|
|
162
|
+
sample_args: Example positional arguments.
|
|
163
|
+
budget: Seconds to spend measuring.
|
|
164
|
+
min_n: Smallest input size.
|
|
165
|
+
max_n: Largest input size.
|
|
166
|
+
measures: How many sizes to try.
|
|
167
|
+
repeats: Timed calls at each size.
|
|
168
|
+
sample_kwargs: Example keyword arguments.
|
|
169
|
+
|
|
170
|
+
Returns:
|
|
171
|
+
The report that was written into the file.
|
|
172
|
+
"""
|
|
173
|
+
file_path = Path(path)
|
|
174
|
+
source = file_path.read_text(encoding="utf-8")
|
|
175
|
+
previous = existing_comment(source, qualname)
|
|
176
|
+
protocol = previous.protocol if previous is not None else None
|
|
177
|
+
previous_s = previous.worst_s if previous is not None else None
|
|
178
|
+
func = load_function(file_path, qualname)
|
|
179
|
+
report = measure(
|
|
180
|
+
func,
|
|
181
|
+
*sample_args,
|
|
182
|
+
budget=budget,
|
|
183
|
+
min_n=min_n,
|
|
184
|
+
max_n=max_n,
|
|
185
|
+
measures=measures,
|
|
186
|
+
repeats=repeats,
|
|
187
|
+
file=str(file_path.resolve()),
|
|
188
|
+
qualname=qualname,
|
|
189
|
+
protocol=protocol,
|
|
190
|
+
previous_s=previous_s,
|
|
191
|
+
**sample_kwargs,
|
|
192
|
+
)
|
|
193
|
+
updated = apply_comment(source, qualname, report.comment)
|
|
194
|
+
with file_path.open("w", encoding="utf-8", newline="") as handle:
|
|
195
|
+
handle.write(updated)
|
|
196
|
+
return report
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _fits(result: BenchResult) -> tuple[FitResult, FitResult]:
|
|
200
|
+
if result.constant:
|
|
201
|
+
done = FitResult("1", "O(1)", "high")
|
|
202
|
+
return done, done
|
|
203
|
+
sizes = [point.n for point in result.points]
|
|
204
|
+
times = [point.time_s for point in result.points]
|
|
205
|
+
time_fit = fit_curve(sizes, times, exponential=result.exponential)
|
|
206
|
+
return time_fit, _space_fit(sizes, [point.memory_bytes for point in result.points])
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _space_fit(sizes, memory) -> FitResult:
|
|
210
|
+
used = np.asarray(memory, dtype=float)
|
|
211
|
+
if used.size == 0 or np.nanmax(used) <= 0:
|
|
212
|
+
return FitResult("1", "O(1)", "high")
|
|
213
|
+
positive = used[used > 0]
|
|
214
|
+
floor = float(positive.min()) if positive.size else 1.0
|
|
215
|
+
used = np.where(used > 0, used, floor)
|
|
216
|
+
return fit_curve(sizes, used)
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
"""Time a function across a size grid and record peak Python allocations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import inspect
|
|
7
|
+
import math
|
|
8
|
+
import multiprocessing
|
|
9
|
+
import time
|
|
10
|
+
import tracemalloc
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from statistics import median
|
|
13
|
+
|
|
14
|
+
from ocalc.comment import Protocol
|
|
15
|
+
from ocalc.generate import InputPlan, load_function
|
|
16
|
+
|
|
17
|
+
DEFAULT_BUDGET = 5.0
|
|
18
|
+
DEFAULT_MEASURES = 8
|
|
19
|
+
DEFAULT_MIN_N = 100
|
|
20
|
+
DEFAULT_MAX_N = 100_000
|
|
21
|
+
DEFAULT_MIN_N_INT = 8
|
|
22
|
+
_REPEATS = 5
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class Point:
|
|
27
|
+
n: int | None
|
|
28
|
+
time_s: float
|
|
29
|
+
memory_bytes: float
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class BenchResult:
|
|
34
|
+
points: list[Point]
|
|
35
|
+
protocol: Protocol
|
|
36
|
+
exponential: bool
|
|
37
|
+
constant: bool
|
|
38
|
+
timed_out: bool
|
|
39
|
+
worst_s: float
|
|
40
|
+
median_s: float
|
|
41
|
+
worst_n: int | None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def geometric_sizes(min_n: int, max_n: int, measures: int) -> list[int]:
|
|
45
|
+
"""Evenly spaced sizes on a log scale, including both ends."""
|
|
46
|
+
if measures < 1:
|
|
47
|
+
raise ValueError("measures must be at least 1")
|
|
48
|
+
if min_n > max_n:
|
|
49
|
+
raise ValueError("min_n cannot be greater than max_n")
|
|
50
|
+
if measures == 1 or min_n == max_n:
|
|
51
|
+
return [int(max_n)]
|
|
52
|
+
ratio = (max_n / min_n) ** (1 / (measures - 1))
|
|
53
|
+
raw = [int(round(min_n * ratio**i)) for i in range(measures)]
|
|
54
|
+
raw[0] = int(min_n)
|
|
55
|
+
raw[-1] = int(max_n)
|
|
56
|
+
sizes: list[int] = []
|
|
57
|
+
for size in raw:
|
|
58
|
+
size = max(int(min_n), size)
|
|
59
|
+
if not sizes or size > sizes[-1]:
|
|
60
|
+
sizes.append(size)
|
|
61
|
+
if sizes[-1] != int(max_n):
|
|
62
|
+
sizes.append(int(max_n))
|
|
63
|
+
return sizes
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def benchmark(
|
|
67
|
+
func,
|
|
68
|
+
plan: InputPlan,
|
|
69
|
+
*,
|
|
70
|
+
budget: float,
|
|
71
|
+
min_n: int | None,
|
|
72
|
+
max_n: int | None,
|
|
73
|
+
measures: int | None,
|
|
74
|
+
repeats: int | None,
|
|
75
|
+
file: str | None,
|
|
76
|
+
qualname: str | None,
|
|
77
|
+
) -> BenchResult:
|
|
78
|
+
measures = DEFAULT_MEASURES if measures is None else measures
|
|
79
|
+
budget = DEFAULT_BUDGET if budget is None else budget
|
|
80
|
+
repeats = _REPEATS if repeats is None else max(1, int(repeats))
|
|
81
|
+
if plan.mode == "constant":
|
|
82
|
+
return _benchmark_constant(func, plan, budget, repeats, file, qualname)
|
|
83
|
+
|
|
84
|
+
if plan.mode == "int_n":
|
|
85
|
+
min_n = DEFAULT_MIN_N_INT if min_n is None else min_n
|
|
86
|
+
max_n = DEFAULT_MAX_N if max_n is None else max_n
|
|
87
|
+
else:
|
|
88
|
+
min_n = DEFAULT_MIN_N if min_n is None else min_n
|
|
89
|
+
max_n = DEFAULT_MAX_N if max_n is None else max_n
|
|
90
|
+
protocol = Protocol(int(min_n), int(max_n), int(measures), float(budget), repeats=repeats)
|
|
91
|
+
sizes = geometric_sizes(protocol.min_n, protocol.max_n, protocol.measures)
|
|
92
|
+
points: list[Point] = []
|
|
93
|
+
exponential = False
|
|
94
|
+
timed_out = False
|
|
95
|
+
started = time.perf_counter()
|
|
96
|
+
|
|
97
|
+
for size in sizes:
|
|
98
|
+
remaining = budget - (time.perf_counter() - started)
|
|
99
|
+
if remaining <= 0.05:
|
|
100
|
+
break
|
|
101
|
+
if len(points) >= 2 and _explosive(
|
|
102
|
+
points[-2].n, points[-2].time_s, points[-1].n, points[-1].time_s
|
|
103
|
+
):
|
|
104
|
+
exponential = True
|
|
105
|
+
break
|
|
106
|
+
if points:
|
|
107
|
+
ratio = size / points[-1].n
|
|
108
|
+
predicted = points[-1].time_s * max(ratio, 1.0) ** 2
|
|
109
|
+
if predicted > remaining:
|
|
110
|
+
break
|
|
111
|
+
args, kwargs = plan.build(size)
|
|
112
|
+
status, times, peak = _measure_call(
|
|
113
|
+
func,
|
|
114
|
+
args,
|
|
115
|
+
kwargs,
|
|
116
|
+
file=file,
|
|
117
|
+
qualname=qualname,
|
|
118
|
+
repeats=repeats,
|
|
119
|
+
timeout=max(0.2, remaining),
|
|
120
|
+
)
|
|
121
|
+
if status == "timeout":
|
|
122
|
+
timed_out = True
|
|
123
|
+
if points and (
|
|
124
|
+
points[-1].n <= 40
|
|
125
|
+
and points[-1].time_s > 1e-4
|
|
126
|
+
or (
|
|
127
|
+
len(points) >= 2
|
|
128
|
+
and _explosive(points[-2].n, points[-2].time_s, points[-1].n, points[-1].time_s)
|
|
129
|
+
)
|
|
130
|
+
):
|
|
131
|
+
exponential = True
|
|
132
|
+
break
|
|
133
|
+
if status == "error":
|
|
134
|
+
raise RuntimeError(times)
|
|
135
|
+
point = Point(size, float(median(times)), float(peak))
|
|
136
|
+
points.append(point)
|
|
137
|
+
if len(points) >= 2 and _explosive(
|
|
138
|
+
points[-2].n, points[-2].time_s, points[-1].n, points[-1].time_s
|
|
139
|
+
):
|
|
140
|
+
exponential = True
|
|
141
|
+
break
|
|
142
|
+
|
|
143
|
+
if not points:
|
|
144
|
+
raise RuntimeError(
|
|
145
|
+
"No measurement finished within the budget. Raise --budget or lower --min-n."
|
|
146
|
+
)
|
|
147
|
+
slowest = max(points, key=lambda item: item.time_s)
|
|
148
|
+
return BenchResult(
|
|
149
|
+
points=points,
|
|
150
|
+
protocol=protocol,
|
|
151
|
+
exponential=exponential,
|
|
152
|
+
constant=False,
|
|
153
|
+
timed_out=timed_out,
|
|
154
|
+
worst_s=slowest.time_s,
|
|
155
|
+
median_s=float(median(item.time_s for item in points)),
|
|
156
|
+
worst_n=slowest.n,
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _benchmark_constant(func, plan, budget, repeats, file, qualname) -> BenchResult:
|
|
161
|
+
args, kwargs = plan.build(0)
|
|
162
|
+
status, times, peak = _measure_call(
|
|
163
|
+
func,
|
|
164
|
+
args,
|
|
165
|
+
kwargs,
|
|
166
|
+
file=file,
|
|
167
|
+
qualname=qualname,
|
|
168
|
+
repeats=repeats,
|
|
169
|
+
timeout=max(0.2, budget),
|
|
170
|
+
)
|
|
171
|
+
if status == "timeout":
|
|
172
|
+
raise RuntimeError(
|
|
173
|
+
"No measurement finished within the budget. Raise --budget or lower --min-n."
|
|
174
|
+
)
|
|
175
|
+
if status == "error":
|
|
176
|
+
raise RuntimeError(times)
|
|
177
|
+
protocol = Protocol(0, 0, 0, float(budget), constant=True, repeats=repeats)
|
|
178
|
+
point = Point(None, float(median(times)), float(peak))
|
|
179
|
+
return BenchResult(
|
|
180
|
+
points=[point],
|
|
181
|
+
protocol=protocol,
|
|
182
|
+
exponential=False,
|
|
183
|
+
constant=True,
|
|
184
|
+
timed_out=False,
|
|
185
|
+
worst_s=float(max(times)),
|
|
186
|
+
median_s=float(median(times)),
|
|
187
|
+
worst_n=None,
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _explosive(n0, t0, n1, t1) -> bool:
|
|
192
|
+
if t0 <= 0 or t1 <= 0 or n1 <= n0:
|
|
193
|
+
return False
|
|
194
|
+
n_ratio = n1 / n0
|
|
195
|
+
t_ratio = t1 / t0
|
|
196
|
+
if n_ratio < 1.5 and t_ratio > 2 and n1 <= 64 and t1 > 1e-4:
|
|
197
|
+
return True
|
|
198
|
+
if t1 < 1e-3 or n_ratio <= 1:
|
|
199
|
+
return False
|
|
200
|
+
exponent = math.log(t_ratio) / math.log(n_ratio)
|
|
201
|
+
return exponent > 5
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _call(func, args, kwargs):
|
|
205
|
+
result = func(*args, **kwargs)
|
|
206
|
+
if inspect.iscoroutine(result):
|
|
207
|
+
return asyncio.run(result)
|
|
208
|
+
return result
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _measure_call(func, args, kwargs, *, file, qualname, repeats, timeout):
|
|
212
|
+
if file and qualname:
|
|
213
|
+
return _run_process(file, qualname, args, kwargs, repeats, timeout)
|
|
214
|
+
times, peak = collect_sample(func, args, kwargs, repeats)
|
|
215
|
+
return "ok", times, peak
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def collect_sample(func, args, kwargs, repeats: int = _REPEATS):
|
|
219
|
+
"""Warm up, time several calls, then record one tracemalloc peak."""
|
|
220
|
+
_call(func, args, kwargs)
|
|
221
|
+
batch = 1
|
|
222
|
+
|
|
223
|
+
def once() -> float:
|
|
224
|
+
started = time.perf_counter()
|
|
225
|
+
for _ in range(batch):
|
|
226
|
+
_call(func, args, kwargs)
|
|
227
|
+
return (time.perf_counter() - started) / batch
|
|
228
|
+
|
|
229
|
+
first = once()
|
|
230
|
+
if first < 0.001:
|
|
231
|
+
batch = min(10_000, max(1, math.ceil(0.002 / max(first, 1e-9))))
|
|
232
|
+
first = once()
|
|
233
|
+
times = [first]
|
|
234
|
+
if first <= 0.2:
|
|
235
|
+
for _ in range(repeats - 1):
|
|
236
|
+
duration = once()
|
|
237
|
+
times.append(duration)
|
|
238
|
+
if duration > 0.2:
|
|
239
|
+
break
|
|
240
|
+
tracemalloc.start()
|
|
241
|
+
try:
|
|
242
|
+
_call(func, args, kwargs)
|
|
243
|
+
_current, peak = tracemalloc.get_traced_memory()
|
|
244
|
+
finally:
|
|
245
|
+
tracemalloc.stop()
|
|
246
|
+
return times, float(peak)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _run_process(file, qualname, args, kwargs, repeats, timeout):
|
|
250
|
+
context = multiprocessing.get_context("spawn")
|
|
251
|
+
parent, child = context.Pipe(duplex=False)
|
|
252
|
+
process = context.Process(
|
|
253
|
+
target=_worker,
|
|
254
|
+
args=(child, str(file), qualname, args, kwargs, repeats),
|
|
255
|
+
daemon=True,
|
|
256
|
+
)
|
|
257
|
+
process.start()
|
|
258
|
+
child.close()
|
|
259
|
+
try:
|
|
260
|
+
process.join(timeout)
|
|
261
|
+
if process.is_alive():
|
|
262
|
+
process.terminate()
|
|
263
|
+
process.join()
|
|
264
|
+
return "timeout", None, None
|
|
265
|
+
if not parent.poll(0.2):
|
|
266
|
+
return "error", "The measurement process stopped without a result.", None
|
|
267
|
+
message = parent.recv()
|
|
268
|
+
finally:
|
|
269
|
+
parent.close()
|
|
270
|
+
if not message or message[0] != "ok":
|
|
271
|
+
detail = message[1] if message and len(message) > 1 else "measurement failed"
|
|
272
|
+
return "error", detail, None
|
|
273
|
+
return "ok", message[1], message[2]
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _worker(conn, file, qualname, args, kwargs, repeats):
|
|
277
|
+
try:
|
|
278
|
+
func = load_function(file, qualname)
|
|
279
|
+
times, peak = collect_sample(func, args, kwargs, repeats)
|
|
280
|
+
conn.send(("ok", times, peak))
|
|
281
|
+
except Exception as exc:
|
|
282
|
+
try:
|
|
283
|
+
conn.send(("err", f"{type(exc).__name__}: {exc}"))
|
|
284
|
+
except Exception:
|
|
285
|
+
pass
|
|
286
|
+
finally:
|
|
287
|
+
conn.close()
|