from __future__ import annotations
import argparse
import re
import sqlite3
import sys
import time
from pathlib import Path
import numpy as np
import pandas as pd
from scipy.sparse import csr_matrix, load_npz, save_npz
from sklearn.feature_extraction.text import TfidfVectorizer
import scipy
import sparse_dot_topn as sdtn
import sp_matmul_rs as sdtn_rs
HERE = Path(__file__).resolve().parent
DEFAULT_SQLITE = HERE.parent.parent / "sparse_dot_topn" / "dev_tests" / "database.sqlite"
def ngrams(string: str, n: int = 3) -> list[str]:
string = re.sub(r"[,-./]|\sBD", r"", string)
return ["".join(g) for g in zip(*[string[i:] for i in range(n)])]
def load_or_build_tfidf(sqlite_path: Path, n_rows: int) -> tuple[csr_matrix, csr_matrix]:
tag = "full" if n_rows <= 0 else str(n_rows)
path_A = HERE / f"tfidf_A_{tag}.npz"
path_B = HERE / f"tfidf_B_{tag}.npz"
if path_A.exists() and path_B.exists():
return load_npz(path_A), load_npz(path_B)
if not sqlite_path.exists():
sys.exit(f"sqlite source not found: {sqlite_path}")
print(f"building TF-IDF from {sqlite_path} (n_rows={tag}) ...", flush=True)
con = sqlite3.connect(sqlite_path)
names = pd.read_sql_query("SELECT name FROM companies", con)["name"]
con.close()
names = names.str.lower().sample(frac=1.0, random_state=0).reset_index(drop=True)
if n_rows <= 0:
vec = TfidfVectorizer(min_df=1).fit(names)
A_out = vec.transform(names).tocsr()
B_out = A_out.transpose().tocsr()
else:
hn = names.size // 2
vec = TfidfVectorizer(min_df=1).fit(names)
A = vec.transform(names[:hn])
B = vec.transform(names[hn:])
rng = np.random.default_rng(0)
A_rows = np.sort(rng.choice(np.arange(hn), size=n_rows, replace=False))
B_rows = np.sort(rng.choice(np.arange(hn), size=n_rows, replace=False))
A_out = A[A_rows, :]
B_out = B[B_rows, :].transpose().tocsr()
save_npz(path_A, A_out)
save_npz(path_B, B_out)
return A_out, B_out
def time_call(fn, repeats: int) -> tuple[float, float, float]:
fn()
ts = []
for _ in range(repeats):
t0 = time.perf_counter()
fn()
ts.append(time.perf_counter() - t0)
return min(ts), sum(ts) / len(ts), max(ts)
def fmt_speedup(baseline: float, candidate: float) -> str:
if candidate <= baseline:
return f"{baseline / candidate:.1f}x"
return f"-{candidate / baseline:.1f}x"
def main() -> None:
p = argparse.ArgumentParser()
p.add_argument(
"--rows",
type=int,
default=20_000,
help="rows per side of the TF-IDF input; pass 0 to use the entire EDGAR corpus (A @ A.T)",
)
p.add_argument("--repeats", type=int, default=5, help="timed repeats per config")
p.add_argument("--sqlite", type=Path, default=DEFAULT_SQLITE, help="EDGAR company sqlite")
p.add_argument("--only", default="scipy,cpp,rust", help="comma-separated subset of {scipy,cpp,rust}")
p.add_argument("--threads", default="1,2,4,8", help="comma-separated n_threads grid")
p.add_argument("--top-ns", default="10,20,30,100,1000", help="comma-separated top_n grid")
p.add_argument(
"--topn-only",
action="store_true",
help="skip the 'sp_matmul (no top-n)' rows (avoids OOM on very large inputs)",
)
p.add_argument("--out", type=Path, default=None, help="write Markdown table to this file too")
args = p.parse_args()
engines = [e.strip() for e in args.only.split(",") if e.strip()]
threads = [int(t) for t in args.threads.split(",")]
top_ns = [int(t) for t in args.top_ns.split(",")]
A, B = load_or_build_tfidf(args.sqlite, args.rows)
print(
f"A.shape={A.shape} A.nnz={A.nnz}\n"
f"B.shape={B.shape} B.nnz={B.nnz}\n"
f"scipy={scipy.__version__} sparse_dot_topn={sdtn.__version__} "
f"(openmp={sdtn._has_openmp_support}) sp_matmul_rs.parallel="
f"{sdtn_rs.kernel_info()['parallel_enabled']}",
flush=True,
)
runs: list[tuple[str, str, str, dict]] = []
if not args.topn_only:
for nt in threads:
runs.append(
(
"sp_matmul (no top-n)",
"",
str(nt),
{
"scipy": (lambda A=A, B=B: A.dot(B)),
"cpp": (lambda A=A, B=B, nt=nt: sdtn.sp_matmul(A, B, n_threads=nt)),
"rust": (lambda A=A, B=B, nt=nt: sdtn_rs.sp_matmul(A, B, n_threads=nt)),
},
)
)
for top_n in top_ns:
for nt in threads:
runs.append(
(
"sp_matmul_topn",
str(top_n),
str(nt),
{
"scipy": (lambda A=A, B=B: A.dot(B)),
"cpp": (lambda A=A, B=B, n=top_n, nt=nt: sdtn.sp_matmul_topn(A, B, n, n_threads=nt)),
"rust": (
lambda A=A, B=B, n=top_n, nt=nt: sdtn_rs.sp_matmul_topn(A, B, n, n_threads=nt)
),
},
)
)
rows = []
for label, top_n_str, nt_str, fns in runs:
print(f" -> {label:<22} top_n={top_n_str or '-':<5} n_threads={nt_str}", flush=True)
rec: dict[str, tuple[float, float, float] | None] = {"scipy": None, "cpp": None, "rust": None}
for eng in engines:
rec[eng] = time_call(fns[eng], args.repeats)
rows.append((label, top_n_str, nt_str, rec))
header = (
"| Benchmark | top_n | n_threads | "
"Scipy (s) | C++ min (s) | C++ vs Scipy | Rust min (s) | Rust vs Scipy | Rust vs C++ |"
)
sep = (
"| :------------------- | :---: | :-------: | "
"--------: | ----------: | -----------: | -----------: | ------------: | ----------: |"
)
md_lines = [header, sep]
for label, top_n_str, nt_str, rec in rows:
sc = rec.get("scipy")
cp = rec.get("cpp")
ru = rec.get("rust")
def cell(t):
return f"{t[0]:.3f}" if t else "—"
sc_speed_cpp = fmt_speedup(sc[0], cp[0]) if (sc and cp) else "—"
sc_speed_rust = fmt_speedup(sc[0], ru[0]) if (sc and ru) else "—"
cpp_speed_rust = fmt_speedup(cp[0], ru[0]) if (cp and ru) else "—"
md_lines.append(
f"| {label:<20} | {top_n_str or '-':^5} | {nt_str:^9} | "
f"{cell(sc):>9} | {cell(cp):>11} | {sc_speed_cpp:>12} | "
f"{cell(ru):>12} | {sc_speed_rust:>13} | {cpp_speed_rust:>11} |"
)
table = "\n".join(md_lines)
print()
print(table)
if args.out is not None:
args.out.write_text(table + "\n")
print(f"\nwrote {args.out}", flush=True)
if __name__ == "__main__":
main()