Skip to content

Commit 554ec22

Browse files
authored
refactor: sharding operations
refactor: sharding operations
2 parents 52f316d + efcbe04 commit 554ec22

16 files changed

Lines changed: 285 additions & 181 deletions

File tree

.ipd/.env

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ CCD_MIRROR_PATH=/projects/ml/frozen_pdb_copies/2025_07_13_ccd
2525
# --- Local MSA directories ---
2626
LOCAL_MSA_DIRS=/projects/msa/hhblits,/projects/msa/mmseqs_gpu,/projects/msa/lab
2727

28+
# --- Cache directories ---
29+
RESIDUE_CACHE_DIR=/net/tukwila/lschaaf/datahub/MACE-Egret-3-noH/mace_embeddings
2830

2931
# --- External tools ---
3032

@@ -54,5 +56,3 @@ COLABFOLD_LOCAL_DB_PATH_CPU=/local/databases/colabfold/
5456
# Network access (fallback; may cause IO-related issues)
5557
COLABFOLD_NET_DB_PATH_GPU=/net/databases/colabfold/gpu
5658
COLABFOLD_NET_DB_PATH_CPU=/net/databases/colabfold/
57-
58-

src/atomworks/io/parser.py

Lines changed: 59 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,13 @@
4242
from atomworks.io.utils.bonds import get_struct_conn_dict_from_atom_array
4343
from atomworks.io.utils.ccd import check_ccd_codes_are_available
4444
from atomworks.io.utils.chain import create_chain_id_generator
45-
from atomworks.io.utils.io_utils import get_structure, infer_pdb_file_type, read_any
45+
from atomworks.io.utils.io_utils import (
46+
apply_sharding_pattern,
47+
build_sharding_pattern,
48+
get_structure,
49+
infer_pdb_file_type,
50+
read_any,
51+
)
4652
from atomworks.io.utils.non_rcsb import (
4753
get_identity_assembly_gen_category,
4854
get_identity_op_expr_category,
@@ -53,20 +59,31 @@
5359

5460
__all__ = ["parse"]
5561

56-
DEFAULT_PARSE_KWARGS = {
62+
STANDARD_PARSER_ARGS = {
5763
"add_missing_atoms": True,
5864
"add_id_and_entity_annotations": True,
5965
"add_bond_types_from_struct_conn": ["covale"],
6066
"remove_ccds": CRYSTALLIZATION_AIDS,
6167
"remove_waters": True,
6268
"fix_ligands_at_symmetry_centers": True,
63-
"hydrogen_policy": "keep",
6469
"fix_arginines": True,
6570
"fix_formal_charges": True,
66-
"convert_mse_to_met": True,
67-
"build_assembly": "all",
71+
"fix_bond_types": True,
72+
"convert_mse_to_met": True, # Changed from False to True vs. atomworks.io.parser.parse default
73+
"hydrogen_policy": "keep",
74+
"model": None, # all models
6875
}
69-
"""Some fairly standard parsing arguments that can be imported for convenience."""
76+
"""Common cif parser arguments for `atomworks.io.parse` for many biomolecular use cases.
77+
78+
Similar to the defaults below but additionally converts selenomethionine (MSE) residues to methionine (MET) residues,
79+
which is desirable for many practical applications but would not be appropriate as a universal default.
80+
81+
This dictionary exists to provide a convenient import for the standard parameters.
82+
"""
83+
84+
# Cache sharding configuration (internal, not exposed to parse() to avoid complexity)
85+
_CACHE_SHARDING_DEPTH = 2 # Use 2-level sharding by default (e.g., ab/cd/abcdef123456/)
86+
_CACHE_SHARDING_CHARS_PER_DIR = 2 # Number of characters per directory level
7087

7188

7289
def _get_atomworks_version() -> str:
@@ -79,6 +96,32 @@ def _get_atomworks_version() -> str:
7996
return "unknown"
8097

8198

99+
def _parse_args_to_hash(parse_arguments: dict[str, Any], truncate: int = 8) -> str:
100+
"""Compute hash from parse arguments with sorted keys."""
101+
args_string = ",".join(str(parse_arguments[k]) for k in sorted(parse_arguments.keys()))
102+
return string_to_md5_hash(args_string, truncate=truncate)
103+
104+
105+
def _build_cache_file_path(
106+
cache_dir: Path,
107+
args_hash: str,
108+
filename: os.PathLike,
109+
assembly_info: str,
110+
) -> Path:
111+
"""Build sharded cache file path for parsed structure."""
112+
structure_id = Path(filename).stem
113+
114+
# Pad structure ID to minimum required length for sharding
115+
min_length = _CACHE_SHARDING_DEPTH * _CACHE_SHARDING_CHARS_PER_DIR
116+
structure_id_padded = structure_id.ljust(min_length, "_")
117+
118+
# Build sharded path
119+
sharding_pattern = build_sharding_pattern(depth=_CACHE_SHARDING_DEPTH, chars_per_dir=_CACHE_SHARDING_CHARS_PER_DIR)
120+
sharded_path = apply_sharding_pattern(structure_id_padded, sharding_pattern)
121+
122+
return cache_dir / args_hash / sharded_path / f"{structure_id}_assembly_{assembly_info}.pkl.gz"
123+
124+
82125
def parse(
83126
filename: os.PathLike | io.StringIO | io.BytesIO,
84127
*,
@@ -182,12 +225,11 @@ def parse(
182225
Should typically not be used directly.
183226
184227
"""
185-
# CCD mirror
186228
if ccd_mirror_path and not os.path.exists(ccd_mirror_path):
187-
logger.warning(
188-
f"Local mirror of the Chemical Component Dictionary does not exist: {ccd_mirror_path}. Falling back to Biotite's built-in CCD."
229+
raise FileNotFoundError(
230+
f"Local mirror of the Chemical Component Dictionary does not exist: {ccd_mirror_path}. "
231+
"To use Biotite's built-in CCD, set `ccd_mirror_path` to None."
189232
)
190-
ccd_mirror_path = None
191233

192234
# Set default value for remove_ccds if None
193235
if remove_ccds is None:
@@ -232,15 +274,13 @@ def parse(
232274
"convert_mse_to_met": convert_mse_to_met,
233275
"hydrogen_policy": hydrogen_policy,
234276
}
235-
# Compose args_string from parse_arguments values (in order)
236-
args_string = ",".join(str(parse_arguments[k]) for k in parse_arguments)
237-
args_hash = string_to_md5_hash(args_string, truncate=8)
277+
args_hash = _parse_args_to_hash(parse_arguments)
238278

239279
# ... generate assembly info
240280
assembly_info = ",".join(build_assembly) if isinstance(build_assembly, list | tuple) else build_assembly
241281

242-
# ... construct the full cache file path
243-
cache_file_path = cache_dir / args_hash / f"{Path(filename).stem}_assembly_{assembly_info}.pkl.gz"
282+
# ... construct the full cache file path with sharding
283+
cache_file_path = _build_cache_file_path(cache_dir, args_hash, filename, assembly_info)
244284

245285
# If we are loading from cache, try to load the result from the cache
246286
if load_from_cache:
@@ -268,8 +308,7 @@ def parse(
268308
result["assemblies"] = assemblies
269309
return result
270310
except Exception as e:
271-
# Log an error, and continue to parse from CIF
272-
logger.error(f"Error loading from cache: {e}")
311+
raise RuntimeError(f"Error loading from cache: {e}, tried path: {cache_file_path}") from e
273312

274313
if file_type == "pdb":
275314
result = _parse_from_pdb(
@@ -315,9 +354,9 @@ def parse(
315354

316355
if not is_buffer and save_to_cache and cache_dir and (not cache_file_path.exists()):
317356
# We want our cache to include:
318-
# (1) All keys in `result` excep the assemblies and
319-
# (2) The information needed to rebuild the assembly(s), which is stored in `result["extra_info"]`
320-
# (3) The parse_arguments and atomworks.io version
357+
# (1) All keys in `result` except the assemblies; and,
358+
# (2) The information needed to rebuild the assembly(s), which is stored in `result["extra_info"]`; and,
359+
# (3) The parse_arguments and atomworks version
321360

322361
# Add parse_arguments and version to metadata before saving
323362
result.setdefault("metadata", {}).update(

src/atomworks/io/tools/inference.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
)
2626
from atomworks.enums import ChainType, ChainTypeInfo
2727
from atomworks.io import parse
28-
from atomworks.io.parser import DEFAULT_PARSE_KWARGS
28+
from atomworks.io.parser import STANDARD_PARSER_ARGS
2929
from atomworks.io.template import build_template_atom_array
3030
from atomworks.io.tools.fasta import one_letter_to_ccd_code, split_generalized_fasta_sequence
3131
from atomworks.io.utils.bonds import (
@@ -257,7 +257,7 @@ def _parse_standard_pdb_or_cif(self) -> None:
257257
self.custom_parse_kwargs = {}
258258

259259
# We add missing atoms later to the fully-concatenated inference AtomArray
260-
parse_kwargs = {**DEFAULT_PARSE_KWARGS, "add_missing_atoms": False} | self.custom_parse_kwargs
260+
parse_kwargs = {**STANDARD_PARSER_ARGS, "add_missing_atoms": False} | self.custom_parse_kwargs
261261

262262
if parse_kwargs["add_missing_atoms"]:
263263
logger.warning(

src/atomworks/io/utils/io_utils.py

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
"""General utility functions for working with CIF files in Biotite."""
22

33
__all__ = [
4+
"apply_sharding_pattern",
5+
"build_sharding_pattern",
46
"get_structure",
57
"load_any",
8+
"parse_sharding_pattern",
69
"read_any",
710
"suppress_logging_messages",
811
"to_cif_buffer",
@@ -14,6 +17,7 @@
1417
import io
1518
import logging
1619
import os
20+
import re
1721
import warnings
1822
from collections.abc import Generator
1923
from contextlib import contextmanager
@@ -934,3 +938,100 @@ def find_files_by_extension(input_dir: Path, extension: str) -> list[Path]:
934938
raise FileNotFoundError(f"No files with extension {extension} found in {input_dir}")
935939

936940
return files
941+
942+
943+
def build_sharding_pattern(depth: int, chars_per_dir: int = 2) -> str:
944+
"""Build a sharding pattern string from depth and characters per directory.
945+
946+
Args:
947+
depth: Number of directory levels.
948+
chars_per_dir: Number of characters to use for each directory level.
949+
950+
Returns:
951+
Sharding pattern string.
952+
953+
Examples:
954+
>>> build_sharding_pattern(2, 2)
955+
'/0:2/2:4/'
956+
>>> build_sharding_pattern(3, 1)
957+
'/0:1/1:2/2:3/'
958+
"""
959+
if depth == 0:
960+
return ""
961+
962+
parts = []
963+
for i in range(depth):
964+
start = i * chars_per_dir
965+
end = start + chars_per_dir
966+
parts.append(f"/{start}:{end}")
967+
968+
return "".join(parts) + "/"
969+
970+
971+
def parse_sharding_pattern(sharding_pattern: str) -> list[tuple[int, int]]:
972+
"""Parse a sharding pattern string into directory levels.
973+
974+
Args:
975+
sharding_pattern: String like ``"/1:2/0:2/"`` where each ``/start:end/`` defines a directory level.
976+
``start:end`` defines the character range to use for that directory level.
977+
978+
Returns:
979+
List of (start, end) tuples for each directory level.
980+
981+
Examples:
982+
>>> parse_sharding_pattern("/1:2/0:2/")
983+
[(1, 2), (0, 2)]
984+
"""
985+
# Find all patterns like /start:end/ using a non-consuming lookahead
986+
pattern = r"/(\d+):(\d+)(?=/)"
987+
matches = []
988+
for match in re.finditer(pattern, sharding_pattern):
989+
matches.append((int(match.group(1)), int(match.group(2))))
990+
991+
if not matches:
992+
raise ValueError(f"Invalid sharding pattern format: {sharding_pattern}. Expected format like '/1:2/0:2/'")
993+
994+
return matches
995+
996+
997+
def apply_sharding_pattern(path: os.PathLike, sharding_pattern: str | None = None) -> Path:
998+
"""Apply a sharding pattern to construct a file path.
999+
1000+
Args:
1001+
path: The base path or identifier (e.g., PDB ID).
1002+
sharding_pattern: Pattern for organizing files in subdirectories. Examples:
1003+
- ``"/0:2/"``: Use first two characters for first directory level
1004+
- ``"/0:2/2:4/"``: Use chars 0-2 for first dir, then chars 2-4 for second dir
1005+
- ``None``: No sharding (default)
1006+
1007+
Returns:
1008+
The constructed file path with sharding applied.
1009+
1010+
Examples:
1011+
>>> apply_sharding_pattern("12as", "/0:2/1:3/")
1012+
Path("12/2a/12as")
1013+
"""
1014+
path_str = str(path)
1015+
assert path_str and path_str != ".", "Path cannot be empty"
1016+
1017+
if not sharding_pattern:
1018+
return Path(path_str)
1019+
1020+
if not sharding_pattern.startswith("/"):
1021+
raise ValueError(f"Sharding pattern must start with '/': {sharding_pattern}")
1022+
1023+
try:
1024+
shard_ranges = parse_sharding_pattern(sharding_pattern)
1025+
except ValueError as e:
1026+
raise ValueError(f"Invalid sharding pattern '{sharding_pattern}': {e}") from e
1027+
1028+
# Validate all ranges before building path
1029+
for start, end in shard_ranges:
1030+
if end > len(path_str):
1031+
raise ValueError(f"Sharding range {start}:{end} exceeds path length {len(path_str)} for '{path_str}'")
1032+
1033+
# Build directory components from sharding ranges
1034+
directory_parts = [path_str[start:end] for start, end in shard_ranges]
1035+
1036+
# Construct final path: directories + filename
1037+
return Path(*directory_parts, path_str)

src/atomworks/ml/datasets/loaders.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@
1111
import pandas as pd
1212
from toolz import keyfilter
1313

14-
from atomworks.io.parser import parse
15-
from atomworks.ml.utils.io import apply_sharding_pattern
14+
from atomworks.io.parser import STANDARD_PARSER_ARGS, parse
15+
from atomworks.io.utils.io_utils import apply_sharding_pattern
1616

1717

1818
def _construct_metadata_hierarchy(row: pd.Series, attrs: dict | None = None) -> dict[str, Any]:
@@ -70,11 +70,13 @@ def _construct_structure_path(
7070

7171

7272
def _load_structure_from_path(path: Path, assembly_id: str, parser_args: dict | None = None) -> dict[str, Any]:
73-
"""Load structure from file path using the CIF parser."""
73+
"""Load structure from file path using the CIF parser, merging with STANDARD_PARSER_ARGS."""
74+
# Merge STANDARD_PARSER_ARGS with parser_args (parser_args takes precedence)
75+
merged_args = {**STANDARD_PARSER_ARGS, **(parser_args or {})}
7476
result_dict = parse(
7577
filename=path,
7678
build_assembly=(assembly_id,),
77-
**(parser_args or {}),
79+
**merged_args,
7880
)
7981
return result_dict
8082

src/atomworks/ml/datasets/parsers/base.py

Lines changed: 2 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -4,26 +4,8 @@
44

55
import pandas as pd
66

7-
from atomworks.constants import CRYSTALLIZATION_AIDS
87
from atomworks.io import parse
9-
10-
DEFAULT_PARSER_ARGS = {
11-
"add_missing_atoms": True,
12-
"add_id_and_entity_annotations": True,
13-
"add_bond_types_from_struct_conn": ["covale"],
14-
"remove_ccds": CRYSTALLIZATION_AIDS,
15-
"remove_waters": True,
16-
"fix_ligands_at_symmetry_centers": True,
17-
"fix_arginines": True,
18-
"fix_formal_charges": True,
19-
"fix_bond_types": True,
20-
"convert_mse_to_met": True,
21-
"hydrogen_policy": "remove",
22-
"model": None, # all models
23-
}
24-
"""Default cif parser arguments for `atomworks.io.parse`.
25-
This dictionary exists to provide a convenient import for the default parameters.
26-
"""
8+
from atomworks.io.parser import STANDARD_PARSER_ARGS
279

2810

2911
class MetadataRowParser(ABC):
@@ -123,7 +105,7 @@ def load_example_from_metadata_row(
123105
cif_parser_args.setdefault("save_to_cache", True)
124106

125107
# Merge DEFAULT_CIF_PARSER_ARGS with cif_parser_args, overriding with the keys present in cif_parser_args
126-
merged_cif_parser_args = {**DEFAULT_PARSER_ARGS, **cif_parser_args}
108+
merged_cif_parser_args = {**STANDARD_PARSER_ARGS, **cif_parser_args}
127109

128110
# Use the parse function with the merged CIF parser arguments
129111
result_dict = parse(

src/atomworks/ml/preprocessing/msa/organizing.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,9 @@
1616

1717
from atomworks.enums import MSAFileExtension
1818
from atomworks.io.utils.compression import transfer_with_compression
19-
from atomworks.io.utils.io_utils import find_files_by_extension
19+
from atomworks.io.utils.io_utils import apply_sharding_pattern, find_files_by_extension
2020
from atomworks.ml.preprocessing.msa.finding import sequence_has_msa
21-
from atomworks.ml.utils.io import apply_sharding_pattern, open_file
21+
from atomworks.ml.utils.io import open_file
2222
from atomworks.ml.utils.misc import hash_sequence
2323

2424
logger = logging.getLogger(__name__)

0 commit comments

Comments
 (0)