Skip to content

Commit b56ca3e

Browse files
committed
Closes #5185: Benchmark for Multidim Binop Performance
1 parent f641b85 commit b56ca3e

File tree

1 file changed

+197
-0
lines changed

1 file changed

+197
-0
lines changed
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
import math
2+
import operator
3+
4+
import pytest
5+
import functools
6+
import arkouda as ak
7+
8+
from benchmark_v2.benchmark_utils import calc_num_bytes
9+
10+
11+
DTYPES = ("uint64", "bigint")
12+
NDIMS = (1, 2, 3)
13+
OPS = ("+", "-", "*", "/", "//", "&", "|", "^")
14+
15+
16+
@functools.cache
17+
def choose_shape(n: int, ndim: int) -> tuple[int, ...]:
18+
"""
19+
Choose an ``ndim``-dimensional shape whose element count is as close as possible
20+
to ``n`` **without exceeding it**, while keeping dimensions as even as possible.
21+
22+
The returned shape has:
23+
- ``prod(shape) <= n`` (unless ``n < 1``, in which case a minimal shape is used)
24+
- minimal dimension spread (``max(shape) - min(shape)``), with ties broken by
25+
maximizing ``prod(shape)`` (i.e., minimizing ``n - prod(shape)``).
26+
27+
Parameters
28+
----------
29+
n : int
30+
Target maximum number of elements. The resulting shape will satisfy
31+
``prod(shape) <= n`` when possible.
32+
ndim : int
33+
Number of dimensions. Supported values are 1, 2, and 3.
34+
35+
Returns
36+
-------
37+
tuple[int, ...]
38+
A tuple of length ``ndim`` representing the chosen shape.
39+
40+
Raises
41+
------
42+
ValueError
43+
If ``ndim`` is not one of {1, 2, 3}.
44+
45+
Examples
46+
--------
47+
>>> choose_shape(36, 3)
48+
(3, 3, 4)
49+
"""
50+
if ndim == 1:
51+
return (max(1, int(n)),)
52+
53+
if ndim == 2:
54+
root = int(math.isqrt(max(1, n)))
55+
best = None
56+
# search around sqrt(n)
57+
for a in range(max(1, root - 64), root + 65):
58+
b = n // a
59+
dims = tuple(sorted((a, b)))
60+
prod = dims[0] * dims[1]
61+
spread = dims[1] - dims[0]
62+
overshoot = prod - n
63+
score = spread * 1_000_000 + overshoot
64+
cand = (score, dims)
65+
if best is None or cand < best:
66+
best = cand
67+
return best[1]
68+
69+
if ndim == 3:
70+
root = int(round(max(1, n) ** (1 / 3)))
71+
best = None
72+
# search a,b around cube-root; compute c as ceil(n/(a*b))
73+
for a in range(max(1, root - 64), root + 65):
74+
for b in range(max(1, root - 64), root + 65):
75+
ab = a * b
76+
if ab <= 0:
77+
continue
78+
c = n // ab
79+
dims = tuple(sorted((a, b, max(1, c))))
80+
prod = dims[0] * dims[1] * dims[2]
81+
spread = dims[2] - dims[0]
82+
overshoot = prod - n
83+
score = spread * 1_000_000 + overshoot
84+
cand = (score, dims)
85+
if best is None or cand < best:
86+
best = cand
87+
return best[1]
88+
89+
raise ValueError(f"Unsupported ndim={ndim}")
90+
91+
92+
def _make_uint64(shape: tuple[int, ...], seed: int):
93+
size = 1
94+
for d in shape:
95+
size *= d
96+
a = ak.randint(0, 2**64, size=size, dtype=ak.uint64, seed=seed)
97+
if len(shape) > 1:
98+
a = a.reshape(*shape)
99+
return a
100+
101+
102+
def _make_bigint_2limb(shape: tuple[int, ...], seed: int):
103+
"""Make a bigint array using exactly two uint64 limbs (hi, lo)."""
104+
size = 1
105+
for d in shape:
106+
size *= d
107+
108+
hi = ak.randint(0, 2**64, size=size, dtype=ak.uint64, seed=seed)
109+
lo = ak.randint(0, 2**64, size=size, dtype=ak.uint64, seed=seed + 1)
110+
111+
bi = ak.bigint_from_uint_arrays([hi, lo])
112+
if len(shape) > 1:
113+
bi = bi.reshape(*shape)
114+
return bi
115+
116+
117+
def _make_arrays(shape: tuple[int, ...], dtype: str, seed: int):
118+
if dtype == "uint64":
119+
a = _make_uint64(shape, seed)
120+
b = _make_uint64(shape, seed + 10_000)
121+
return a, b
122+
elif dtype == "bigint":
123+
a = _make_bigint_2limb(shape, seed)
124+
b = _make_bigint_2limb(shape, seed + 10_000)
125+
return a, b
126+
else:
127+
raise ValueError(f"Unsupported dtype={dtype}")
128+
129+
130+
def _get_binop(op: str):
131+
# Use Python operators so this works naturally on arkouda pdarrays.
132+
if op == "+":
133+
return operator.add
134+
if op == "-":
135+
return operator.sub
136+
if op == "*":
137+
return operator.mul
138+
if op == "/":
139+
return operator.truediv
140+
if op == "//":
141+
return operator.floordiv
142+
if op == "&":
143+
return operator.and_
144+
if op == "|":
145+
return operator.or_
146+
if op == "^":
147+
return operator.xor
148+
raise ValueError(f"Unknown op={op}")
149+
150+
151+
@pytest.mark.skip_numpy(True)
152+
@pytest.mark.skip_if_rank_not_compiled([2, 3])
153+
@pytest.mark.benchmark(group="AK_binop_ops")
154+
@pytest.mark.parametrize("dtype", DTYPES)
155+
@pytest.mark.parametrize("ndim", NDIMS)
156+
@pytest.mark.parametrize("op", OPS)
157+
def bench_binop_ops(benchmark, dtype, ndim, op):
158+
"""
159+
Benchmark binary operations on uint64 and bigint across 1D/2D/3D shapes.
160+
161+
- Total element target is ~ pytest.prob_size * cfg["numLocales"]
162+
- Shapes are chosen to be as even as possible while keeping product close to N.
163+
- Bigint arrays are built from exactly two uint64 limbs via ak.bigint_from_uint_arrays.
164+
"""
165+
cfg = ak.get_config()
166+
N = pytest.prob_size * cfg["numLocales"]
167+
seed = pytest.seed or 0
168+
169+
shape = choose_shape(N, ndim)
170+
a, b = _make_arrays(shape, dtype, seed)
171+
172+
fn = _get_binop(op)
173+
174+
bytes_a = calc_num_bytes(a)
175+
bytes_b = calc_num_bytes(b)
176+
num_bytes = bytes_a + bytes_b
177+
178+
benchmark.pedantic(
179+
fn,
180+
args=[a, b],
181+
rounds=pytest.trials,
182+
)
183+
184+
# metadata
185+
benchmark.extra_info["description"] = (
186+
f"Binary op '{op}' on dtype={dtype} with shape={shape} (target N={N}, "
187+
f"actual elements={math.prod(shape)})."
188+
)
189+
benchmark.extra_info["problem_size"] = N
190+
benchmark.extra_info["shape"] = shape
191+
benchmark.extra_info["ndim"] = ndim
192+
benchmark.extra_info["dtype"] = dtype
193+
benchmark.extra_info["op"] = op
194+
benchmark.extra_info["num_bytes"] = num_bytes
195+
benchmark.extra_info["transfer_rate"] = "{:.4f} GiB/sec".format(
196+
(num_bytes / benchmark.stats["mean"]) / 2**30
197+
)

0 commit comments

Comments
 (0)