Skip to content

Commit 6fee54e

Browse files
committed
Add benchmark harness to profile sequence search pipeline for hotspots.
1 parent 7df2c5e commit 6fee54e

1 file changed

Lines changed: 250 additions & 0 deletions

File tree

flask/benchmark.py

Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,250 @@
1+
import csv
2+
import os
3+
import random
4+
import subprocess
5+
import tempfile
6+
import time
7+
import tracemalloc
8+
from sys import platform
9+
10+
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
11+
CORPUS_PATH = os.path.join(BASE_DIR, "dumps/sequences.fsa")
12+
UCLUST_RESULTS = os.path.join(BASE_DIR, "benchmark/benchmark_uclust.uc")
13+
CSV_OUTPUT = os.path.join(BASE_DIR, "benchmark/baseline.csv")
14+
15+
UCLUST_IDENTITY = '0.8'
16+
CSV_FIELDNAMES = ['hotspot', 'run', 'seq_len', 'elapsed_s', 'peak_kb']
17+
18+
# Load correct vsearch binary for OS
19+
vsearch_binaries = {
20+
"linux": os.path.join(BASE_DIR, "usearch/vsearch_linux"),
21+
"darwin": os.path.join(BASE_DIR, "usearch/vsearch_macos")
22+
}
23+
24+
vsearch_binary_filename = vsearch_binaries.get(platform, None)
25+
if not vsearch_binary_filename:
26+
print("Sorry, your OS is not supported for this benchmark.")
27+
exit(1)
28+
29+
30+
def load_fasta(path):
31+
"""
32+
Loads a FASTA file and returns its contents.
33+
34+
Arguments:
35+
path {str} -- Path to FASTA file
36+
37+
Returns:
38+
list -- List of sequences
39+
"""
40+
sequences = []
41+
with open(path) as f:
42+
for line in f:
43+
line = line.strip()
44+
if not line.startswith(">"):
45+
sequences.append(line)
46+
return sequences
47+
48+
49+
def write_to_temp(sequence):
50+
"""
51+
Writes a text sequence to a temporary FASTA file for search.
52+
53+
Arguments:
54+
sequence {str} -- Sequence to write to file
55+
56+
Returns:
57+
str -- Path to the temp file
58+
"""
59+
with tempfile.NamedTemporaryFile(suffix=".fsa", delete=False, mode='w') as temp_file:
60+
temp_file.write(f'>sequence_to_search\n{sequence}\n')
61+
return temp_file.name
62+
63+
64+
def get_uris(uc_file):
65+
"""
66+
Gets the hit URIs from a UC file.
67+
68+
Arguments:
69+
uc_file {str} -- Path to .uc file
70+
71+
Returns:
72+
list -- List of URIs
73+
"""
74+
uris = []
75+
with open(uc_file) as file:
76+
for line in file:
77+
parts = line.split()
78+
if parts[0] == 'H':
79+
uris.append(parts[9])
80+
return uris
81+
82+
83+
def run_vsearch(file_name):
84+
"""
85+
Runs vsearch usearch_global on a query file against the corpus and writes results to a .uc file.
86+
87+
Arguments:
88+
file_name {str} -- Path to query FASTA file
89+
"""
90+
args = [vsearch_binary_filename, '--usearch_global', file_name, '--db', CORPUS_PATH,
91+
'--uc', file_name[:-4] + '.uc', '--uc_allhits']
92+
93+
global_args = {
94+
'maxaccepts': '50',
95+
'id': '0.8',
96+
'iddef': '2',
97+
'maxrejects': '0',
98+
'maxseqlength': '5000',
99+
'minseqlength': '20'
100+
}
101+
102+
for flag in global_args:
103+
args.append("--" + flag)
104+
args.append(global_args[flag])
105+
106+
subprocess.run(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True)
107+
108+
109+
def run_uc_scan(uc_file, uris):
110+
"""
111+
Simulates the per-hit .uc file scans performed by get_info_from_uc_table in search.py
112+
-- reads percent match, strand, and CIGAR for each hit URI.
113+
114+
Arguments:
115+
uc_file {str} -- Path to .uc file
116+
uris {list} -- List of hit URIs
117+
"""
118+
hits = []
119+
120+
for uri in uris:
121+
for col in [3, 4, 7]: # percent_match, strand, CIGAR
122+
with open(uc_file) as file:
123+
for line in file:
124+
parts = line.split()
125+
if parts[9] == uri:
126+
hits.append(parts[col])
127+
break
128+
129+
return hits
130+
131+
132+
def run_uclust():
133+
"""
134+
Runs vsearch cluster_fast on the full corpus and writes results to a .uc file.
135+
"""
136+
args = [vsearch_binary_filename, '--cluster_fast', CORPUS_PATH, '--id', UCLUST_IDENTITY, '--uc', UCLUST_RESULTS]
137+
subprocess.run(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True)
138+
139+
140+
def benchmark_function(fn, *args, **kwargs):
141+
"""
142+
Runs fn(*args, **kwargs) and benchmarks its execution time and memory usage.
143+
144+
Arguments:
145+
fn {function} -- Function to run
146+
*args {list} -- List of arguments
147+
**kwargs {dict} -- Keyword arguments
148+
149+
Returns:
150+
tuple -- (elapsed time in seconds, peak memory usage in bytes)
151+
"""
152+
tracemalloc.start()
153+
t0 = time.perf_counter()
154+
155+
fn(*args, **kwargs)
156+
157+
elapsed = time.perf_counter() - t0
158+
_, peak = tracemalloc.get_traced_memory()
159+
tracemalloc.stop()
160+
161+
return elapsed, peak
162+
163+
164+
def print_results(label, elapsed, peak):
165+
"""
166+
Prints benchmark results in a readable format.
167+
168+
Arguments:
169+
label {str} -- Name of the hotspot being benchmarked
170+
elapsed {float} -- Elapsed time in seconds
171+
peak {int} -- Peak memory usage in bytes
172+
"""
173+
print(f"\n{'='*50}")
174+
print(f" {label}")
175+
print(f"{'='*50}")
176+
print(f" Elapsed time : {elapsed:.4f}s")
177+
print(f" Peak memory : {peak / 1024:.2f} KB")
178+
179+
180+
def benchmark_search_pipeline(sequences, n=20):
181+
"""
182+
Runs the hotspot 1 and 2 pipeline n times on random sequences and returns averages.
183+
Only samples sequences up to 5000bp to match vsearch's maxseqlength limit.
184+
185+
Arguments:
186+
sequences {list} -- List of sequences to sample from
187+
n {int} -- Number of iterations
188+
189+
Returns:
190+
tuple -- (rows, avg_elapsed_1, avg_peak_1, avg_elapsed_2, avg_peak_2)
191+
"""
192+
rows = []
193+
eligible = [s for s in sequences if len(s) <= 5000]
194+
195+
for i in range(n):
196+
random_sequence = random.choice(eligible)
197+
tmp_fasta = write_to_temp(random_sequence)
198+
199+
# Hotspot 1: Synchronous blocking subprocess
200+
elapsed_1, peak_1 = benchmark_function(run_vsearch, tmp_fasta)
201+
202+
uc_file = tmp_fasta[:-4] + '.uc'
203+
uris = get_uris(uc_file)
204+
205+
# Hotspot 2: Repeated linear .uc file scans per hit
206+
elapsed_2, peak_2 = benchmark_function(run_uc_scan, uc_file, uris)
207+
208+
rows.append({'hotspot': 1, 'run': i + 1, 'seq_len': len(random_sequence), 'elapsed_s': round(elapsed_1, 4), 'peak_kb': round(peak_1 / 1024, 2)})
209+
rows.append({'hotspot': 2, 'run': i + 1, 'seq_len': len(random_sequence), 'elapsed_s': round(elapsed_2, 4), 'peak_kb': round(peak_2 / 1024, 2)})
210+
211+
h1_rows = [r for r in rows if r['hotspot'] == 1]
212+
h2_rows = [r for r in rows if r['hotspot'] == 2]
213+
avg_elapsed_1 = sum(r['elapsed_s'] for r in h1_rows) / n
214+
avg_peak_1 = sum(r['peak_kb'] for r in h1_rows) / n
215+
avg_elapsed_2 = sum(r['elapsed_s'] for r in h2_rows) / n
216+
avg_peak_2 = sum(r['peak_kb'] for r in h2_rows) / n
217+
218+
return rows, avg_elapsed_1, avg_peak_1, avg_elapsed_2, avg_peak_2
219+
220+
221+
def main():
222+
os.makedirs(os.path.join(BASE_DIR, "benchmark"), exist_ok=True)
223+
224+
sequences = load_fasta(CORPUS_PATH)
225+
n = 20
226+
227+
# Hotspots 1 & 2: averaged over n runs
228+
rows, avg_elapsed_1, avg_peak_1, avg_elapsed_2, avg_peak_2 = benchmark_search_pipeline(sequences, n)
229+
print_results(f"Hotspot 1: Synchronous blocking subprocess (avg {n} runs)", avg_elapsed_1, avg_peak_1 * 1024)
230+
print_results(f"Hotspot 2: Repeated linear .uc file scans per hit (avg {n} runs)", avg_elapsed_2, avg_peak_2 * 1024)
231+
232+
# Hotspot 3: Full-corpus re-cluster on every index rebuild (single run)
233+
elapsed_3, peak_3 = benchmark_function(run_uclust)
234+
print_results("Hotspot 3: Full-corpus re-cluster on every index rebuild", elapsed_3, peak_3)
235+
236+
# Write results to CSV
237+
with open(CSV_OUTPUT, 'w', newline='') as f:
238+
writer = csv.DictWriter(f, fieldnames=CSV_FIELDNAMES)
239+
writer.writeheader()
240+
writer.writerows(rows)
241+
h1_rows = [r for r in rows if r['hotspot'] == 1]
242+
avg_seq_len = round(sum(r['seq_len'] for r in h1_rows) / len(h1_rows), 1)
243+
writer.writerow({'hotspot': 1, 'run': 'AVERAGE', 'seq_len': avg_seq_len, 'elapsed_s': round(avg_elapsed_1, 4), 'peak_kb': round(avg_peak_1, 2)})
244+
writer.writerow({'hotspot': 2, 'run': 'AVERAGE', 'seq_len': avg_seq_len, 'elapsed_s': round(avg_elapsed_2, 4), 'peak_kb': round(avg_peak_2, 2)})
245+
writer.writerow({'hotspot': 3, 'run': 1, 'seq_len': 'full_corpus', 'elapsed_s': round(elapsed_3, 4), 'peak_kb': round(peak_3 / 1024, 2)})
246+
247+
print(f"\nResults written to {CSV_OUTPUT}")
248+
249+
if __name__ == "__main__":
250+
main()

0 commit comments

Comments
 (0)