Skip to content

Commit f854b0f

Browse files
authored
Merge pull request #43 from Vedasheersh/codex/perf-accepted
Cache and deduplicate repeated inference requests
2 parents 2e70135 + a81e1d2 commit f854b0f

2 files changed

Lines changed: 282 additions & 7 deletions

File tree

catpred/inference/service.py

Lines changed: 156 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
from __future__ import annotations
22

3+
from collections import OrderedDict
34
from pathlib import Path
45
from functools import lru_cache
56
import gzip
7+
import hashlib
68
import json
79
import os
810
import subprocess
11+
import threading
912
from typing import Tuple
1013

1114
import numpy as np
@@ -22,6 +25,9 @@
2225
}
2326
_VALID_AAS = set("ACDEFGHIKLMNPQRSTVWY")
2427
_MODEL_CACHE_SIZE = max(int(os.environ.get("CATPRED_MODEL_CACHE_SIZE", "6")), 0)
28+
_PREDICTION_CACHE_SIZE = max(int(os.environ.get("CATPRED_PREDICTION_CACHE_SIZE", "128")), 0)
29+
_PREDICTION_CACHE: OrderedDict[tuple, str] = OrderedDict()
30+
_PREDICTION_CACHE_LOCK = threading.Lock()
2531

2632

2733
def _validate_parameter(parameter: str) -> str:
@@ -234,6 +240,140 @@ def _checkpoint_fingerprint(checkpoint_paths: tuple[str, ...]) -> tuple[tuple[st
234240
return tuple(fingerprint)
235241

236242

243+
def _deduplicate_prediction_input(paths: PreparedInputPaths) -> tuple[PreparedInputPaths, bool]:
244+
input_df = pd.read_csv(paths.input_csv)
245+
key_columns = ["SMILES", "sequence"]
246+
if any(column not in input_df.columns for column in key_columns):
247+
return paths, False
248+
249+
unique_df = input_df.drop_duplicates(subset=key_columns, keep="first")
250+
if len(unique_df) == len(input_df):
251+
return paths, False
252+
253+
input_path = Path(paths.input_csv)
254+
unique_input_csv = input_path.with_name(f"{input_path.stem}_unique{input_path.suffix}")
255+
unique_output_csv = unique_input_csv.with_name(f"{unique_input_csv.with_suffix('').name}_output.csv")
256+
unique_df.to_csv(unique_input_csv, index=False)
257+
return (
258+
PreparedInputPaths(
259+
input_csv=str(unique_input_csv),
260+
records_file=paths.records_file,
261+
output_csv=str(unique_output_csv),
262+
),
263+
True,
264+
)
265+
266+
267+
def _expand_deduplicated_prediction_output(
268+
original_paths: PreparedInputPaths,
269+
deduplicated_paths: PreparedInputPaths,
270+
) -> None:
271+
full_df = pd.read_csv(original_paths.input_csv)
272+
unique_input_df = pd.read_csv(deduplicated_paths.input_csv)
273+
unique_output_df = pd.read_csv(deduplicated_paths.output_csv)
274+
key_columns = ["SMILES", "sequence"]
275+
prediction_columns = [
276+
column for column in unique_output_df.columns if column not in unique_input_df.columns
277+
]
278+
279+
if not prediction_columns:
280+
raise ValueError("Deduplicated prediction output did not contain prediction columns.")
281+
282+
prediction_lookup = unique_output_df[key_columns + prediction_columns].drop_duplicates(
283+
subset=key_columns,
284+
keep="first",
285+
)
286+
expanded_df = full_df.merge(prediction_lookup, on=key_columns, how="left", sort=False)
287+
288+
if expanded_df[prediction_columns].isnull().any().any():
289+
raise ValueError("Failed to expand deduplicated predictions to all input rows.")
290+
291+
expanded_df.to_csv(original_paths.output_csv, index=False)
292+
293+
294+
def _file_digest(path: str) -> str:
295+
digest = hashlib.sha256()
296+
with open(path, "rb") as handle:
297+
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
298+
digest.update(chunk)
299+
return digest.hexdigest()
300+
301+
302+
def _prediction_checkpoint_paths(checkpoint_dir: str, repo_root: Path) -> tuple[str, ...]:
303+
checkpoint_path = Path(checkpoint_dir)
304+
if not checkpoint_path.is_absolute():
305+
checkpoint_path = (repo_root / checkpoint_path).resolve()
306+
if checkpoint_path.is_file():
307+
return (str(checkpoint_path),)
308+
if not checkpoint_path.is_dir():
309+
raise FileNotFoundError(f'Checkpoint directory not found: "{checkpoint_path}"')
310+
model_paths = sorted(str(path.resolve()) for path in checkpoint_path.rglob("model.pt"))
311+
if not model_paths:
312+
raise FileNotFoundError(f'No model.pt checkpoints found in "{checkpoint_path}"')
313+
return tuple(model_paths)
314+
315+
316+
def _prediction_cache_key(
317+
parameter: str,
318+
request: PredictionRequest,
319+
paths: PreparedInputPaths,
320+
repo_root: Path,
321+
) -> tuple:
322+
protein_records_digest = None
323+
if request.protein_records_file:
324+
protein_records_path = _resolve_existing_path(
325+
request.protein_records_file,
326+
repo_root=repo_root,
327+
purpose="Protein records file",
328+
)
329+
protein_records_digest = _file_digest(str(protein_records_path))
330+
331+
checkpoint_paths = _prediction_checkpoint_paths(request.checkpoint_dir, repo_root)
332+
return (
333+
parameter,
334+
bool(request.use_gpu),
335+
_file_digest(paths.input_csv),
336+
protein_records_digest,
337+
_checkpoint_fingerprint(checkpoint_paths),
338+
)
339+
340+
341+
def _prediction_cache_get(cache_key: tuple) -> str | None:
342+
if _PREDICTION_CACHE_SIZE <= 0:
343+
return None
344+
with _PREDICTION_CACHE_LOCK:
345+
cached = _PREDICTION_CACHE.get(cache_key)
346+
if cached is not None:
347+
_PREDICTION_CACHE.move_to_end(cache_key)
348+
return cached
349+
350+
351+
def _prediction_cache_put(cache_key: tuple, csv_text: str) -> None:
352+
if _PREDICTION_CACHE_SIZE <= 0:
353+
return
354+
with _PREDICTION_CACHE_LOCK:
355+
_PREDICTION_CACHE[cache_key] = csv_text
356+
_PREDICTION_CACHE.move_to_end(cache_key)
357+
while len(_PREDICTION_CACHE) > _PREDICTION_CACHE_SIZE:
358+
_PREDICTION_CACHE.popitem(last=False)
359+
360+
361+
def _write_cached_prediction(
362+
cached_csv: str,
363+
paths: PreparedInputPaths,
364+
repo_root: str | None,
365+
results_dir: str,
366+
) -> str:
367+
results_path = Path(results_dir)
368+
if not results_path.is_absolute():
369+
results_path = (_resolve_repo_root(repo_root) / results_path).resolve()
370+
results_path.mkdir(parents=True, exist_ok=True)
371+
372+
final_output = results_path / Path(paths.output_csv).name
373+
final_output.write_text(cached_csv, encoding="utf-8")
374+
return str(final_output)
375+
376+
237377
@lru_cache(maxsize=_MODEL_CACHE_SIZE)
238378
def _load_cached_model_objects(
239379
checkpoint_paths: tuple[str, ...],
@@ -390,8 +530,22 @@ def run_inprocess_prediction_pipeline(
390530
) -> str:
391531
parameter = _validate_parameter(request.parameter)
392532
paths = prepare_prediction_inputs(parameter, request.input_file, request.repo_root)
393-
run_inprocess_prediction(request, paths)
394-
return _write_postprocessed_predictions(parameter, paths, request.repo_root, results_dir)
533+
root = _resolve_repo_root(request.repo_root)
534+
cache_key = _prediction_cache_key(parameter, request, paths, root)
535+
cached_csv = _prediction_cache_get(cache_key)
536+
if cached_csv is not None:
537+
return _write_cached_prediction(cached_csv, paths, request.repo_root, results_dir)
538+
539+
if request.protein_records_file:
540+
prediction_paths, was_deduplicated = paths, False
541+
else:
542+
prediction_paths, was_deduplicated = _deduplicate_prediction_input(paths)
543+
run_inprocess_prediction(request, prediction_paths)
544+
if was_deduplicated:
545+
_expand_deduplicated_prediction_output(paths, prediction_paths)
546+
final_output = _write_postprocessed_predictions(parameter, paths, request.repo_root, results_dir)
547+
_prediction_cache_put(cache_key, Path(final_output).read_text(encoding="utf-8"))
548+
return final_output
395549

396550

397551
def _write_postprocessed_predictions(

tests/test_inference_fast_path.py

Lines changed: 126 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import importlib.util
12
import sys
23
import types
34
import unittest
@@ -8,12 +9,14 @@
89

910

1011
def install_import_stubs() -> None:
11-
pandas = types.ModuleType("pandas")
12-
pandas.DataFrame = object
13-
sys.modules.setdefault("pandas", pandas)
12+
if importlib.util.find_spec("pandas") is None:
13+
pandas = types.ModuleType("pandas")
14+
pandas.DataFrame = object
15+
sys.modules.setdefault("pandas", pandas)
1416

15-
numpy = types.ModuleType("numpy")
16-
sys.modules.setdefault("numpy", numpy)
17+
if importlib.util.find_spec("numpy") is None:
18+
numpy = types.ModuleType("numpy")
19+
sys.modules.setdefault("numpy", numpy)
1720

1821
rdkit = types.ModuleType("rdkit")
1922
chem = types.ModuleType("rdkit.Chem")
@@ -138,6 +141,124 @@ def fake_update_prediction_args(predict_args, train_args):
138141
self.assertEqual(second_args.updated_from, "train_args")
139142

140143

144+
class PredictionInputDeduplicationTests(unittest.TestCase):
145+
def test_duplicate_inputs_are_expanded_back_to_original_rows(self) -> None:
146+
if not hasattr(service.pd, "read_csv"):
147+
self.skipTest("pandas is stubbed in this test environment")
148+
149+
with TemporaryDirectory() as tmp_dir:
150+
tmp_path = Path(tmp_dir)
151+
input_csv = tmp_path / "input.csv"
152+
records_file = tmp_path / "input.json.gz"
153+
output_csv = tmp_path / "input_output.csv"
154+
input_csv.write_text(
155+
"\n".join(
156+
[
157+
"Substrate,SMILES,sequence,pdbpath",
158+
"first,C,AAAA,seq1.pdb",
159+
"second,C,AAAA,seq1-copy.pdb",
160+
"third,O,BBBB,seq2.pdb",
161+
]
162+
)
163+
+ "\n",
164+
encoding="utf-8",
165+
)
166+
167+
paths = service.PreparedInputPaths(
168+
input_csv=str(input_csv),
169+
records_file=str(records_file),
170+
output_csv=str(output_csv),
171+
)
172+
173+
deduplicated_paths, was_deduplicated = service._deduplicate_prediction_input(paths)
174+
self.assertTrue(was_deduplicated)
175+
176+
unique_output = Path(deduplicated_paths.output_csv)
177+
unique_output.write_text(
178+
"\n".join(
179+
[
180+
"Substrate,SMILES,sequence,pdbpath,log10kcat_max,log10kcat_max_mve_uncal_var",
181+
"first,C,AAAA,seq1.pdb,1.0,0.1",
182+
"third,O,BBBB,seq2.pdb,2.0,0.2",
183+
]
184+
)
185+
+ "\n",
186+
encoding="utf-8",
187+
)
188+
189+
service._expand_deduplicated_prediction_output(paths, deduplicated_paths)
190+
expanded_lines = output_csv.read_text(encoding="utf-8").splitlines()
191+
192+
self.assertEqual(len(expanded_lines), 4)
193+
self.assertIn("first,C,AAAA,seq1.pdb,1.0,0.1", expanded_lines)
194+
self.assertIn("second,C,AAAA,seq1-copy.pdb,1.0,0.1", expanded_lines)
195+
self.assertIn("third,O,BBBB,seq2.pdb,2.0,0.2", expanded_lines)
196+
197+
198+
class PredictionResultCacheTests(unittest.TestCase):
199+
def setUp(self) -> None:
200+
service._PREDICTION_CACHE.clear()
201+
202+
def tearDown(self) -> None:
203+
service._PREDICTION_CACHE.clear()
204+
205+
def test_pipeline_reuses_cached_prediction_for_identical_request(self) -> None:
206+
with TemporaryDirectory() as tmp_dir:
207+
repo_root = Path(tmp_dir)
208+
input_csv = repo_root / "prepared.csv"
209+
input_csv.write_text("SMILES,sequence\nC,AAAA\n", encoding="utf-8")
210+
records_file = repo_root / "prepared.json.gz"
211+
records_file.write_bytes(b"records")
212+
output_csv = repo_root / "prepared_output.csv"
213+
checkpoint = repo_root / "checkpoints" / "fold_0" / "model_0" / "model.pt"
214+
checkpoint.parent.mkdir(parents=True)
215+
checkpoint.write_bytes(b"checkpoint")
216+
217+
paths = service.PreparedInputPaths(
218+
input_csv=str(input_csv),
219+
records_file=str(records_file),
220+
output_csv=str(output_csv),
221+
)
222+
request = PredictionRequest(
223+
parameter="kcat",
224+
input_file=str(input_csv),
225+
checkpoint_dir=str(checkpoint.parent.parent.parent),
226+
repo_root=str(repo_root),
227+
)
228+
229+
def write_final(parameter, prepared_paths, repo_root_arg, results_dir):
230+
final_output = Path(results_dir) / Path(prepared_paths.output_csv).name
231+
final_output.parent.mkdir(parents=True, exist_ok=True)
232+
final_output.write_text("SMILES,kcat\nC,1.23\n", encoding="utf-8")
233+
return str(final_output)
234+
235+
with patch(
236+
"catpred.inference.service.prepare_prediction_inputs",
237+
return_value=paths,
238+
), patch(
239+
"catpred.inference.service.run_inprocess_prediction",
240+
) as runner, patch(
241+
"catpred.inference.service._write_postprocessed_predictions",
242+
side_effect=write_final,
243+
) as postprocess:
244+
first = service.run_inprocess_prediction_pipeline(
245+
request,
246+
results_dir=str(repo_root / "results" / "first"),
247+
)
248+
second = service.run_inprocess_prediction_pipeline(
249+
request,
250+
results_dir=str(repo_root / "results" / "second"),
251+
)
252+
first_text = Path(first).read_text(encoding="utf-8")
253+
second_text = Path(second).read_text(encoding="utf-8")
254+
255+
runner.assert_called_once()
256+
postprocess.assert_called_once()
257+
self.assertEqual(first_text, "SMILES,kcat\nC,1.23\n")
258+
self.assertEqual(second_text, "SMILES,kcat\nC,1.23\n")
259+
self.assertNotEqual(first, second)
260+
261+
141262
class FastPredictArgsTests(unittest.TestCase):
142263
def test_fast_predict_args_save_components_without_individual_predictions(self) -> None:
143264
class FakePredictArgs:

0 commit comments

Comments
 (0)