xtrax 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- xtrax/__init__.py +120 -0
- xtrax/checkpoint/__init__.py +13 -0
- xtrax/checkpoint/orbax.py +100 -0
- xtrax/data/__init__.py +9 -0
- xtrax/data/module.py +37 -0
- xtrax/data/pipeline.py +20 -0
- xtrax/distributed/__init__.py +16 -0
- xtrax/distributed/init.py +153 -0
- xtrax/distributed/sharding.py +174 -0
- xtrax/engine/__init__.py +6 -0
- xtrax/engine/engine.py +226 -0
- xtrax/engine/io.py +184 -0
- xtrax/io/__init__.py +8 -0
- xtrax/io/callbacks.py +25 -0
- xtrax/py.typed +0 -0
- xtrax/safety/__init__.py +3 -0
- xtrax/safety/manager.py +64 -0
- xtrax/safety/ops.py +36 -0
- xtrax/safety/preemption.py +54 -0
- xtrax/sparse/__init__.py +17 -0
- xtrax/sparse/config.py +16 -0
- xtrax/sparse/inference.py +183 -0
- xtrax/sparse/manager.py +54 -0
- xtrax/sparse/policy.py +49 -0
- xtrax/stages/__init__.py +5 -0
- xtrax/stages/bundle.py +110 -0
- xtrax/stages/protocols.py +45 -0
- xtrax/tiling/__init__.py +26 -0
- xtrax/tiling/dedup.py +60 -0
- xtrax/tiling/dispatch.py +64 -0
- xtrax/tiling/iterator.py +165 -0
- xtrax/tiling/plan.py +230 -0
- xtrax/tiling/strategy.py +60 -0
- xtrax/training/__init__.py +20 -0
- xtrax/training/grad.py +100 -0
- xtrax/training/loss.py +109 -0
- xtrax/training/optim.py +128 -0
- xtrax/training/step.py +123 -0
- xtrax/training/trainer.py +74 -0
- xtrax/training/types.py +57 -0
- xtrax/transforms/__init__.py +4 -0
- xtrax/transforms/map.py +40 -0
- xtrax/transforms/scan.py +47 -0
- xtrax-0.2.0.dist-info/METADATA +114 -0
- xtrax-0.2.0.dist-info/RECORD +47 -0
- xtrax-0.2.0.dist-info/WHEEL +4 -0
- xtrax-0.2.0.dist-info/licenses/LICENSE +186 -0
xtrax/__init__.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
__version__ = "0.2.0"
|
|
2
|
+
|
|
3
|
+
__all__ = [
|
|
4
|
+
# Core training
|
|
5
|
+
"Trainer",
|
|
6
|
+
"SafetyTrainStep",
|
|
7
|
+
"create_train_step",
|
|
8
|
+
"LossFunction",
|
|
9
|
+
"Callback",
|
|
10
|
+
"ResumableState",
|
|
11
|
+
"WeightedLoss",
|
|
12
|
+
"MultiTaskLoss",
|
|
13
|
+
"make_optimizer",
|
|
14
|
+
"adamw_with_schedule",
|
|
15
|
+
# Engine and IO
|
|
16
|
+
"Engine",
|
|
17
|
+
"BoundedCallbackHandler",
|
|
18
|
+
"save_checkpoint",
|
|
19
|
+
"load_checkpoint",
|
|
20
|
+
# Data
|
|
21
|
+
"DataModule",
|
|
22
|
+
"create_distributed_pipeline",
|
|
23
|
+
# Tiling
|
|
24
|
+
"AxisSpec",
|
|
25
|
+
"AxisDecision",
|
|
26
|
+
"BatchPlan",
|
|
27
|
+
"BatchPlanner",
|
|
28
|
+
"Vmap",
|
|
29
|
+
"SafeMap",
|
|
30
|
+
"DedupGather",
|
|
31
|
+
# Sparse
|
|
32
|
+
"SparseConfig",
|
|
33
|
+
"SparsePolicy",
|
|
34
|
+
"SparseMaskManager",
|
|
35
|
+
"sparsify_model",
|
|
36
|
+
"make_sparse_forward_fn",
|
|
37
|
+
"sparse_filter_jit",
|
|
38
|
+
# Distributed
|
|
39
|
+
"init_dist",
|
|
40
|
+
"is_distributed",
|
|
41
|
+
"LogicalMesh",
|
|
42
|
+
"with_manual_axes",
|
|
43
|
+
# Transforms
|
|
44
|
+
"safe_map",
|
|
45
|
+
"safe_scan",
|
|
46
|
+
# Safety
|
|
47
|
+
"safe_norm",
|
|
48
|
+
"safe_reciprocal",
|
|
49
|
+
# Stages
|
|
50
|
+
"TransformFn",
|
|
51
|
+
"RollingFn",
|
|
52
|
+
"FuseFn",
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
_LAZY = {
|
|
56
|
+
# Training subpackage
|
|
57
|
+
"Trainer": "xtrax.training",
|
|
58
|
+
"SafetyTrainStep": "xtrax.training",
|
|
59
|
+
"create_train_step": "xtrax.training",
|
|
60
|
+
"LossFunction": "xtrax.training",
|
|
61
|
+
"Callback": "xtrax.training",
|
|
62
|
+
"ResumableState": "xtrax.training",
|
|
63
|
+
"WeightedLoss": "xtrax.training",
|
|
64
|
+
"MultiTaskLoss": "xtrax.training",
|
|
65
|
+
"make_optimizer": "xtrax.training",
|
|
66
|
+
"adamw_with_schedule": "xtrax.training",
|
|
67
|
+
# Engine subpackage
|
|
68
|
+
"Engine": "xtrax.engine",
|
|
69
|
+
"BoundedCallbackHandler": "xtrax.engine",
|
|
70
|
+
# Checkpoint subpackage
|
|
71
|
+
"save_checkpoint": "xtrax.checkpoint",
|
|
72
|
+
"load_checkpoint": "xtrax.checkpoint",
|
|
73
|
+
# Data subpackage
|
|
74
|
+
"DataModule": "xtrax.data",
|
|
75
|
+
"create_distributed_pipeline": "xtrax.data",
|
|
76
|
+
# Tiling subpackage
|
|
77
|
+
"AxisSpec": "xtrax.tiling",
|
|
78
|
+
"AxisDecision": "xtrax.tiling",
|
|
79
|
+
"BatchPlan": "xtrax.tiling",
|
|
80
|
+
"BatchPlanner": "xtrax.tiling",
|
|
81
|
+
"Vmap": "xtrax.tiling",
|
|
82
|
+
"SafeMap": "xtrax.tiling",
|
|
83
|
+
"DedupGather": "xtrax.tiling",
|
|
84
|
+
# Sparse subpackage
|
|
85
|
+
"SparseConfig": "xtrax.sparse",
|
|
86
|
+
"SparsePolicy": "xtrax.sparse",
|
|
87
|
+
"SparseMaskManager": "xtrax.sparse",
|
|
88
|
+
"sparsify_model": "xtrax.sparse",
|
|
89
|
+
"make_sparse_forward_fn": "xtrax.sparse",
|
|
90
|
+
"sparse_filter_jit": "xtrax.sparse",
|
|
91
|
+
# Distributed subpackage
|
|
92
|
+
"init_dist": "xtrax.distributed",
|
|
93
|
+
"is_distributed": "xtrax.distributed",
|
|
94
|
+
"LogicalMesh": "xtrax.distributed",
|
|
95
|
+
"with_manual_axes": "xtrax.distributed",
|
|
96
|
+
# Transforms subpackage
|
|
97
|
+
"safe_map": "xtrax.transforms",
|
|
98
|
+
"safe_scan": "xtrax.transforms",
|
|
99
|
+
# Safety subpackage
|
|
100
|
+
"safe_norm": "xtrax.safety",
|
|
101
|
+
"safe_reciprocal": "xtrax.safety",
|
|
102
|
+
# Stages subpackage
|
|
103
|
+
"TransformFn": "xtrax.stages",
|
|
104
|
+
"RollingFn": "xtrax.stages",
|
|
105
|
+
"FuseFn": "xtrax.stages",
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def __getattr__(name):
|
|
110
|
+
"""Lazy import on attribute access."""
|
|
111
|
+
if name in _LAZY:
|
|
112
|
+
import importlib
|
|
113
|
+
|
|
114
|
+
return getattr(importlib.import_module(_LAZY[name]), name)
|
|
115
|
+
raise AttributeError(f"module 'xtrax' has no attribute {name!r}")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def __dir__():
|
|
119
|
+
"""Return sorted list of public attributes."""
|
|
120
|
+
return sorted(__all__)
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Checkpoint utilities using Orbax."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import TYPE_CHECKING
|
|
5
|
+
|
|
6
|
+
import orbax.checkpoint as ocp
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from xtrax.training.types import ResumableState
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def get_checkpoint_manager(
|
|
13
|
+
directory: str | Path,
|
|
14
|
+
max_to_keep: int = 5,
|
|
15
|
+
keep_period: int | None = None,
|
|
16
|
+
) -> ocp.CheckpointManager:
|
|
17
|
+
"""Create and return a CheckpointManager for the given directory.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
directory: Directory to store checkpoints.
|
|
21
|
+
max_to_keep: Maximum number of checkpoints to keep. Defaults to 5.
|
|
22
|
+
keep_period: Period (in steps) for keeping checkpoints. Defaults to None.
|
|
23
|
+
|
|
24
|
+
Returns:
|
|
25
|
+
A CheckpointManager instance with PyTreeCheckpointHandler configured.
|
|
26
|
+
"""
|
|
27
|
+
directory = Path(directory)
|
|
28
|
+
|
|
29
|
+
# Create options for the checkpoint manager
|
|
30
|
+
options = ocp.CheckpointManagerOptions(
|
|
31
|
+
max_to_keep=max_to_keep,
|
|
32
|
+
keep_period=keep_period,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
# Create the manager with PyTreeCheckpointHandler
|
|
36
|
+
manager = ocp.CheckpointManager(
|
|
37
|
+
directory,
|
|
38
|
+
options=options,
|
|
39
|
+
item_handlers={"state": ocp.PyTreeCheckpointHandler()},
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
return manager
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def save_checkpoint(
|
|
46
|
+
manager: ocp.CheckpointManager,
|
|
47
|
+
state: "ResumableState",
|
|
48
|
+
step: int | None = None,
|
|
49
|
+
) -> None:
|
|
50
|
+
"""Save a checkpoint to the manager.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
manager: CheckpointManager instance.
|
|
54
|
+
state: ResumableState to save.
|
|
55
|
+
step: Step number to save at. If None, uses int(state.step).
|
|
56
|
+
|
|
57
|
+
Note:
|
|
58
|
+
This function must be called outside JAX-traced contexts.
|
|
59
|
+
Calls manager.wait_until_finished() after saving.
|
|
60
|
+
"""
|
|
61
|
+
# Extract step: use provided step or convert state.step to int
|
|
62
|
+
step_int = step if step is not None else int(state.step)
|
|
63
|
+
|
|
64
|
+
# Save the checkpoint
|
|
65
|
+
manager.save(step=step_int, items={"state": state})
|
|
66
|
+
|
|
67
|
+
# Wait for save to complete
|
|
68
|
+
manager.wait_until_finished()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def load_checkpoint(
|
|
72
|
+
manager: ocp.CheckpointManager,
|
|
73
|
+
state_template: "ResumableState",
|
|
74
|
+
step: int | None = None,
|
|
75
|
+
) -> "ResumableState":
|
|
76
|
+
"""Load a checkpoint from the manager.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
manager: CheckpointManager instance.
|
|
80
|
+
state_template: Template ResumableState for pytree structure.
|
|
81
|
+
Required for orbax to reconstruct the pytree.
|
|
82
|
+
step: Step number to load. If None, uses latest_step().
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
The loaded ResumableState.
|
|
86
|
+
|
|
87
|
+
Raises:
|
|
88
|
+
FileNotFoundError: If checkpoint doesn't exist or directory is empty.
|
|
89
|
+
"""
|
|
90
|
+
# Determine which step to load
|
|
91
|
+
if step is None:
|
|
92
|
+
step = manager.latest_step()
|
|
93
|
+
if step is None:
|
|
94
|
+
raise FileNotFoundError("No checkpoints found in directory")
|
|
95
|
+
|
|
96
|
+
# Restore the checkpoint
|
|
97
|
+
loaded = manager.restore(step=step, items={"state": state_template})
|
|
98
|
+
|
|
99
|
+
# Extract the state from the composite result
|
|
100
|
+
return loaded["state"]
|
xtrax/data/__init__.py
ADDED
xtrax/data/module.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
from collections.abc import Callable, Iterator
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
import equinox as eqx
|
|
5
|
+
|
|
6
|
+
# Module-level flag for distributed init state (real integration with
|
|
7
|
+
# init_dist deferred to Phase 5/6)
|
|
8
|
+
_dist_initialized: bool = False
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _mark_dist_initialized() -> None:
|
|
12
|
+
"""Call after init_dist() to allow DataModule iterators to proceed."""
|
|
13
|
+
global _dist_initialized
|
|
14
|
+
_dist_initialized = True
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class DataModule(eqx.Module):
|
|
18
|
+
dataset: Any
|
|
19
|
+
batch_size: int = eqx.field(static=True)
|
|
20
|
+
num_epochs: int | None = eqx.field(static=True) # None = cycle indefinitely
|
|
21
|
+
seed: int = eqx.field(static=True)
|
|
22
|
+
distributed: bool = eqx.field(static=True)
|
|
23
|
+
collate_fn: Callable | None = eqx.field(static=True, default=None)
|
|
24
|
+
|
|
25
|
+
def train_iter(self) -> Iterator[Any]:
|
|
26
|
+
if self.distributed and not _dist_initialized:
|
|
27
|
+
raise RuntimeError(
|
|
28
|
+
"DataModule: distributed=True requires init_dist() before train_iter()."
|
|
29
|
+
)
|
|
30
|
+
yield from self.dataset
|
|
31
|
+
|
|
32
|
+
def eval_iter(self) -> Iterator[Any]:
|
|
33
|
+
if self.distributed and not _dist_initialized:
|
|
34
|
+
raise RuntimeError(
|
|
35
|
+
"DataModule: distributed=True requires init_dist() before eval_iter()."
|
|
36
|
+
)
|
|
37
|
+
yield from self.dataset
|
xtrax/data/pipeline.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def create_distributed_pipeline(
|
|
5
|
+
dataset: Any,
|
|
6
|
+
global_batch_size: int,
|
|
7
|
+
num_devices: int,
|
|
8
|
+
seed: int,
|
|
9
|
+
) -> Any:
|
|
10
|
+
"""Create distributed pipeline with per-device batch size validation.
|
|
11
|
+
|
|
12
|
+
Raises ValueError if global_batch_size is not divisible by num_devices.
|
|
13
|
+
Stub implementation — real grain sharding deferred to Phase 5/6.
|
|
14
|
+
"""
|
|
15
|
+
if global_batch_size % num_devices != 0:
|
|
16
|
+
raise ValueError(
|
|
17
|
+
f"create_distributed_pipeline: global_batch_size={global_batch_size} "
|
|
18
|
+
f"must be divisible by num_devices={num_devices}."
|
|
19
|
+
)
|
|
20
|
+
return dataset
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Distributed training utilities for JAX."""
|
|
2
|
+
|
|
3
|
+
from xtrax.distributed.init import init_dist, is_distributed
|
|
4
|
+
from xtrax.distributed.sharding import (
|
|
5
|
+
ShardingPolicy,
|
|
6
|
+
get_device_mesh,
|
|
7
|
+
get_hardware_mesh_profile,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"init_dist",
|
|
12
|
+
"is_distributed",
|
|
13
|
+
"ShardingPolicy",
|
|
14
|
+
"get_device_mesh",
|
|
15
|
+
"get_hardware_mesh_profile",
|
|
16
|
+
]
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""Distributed initialization for JAX multi-process training."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import jax.distributed
|
|
7
|
+
|
|
8
|
+
# Module-level state: stores initialization args
|
|
9
|
+
_init_state: dict[str, Any] = {
|
|
10
|
+
"initialized": False,
|
|
11
|
+
"coordinator_address": None,
|
|
12
|
+
"num_processes": None,
|
|
13
|
+
"process_id": None,
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _derive_coordinator_from_nodelist(nodelist: str) -> str:
|
|
18
|
+
"""Extract first node from SLURM_JOB_NODELIST and return as coordinator address."""
|
|
19
|
+
# nodelist can be "node-001,node-002,node-003" or "node-001" or "node-[001-003]"
|
|
20
|
+
# For simplicity, extract the first component (comma-separated or bracket-expanded)
|
|
21
|
+
first_node = nodelist.split(",")[0]
|
|
22
|
+
# Remove bracket ranges if present (e.g., "node-[001-003]" -> "node-[001-003]")
|
|
23
|
+
# For now, just use the first node as-is; return with default JAX port
|
|
24
|
+
return f"{first_node}:1234"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def init_dist(
|
|
28
|
+
*,
|
|
29
|
+
coordinator_address: str | None = None,
|
|
30
|
+
num_processes: int | None = None,
|
|
31
|
+
process_id: int | None = None,
|
|
32
|
+
) -> None:
|
|
33
|
+
"""
|
|
34
|
+
Initialize JAX distributed training with optional auto-discovery.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
coordinator_address: Address of the coordinator (e.g., "localhost:1234").
|
|
38
|
+
If None, attempts SLURM env discovery or falls back
|
|
39
|
+
to localhost.
|
|
40
|
+
num_processes: Total number of processes in the distributed setup.
|
|
41
|
+
If None, attempts SLURM env discovery or falls back to 1.
|
|
42
|
+
process_id: This process's ID (0-indexed).
|
|
43
|
+
If None, attempts SLURM env discovery or falls back to 0.
|
|
44
|
+
|
|
45
|
+
Raises:
|
|
46
|
+
RuntimeError: If init_dist has already been called with different arguments.
|
|
47
|
+
|
|
48
|
+
Notes:
|
|
49
|
+
- Idempotent: same args on repeat call → no-op.
|
|
50
|
+
- Different args on second call → RuntimeError.
|
|
51
|
+
- SLURM discovery: uses SLURM_PROCID, SLURM_NTASKS, SLURM_JOB_NODELIST.
|
|
52
|
+
- Single-process fallback: skips jax.distributed.initialize for
|
|
53
|
+
num_processes==1 (spec deviation: see below).
|
|
54
|
+
- After successful init, calls _mark_dist_initialized() to enable
|
|
55
|
+
DataModule(distributed=True).
|
|
56
|
+
|
|
57
|
+
Spec Deviation (§3.22):
|
|
58
|
+
The spec states that localhost fallback should call jax.distributed.initialize
|
|
59
|
+
with single-process config. However, calling jax.distributed.initialize with
|
|
60
|
+
num_processes=1 can be unstable in some environments. This implementation skips
|
|
61
|
+
the call for num_processes==1, instead calling _mark_dist_initialized() and
|
|
62
|
+
returning. If future requirements demand the single-process initialize call,
|
|
63
|
+
this can be made a parameter.
|
|
64
|
+
"""
|
|
65
|
+
global _init_state
|
|
66
|
+
|
|
67
|
+
# Auto-discovery: explicit args → SLURM env → localhost fallback
|
|
68
|
+
if coordinator_address is None or num_processes is None or process_id is None:
|
|
69
|
+
# Try SLURM environment variables
|
|
70
|
+
slurm_ntasks = os.environ.get("SLURM_NTASKS")
|
|
71
|
+
slurm_procid = os.environ.get("SLURM_PROCID")
|
|
72
|
+
slurm_nodelist = os.environ.get("SLURM_JOB_NODELIST")
|
|
73
|
+
|
|
74
|
+
if slurm_ntasks is not None and slurm_procid is not None:
|
|
75
|
+
# SLURM env found: use it to fill in missing args
|
|
76
|
+
if num_processes is None:
|
|
77
|
+
num_processes = int(slurm_ntasks)
|
|
78
|
+
if process_id is None:
|
|
79
|
+
process_id = int(slurm_procid)
|
|
80
|
+
if coordinator_address is None:
|
|
81
|
+
if slurm_nodelist:
|
|
82
|
+
coordinator_address = _derive_coordinator_from_nodelist(
|
|
83
|
+
slurm_nodelist
|
|
84
|
+
)
|
|
85
|
+
else:
|
|
86
|
+
coordinator_address = "localhost:1234"
|
|
87
|
+
else:
|
|
88
|
+
# No SLURM env: use localhost fallback
|
|
89
|
+
if coordinator_address is None:
|
|
90
|
+
coordinator_address = "localhost:1234"
|
|
91
|
+
if num_processes is None:
|
|
92
|
+
num_processes = 1
|
|
93
|
+
if process_id is None:
|
|
94
|
+
process_id = 0
|
|
95
|
+
|
|
96
|
+
# Check idempotency: if already initialized, verify args match
|
|
97
|
+
if _init_state["initialized"]:
|
|
98
|
+
if (
|
|
99
|
+
_init_state["coordinator_address"] != coordinator_address
|
|
100
|
+
or _init_state["num_processes"] != num_processes
|
|
101
|
+
or _init_state["process_id"] != process_id
|
|
102
|
+
):
|
|
103
|
+
raise RuntimeError(
|
|
104
|
+
f"init_dist already initialized with different args. "
|
|
105
|
+
f"Previous: coordinator_address="
|
|
106
|
+
f"{_init_state['coordinator_address']}, "
|
|
107
|
+
f"num_processes={_init_state['num_processes']}, "
|
|
108
|
+
f"process_id={_init_state['process_id']}. "
|
|
109
|
+
f"New: coordinator_address={coordinator_address}, "
|
|
110
|
+
f"num_processes={num_processes}, process_id={process_id}."
|
|
111
|
+
)
|
|
112
|
+
# Same args: idempotent, no-op
|
|
113
|
+
return
|
|
114
|
+
|
|
115
|
+
# Store state
|
|
116
|
+
_init_state["coordinator_address"] = coordinator_address
|
|
117
|
+
_init_state["num_processes"] = num_processes
|
|
118
|
+
_init_state["process_id"] = process_id
|
|
119
|
+
|
|
120
|
+
# Initialize JAX distributed if num_processes > 1
|
|
121
|
+
if num_processes > 1:
|
|
122
|
+
jax.distributed.initialize(
|
|
123
|
+
coordinator_address=coordinator_address,
|
|
124
|
+
num_processes=num_processes,
|
|
125
|
+
process_id=process_id,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
# Mark distributed initialization complete (enables DataModule(distributed=True))
|
|
129
|
+
from xtrax.data.module import _mark_dist_initialized
|
|
130
|
+
|
|
131
|
+
_mark_dist_initialized()
|
|
132
|
+
|
|
133
|
+
_init_state["initialized"] = True
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def is_distributed() -> bool:
|
|
137
|
+
"""Return True if init_dist has been called, False otherwise."""
|
|
138
|
+
return _init_state["initialized"]
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _reset_for_testing() -> None:
|
|
142
|
+
"""Reset internal state for testing. Not for production use."""
|
|
143
|
+
global _init_state
|
|
144
|
+
_init_state = {
|
|
145
|
+
"initialized": False,
|
|
146
|
+
"coordinator_address": None,
|
|
147
|
+
"num_processes": None,
|
|
148
|
+
"process_id": None,
|
|
149
|
+
}
|
|
150
|
+
# Also reset DataModule's distributed flag
|
|
151
|
+
import xtrax.data.module as dm
|
|
152
|
+
|
|
153
|
+
dm._dist_initialized = False
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""Distributed sharding utilities for JAX arrays and pytrees."""
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
import re
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import equinox as eqx
|
|
8
|
+
import jax
|
|
9
|
+
import jax.sharding
|
|
10
|
+
import jax.tree_util
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ShardingPolicy(eqx.Module):
|
|
14
|
+
"""Policy for assigning PartitionSpec to pytree leaves.
|
|
15
|
+
|
|
16
|
+
Uses regex pattern matching where first match wins. Matching is done via re.search,
|
|
17
|
+
so patterns can match anywhere in the path.
|
|
18
|
+
|
|
19
|
+
Attributes:
|
|
20
|
+
rules: Ordered tuple of (regex_pattern, PartitionSpec) pairs.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
rules: tuple[tuple[str, jax.sharding.PartitionSpec], ...] = eqx.field(static=True)
|
|
24
|
+
|
|
25
|
+
def get_partition_spec(self, path: str) -> jax.sharding.PartitionSpec:
|
|
26
|
+
"""Get the PartitionSpec for a given path by matching against rules in order.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
path: String path to a pytree leaf (e.g., "layer_0/weight" or "bias").
|
|
30
|
+
|
|
31
|
+
Returns:
|
|
32
|
+
The PartitionSpec for the first matching rule, or PartitionSpec() if no
|
|
33
|
+
rule matches (defaults to fully replicated).
|
|
34
|
+
"""
|
|
35
|
+
for pattern, partition_spec in self.rules:
|
|
36
|
+
if re.search(pattern, path):
|
|
37
|
+
return partition_spec
|
|
38
|
+
return jax.sharding.PartitionSpec()
|
|
39
|
+
|
|
40
|
+
def apply_to_pytree(self, pytree: Any) -> Any:
|
|
41
|
+
"""Apply sharding policy to all leaves of a pytree.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
pytree: A pytree (dict, list, etc.) with any leaf values.
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
A pytree with the same structure but with leaves replaced by PartitionSpec
|
|
48
|
+
objects derived from their paths.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
def apply_to_leaf(path, leaf):
|
|
52
|
+
# Convert jax.tree_util path to a string representation
|
|
53
|
+
path_str = self._path_to_string(path)
|
|
54
|
+
return self.get_partition_spec(path_str)
|
|
55
|
+
|
|
56
|
+
# Use jax.tree_util.tree_map_with_path to traverse with path tracking
|
|
57
|
+
# This preserves the pytree structure and applies the function to each leaf
|
|
58
|
+
return jax.tree_util.tree_map_with_path(apply_to_leaf, pytree)
|
|
59
|
+
|
|
60
|
+
@staticmethod
|
|
61
|
+
def _path_to_string(path: tuple) -> str:
|
|
62
|
+
"""Convert a jax.tree_util path to a string representation.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
path: A tuple of GetAttrKey, DictKey, SequenceKey, etc.
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
A string representation of the path (e.g., "layer/weight").
|
|
69
|
+
"""
|
|
70
|
+
parts = []
|
|
71
|
+
for key in path:
|
|
72
|
+
if isinstance(key, jax.tree_util.DictKey):
|
|
73
|
+
parts.append(str(key.key))
|
|
74
|
+
elif isinstance(key, jax.tree_util.GetAttrKey):
|
|
75
|
+
parts.append(key.name)
|
|
76
|
+
elif isinstance(key, jax.tree_util.SequenceKey):
|
|
77
|
+
parts.append(f"[{key.idx}]")
|
|
78
|
+
else:
|
|
79
|
+
parts.append(str(key))
|
|
80
|
+
return "/".join(parts)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def get_device_mesh(
|
|
84
|
+
shape: tuple[int, ...],
|
|
85
|
+
axis_names: tuple[str, ...],
|
|
86
|
+
) -> jax.sharding.Mesh:
|
|
87
|
+
"""Create a Mesh object for JAX distributed arrays.
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
shape: The mesh shape, e.g., (2, 4) for 2 data-parallel x 4 model-parallel.
|
|
91
|
+
axis_names: The axis names, e.g., ("data", "model").
|
|
92
|
+
|
|
93
|
+
Returns:
|
|
94
|
+
A jax.sharding.Mesh with the specified shape and axis names.
|
|
95
|
+
|
|
96
|
+
Raises:
|
|
97
|
+
ValueError: If the product of shape does not equal the number of JAX devices.
|
|
98
|
+
"""
|
|
99
|
+
import numpy as np
|
|
100
|
+
|
|
101
|
+
num_devices = len(jax.devices())
|
|
102
|
+
shape_product = math.prod(shape) if shape else 1
|
|
103
|
+
|
|
104
|
+
if shape_product != num_devices:
|
|
105
|
+
raise ValueError(
|
|
106
|
+
f"Shape product ({shape_product}) must equal number of devices "
|
|
107
|
+
f"({num_devices}). Got shape={shape}."
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
# Reshape devices array to match the requested shape
|
|
111
|
+
devices_array = np.array(jax.devices()).reshape(shape)
|
|
112
|
+
return jax.sharding.Mesh(devices_array, axis_names)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def get_hardware_mesh_profile() -> dict[str, Any]:
|
|
116
|
+
"""Get recommended mesh configuration for the current hardware.
|
|
117
|
+
|
|
118
|
+
Returns a dict with the following keys:
|
|
119
|
+
- device_type (str): "cpu", "gpu", or "tpu"
|
|
120
|
+
- num_devices (int): Number of available devices
|
|
121
|
+
- recommended_shape (tuple[int, ...]): Suggested mesh shape
|
|
122
|
+
- recommended_axis_names (tuple[str, ...]): Suggested axis names
|
|
123
|
+
|
|
124
|
+
This function never raises, even on unknown hardware. For a single CPU device,
|
|
125
|
+
it returns shape=(1,) and axis_names=("batch",).
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
A dictionary with device info and recommendations.
|
|
129
|
+
"""
|
|
130
|
+
try:
|
|
131
|
+
devices = jax.devices()
|
|
132
|
+
num_devices = len(devices)
|
|
133
|
+
|
|
134
|
+
# Determine device type from first device
|
|
135
|
+
device_type = devices[0].platform if hasattr(devices[0], "platform") else "cpu"
|
|
136
|
+
|
|
137
|
+
# Normalize device_type
|
|
138
|
+
if device_type.lower() in ("gpu", "cuda"):
|
|
139
|
+
device_type = "gpu"
|
|
140
|
+
elif device_type.lower() in ("tpu",):
|
|
141
|
+
device_type = "tpu"
|
|
142
|
+
else:
|
|
143
|
+
device_type = "cpu"
|
|
144
|
+
|
|
145
|
+
# Recommend shape and axis names based on device count
|
|
146
|
+
if num_devices == 1:
|
|
147
|
+
recommended_shape = (1,)
|
|
148
|
+
recommended_axis_names = ("batch",)
|
|
149
|
+
elif num_devices == 2:
|
|
150
|
+
recommended_shape = (2,)
|
|
151
|
+
recommended_axis_names = ("data",)
|
|
152
|
+
elif num_devices % 8 == 0:
|
|
153
|
+
# For TPU pods or large GPU clusters, suggest data-parallel axis
|
|
154
|
+
recommended_shape = (num_devices,)
|
|
155
|
+
recommended_axis_names = ("data",)
|
|
156
|
+
else:
|
|
157
|
+
# Default: single axis, data-parallel
|
|
158
|
+
recommended_shape = (num_devices,)
|
|
159
|
+
recommended_axis_names = ("data",)
|
|
160
|
+
|
|
161
|
+
return {
|
|
162
|
+
"device_type": device_type,
|
|
163
|
+
"num_devices": num_devices,
|
|
164
|
+
"recommended_shape": recommended_shape,
|
|
165
|
+
"recommended_axis_names": recommended_axis_names,
|
|
166
|
+
}
|
|
167
|
+
except Exception:
|
|
168
|
+
# Fallback for any errors
|
|
169
|
+
return {
|
|
170
|
+
"device_type": "cpu",
|
|
171
|
+
"num_devices": 1,
|
|
172
|
+
"recommended_shape": (1,),
|
|
173
|
+
"recommended_axis_names": ("batch",),
|
|
174
|
+
}
|