Skip to content

Commit a81e1d2

Browse files
author
Veda Sheersh Boorla
committed
Cache repeated in-process predictions
1 parent a2f5993 commit a81e1d2

2 files changed

Lines changed: 163 additions & 1 deletion

File tree

catpred/inference/service.py

Lines changed: 98 additions & 1 deletion
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:
@@ -285,6 +291,89 @@ def _expand_deduplicated_prediction_output(
285291
expanded_df.to_csv(original_paths.output_csv, index=False)
286292

287293

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+
288377
@lru_cache(maxsize=_MODEL_CACHE_SIZE)
289378
def _load_cached_model_objects(
290379
checkpoint_paths: tuple[str, ...],
@@ -441,14 +530,22 @@ def run_inprocess_prediction_pipeline(
441530
) -> str:
442531
parameter = _validate_parameter(request.parameter)
443532
paths = prepare_prediction_inputs(parameter, request.input_file, request.repo_root)
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+
444539
if request.protein_records_file:
445540
prediction_paths, was_deduplicated = paths, False
446541
else:
447542
prediction_paths, was_deduplicated = _deduplicate_prediction_input(paths)
448543
run_inprocess_prediction(request, prediction_paths)
449544
if was_deduplicated:
450545
_expand_deduplicated_prediction_output(paths, prediction_paths)
451-
return _write_postprocessed_predictions(parameter, paths, request.repo_root, results_dir)
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
452549

453550

454551
def _write_postprocessed_predictions(

tests/test_inference_fast_path.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,7 @@ def test_duplicate_inputs_are_expanded_back_to_original_rows(self) -> None:
163163
+ "\n",
164164
encoding="utf-8",
165165
)
166+
166167
paths = service.PreparedInputPaths(
167168
input_csv=str(input_csv),
168169
records_file=str(records_file),
@@ -194,6 +195,70 @@ def test_duplicate_inputs_are_expanded_back_to_original_rows(self) -> None:
194195
self.assertIn("third,O,BBBB,seq2.pdb,2.0,0.2", expanded_lines)
195196

196197

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+
197262
class FastPredictArgsTests(unittest.TestCase):
198263
def test_fast_predict_args_save_components_without_individual_predictions(self) -> None:
199264
class FakePredictArgs:

0 commit comments

Comments
 (0)