ho22joshua commited on
Commit
c5cf2cc
·
1 Parent(s): d2d4ccf

feat: add restartable sharded graph preparation

Browse files
docs/perlmutter.md CHANGED
@@ -56,3 +56,32 @@ An unavailable DGL wheel, incompatible driver, missing cache, or mismatched
56
  cache schema should be fixed in the environment/input rather than hidden with
57
  package-level path changes. GPU kernels and distributed execution are not
58
  promised bitwise deterministic.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56
  cache schema should be fixed in the environment/input rather than hidden with
57
  package-level path changes. GPU kernels and distributed execution are not
58
  promised bitwise deterministic.
59
+
60
+ ## Large ROOT graph preparation
61
+
62
+ Large samples should be prepared as persistent shards. Each Slurm array task
63
+ reads one contiguous global event range and writes one `shard-NNNNN.pt` file;
64
+ completed shards are validated and reused when an interrupted array is
65
+ resubmitted. No full-cache in-memory merge is performed.
66
+
67
+ Create the range manifest once:
68
+
69
+ ```bash
70
+ uv run gnn4colliders prepare-manifest \
71
+ --config-name config_tth_cp_even_odd \
72
+ --shard-count 256 \
73
+ --output-dir outputs/ttH_cp_even_odd/shards
74
+ ```
75
+
76
+ Submit the CPU array, limiting concurrent tasks to match the filesystem and
77
+ allocation capacity:
78
+
79
+ ```bash
80
+ sbatch --array=0-255%32 scripts/slurm/prepare_tth_cp_even_odd.sh
81
+ ```
82
+
83
+ The script defaults to 64 CPUs per task and 256 shards. Set `SHARD_DIR`,
84
+ `SHARD_COUNT`, or `CONFIG` at submission time to use another location or
85
+ configuration. The current training loader still expects a single cache, so
86
+ the next step after extraction is a streaming sharded loader; do not merge the
87
+ full benchmark into one `.pt` file.
scripts/slurm/prepare_tth_cp_even_odd.sh ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/bash
2
+ # Prepare one independent graph-cache shard for the ttH CP benchmark.
3
+ # Submit with: sbatch --array=0-255%32 scripts/slurm/prepare_tth_cp_even_odd.sh
4
+ # Override SHARD_DIR, SHARD_COUNT, and CONFIG when needed.
5
+ #SBATCH -N 1
6
+ #SBATCH -C cpu
7
+ #SBATCH -t 04:00:00
8
+ #SBATCH -n 1
9
+ #SBATCH -c 64
10
+ #SBATCH -o logs/tth_cp_even_odd_%A_%a.out
11
+ #SBATCH -e logs/tth_cp_even_odd_%A_%a.err
12
+
13
+ set -euo pipefail
14
+ cd "${SLURM_SUBMIT_DIR:-$(pwd)}"
15
+
16
+ : "${SLURM_ARRAY_TASK_ID:?submit this script as a Slurm array}"
17
+ SHARD_COUNT="${SHARD_COUNT:-256}"
18
+ SHARD_DIR="${SHARD_DIR:-outputs/ttH_cp_even_odd/shards}"
19
+ CONFIG="${CONFIG:-config_tth_cp_even_odd}"
20
+
21
+ # Prevent native math libraries from multiplying the Slurm CPU allocation.
22
+ export OMP_NUM_THREADS=1
23
+ export MKL_NUM_THREADS=1
24
+ export OPENBLAS_NUM_THREADS=1
25
+ export NUMEXPR_NUM_THREADS=1
26
+
27
+ mkdir -p logs "${SHARD_DIR}"
28
+ uv run gnn4colliders prepare-shard \
29
+ --config-name "${CONFIG}" \
30
+ --shard-index "${SLURM_ARRAY_TASK_ID}" \
31
+ --shard-count "${SHARD_COUNT}" \
32
+ --output-dir "${SHARD_DIR}"
src/gnn4colliders/cli/__init__.py CHANGED
@@ -14,10 +14,20 @@ from gnn4colliders.config.application import (
14
  export_onnx,
15
  predict_or_evaluate,
16
  prepare,
 
17
  train,
 
18
  )
19
 
20
- _COMMANDS = {"prepare", "train", "evaluate", "predict", "export"}
 
 
 
 
 
 
 
 
21
 
22
 
23
  def _is_main_process() -> bool:
@@ -30,7 +40,8 @@ def _help(command: str | None = None) -> None:
30
  print("Compose Hydra configuration with semantic overrides.")
31
  else:
32
  print(
33
- "Usage: gnn4colliders <prepare|train|evaluate|predict|export> "
 
34
  "[key=value ...]"
35
  )
36
  print("Use gnn4colliders <command> --help for command help.")
@@ -68,6 +79,39 @@ def _config_name(args: list[str]) -> tuple[str, list[str]]:
68
  return config_name, remaining
69
 
70
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
71
  def main(argv: list[str] | None = None) -> int:
72
  args = list(sys.argv[1:] if argv is None else argv)
73
  if not args or args[0] in {"-h", "--help"}:
@@ -85,12 +129,35 @@ def main(argv: list[str] | None = None) -> int:
85
  return 0
86
  try:
87
  config_name, overrides = _config_name(args)
 
88
  config = _config(overrides, config_name=config_name)
89
  logging.basicConfig(
90
  level=getattr(logging, str(config.logging.level).upper(), logging.INFO)
91
  )
92
  if command == "prepare":
93
  print(f"prepared cache: {prepare(config)}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
94
  elif command == "train":
95
  if _is_main_process():
96
  print(f"saved checkpoint: {train(config)}")
 
14
  export_onnx,
15
  predict_or_evaluate,
16
  prepare,
17
+ prepare_shard,
18
  train,
19
+ write_shard_manifest,
20
  )
21
 
22
+ _COMMANDS = {
23
+ "prepare",
24
+ "prepare-manifest",
25
+ "prepare-shard",
26
+ "train",
27
+ "evaluate",
28
+ "predict",
29
+ "export",
30
+ }
31
 
32
 
33
  def _is_main_process() -> bool:
 
40
  print("Compose Hydra configuration with semantic overrides.")
41
  else:
42
  print(
43
+ "Usage: gnn4colliders <prepare|prepare-manifest|prepare-shard|train|"
44
+ "evaluate|predict|export> "
45
  "[key=value ...]"
46
  )
47
  print("Use gnn4colliders <command> --help for command help.")
 
79
  return config_name, remaining
80
 
81
 
82
+ def _shard_options(
83
+ args: list[str],
84
+ ) -> tuple[int | None, int | None, str | None, list[str]]:
85
+ values: dict[str, int | str] = {}
86
+ remaining: list[str] = []
87
+ names = {
88
+ "--shard-index": "index",
89
+ "--shard-count": "count",
90
+ "--output-dir": "output",
91
+ }
92
+ index = 0
93
+ while index < len(args):
94
+ argument = args[index]
95
+ matched = next((name for name in names if argument == name), None)
96
+ if matched:
97
+ if index + 1 >= len(args):
98
+ raise ValueError(f"{matched} requires a value")
99
+ values[names[matched]] = args[index + 1]
100
+ index += 2
101
+ continue
102
+ prefix = next((name for name in names if argument.startswith(name + "=")), None)
103
+ if prefix:
104
+ values[names[prefix]] = argument.split("=", 1)[1]
105
+ index += 1
106
+ continue
107
+ remaining.append(argument)
108
+ index += 1
109
+ shard_index = int(values["index"]) if "index" in values else None
110
+ shard_count = int(values["count"]) if "count" in values else None
111
+ output_dir = str(values["output"]) if "output" in values else None
112
+ return shard_index, shard_count, output_dir, remaining
113
+
114
+
115
  def main(argv: list[str] | None = None) -> int:
116
  args = list(sys.argv[1:] if argv is None else argv)
117
  if not args or args[0] in {"-h", "--help"}:
 
129
  return 0
130
  try:
131
  config_name, overrides = _config_name(args)
132
+ shard_index, shard_count, output_dir, overrides = _shard_options(overrides)
133
  config = _config(overrides, config_name=config_name)
134
  logging.basicConfig(
135
  level=getattr(logging, str(config.logging.level).upper(), logging.INFO)
136
  )
137
  if command == "prepare":
138
  print(f"prepared cache: {prepare(config)}")
139
+ elif command == "prepare-manifest":
140
+ if shard_count is None or output_dir is None:
141
+ raise ValueError(
142
+ "prepare-manifest requires --shard-count and --output-dir"
143
+ )
144
+ manifest = write_shard_manifest(
145
+ config, shard_count=shard_count, output_dir=output_dir
146
+ )
147
+ print(f"wrote shard manifest: {manifest}")
148
+ elif command == "prepare-shard":
149
+ if shard_index is None or shard_count is None or output_dir is None:
150
+ raise ValueError(
151
+ "prepare-shard requires --shard-index, --shard-count, and "
152
+ "--output-dir"
153
+ )
154
+ shard = prepare_shard(
155
+ config,
156
+ shard_index=shard_index,
157
+ shard_count=shard_count,
158
+ output_dir=output_dir,
159
+ )
160
+ print(f"prepared graph shard: {shard}")
161
  elif command == "train":
162
  if _is_main_process():
163
  print(f"saved checkpoint: {train(config)}")
src/gnn4colliders/config/application.py CHANGED
@@ -2,6 +2,7 @@
2
 
3
  from __future__ import annotations
4
 
 
5
  import logging
6
  import multiprocessing
7
  import os
@@ -177,6 +178,108 @@ def _write_graph_shard(args: tuple[Any, ...]) -> tuple[Path, int]:
177
  return shard_path, len(samples)
178
 
179
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
180
  def prepare(config: DictConfig) -> Path:
181
  if bool(config.distributed.enabled) or int(os.environ.get("WORLD_SIZE", "1")) > 1:
182
  raise ValueError(
 
2
 
3
  from __future__ import annotations
4
 
5
+ import json
6
  import logging
7
  import multiprocessing
8
  import os
 
178
  return shard_path, len(samples)
179
 
180
 
181
+ def _shard_range(total: int, shard_index: int, shard_count: int) -> tuple[int, int]:
182
+ if shard_count < 1:
183
+ raise ValueError("shard_count must be positive")
184
+ if shard_index < 0 or shard_index >= shard_count:
185
+ raise ValueError("shard_index must be in [0, shard_count)")
186
+ return (
187
+ total * shard_index // shard_count,
188
+ total * (shard_index + 1) // shard_count,
189
+ )
190
+
191
+
192
+ def _prepare_source(config: DictConfig):
193
+ data = config.data
194
+ files, labels = resolve_data_files(data)
195
+ labels_per_file = (
196
+ list(labels) if isinstance(labels, (list, tuple)) else [labels] * len(files)
197
+ )
198
+ if not isinstance(labels, (list, tuple)) and isinstance(labels, Sequence):
199
+ labels_per_file = list(labels)
200
+ source = RootEventDataset(
201
+ files,
202
+ tree_name=str(data.tree_name),
203
+ label=labels_per_file,
204
+ feature_branches=plain(data.get("feature_branches")),
205
+ global_features=plain(data.get("global_features", [])),
206
+ fold_var=str(data.get("fold_var", "eventNumber")),
207
+ weight_var=data.get("weight_var"),
208
+ )
209
+ return source, files, labels_per_file
210
+
211
+
212
+ def prepare_shard(
213
+ config: DictConfig,
214
+ *,
215
+ shard_index: int,
216
+ shard_count: int,
217
+ output_dir: str | Path,
218
+ ) -> Path:
219
+ """Prepare one persistent, restartable graph-cache shard.
220
+
221
+ Shards use contiguous global event ranges and are independent of one
222
+ another, making this function suitable for a Slurm job array. Existing
223
+ valid shards are reused so interrupted arrays can be resubmitted safely.
224
+ """
225
+ source, files, labels = _prepare_source(config)
226
+ start, stop = _shard_range(len(source), shard_index, shard_count)
227
+ target_dir = Path(output_dir)
228
+ target_dir.mkdir(parents=True, exist_ok=True)
229
+ target = target_dir / f"shard-{shard_index:05d}.pt"
230
+ expected = stop - start
231
+ if target.is_file():
232
+ try:
233
+ if len(GraphSampleCache(target).load()) == expected:
234
+ logger.info("reusing completed shard %s", target)
235
+ return target
236
+ except (OSError, RuntimeError, ValueError, EOFError):
237
+ logger.warning("discarding incomplete or incompatible shard %s", target)
238
+
239
+ data = plain(config.data)
240
+ samples = _build_graph_samples(data, files, labels, start, stop)
241
+ if len(samples) != expected:
242
+ raise RuntimeError(
243
+ f"shard {shard_index} produced {len(samples)} samples, expected {expected}"
244
+ )
245
+ temporary = target.with_suffix(target.suffix + f".tmp-{os.getpid()}")
246
+ GraphSampleCache(temporary).save(samples)
247
+ os.replace(temporary, target)
248
+ return target
249
+
250
+
251
+ def write_shard_manifest(
252
+ config: DictConfig, *, shard_count: int, output_dir: str | Path
253
+ ) -> Path:
254
+ """Write an immutable description of the event ranges in a shard set."""
255
+ source, files, labels = _prepare_source(config)
256
+ target_dir = Path(output_dir)
257
+ target_dir.mkdir(parents=True, exist_ok=True)
258
+ manifest = {
259
+ "format": 1,
260
+ "total_events": len(source),
261
+ "shard_count": int(shard_count),
262
+ "tree_name": str(config.data.tree_name),
263
+ "files": [str(path) for path in files],
264
+ "labels": labels,
265
+ "shards": [
266
+ {
267
+ "index": index,
268
+ "start": start,
269
+ "stop": stop,
270
+ "path": f"shard-{index:05d}.pt",
271
+ }
272
+ for index in range(shard_count)
273
+ for start, stop in [_shard_range(len(source), index, shard_count)]
274
+ ],
275
+ }
276
+ target = target_dir / "manifest.json"
277
+ temporary = target.with_suffix(".json.tmp")
278
+ temporary.write_text(json.dumps(manifest, indent=2) + "\n")
279
+ os.replace(temporary, target)
280
+ return target
281
+
282
+
283
  def prepare(config: DictConfig) -> Path:
284
  if bool(config.distributed.enabled) or int(os.environ.get("WORLD_SIZE", "1")) > 1:
285
  raise ValueError(
tests/integration/test_parallel_prepare.py CHANGED
@@ -6,7 +6,11 @@ import pytest
6
  import uproot
7
  from omegaconf import OmegaConf
8
 
9
- from gnn4colliders.config.application import prepare
 
 
 
 
10
  from gnn4colliders.data import GraphSampleCache
11
 
12
 
@@ -65,3 +69,49 @@ def test_parallel_prepare_matches_serial_cache(tmp_path: Path):
65
  assert left.label.equal(right.label)
66
  assert left.graph.ndata["features"].equal(right.graph.ndata["features"])
67
  assert left.graph.edata["features"].equal(right.graph.edata["features"])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6
  import uproot
7
  from omegaconf import OmegaConf
8
 
9
+ from gnn4colliders.config.application import (
10
+ prepare,
11
+ prepare_shard,
12
+ write_shard_manifest,
13
+ )
14
  from gnn4colliders.data import GraphSampleCache
15
 
16
 
 
69
  assert left.label.equal(right.label)
70
  assert left.graph.ndata["features"].equal(right.graph.ndata["features"])
71
  assert left.graph.edata["features"].equal(right.graph.edata["features"])
72
+
73
+
74
+ @pytest.mark.integration
75
+ def test_shards_are_restartable_and_cover_source(tmp_path: Path):
76
+ pytest.importorskip("dgl")
77
+ root_file = tmp_path / "events.root"
78
+ _write_fixture(root_file, events=10)
79
+ data = {
80
+ "files": [str(root_file)],
81
+ "tree_name": "Events",
82
+ "feature_branches": [
83
+ ["jet_pt"],
84
+ ["jet_eta"],
85
+ ["jet_phi"],
86
+ "CALC_E",
87
+ [1.0],
88
+ [0.0],
89
+ "NODE_TYPE",
90
+ ],
91
+ "object_types": ["vector"],
92
+ "scales": [1, 1, 1, 1, 1, 1, 1],
93
+ "global_features": [],
94
+ "fold_var": "eventNumber",
95
+ "weight_var": "weight",
96
+ }
97
+ config = OmegaConf.create({"data": data})
98
+ output = tmp_path / "shards"
99
+
100
+ manifest = write_shard_manifest(config, shard_count=3, output_dir=output)
101
+ assert manifest.is_file()
102
+ paths = [
103
+ prepare_shard(
104
+ config,
105
+ shard_index=index,
106
+ shard_count=3,
107
+ output_dir=output,
108
+ )
109
+ for index in range(3)
110
+ ]
111
+ assert [len(GraphSampleCache(path).load()) for path in paths] == [3, 3, 4]
112
+ assert prepare_shard(
113
+ config,
114
+ shard_index=1,
115
+ shard_count=3,
116
+ output_dir=output,
117
+ ) == paths[1]