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.
Files changed (47) hide show
  1. xtrax/__init__.py +120 -0
  2. xtrax/checkpoint/__init__.py +13 -0
  3. xtrax/checkpoint/orbax.py +100 -0
  4. xtrax/data/__init__.py +9 -0
  5. xtrax/data/module.py +37 -0
  6. xtrax/data/pipeline.py +20 -0
  7. xtrax/distributed/__init__.py +16 -0
  8. xtrax/distributed/init.py +153 -0
  9. xtrax/distributed/sharding.py +174 -0
  10. xtrax/engine/__init__.py +6 -0
  11. xtrax/engine/engine.py +226 -0
  12. xtrax/engine/io.py +184 -0
  13. xtrax/io/__init__.py +8 -0
  14. xtrax/io/callbacks.py +25 -0
  15. xtrax/py.typed +0 -0
  16. xtrax/safety/__init__.py +3 -0
  17. xtrax/safety/manager.py +64 -0
  18. xtrax/safety/ops.py +36 -0
  19. xtrax/safety/preemption.py +54 -0
  20. xtrax/sparse/__init__.py +17 -0
  21. xtrax/sparse/config.py +16 -0
  22. xtrax/sparse/inference.py +183 -0
  23. xtrax/sparse/manager.py +54 -0
  24. xtrax/sparse/policy.py +49 -0
  25. xtrax/stages/__init__.py +5 -0
  26. xtrax/stages/bundle.py +110 -0
  27. xtrax/stages/protocols.py +45 -0
  28. xtrax/tiling/__init__.py +26 -0
  29. xtrax/tiling/dedup.py +60 -0
  30. xtrax/tiling/dispatch.py +64 -0
  31. xtrax/tiling/iterator.py +165 -0
  32. xtrax/tiling/plan.py +230 -0
  33. xtrax/tiling/strategy.py +60 -0
  34. xtrax/training/__init__.py +20 -0
  35. xtrax/training/grad.py +100 -0
  36. xtrax/training/loss.py +109 -0
  37. xtrax/training/optim.py +128 -0
  38. xtrax/training/step.py +123 -0
  39. xtrax/training/trainer.py +74 -0
  40. xtrax/training/types.py +57 -0
  41. xtrax/transforms/__init__.py +4 -0
  42. xtrax/transforms/map.py +40 -0
  43. xtrax/transforms/scan.py +47 -0
  44. xtrax-0.2.0.dist-info/METADATA +114 -0
  45. xtrax-0.2.0.dist-info/RECORD +47 -0
  46. xtrax-0.2.0.dist-info/WHEEL +4 -0
  47. 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,13 @@
1
+ """Checkpoint utilities for xtrax."""
2
+
3
+ from xtrax.checkpoint.orbax import (
4
+ get_checkpoint_manager,
5
+ load_checkpoint,
6
+ save_checkpoint,
7
+ )
8
+
9
+ __all__ = [
10
+ "get_checkpoint_manager",
11
+ "save_checkpoint",
12
+ "load_checkpoint",
13
+ ]
@@ -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
@@ -0,0 +1,9 @@
1
+ """Data module for dataset loading and preprocessing."""
2
+
3
+ from xtrax.data.module import DataModule
4
+ from xtrax.data.pipeline import create_distributed_pipeline
5
+
6
+ __all__ = [
7
+ "DataModule",
8
+ "create_distributed_pipeline",
9
+ ]
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
+ }
@@ -0,0 +1,6 @@
1
+ """Engine module for training and inference orchestration."""
2
+
3
+ from xtrax.engine.engine import Engine
4
+ from xtrax.engine.io import BoundedCallbackHandler
5
+
6
+ __all__ = ["Engine", "BoundedCallbackHandler"]