Speed up giant train's setup stage
CI / Format (ruff format) (push) Successful in 26s
CI / Lint (ruff check) (push) Successful in 27s
CI / Sync project version with tag (push) Has been skipped
CI / Type check (ty) (push) Successful in 27s
CI / Tests (push) Successful in 51s
CI / Lint (ruff check) (pull_request) Successful in 28s
CI / Format (ruff format) (pull_request) Successful in 26s
CI / Sync project version with tag (pull_request) Has been skipped
CI / Type check (ty) (pull_request) Successful in 33s
CI / Tests (pull_request) Successful in 57s

Fits normalizers over multi-hundred-million-row datasets, so the setup
pass's per-row Python overhead compounds fast: encode_secondaries
recomputed an O(K) prefix sum from scratch on every one of its 15
stick-breaking iterations, np.isin re-sorted the full train-event-id
array on every chunk, and pdg/material/process index lookups ran a
Python dict lookup per row. The normalizer-fit pass also computed
encode_secondaries's stick-logit and direction-rotation blocks in full
even though it only ever reads the mass/charge columns.

Replace the prefix-sum recompute with a single np.cumsum, add a
sorted_membership helper (searchsorted-based) in place of np.isin at
both the setup-pass and per-epoch call sites, vectorize the index
lookups via _vectorized_map_lookup, and add an opt-in phys_only path
so the setup pass skips the stick-breaking/rotation work it discards
anyway. All four changes are output-identical performance refactors,
backed by new unit tests plus the existing suite.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-29 13:34:32 +02:00
parent f2f89023d5
commit 09e4c765c7
5 changed files with 256 additions and 39 deletions
+2 -2
View File
@@ -7,7 +7,7 @@ import torch
from torch.utils.data import IterableDataset
from giant.data.loader import iter_file_chunks
from giant.data.transforms import Normalizer, build_features
from giant.data.transforms import Normalizer, build_features, sorted_membership
def make_event_split(
@@ -98,7 +98,7 @@ class StreamingStepsDataset(IterableDataset):
for path in files:
for chunk in iter_file_chunks(path):
mask = np.isin(chunk["event_id"], self._events_arr)
mask = sorted_membership(chunk["event_id"], self._events_arr)
if not mask.any():
continue
chunk = {k: v[mask] for k, v in chunk.items()}
+92 -32
View File
@@ -276,6 +276,44 @@ class _ReservoirSampler:
return self._reservoir.astype(np.float32)
def sorted_membership(values: np.ndarray, sorted_arr: np.ndarray) -> np.ndarray:
"""Boolean membership of `values` (any order) in `sorted_arr` (ascending, unique).
Equivalent to `np.isin(values, sorted_arr)`, but `np.isin`'s default path
sorts both inputs on every call — costly when `sorted_arr` is a large,
already-sorted array (e.g. all train-split event ids) reused across many
chunks. This does one `searchsorted` per call instead. `values` need not
be sorted; `sorted_arr` must be ascending and duplicate-free.
"""
values = np.asarray(values)
if sorted_arr.size == 0:
return np.zeros(values.shape, dtype=bool)
idx = np.searchsorted(sorted_arr, values)
idx = np.clip(idx, 0, len(sorted_arr) - 1)
return sorted_arr[idx] == values
def _vectorized_map_lookup(values: np.ndarray, mapping: dict) -> np.ndarray:
"""Vectorized equivalent of `np.array([mapping[v] for v in values], dtype=np.int64)`.
Replaces a per-element Python dict lookup with one `searchsorted` call.
Raises `KeyError` if any value in `values` isn't a key of `mapping`,
matching the dict-comprehension it replaces (never silently misassigns).
"""
keys = np.asarray(list(mapping.keys()))
vals = np.asarray(list(mapping.values()), dtype=np.int64)
order = np.argsort(keys, kind="stable")
keys_sorted, vals_sorted = keys[order], vals[order]
values = np.asarray(values)
pos = np.searchsorted(keys_sorted, values)
pos = np.clip(pos, 0, len(keys_sorted) - 1)
found = keys_sorted[pos] == values
if not found.all():
missing = np.unique(values[~found])
raise KeyError(f"value(s) not in mapping: {missing[:10].tolist()}")
return vals_sorted[pos]
def travel_direction(pre_pos: np.ndarray, post_pos: np.ndarray) -> np.ndarray:
"""World-frame unit vector pointing from pre_pos to post_pos.
@@ -338,6 +376,7 @@ def encode_secondaries(
e_sec: np.ndarray,
pre_dir: np.ndarray,
sec_pdg_list: np.ndarray | None = None,
phys_only: bool = False,
) -> np.ndarray:
"""Encode per-secondary attributes into continuous per-slot targets.
@@ -359,38 +398,52 @@ def encode_secondaries(
`sec_pdg_list` is optional so callers that only need the continuous
stick/dir block (e.g. inference-time re-encoding) can omit it; omitting
it zero-fills the last two columns, matching the padding-slot convention.
`phys_only=True` skips the stick-breaking and direction-rotation blocks
(zero-filling them instead) and computes only log_mass/charge — for
callers (normalizer fitting) that discard the other four columns anyway,
so computing them would be wasted work repeated over the whole dataset.
"""
N, K = sec_E_list.shape
e_sec = np.asarray(e_sec, dtype=np.float64)
stick_logits = np.zeros((N, K), dtype=np.float32)
for i in range(K):
if i == 0:
remaining = np.maximum(e_sec, _EPS)
else:
remaining = np.maximum(e_sec - sec_E_list[:, :i].sum(axis=1), _EPS)
f = np.clip(sec_E_list[:, i].astype(np.float64) / remaining, _EPS, 1.0 - _EPS)
logit = np.log(f / (1.0 - f)).astype(np.float32)
# Last valid slot: give it the full remaining budget
is_last = sec_valid[:, i] & ~(
sec_valid[:, i + 1] if i + 1 < K else np.zeros(N, dtype=bool)
)
logit = np.where(is_last, _STICK_LOGIT_CLIP, logit)
logit = np.where(
sec_valid[:, i], np.clip(logit, -_STICK_LOGIT_CLIP, _STICK_LOGIT_CLIP), 0.0
)
stick_logits[:, i] = logit.astype(np.float32)
# Rotate each slot's direction into the local frame of the primary.
# pre_dir is broadcast across all K slots.
dir_local = np.zeros((N, K, 3), dtype=np.float32)
for i in range(K):
# Only rotate valid slots; leave padded slots as (0,0,1) or whatever.
valid_mask = sec_valid[:, i]
if valid_mask.any():
dir_local[valid_mask, i] = local_frame_rotation(
pre_dir[valid_mask], sec_dir_list[valid_mask, i]
if phys_only:
stick_logits = np.zeros((N, K), dtype=np.float32)
dir_local = np.zeros((N, K, 3), dtype=np.float32)
else:
cumsum = np.cumsum(sec_E_list.astype(np.float64), axis=1)
stick_logits = np.zeros((N, K), dtype=np.float32)
for i in range(K):
if i == 0:
remaining = np.maximum(e_sec, _EPS)
else:
remaining = np.maximum(e_sec - cumsum[:, i - 1], _EPS)
f = np.clip(
sec_E_list[:, i].astype(np.float64) / remaining, _EPS, 1.0 - _EPS
)
logit = np.log(f / (1.0 - f)).astype(np.float32)
# Last valid slot: give it the full remaining budget
is_last = sec_valid[:, i] & ~(
sec_valid[:, i + 1] if i + 1 < K else np.zeros(N, dtype=bool)
)
logit = np.where(is_last, _STICK_LOGIT_CLIP, logit)
logit = np.where(
sec_valid[:, i],
np.clip(logit, -_STICK_LOGIT_CLIP, _STICK_LOGIT_CLIP),
0.0,
)
stick_logits[:, i] = logit.astype(np.float32)
# Rotate each slot's direction into the local frame of the primary.
# pre_dir is broadcast across all K slots.
dir_local = np.zeros((N, K, 3), dtype=np.float32)
for i in range(K):
# Only rotate valid slots; leave padded slots as (0,0,1) or whatever.
valid_mask = sec_valid[:, i]
if valid_mask.any():
dir_local[valid_mask, i] = local_frame_rotation(
pre_dir[valid_mask], sec_dir_list[valid_mask, i]
)
if sec_pdg_list is not None:
from giant.particles import particle_phys_array
@@ -573,8 +626,8 @@ def build_cond_features(
[cond_cont, _physical_cond_columns(data, conditioning)]
).astype(np.float32)
pdg_idx = np.array([pdg_map[int(p)] for p in data["pdg"]], dtype=np.int64)
mat_idx = np.array([mat_map[str(m)] for m in data["material"]], dtype=np.int64)
pdg_idx = _vectorized_map_lookup(data["pdg"], pdg_map)
mat_idx = _vectorized_map_lookup(data["material"], mat_map)
cond_cat = np.column_stack([pdg_idx, mat_idx])
if cond_normalizer is not None:
@@ -627,6 +680,7 @@ def build_features(
proc_map: dict[str, int] | None = None,
require_secondaries: bool = False,
conditioning: str = "embedding",
sec_phys_only: bool = False,
) -> tuple[
np.ndarray,
np.ndarray,
@@ -653,6 +707,11 @@ def build_features(
per-secondary list columns are absent (a mis-converted file that would
otherwise silently zero all Stage-2 targets). Training paths set this;
Stage-1-only callers (e.g. `giant predict`) leave it False.
sec_phys_only: passed straight through to `encode_secondaries` — skips
the stick-breaking/direction-rotation blocks of `sec_cont` (zero-filled
instead) for callers (normalizer fitting) that only read
`sec_cont[:, :, 4:6]` and would otherwise discard that work.
"""
from giant.constants import K_MAX
@@ -687,8 +746,8 @@ def build_features(
[cond_cont, _physical_cond_columns(data, conditioning)]
).astype(np.float32) # (N, COND_DIM=15)
pdg_idx = np.array([pdg_map[int(p)] for p in data["pdg"]], dtype=np.int64)
mat_idx = np.array([mat_map[str(m)] for m in data["material"]], dtype=np.int64)
pdg_idx = _vectorized_map_lookup(data["pdg"], pdg_map)
mat_idx = _vectorized_map_lookup(data["material"], mat_map)
cond_cat = np.column_stack([pdg_idx, mat_idx]) # (N, 2)
n_sec_raw = data["n_sec"].astype(
@@ -715,6 +774,7 @@ def build_features(
data["e_sec"],
data["pre_dir"],
sec_pdg_list=sec_pdg_list,
phys_only=sec_phys_only,
) # (N, K_MAX, 6)
else:
# Guard against silently training Stage 2 on zeroed targets: if any step
@@ -755,7 +815,7 @@ def build_features(
process = data.get("process")
if proc_map is not None and process is not None:
proc_idx = np.array([proc_map[str(p)] for p in process], dtype=np.int64)
proc_idx = _vectorized_map_lookup(process, proc_map)
else:
proc_idx = np.zeros(len(cond_cat), dtype=np.int64)
+13 -5
View File
@@ -20,7 +20,12 @@ from giant.data.loader import (
build_index_maps_from_files,
build_process_map_from_files,
)
from giant.data.transforms import build_features, _WelfordAccumulator, _ReservoirSampler
from giant.data.transforms import (
build_features,
_WelfordAccumulator,
_ReservoirSampler,
sorted_membership,
)
from giant.data.dataset import make_event_split, StreamingStepsDataset
from giant.model.network import build_models, build_critics
from giant.train import train as run_training
@@ -50,11 +55,9 @@ def run_train_job(
all_event_ids, val_fraction=t["val_fraction"]
)
events_arr = np.array(sorted(train_events))
n_train_steps = int(np.isin(all_event_ids, events_arr).sum())
total_train_batches = n_train_steps // t["batch_size"]
echo(
f" {len(all_event_ids):,} steps | "
f"{len(train_events)} train events (~{n_train_steps:,} steps, ~{total_train_batches:,} batches) | "
f"{len(train_events)} train events | "
f"{len(val_events)} val events"
)
@@ -95,11 +98,13 @@ def run_train_job(
energy_sampler = (
_ReservoirSampler(capacity=100_000) if energy_router_active else None
)
n_train_steps = 0
for path in files:
for chunk in iter_file_chunks(path):
mask = np.isin(chunk["event_id"], events_arr)
mask = sorted_membership(chunk["event_id"], events_arr)
if not mask.any():
continue
n_train_steps += int(mask.sum())
chunk_tr = {k: v[mask] for k, v in chunk.items()}
cond_cont, _, target_s1, n_sec, sec_cont, _proc, _, _ = build_features(
chunk_tr,
@@ -108,6 +113,7 @@ def run_train_job(
proc_map=proc_map,
require_secondaries=True,
conditioning=conditioning,
sec_phys_only=True,
)
cond_acc.update(cond_cont)
tgt_acc.update(target_s1)
@@ -120,6 +126,8 @@ def run_train_job(
cond_norm = cond_acc.to_normalizer()
tgt_norm = tgt_acc.to_normalizer()
sec_phys_norm = sec_phys_acc.to_normalizer()
total_train_batches = n_train_steps // t["batch_size"]
echo(f" ~{n_train_steps:,} train steps, ~{total_train_batches:,} batches")
if energy_sampler is not None and energy_sampler.n_seen > 0:
assert cond_norm.mean is not None and cond_norm.std is not None
+98
View File
@@ -231,6 +231,53 @@ def test_encode_secondaries_energy_conservation():
assert np.isfinite(sec_cont).all()
def test_encode_secondaries_stick_logits_match_naive_reference():
"""Cumsum-based remaining-budget computation must match a naive
per-row, per-slot Python reference (no cumsum) within float tolerance."""
from giant.data.transforms import encode_secondaries, _EPS, _STICK_LOGIT_CLIP
rng = np.random.default_rng(11)
N = 25
n_sec = rng.integers(1, K_MAX + 1, size=N)
e_sec = rng.uniform(0.1, 10.0, size=N).astype(np.float32)
sec_E_list = np.zeros((N, K_MAX), dtype=np.float32)
sec_dir_list = np.zeros((N, K_MAX, 3), dtype=np.float32)
sec_dir_list[:, :, 2] = 1.0
sec_valid = np.zeros((N, K_MAX), dtype=bool)
for i in range(N):
k = n_sec[i]
energies = rng.dirichlet(np.ones(k)) * e_sec[i]
energies = np.sort(energies)[::-1]
sec_E_list[i, :k] = energies.astype(np.float32)
sec_valid[i, :k] = True
pre_dir = np.tile([0.0, 0.0, 1.0], (N, 1)).astype(np.float32)
sec_cont = encode_secondaries(sec_E_list, sec_dir_list, sec_valid, e_sec, pre_dir)
stick_logits = sec_cont[:, :, 0]
# Naive reference: recompute the remaining budget from scratch each slot,
# exactly what the pre-cumsum implementation did.
expected = np.zeros((N, K_MAX), dtype=np.float64)
for row in range(N):
for i in range(K_MAX):
if not sec_valid[row, i]:
continue
remaining = max(float(e_sec[row]) - float(sec_E_list[row, :i].sum()), _EPS)
f = min(max(float(sec_E_list[row, i]) / remaining, _EPS), 1.0 - _EPS)
logit = np.log(f / (1.0 - f))
is_last = not (i + 1 < K_MAX and sec_valid[row, i + 1])
if is_last:
logit = _STICK_LOGIT_CLIP
logit = np.clip(logit, -_STICK_LOGIT_CLIP, _STICK_LOGIT_CLIP)
expected[row, i] = logit
np.testing.assert_allclose(
stick_logits[sec_valid], expected[sec_valid], rtol=1e-4, atol=1e-4
)
def test_encode_secondaries_direction_encoding():
"""Local-frame secondary directions should be unit vectors for valid slots."""
from giant.data.transforms import encode_secondaries
@@ -272,6 +319,57 @@ def test_encode_secondaries_physical_columns_without_pdg_list():
np.testing.assert_allclose(sec_cont[:, :, 4:6], 0.0)
def test_encode_secondaries_phys_only_matches_full_and_zero_fills_rest():
"""phys_only=True must reproduce the mass/charge columns exactly and
zero-fill the stick-logit/direction columns it skips computing."""
from giant.data.transforms import encode_secondaries
rng = np.random.default_rng(3)
N = 30
n_sec = rng.integers(1, 5, size=N)
e_sec = rng.uniform(0.1, 10.0, size=N).astype(np.float32)
sec_E_list = np.zeros((N, K_MAX), dtype=np.float32)
sec_dir_list = rng.standard_normal((N, K_MAX, 3)).astype(np.float32)
norms = np.linalg.norm(sec_dir_list, axis=-1, keepdims=True)
sec_dir_list /= np.where(norms > 0, norms, 1.0)
sec_pdg_list = np.zeros((N, K_MAX), dtype=np.int64)
sec_valid = np.zeros((N, K_MAX), dtype=bool)
for i in range(N):
k = n_sec[i]
energies = rng.dirichlet(np.ones(k)) * e_sec[i]
energies = np.sort(energies)[::-1]
sec_E_list[i, :k] = energies.astype(np.float32)
sec_valid[i, :k] = True
sec_pdg_list[i, :k] = 11 # electron — resolvable by giant.particles
pre_dir = rng.standard_normal((N, 3)).astype(np.float32)
pre_dir /= np.linalg.norm(pre_dir, axis=1, keepdims=True)
full = encode_secondaries(
sec_E_list,
sec_dir_list,
sec_valid,
e_sec,
pre_dir,
sec_pdg_list=sec_pdg_list,
phys_only=False,
)
phys_only = encode_secondaries(
sec_E_list,
sec_dir_list,
sec_valid,
e_sec,
pre_dir,
sec_pdg_list=sec_pdg_list,
phys_only=True,
)
np.testing.assert_array_equal(phys_only[:, :, 4:6], full[:, :, 4:6])
np.testing.assert_array_equal(phys_only[:, :, 0], np.zeros((N, K_MAX)))
np.testing.assert_array_equal(phys_only[:, :, 1:4], np.zeros((N, K_MAX, 3)))
def test_encode_secondaries_physical_columns_match_ground_truth_pdg():
"""log_mass/charge for a valid slot match giant.particles for that PDG."""
from giant.data.transforms import encode_secondaries, log_transform
+51
View File
@@ -12,7 +12,9 @@ from giant.data.transforms import (
log_transform,
Normalizer,
reconstruct_post_pos,
sorted_membership,
travel_direction,
_vectorized_map_lookup,
)
@@ -440,3 +442,52 @@ def test_build_cond_features_rejects_legacy_normalizer_in_physical_mode(
cond_normalizer=legacy_norm,
conditioning="physical",
)
# ── sorted_membership / _vectorized_map_lookup ──────────────────────────────
def test_sorted_membership_matches_np_isin():
rng = np.random.default_rng(0)
sorted_arr = np.unique(rng.integers(0, 10_000, size=500))
values = rng.integers(-100, 10_100, size=2_000) # some in, some out of range
# values deliberately not sorted
rng.shuffle(values)
result = sorted_membership(values, sorted_arr)
expected = np.isin(values, sorted_arr)
np.testing.assert_array_equal(result, expected)
def test_sorted_membership_empty_sorted_arr():
values = np.array([1, 2, 3])
sorted_arr = np.array([], dtype=np.int64)
result = sorted_membership(values, sorted_arr)
np.testing.assert_array_equal(result, np.zeros(3, dtype=bool))
def test_vectorized_map_lookup_matches_dict_comprehension_int_keys():
rng = np.random.default_rng(1)
keys = np.unique(rng.integers(-1000, 1000, size=200))
mapping = {int(k): i for i, k in enumerate(keys)}
values = rng.choice(keys, size=500)
result = _vectorized_map_lookup(values, mapping)
expected = np.array([mapping[int(v)] for v in values], dtype=np.int64)
np.testing.assert_array_equal(result, expected)
def test_vectorized_map_lookup_matches_dict_comprehension_str_keys():
mapping = {"PbWO4": 0, "G4_AIR": 1, "G4_Fe": 2}
values = np.array(["G4_Fe", "PbWO4", "G4_AIR", "PbWO4"], dtype=object)
result = _vectorized_map_lookup(values, mapping)
expected = np.array([mapping[str(v)] for v in values], dtype=np.int64)
np.testing.assert_array_equal(result, expected)
def test_vectorized_map_lookup_raises_keyerror_on_missing_value():
mapping = {1: 0, 2: 1}
values = np.array([1, 2, 3])
with pytest.raises(KeyError):
_vectorized_map_lookup(values, mapping)