Skip to content

Commit 2e70135

Browse files
authored
Merge pull request #42 from Vedasheersh/codex/inference-speedup
Batch uncached ESM embeddings
2 parents e5b9b5c + 3f3cb1e commit 2e70135

5 files changed

Lines changed: 286 additions & 27 deletions

File tree

catpred/data/cache_utils.py

Lines changed: 42 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,37 @@ def md5_hash_fn(s):
2828
encoded = s.encode('utf-8')
2929
return hashlib.md5(encoded).hexdigest()
3030

31+
32+
def cache_entry_path(path='', cache_key=None, hash_fn=md5_hash_fn, name=None):
33+
(CACHE_PATH / path).mkdir(parents=True, exist_ok=True)
34+
if name is None:
35+
key = hash_fn(cache_key)
36+
else:
37+
key = name
38+
return CACHE_PATH / path / f'{key}.pt'
39+
40+
41+
def load_cache_value(path='', cache_key=None, *, purpose="cache entry", hash_fn=md5_hash_fn, name=None, map_location=None):
42+
entry_path = cache_entry_path(path=path, cache_key=cache_key, hash_fn=hash_fn, name=name)
43+
if not entry_path.exists():
44+
return None
45+
46+
log(f'cache hit: fetching {cache_key} from {str(entry_path)}')
47+
return load_torch_artifact(
48+
str(entry_path),
49+
purpose=purpose,
50+
map_location=map_location,
51+
roots=[CACHE_PATH],
52+
)
53+
54+
55+
def save_cache_value(value, path='', cache_key=None, *, hash_fn=md5_hash_fn, name=None):
56+
entry_path = cache_entry_path(path=path, cache_key=cache_key, hash_fn=hash_fn, name=name)
57+
log(f'saving: {cache_key} to {str(entry_path)}')
58+
cache_value = value.detach().cpu() if isinstance(value, torch.Tensor) else value
59+
torch.save(cache_value, str(entry_path))
60+
return entry_path
61+
3162
# run once function
3263

3364
GLOBAL_RUN_RECORDS = dict()
@@ -83,27 +114,20 @@ def inner(t, *args, __cache_key = None, **kwargs):
83114
if clear:
84115
clear_cache_folder_()
85116

86-
if name is None:
87-
cache_str = __cache_key if exists(__cache_key) else t
88-
key = hash_fn(cache_str)
89-
else:
90-
key = name
91-
92-
entry_path = CACHE_PATH / path / f'{key}.pt'
93-
94-
if entry_path.exists():
95-
log(f'cache hit: fetching {t} from {str(entry_path)}')
96-
return load_torch_artifact(
97-
str(entry_path),
98-
purpose="esm cache entry",
99-
roots=[CACHE_PATH],
100-
)
117+
cache_str = __cache_key if exists(__cache_key) else t
118+
cached = load_cache_value(
119+
path=path,
120+
cache_key=cache_str,
121+
purpose="esm cache entry",
122+
hash_fn=hash_fn,
123+
name=name,
124+
)
125+
if cached is not None:
126+
return cached
101127

102128
out = fn(t, *args, **kwargs)
103129

104-
log(f'saving: {t} to {str(entry_path)}')
105-
cache_out = out.detach().cpu() if isinstance(out, torch.Tensor) else out
106-
torch.save(cache_out, str(entry_path))
130+
save_cache_value(out, path=path, cache_key=cache_str, hash_fn=hash_fn, name=name)
107131
return out
108132

109133
return inner

catpred/data/esm_utils.py

Lines changed: 88 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from functools import partial
44
import esm
55
from torch.nn.utils.rnn import pad_sequence
6-
from .cache_utils import cache_fn, run_once
6+
from .cache_utils import cache_fn, load_cache_value, run_once, save_cache_value
77

88
def exists(val):
99
return val is not None
@@ -63,6 +63,11 @@ def calc_protein_representations_with_subunits(proteins, get_repr_fn, *, device)
6363

6464
ESM_MAX_LENGTH = 2048
6565
ESM_EMBED_DIM = 1280
66+
ESM_CACHE_PATH = 'esm/proteins'
67+
DEFAULT_ESM_BATCH_SIZE = max(
68+
int(os.getenv("CATPRED_ESM_BATCH_SIZE", "1" if PROTEIN_EMBED_USE_CPU else "4")),
69+
1,
70+
)
6671

6772
INT_TO_AA_STR_MAP = {
6873
0: '<cls>',
@@ -154,14 +159,94 @@ def get_single_esm_repr(protein_str):
154159
representation = token_representations[0][1 : len(protein_str) + 1]
155160
return representation
156161

162+
163+
def _run_esm_batch(protein_strs):
164+
init_esm()
165+
model, batch_converter = GLOBAL_VARIABLES['model']
166+
167+
data = [(f'protein_{index}', protein_str) for index, protein_str in enumerate(protein_strs)]
168+
batch_labels, batch_strs, batch_tokens = batch_converter(data)
169+
170+
if batch_tokens.shape[1] > ESM_MAX_LENGTH:
171+
print(f'warning max length protein esm')
172+
173+
batch_tokens = batch_tokens[:, :ESM_MAX_LENGTH]
174+
175+
if not PROTEIN_EMBED_USE_CPU:
176+
batch_tokens = batch_tokens.to(next(model.parameters()).device)
177+
178+
with torch.no_grad():
179+
results = model(batch_tokens, repr_layers=[33])
180+
181+
token_representations = results['representations'][33]
182+
representations = []
183+
max_residue_tokens = ESM_MAX_LENGTH - 1
184+
for index, protein_str in enumerate(protein_strs):
185+
representation_length = min(len(protein_str), max_residue_tokens)
186+
representations.append(
187+
token_representations[index][1 : representation_length + 1].detach()
188+
)
189+
return representations
190+
191+
192+
def _run_esm_batch_with_fallback(protein_strs):
193+
try:
194+
return _run_esm_batch(protein_strs)
195+
except RuntimeError as e:
196+
if 'out of memory' not in str(e) or len(protein_strs) == 1:
197+
raise e
198+
print('| WARNING: ran out of memory, retrying smaller ESM batches')
199+
if torch.cuda.is_available():
200+
torch.cuda.empty_cache()
201+
midpoint = len(protein_strs) // 2
202+
return (
203+
_run_esm_batch_with_fallback(protein_strs[:midpoint])
204+
+ _run_esm_batch_with_fallback(protein_strs[midpoint:])
205+
)
206+
207+
208+
def get_many_esm_reprs(proteins, device='cpu', batch_size=None):
209+
if isinstance(proteins, torch.Tensor):
210+
proteins = tensor_to_aa_str(proteins)
211+
212+
batch_size = max(int(batch_size or DEFAULT_ESM_BATCH_SIZE), 1)
213+
ordered_unique_proteins = list(dict.fromkeys(proteins))
214+
representations_by_sequence = {}
215+
uncached_proteins = []
216+
217+
for protein_str in ordered_unique_proteins:
218+
cached = load_cache_value(
219+
path=ESM_CACHE_PATH,
220+
cache_key=protein_str,
221+
purpose="esm cache entry",
222+
map_location='cpu',
223+
)
224+
if cached is None:
225+
uncached_proteins.append(protein_str)
226+
else:
227+
representations_by_sequence[protein_str] = cached
228+
229+
for start in range(0, len(uncached_proteins), batch_size):
230+
batch = uncached_proteins[start : start + batch_size]
231+
batch_representations = _run_esm_batch_with_fallback(batch)
232+
for protein_str, representation in zip(batch, batch_representations):
233+
save_cache_value(representation, path=ESM_CACHE_PATH, cache_key=protein_str)
234+
representations_by_sequence[protein_str] = representation
235+
236+
return {
237+
protein_str: representations_by_sequence[protein_str].to(device)
238+
for protein_str in ordered_unique_proteins
239+
}
240+
241+
157242
def get_esm_repr(proteins, name, device):
158243
if isinstance(proteins, torch.Tensor):
159244
proteins = tensor_to_aa_str(proteins)
160245

161246
# Cache by sequence content to avoid collisions when different proteins
162247
# are accidentally given the same pdb/name identifier.
163248
_ = name
164-
get_protein_repr_fn = cache_fn(get_single_esm_repr, path='esm/proteins')
249+
get_protein_repr_fn = cache_fn(get_single_esm_repr, path=ESM_CACHE_PATH)
165250

166251
return calc_protein_representations_with_subunits([proteins], get_protein_repr_fn, device=device)
167252

@@ -208,6 +293,7 @@ def get_esm_tokens(protein_str, device):
208293
'esm': {
209294
'dim': ESM_EMBED_DIM,
210295
'fn': get_esm_repr,
296+
'batch_fn': get_many_esm_reprs,
211297
'tokenizer': get_esm_tokens,
212298
}
213299
}

catpred/data/utils.py

Lines changed: 36 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,30 @@ def _load_protein_records(protein_records_path: str):
5858
"Expected a JSON mapping in .json or .json.gz format."
5959
) from plain_err
6060

61+
62+
def _populate_missing_esm2_features(
63+
protein_records,
64+
sequence_feat_getter,
65+
sequence_batch_getter=None):
66+
if not protein_records:
67+
return
68+
69+
unique_sequences = list(dict.fromkeys(record['seq'] for record in protein_records))
70+
if sequence_batch_getter is not None:
71+
features_by_sequence = sequence_batch_getter(unique_sequences, device='cpu')
72+
for record in protein_records:
73+
record['esm2_feats'] = features_by_sequence[record['seq']]
74+
return
75+
76+
features_by_sequence = {}
77+
for record in protein_records:
78+
sequence = record['seq']
79+
if sequence not in features_by_sequence:
80+
sequence_features, _ = sequence_feat_getter(sequence, name=record['name'], device='cpu')
81+
features_by_sequence[sequence] = sequence_features[0] # batch dim
82+
record['esm2_feats'] = features_by_sequence[sequence]
83+
84+
6185
def get_header(path: str) -> List[str]:
6286
"""
6387
Returns the header of a data CSV file.
@@ -509,7 +533,9 @@ def get_data(path: str,
509533

510534
# Load sequence features
511535
if not protein_records is None:
512-
sequence_feat_getter = get_protein_embedder('esm')['fn']
536+
protein_embedder = get_protein_embedder('esm')
537+
sequence_feat_getter = protein_embedder['fn']
538+
sequence_batch_getter = protein_embedder.get('batch_fn')
513539

514540
# Load data
515541
smoke_test_counter = 0
@@ -523,6 +549,7 @@ def get_data(path: str,
523549

524550
all_smiles, all_sequences, all_targets, all_atom_targets, all_bond_targets, all_rows, all_features, all_phase_features, all_constraints_data, all_raw_constraints_data, all_weights, all_gt, all_lt = [], [], [], [], [], [], [], [], [], [], [], [], []
525551
all_protein_records = []
552+
missing_esm2_records = []
526553
for i, row in enumerate(tqdm(reader)):
527554
smoke_test_counter+=1
528555
if args.smoke_test:
@@ -571,12 +598,7 @@ def get_data(path: str,
571598
roots=[esm2_feats_path.parent],
572599
)
573600
else:
574-
sequence_features, _ = sequence_feat_getter(
575-
protein_record['seq'],
576-
name=pdbname,
577-
device='cpu'
578-
)
579-
protein_record['esm2_feats'] = sequence_features[0] # batch dim
601+
missing_esm2_records.append(protein_record)
580602

581603
targets, atom_targets, bond_targets = [], [], []
582604
for column in target_columns:
@@ -650,6 +672,13 @@ def get_data(path: str,
650672
if len(all_smiles) >= max_data_size:
651673
break
652674

675+
if protein_records is not None:
676+
_populate_missing_esm2_features(
677+
missing_esm2_records,
678+
sequence_feat_getter=sequence_feat_getter,
679+
sequence_batch_getter=sequence_batch_getter,
680+
)
681+
653682
atom_features = None
654683
atom_descriptors = None
655684
if args is not None and args.atom_descriptors is not None:

tests/test_esm_batching.py

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import importlib.util
2+
import unittest
3+
from unittest.mock import patch
4+
5+
6+
def _has_module(name: str) -> bool:
7+
try:
8+
return importlib.util.find_spec(name) is not None
9+
except ValueError:
10+
return False
11+
12+
13+
HAS_DATA_DEPS = all(_has_module(name) for name in ("numpy", "pandas", "rdkit", "torch"))
14+
HAS_ESM_DEPS = HAS_DATA_DEPS and _has_module("esm")
15+
16+
17+
@unittest.skipUnless(HAS_DATA_DEPS, "CatPred data dependencies are required")
18+
class PopulateMissingEsmFeaturesTests(unittest.TestCase):
19+
def test_uses_batch_getter_once_for_unique_missing_sequences(self) -> None:
20+
from catpred.data import utils
21+
22+
records = [
23+
{"name": "protein_a", "seq": "AAA"},
24+
{"name": "protein_b", "seq": "BBB"},
25+
{"name": "protein_c", "seq": "AAA"},
26+
]
27+
calls = []
28+
29+
def batch_getter(sequences, device):
30+
calls.append((list(sequences), device))
31+
return {sequence: f"features-{sequence}" for sequence in sequences}
32+
33+
utils._populate_missing_esm2_features(
34+
records,
35+
sequence_feat_getter=None,
36+
sequence_batch_getter=batch_getter,
37+
)
38+
39+
self.assertEqual(calls, [(["AAA", "BBB"], "cpu")])
40+
self.assertEqual(records[0]["esm2_feats"], "features-AAA")
41+
self.assertEqual(records[1]["esm2_feats"], "features-BBB")
42+
self.assertEqual(records[2]["esm2_feats"], "features-AAA")
43+
44+
def test_fallback_getter_deduplicates_sequences(self) -> None:
45+
from catpred.data import utils
46+
47+
records = [
48+
{"name": "protein_a", "seq": "AAA"},
49+
{"name": "protein_b", "seq": "BBB"},
50+
{"name": "protein_c", "seq": "AAA"},
51+
]
52+
calls = []
53+
54+
def single_getter(sequence, name, device):
55+
calls.append((sequence, name, device))
56+
return ([f"features-{sequence}"], None)
57+
58+
utils._populate_missing_esm2_features(
59+
records,
60+
sequence_feat_getter=single_getter,
61+
sequence_batch_getter=None,
62+
)
63+
64+
self.assertEqual(
65+
calls,
66+
[
67+
("AAA", "protein_a", "cpu"),
68+
("BBB", "protein_b", "cpu"),
69+
],
70+
)
71+
self.assertEqual(records[0]["esm2_feats"], "features-AAA")
72+
self.assertEqual(records[1]["esm2_feats"], "features-BBB")
73+
self.assertEqual(records[2]["esm2_feats"], "features-AAA")
74+
75+
76+
@unittest.skipUnless(HAS_ESM_DEPS, "ESM and torch dependencies are required")
77+
class BatchedEsmCacheTests(unittest.TestCase):
78+
def test_get_many_esm_reprs_skips_cached_and_batches_unique_sequences(self) -> None:
79+
import torch
80+
81+
from catpred.data import esm_utils
82+
83+
cached = {"AAA": torch.tensor([[1.0]])}
84+
saved = []
85+
batch_calls = []
86+
87+
def fake_load(path, cache_key, purpose, map_location):
88+
return cached.get(cache_key)
89+
90+
def fake_save(value, path, cache_key):
91+
saved.append((cache_key, value.detach().cpu().clone()))
92+
93+
def fake_batch(sequences):
94+
batch_calls.append(list(sequences))
95+
return [torch.tensor([[float(index + 2)]]) for index, _ in enumerate(sequences)]
96+
97+
with patch("catpred.data.esm_utils.load_cache_value", side_effect=fake_load), patch(
98+
"catpred.data.esm_utils.save_cache_value", side_effect=fake_save
99+
), patch(
100+
"catpred.data.esm_utils._run_esm_batch_with_fallback",
101+
side_effect=fake_batch,
102+
):
103+
result = esm_utils.get_many_esm_reprs(
104+
["AAA", "BBB", "CCC", "BBB"],
105+
device="cpu",
106+
batch_size=2,
107+
)
108+
109+
self.assertEqual(batch_calls, [["BBB", "CCC"]])
110+
self.assertEqual([key for key, _ in saved], ["BBB", "CCC"])
111+
self.assertEqual(set(result), {"AAA", "BBB", "CCC"})
112+
self.assertTrue(torch.equal(result["AAA"], torch.tensor([[1.0]])))
113+
self.assertTrue(torch.equal(result["BBB"], torch.tensor([[2.0]])))
114+
self.assertTrue(torch.equal(result["CCC"], torch.tensor([[3.0]])))
115+
116+
117+
if __name__ == "__main__":
118+
unittest.main()

0 commit comments

Comments
 (0)