Skip to content

Commit 9a35694

Browse files
committed
arrow: add a benchmark to measure query performance
Before #252, we had both correctness and performance problems related to morsel driven parallelism. The benchmark verifies that queries are getting properly distributed across cores and generate the expected results (especially for non native columnar sources). Building Arrow table (target ~8.00 GiB)... and 12 query threads Built table with 80,000,000 rows, 8.05 GiB in 7.15s Registering Arrow memory-backed table 'arrow_cpu_bench'... Running CPU-intensive Cypher query (run 1)... Query time: 1.54s CPU usage during query: avg=219.0% max=302.0% Expected cnt=2,000,000, actual cnt=2,000,000 Expected checksum=502,110,822,616, actual checksum=502,110,822,616 Running CPU-intensive Cypher query (run 2)... Query time: 1.53s CPU usage during query: avg=379.9% max=441.0% Expected cnt=2,000,000, actual cnt=2,000,000 Expected checksum=502,110,822,616, actual checksum=502,110,822,616 Running CPU-intensive Cypher query (run 3)... Query time: 1.54s CPU usage during query: avg=490.7% max=536.0% Expected cnt=2,000,000, actual cnt=2,000,000 Expected checksum=502,110,822,616, actual checksum=502,110,822,616 Average query time over 3 runs: 1.54s Maximum observed CPU across runs: 536.0% Observed CPU > 100%, indicating multi-core usage.
1 parent 0b629b7 commit 9a35694

1 file changed

Lines changed: 279 additions & 0 deletions

File tree

test/benchmark_arrow.py

Lines changed: 279 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,279 @@
1+
from __future__ import annotations
2+
3+
import argparse
4+
import os
5+
import subprocess
6+
import sys
7+
import tempfile
8+
import threading
9+
import time
10+
from pathlib import Path
11+
12+
import numpy as np
13+
import pyarrow as pa
14+
15+
python_build_dir = Path(__file__).parent.parent / "build"
16+
try:
17+
import real_ladybug as lb
18+
except ModuleNotFoundError:
19+
sys.path.append(str(python_build_dir))
20+
import real_ladybug as lb
21+
22+
23+
def parse_args() -> argparse.Namespace:
24+
parser = argparse.ArgumentParser(
25+
description=(
26+
"Create a large in-memory Arrow-backed table, run a CPU-intensive Cypher filter query, "
27+
"and validate deterministic results."
28+
)
29+
)
30+
parser.add_argument("--target-gb", type=float, default=8.0, help="Target Arrow table size in GiB.")
31+
parser.add_argument(
32+
"--chunk-rows", type=int, default=1_000_000, help="Rows per generated Arrow record batch."
33+
)
34+
parser.add_argument(
35+
"--filter-cutoff",
36+
type=int,
37+
default=25,
38+
help="Filter predicate uses n.filter_key < cutoff where filter_key is in [0, 999].",
39+
)
40+
parser.add_argument(
41+
"--threads", type=int, default=max(2, os.cpu_count() or 2), help="Ladybug query worker threads."
42+
)
43+
parser.add_argument("--db-path", type=str, default="", help="Optional database path.")
44+
parser.add_argument(
45+
"--query-runs",
46+
type=int,
47+
default=3,
48+
help="How many times to execute the Cypher query for timing measurement.",
49+
)
50+
return parser.parse_args()
51+
52+
53+
def read_process_cpu_percent(pid: int) -> float:
54+
output = subprocess.check_output(["ps", "-o", "%cpu=", "-p", str(pid)], text=True).strip()
55+
if not output:
56+
return 0.0
57+
return float(output)
58+
59+
60+
def build_large_arrow_table(
61+
target_bytes: int, chunk_rows: int, filter_cutoff: int
62+
) -> tuple[pa.Table, int, int]:
63+
batches: list[pa.RecordBatch] = []
64+
total_bytes = 0
65+
row_start = 0
66+
expected_count = 0
67+
expected_checksum = 0
68+
69+
while total_bytes < target_bytes:
70+
row_end = row_start + chunk_rows
71+
ids = np.arange(row_start, row_end, dtype=np.int64)
72+
filter_key = ((ids * 37 + 17) % 1000).astype(np.int32)
73+
74+
x0 = ids % 997
75+
x1 = (ids * 7 + 3) % 991
76+
x2 = (ids * 11 + 5) % 983
77+
x3 = (ids * 13 + 7) % 977
78+
x4 = (ids * 17 + 11) % 971
79+
x5 = (ids * 19 + 13) % 967
80+
x6 = (ids * 23 + 17) % 953
81+
x7 = (ids * 29 + 19) % 947
82+
x8 = (ids * 31 + 23) % 941
83+
x9 = (ids * 37 + 29) % 937
84+
x10 = (ids * 41 + 31) % 929
85+
x11 = (ids * 43 + 37) % 919
86+
87+
mask = filter_key < filter_cutoff
88+
expected_count += int(mask.sum())
89+
expected_checksum += int(
90+
(
91+
(x0[mask] * x1[mask])
92+
+ (x2[mask] * x3[mask])
93+
- (x4[mask] * x5[mask])
94+
+ (x6[mask] % 97)
95+
+ (x7[mask] % 89)
96+
+ (x8[mask] * 3)
97+
- (x9[mask] * 5)
98+
+ (x10[mask] % 71)
99+
- (x11[mask] % 67)
100+
).sum()
101+
)
102+
103+
batch = pa.record_batch(
104+
{
105+
"id": pa.array(ids),
106+
"filter_key": pa.array(filter_key),
107+
"x0": pa.array(x0),
108+
"x1": pa.array(x1),
109+
"x2": pa.array(x2),
110+
"x3": pa.array(x3),
111+
"x4": pa.array(x4),
112+
"x5": pa.array(x5),
113+
"x6": pa.array(x6),
114+
"x7": pa.array(x7),
115+
"x8": pa.array(x8),
116+
"x9": pa.array(x9),
117+
"x10": pa.array(x10),
118+
"x11": pa.array(x11),
119+
}
120+
)
121+
batches.append(batch)
122+
total_bytes += batch.nbytes
123+
row_start = row_end
124+
125+
return pa.Table.from_batches(batches), expected_count, expected_checksum
126+
127+
128+
def measure_query_once(conn: lb.Connection, query: str) -> tuple[float, int, int, float, float]:
129+
samples: list[float] = []
130+
stop_event = threading.Event()
131+
132+
def cpu_sampler() -> None:
133+
pid = os.getpid()
134+
while not stop_event.is_set():
135+
try:
136+
samples.append(read_process_cpu_percent(pid))
137+
except Exception:
138+
pass
139+
time.sleep(0.2)
140+
141+
sampler_thread = threading.Thread(target=cpu_sampler, daemon=True)
142+
sampler_thread.start()
143+
144+
query_start = time.perf_counter()
145+
result = conn.execute(query)
146+
row = result.get_next()
147+
elapsed = time.perf_counter() - query_start
148+
149+
stop_event.set()
150+
sampler_thread.join(timeout=1.0)
151+
152+
actual_count = int(row[0])
153+
actual_checksum = int(row[1])
154+
max_cpu = max(samples) if samples else 0.0
155+
avg_cpu = (sum(samples) / len(samples)) if samples else 0.0
156+
157+
return elapsed, actual_count, actual_checksum, avg_cpu, max_cpu
158+
159+
160+
def main() -> int:
161+
args = parse_args()
162+
if not (0 < args.filter_cutoff <= 1000):
163+
raise ValueError("--filter-cutoff must be in [1, 1000].")
164+
if args.chunk_rows <= 0:
165+
raise ValueError("--chunk-rows must be positive.")
166+
if args.query_runs <= 0:
167+
raise ValueError("--query-runs must be positive.")
168+
169+
target_bytes = int(args.target_gb * (1024**3))
170+
db_path_value = args.db_path
171+
172+
temp_dir: tempfile.TemporaryDirectory[str] | None = None
173+
if not db_path_value:
174+
temp_dir = tempfile.TemporaryDirectory(prefix="ladybug_arrow_bench_")
175+
db_path_value = str(Path(temp_dir.name) / "bench.lbdb")
176+
177+
print(f"Building Arrow table (target ~{args.target_gb:.2f} GiB)... and {args.threads} query threads")
178+
build_start = time.perf_counter()
179+
table, expected_count, expected_checksum = build_large_arrow_table(
180+
target_bytes=target_bytes, chunk_rows=args.chunk_rows, filter_cutoff=args.filter_cutoff
181+
)
182+
build_secs = time.perf_counter() - build_start
183+
print(f"Built table with {table.num_rows:,} rows, {table.nbytes / (1024**3):.2f} GiB in {build_secs:.2f}s")
184+
185+
db = lb.Database(database_path=db_path_value, buffer_pool_size=256 * 1024 * 1024, read_only=False)
186+
conn = lb.Connection(db, num_threads=args.threads)
187+
188+
table_name = "arrow_cpu_bench"
189+
using_arrow_memory_table = hasattr(conn._connection, "create_arrow_table")
190+
if using_arrow_memory_table:
191+
print(f"Registering Arrow memory-backed table '{table_name}'...")
192+
conn.create_arrow_table(table_name, table)
193+
else:
194+
print(f"Creating node table '{table_name}' and loading from Arrow...")
195+
conn.execute(
196+
f"""
197+
CREATE NODE TABLE {table_name}(
198+
id INT64,
199+
filter_key INT32,
200+
x0 INT64,
201+
x1 INT64,
202+
x2 INT64,
203+
x3 INT64,
204+
x4 INT64,
205+
x5 INT64,
206+
x6 INT64,
207+
x7 INT64,
208+
x8 INT64,
209+
x9 INT64,
210+
x10 INT64,
211+
x11 INT64,
212+
PRIMARY KEY(id)
213+
)
214+
"""
215+
)
216+
conn.execute(f"COPY {table_name} FROM $df", {"df": table})
217+
218+
query = f"""
219+
MATCH (n:{table_name})
220+
WHERE n.filter_key < {args.filter_cutoff}
221+
RETURN
222+
COUNT(*) AS cnt,
223+
SUM(
224+
(n.x0 * n.x1) +
225+
(n.x2 * n.x3) -
226+
(n.x4 * n.x5) +
227+
(n.x6 % 97) +
228+
(n.x7 % 89) +
229+
(n.x8 * 3) -
230+
(n.x9 * 5) +
231+
(n.x10 % 71) -
232+
(n.x11 % 67)
233+
) AS checksum
234+
"""
235+
236+
run_stats: list[tuple[float, float, float]] = []
237+
for run_idx in range(1, args.query_runs + 1):
238+
print(f"Running CPU-intensive Cypher query (run {run_idx})...")
239+
elapsed, actual_count, actual_checksum, avg_cpu, max_cpu = measure_query_once(conn, query)
240+
print(f"Query time: {elapsed:.2f}s")
241+
print(f"CPU usage during query: avg={avg_cpu:.1f}% max={max_cpu:.1f}%")
242+
print(f"Expected cnt={expected_count:,}, actual cnt={actual_count:,}")
243+
print(f"Expected checksum={expected_checksum:,}, actual checksum={actual_checksum:,}")
244+
245+
if actual_count != expected_count or actual_checksum != expected_checksum:
246+
if using_arrow_memory_table:
247+
conn.drop_arrow_table(table_name)
248+
else:
249+
conn.execute(f"DROP TABLE {table_name}")
250+
conn.close()
251+
if temp_dir:
252+
temp_dir.cleanup()
253+
raise AssertionError("Query result validation failed.")
254+
255+
run_stats.append((elapsed, avg_cpu, max_cpu))
256+
257+
avg_elapsed = sum(stat[0] for stat in run_stats) / len(run_stats)
258+
max_cpu_overall = max(stat[2] for stat in run_stats)
259+
print(f"Average query time over {len(run_stats)} runs: {avg_elapsed:.2f}s")
260+
print(f"Maximum observed CPU across runs: {max_cpu_overall:.1f}%")
261+
262+
# >100% indicates more than one core on ps-based accounting.
263+
if max_cpu_overall <= 100.0:
264+
print("Warning: max CPU did not exceed 100%; try larger target-gb/chunk-rows or more threads.")
265+
else:
266+
print("Observed CPU > 100%, indicating multi-core usage.")
267+
268+
if using_arrow_memory_table:
269+
conn.drop_arrow_table(table_name)
270+
else:
271+
conn.execute(f"DROP TABLE {table_name}")
272+
conn.close()
273+
if temp_dir:
274+
temp_dir.cleanup()
275+
return 0
276+
277+
278+
if __name__ == "__main__":
279+
raise SystemExit(main())

0 commit comments

Comments
 (0)