JAX on NVIDIA GPUs Part 2: A practical guide for ML engineers

JAX on NVIDIA GPUs Part 2: A practical guide for ML engineers

January 8, 2026• 84 min read

This guide demonstrates how to scale JAX-based LLM training from a single GPU to multi-node clusters on NVIDIA Blackwell infrastructure. We present a methodologically rigorous performance analysis with controlled experiments that reveal both the power and limitations of distributed training for language models.

Executive summary

Experimental setup

Model architecture

Dataset details

Prepare code on different servers

Create directories on all servers

Send script to all servers

Add bash script

Benchmark results

Implementation guide

Conclusion

Code Appendix

Complete training script

Bash script

Executive summary

We trained a 27M parameter Transformer model on 4.9 million tokens across three configurations:

Configuration NVIDIA GPUs Nodes Time Speedup Efficiency Throughput Final accuracy
Single GPU 1 1 43.7s 1.0x 100% 112,364 tok/s 24.0%
Multi-GPU 8 1 18.9s 2.31x 28.9% 259,885 tok/s 21.8%
Multi-node 16 2 10.7s 4.08x 25.5% 458,147 tok/s 20.7%

Key findings:

For production workloads: Larger models (100M+ parameters) typically achieve 6-7x speedup on 8 GPUs and 12-14x on 16 GPUs, since computation time dominates communication overhead.

Experimental setup

Hardware configuration:

Controlled variables:

To ensure scientifically valid results, we controlled three key variables:

  1. Fixed token budget

All configurations processed exactly 4,915,200 tokens:

def calculate_training_steps(target_tokens, seq_len, global_batch_size):
    """Calculate steps needed to process target_tokens"""
    tokens_per_step = seq_len * global_batch_size
    steps = target_tokens // tokens_per_step
    return steps
  1. Constant batch per GPU

Every GPU processed 8 samples regardless of configuration:

  1. Learning rate scaling

Learning rate scaled by √(batch_size_ratio) to maintain convergence:

base_lr = 2e-4
batch_scale = global_batch_size / batch_per_gpu  # Number of GPUs
learning_rate = base_lr * jnp.sqrt(batch_scale)

# Results:
# Single GPU:  2.00e-04 (base)
# Multi-GPU:   5.66e-04 (base × √8)
# Multi-Node:  8.00e-04 (base × √16)

This scaling rule ( Goyal et al., 2017) ensures that all configurations converge to similar accuracy despite different batch sizes.

Model architecture

SimpleWordLLM specifications:

Architecture components:

Component Specification Details
Embedding layer Token + positional 512d embeddings for tokens and positions
Transformer blocks 8 layers Each with attention + feed-forward
Attention heads 8 heads per layer Multi-head self-attention (64d per head)
Feed-forward dimension 2,048-dimensional 4x model dimension with ReLU activation
Layer normalization Pre-norm (LayerNorm) Applied before attention and FFN
Output layer Linear projection Projects to vocabulary size

Training configuration:

Dataset details

TinyShakespeare Corpus: https://raw.githubusercontent.com/karpathy/char-rnn/master/data/tinyshakespeare/input.txt

Fallback: Built-in Hamlet soliloquy repeated 50x for offline testing

Dataset statistics:

Preprocessing pipeline

  1. Text normalization:
# Add spaces around punctuation
text = re.sub(r'([.!?,:;()])', r' \1 ', text)
# Normalize whitespace
text = re.sub(r'\s+', ' ', text) spaces
  1. Tokenization strategy:
    • Level: Word-level (not character or subword)
    • Vocabulary building: Frequency-based selection
    • Special tokens: (index 0) for padding
    • OOV handling: Unknown words mapped to
  2. Sequence generation:
    • Method: Sliding window with 25% overlap
    • Window size: 64 words
    • Step size: 16 words (seq_len // 4)
    • Target: Next word prediction (shifted by 1)

Training data splits:

Distributed data handling:

Prepare code on different servers

Create directories on all servers:

ssh mle-team-node-001 "mkdir -p ~/jax_llm_test"
ssh mle-team-node-002 "mkdir -p ~/jax_llm_test"

Send script to all servers:

scp jax_scaling_benchmark.py mle-team-node-001:~/jax_llm_test/
scp jax_scaling_benchmark.py mle-team-node-002:~/jax_llm_test/

Install all necessary packages:

pip install jax[cuda12] flax optax requests numpy

Add bash script:

nano run_jax_benchmark.sh

Key JAX patterns

Pattern 1: Single GPU training

@jit
def single_gpu_train_step(state, batch):
    """Standard JAX training step with JIT compilation"""
    def loss_fn(params):
        metrics = compute_metrics(params, state.apply_fn, batch)
        return metrics['loss']

# Compute loss and gradients
    loss, grads = jax.value_and_grad(loss_fn)(state.params)

# Update parameters
    new_state = state.apply_gradients(grads=grads)
    metrics = compute_metrics(new_state.params, state.apply_fn, batch)

return new_state, metrics

Key JAX features:

Pattern 2: Multi-GPU training (single node)

@partial(pmap, axis_name='devices')
def multi_gpu_train_step(state, batch):
    """Parallel training across multiple GPUs"""
    def loss_fn(params):
        metrics = compute_metrics(params, state.apply_fn, batch)
        return metrics['loss']

loss, grads = jax.value_and_grad(loss_fn)(state.params)

# KEY DIFFERENCE: Synchronize gradients across GPUs
    grads = jax.lax.pmean(grads, axis_name='devices')

new_state = state.apply_gradients(grads=grads)
    metrics = compute_metrics(new_state.params, state.apply_fn, batch)

# Synchronize metrics across GPUs
    metrics = jax.lax.pmean(metrics, axis_name='devices')

return new_state, metrics

Key JAX features:

Pattern 3: Multi-node training (distributed)

# Step 1: Initialize JAX distributed
jax.distributed.initialize(
    coordinator_address=f"{coordinator_ip}:{port}",
    num_processes=world_size,
    process_id=rank,
    initialization_timeout=300
)

# Step 2: Define training step (simpler than pmap!)
def simple_distributed_step(state, batch):
    def loss_fn(params):
        metrics = compute_metrics(params, state.apply_fn, batch)
        return metrics['loss']

# Compute gradients locally
    loss, grads = jax.value_and_grad(loss_fn)(state.params)

# JAX distributed automatically synchronizes gradients
    new_state = state.apply_gradients(grads=grads)

metrics = compute_metrics(new_state.params, state.apply_fn, batch)
    return new_state, metrics

distributed_step_jit = jit(simple_distributed_step)

Key JAX features:

Pattern comparison

Feature Single GPU Multi-GPU (pmap) Multi-node (distributed)
Decorator @jax.jit @partial(pmap, axis_name='devices') @jax.jit
Gradient sync Not needed jax.lax.pmean(grads, 'devices') Automatic
Device management jax.device_put() jax.device_put_replicated() Environment variables
Data shape [batch, seq] [devices, batch_per_device, seq] [local_batch, seq]
Complexity Simple Medium Simple (JAX handles sync)

Benchmark results

Configuration comparison

Mode GPUs Steps Batch Learning rate Time (s) Loss Accuracy
Single GPU 1 9,600 8 2.00e-04 43.7 3.9696 0.240
Multi-GPU 8 1,200 64 5.66e-04 18.9 4.2123 0.218
Multi-server 16 600 128 8.00e-04 10.7 4.3686 0.207

Scaling efficiency analysis

Mode GPUs Speedup Efficiency Throughput (tokens/sec)
Single GPU 1 1.00x 100.0% 112,364
Multi-GPU 8 2.31x 28.9% 259,885
Multi-server 16 4.08x 25.5% 458,147

Why not 8x/16x speedup?

Our efficiency numbers (28.9% for 8 GPUs, 25.5% for 16 GPUs) are honest reflections of three bottlenecks:

1. Model size bottleneck

The 27M parameter model is too small to saturate modern GPUs:

2. Communication overhead

Gradient synchronization dominates for small models:

# Multi-GPU uses NCCL all-reduce
grads = jax.lax.pmean(grads, axis_name='devices')  # ← Time cost increases with GPUs

Breakdown for our 27M model:

3. JIT compilation overhead

JAX's JIT compilation adds fixed startup costs:

Implementation guide

Prerequisites

# Install JAX with CUDA support
pip install --upgrade "jax[cuda12]"

# Install Flax and dependencies
pip install flax optax requests numpy

# Verify installation
python3 -c "import jax; print(f'JAX devices: {jax.devices()}')"

Step 1: Single GPU training

python3 jax_scaling_benchmark.py \
  --mode single \
  --target-tokens 4915200 \
  --seq-len 64 \
  --batch-per-gpu 8 \
  --save-results single_gpu_results.json

Step 2: Multi-GPU training (single node)

python3 jax_scaling_benchmark.py \
  --mode multi-gpu \
  --gpus 8 \
  --target-tokens 4915200 \
  --seq-len 64 \
  --batch-per-gpu 8 \
  --save-results multi_gpu_results.json

Step 3: Multi-node training (distributed)

./run_jax_benchmark.sh

Conclusion

This guide demonstrates:

  1. JAX scales effectively from single GPU to multi-node clusters
  2. Controlled experiments reveal true scaling characteristics
  3. Communication overhead is real but manageable for large models
  4. Three distinct JAX patterns for different scaling scenarios

Key takeaways:

Code appendix

Complete training script:

#!/usr/bin/env python3
"""
JAX LLM Scaling Benchmark - Properly Controlled with Learning Rate Scaling
==========================================================================
Measures pure scaling efficiency by controlling for total tokens processed
with proper learning rate scaling for different batch sizes.

Key principles:
- Same total tokens processed across all configurations
- Learning rate scaled by sqrt(batch_size_ratio)
- Batch size per GPU kept constant (default: 8)
- Steps adjusted inversely to global batch size

Usage:
    python3 jax_scaling_benchmark.py --mode single --target-tokens 4915200
    python3 jax_scaling_benchmark.py --mode multi-gpu --gpus 8 --target-tokens 4915200
    python3 jax_scaling_benchmark.py --mode multi-server --rank 0 --world-size 2 --coordinator-ip 172.26.135.55 --target-tokens 4915200
"""

import os
import sys
import time
import gc
import re
import argparse
import json
from typing import List, Tuple, Dict, Any
from functools import partial

def parse_args():
    parser = argparse.ArgumentParser(description='JAX LLM Scaling Benchmark with Controlled Variables')
    parser.add_argument('--mode', choices=['single', 'multi-gpu', 'multi-server', 'all', 'compare'],
                       default='all', help='Which experiment to run')

# Critical: Control total tokens, not epochs
    parser.add_argument('--target-tokens', type=int, default=4915200,
                       help='Total tokens to process (controls training duration)')
    parser.add_argument('--seq-len', type=int, default=64,
                       help='Sequence length')

# Batch size PER GPU (kept constant for fair comparison)
    parser.add_argument('--batch-per-gpu', type=int, default=8,
                       help='Batch size per GPU (kept constant across configs)')

# Multi-GPU args
    parser.add_argument('--gpus', type=int, default=8, help='Number of GPUs for multi-GPU mode')

# Multi-server args
    parser.add_argument('--rank', type=int, help='Rank for multi-server mode')
    parser.add_argument('--world-size', type=int, default=2, help='World size for multi-server mode')
    parser.add_argument('--coordinator-ip', type=str, help='Coordinator IP for multi-server mode')
    parser.add_argument('--coordinator-port', type=int, default=12345, help='Coordinator port')

# Results management
    parser.add_argument('--save-results', type=str, default='jax_scaling_results.json',
                       help='File to save results')
    parser.add_argument('--load-results', type=str, help='File to load previous results from')

return parser.parse_args()

def calculate_training_steps(target_tokens: int, seq_len: int, global_batch_size: int) -> int:
    """
    Calculate number of training steps needed to process target_tokens.

Key formula: steps = target_tokens / (seq_len * global_batch_size)
    """
    tokens_per_step = seq_len * global_batch_size
    steps = target_tokens // tokens_per_step

print(f"📊 Training budget:")
    print(f"   Target tokens: {target_tokens:,}")
    print(f"   Tokens per step: {tokens_per_step:,} (seq_len={seq_len} × batch={global_batch_size})")
    print(f"   Required steps: {steps:,}")

return steps

def configure_environment(args):
    """Configure JAX environment based on mode"""
    print("🔧 Configuring environment...")

# Basic JAX configuration
    os.environ['XLA_PYTHON_CLIENT_PREALLOCATE'] = 'false'
    os.environ['XLA_PYTHON_CLIENT_MEM_FRACTION'] = '0.8'
    os.environ['XLA_FLAGS'] = '--xla_gpu_enable_triton_gemm=false'

# Multi-server specific configuration
    if args.mode == 'multi-server' and args.rank is not None:
        coordinator_address = f"{args.coordinator_ip}:{args.coordinator_port}"
        os.environ['JAX_COORDINATOR_ADDRESS'] = coordinator_address
        os.environ['JAX_PROCESS_COUNT'] = str(args.world_size)
        os.environ['JAX_PROCESS_INDEX'] = str(args.rank)

# NCCL settings
        os.environ['NCCL_DEBUG'] = 'WARN'
        os.environ['NCCL_TREE_THRESHOLD'] = '0'
        os.environ['NCCL_P2P_DISABLE'] = '1'

print(f"✓ Distributed environment configured for rank {args.rank}")
    else:
        print("✓ Single-node environment configured")

# Parse arguments and configure environment
args = parse_args()
configure_environment(args)

# Now import JAX and dependencies
import jax
import jax.numpy as jnp
from jax import random, jit, grad, pmap
import flax.linen as nn
from flax.training import train_state
import optax
import numpy as np
import requests

# Initialize distributed if needed
if args.mode == 'multi-server' and args.rank is not None:
    print(f"🌐 Initializing JAX distributed for rank {args.rank}...")
    try:
        jax.distributed.initialize(
            coordinator_address=os.environ.get('JAX_COORDINATOR_ADDRESS'),
            num_processes=int(os.environ.get('JAX_PROCESS_COUNT', '1')),
            process_id=int(os.environ.get('JAX_PROCESS_INDEX', '0')),
            initialization_timeout=300
        )
        print(f"✓ JAX distributed initialized for rank {args.rank}")
    except Exception as e:
        print(f"❌ Distributed initialization failed: {e}")
        sys.exit(1)

print(f"📱 Available devices: {len(jax.devices())} ({jax.devices()})")

# =============================================================================
# DATA PROCESSING
# =============================================================================

def download_shakespeare():
    """Download TinyShakespeare dataset with fallback"""
    print("📚 Downloading TinyShakespeare dataset...")
    url = "https://raw.githubusercontent.com/karpathy/char-rnn/master/data/tinyshakespeare/input.txt"

try:
        response = requests.get(url, timeout=30)
        response.raise_for_status()
        text = response.text
        print(f"✓ Downloaded {len(text):,} characters")
        return text
    except Exception as e:
        print(f"⚠️  Download failed, using fallback...")
        fallback = """HAMLET:
To be, or not to be, that is the question:
Whether 'tis nobler in the mind to suffer
The slings and arrows of outrageous fortune,
Or to take arms against a sea of troubles
And by opposing end them. To die: to sleep;
No more; and by a sleep to say we end
The heart-ache and the thousand natural shocks
That flesh is heir to, 'tis a consummation
Devoutly to be wish'd. To die, to sleep;
To sleep: perchance to dream: ay, there's the rub;
For in that sleep of death what dreams may come
When we have shuffled off this mortal coil,
Must give us pause: there's the respect
That makes calamity of so long life.
""" * 50
        print(f"✓ Using fallback text ({len(fallback):,} characters)")
        return fallback

class SimpleWordTokenizer:
    """Word-level tokenizer for Shakespeare text"""

def __init__(self, text: str):
        text = re.sub(r'([.!?,:;()])', r' \1 ', text)
        text = re.sub(r'\s+', ' ', text)
        words = [w for w in text.split() if w.strip()]

word_counts = {}
        for word in words:
            word_counts[word] = word_counts.get(word, 0) + 1

vocab_words = ['<PAD>']
        sorted_words = sorted(word_counts.items(), key=lambda x: x[1], reverse=True)

for word, count in sorted_words[:2000]:
            if word not in vocab_words:
                vocab_words.append(word)

self.vocab_size = len(vocab_words)
        self.word_to_idx = {word: i for i, word in enumerate(vocab_words)}
        self.idx_to_word = {i: word for i, word in enumerate(vocab_words)}
        self.processed_words = words

print(f"📝 Tokenizer: {self.vocab_size} vocab, {len(words):,} words")

def encode(self, text: str) -> List[int]:
        text = re.sub(r'([.!?,:;()])', r' \1 ', text)
        text = re.sub(r'\s+', ' ', text)
        words = [w for w in text.split() if w.strip()]
        return [self.word_to_idx.get(word, 0) for word in words]

def decode(self, tokens: List[int]) -> str:
        words = []
        for tok in tokens:
            if tok == 0:
                continue
            word = self.idx_to_word.get(tok, '')
            if word:
                words.append(word)

text = ' '.join(words)
        text = re.sub(r' ([.!?,:;()])', r'\1', text)
        return text.strip()

def create_training_data(text: str, seq_len: int, batch_size: int, num_steps: int,
                        mode: str = 'single', num_devices: int = 1,
                        rank: int = 0, world_size: int = 1):
    """
    Create training data with controlled number of steps.

Key: We generate exactly num_steps batches to control total tokens processed.
    """
    tokenizer = SimpleWordTokenizer(text)
    tokens = [tokenizer.word_to_idx.get(word, 0) for word in tokenizer.processed_words]

sequences = []
    labels = []

# Generate sequences with sliding window
    step_size = seq_len // 4
    for i in range(0, len(tokens) - seq_len, step_size):
        if i + seq_len + 1 < len(tokens):
            seq = tokens[i:i + seq_len]
            label = tokens[i + 1:i + seq_len + 1]
            sequences.append(seq)
            labels.append(label)

sequences = jnp.array(sequences, dtype=jnp.int32)
    labels = jnp.array(labels, dtype=jnp.int32)

# We'll cycle through data if needed to hit exact num_steps
    total_examples_needed = num_steps * batch_size

if mode == 'single':
        # Replicate data if needed to hit target steps
        if len(sequences) < total_examples_needed:
            repeats = (total_examples_needed // len(sequences)) + 1
            sequences = jnp.tile(sequences, (repeats, 1))[:total_examples_needed]
            labels = jnp.tile(labels, (repeats, 1))[:total_examples_needed]
        else:
            sequences = sequences[:total_examples_needed]
            labels = labels[:total_examples_needed]

sequences = sequences.reshape(num_steps, batch_size, seq_len)
        labels = labels.reshape(num_steps, batch_size, seq_len)
        batches = [(sequences[i], labels[i]) for i in range(num_steps)]

elif mode == 'multi-gpu':
        batch_per_device = batch_size // num_devices

if len(sequences) < total_examples_needed:
            repeats = (total_examples_needed // len(sequences)) + 1
            sequences = jnp.tile(sequences, (repeats, 1))[:total_examples_needed]
            labels = jnp.tile(labels, (repeats, 1))[:total_examples_needed]
        else:
            sequences = sequences[:total_examples_needed]
            labels = labels[:total_examples_needed]

sequences = sequences.reshape(num_steps, num_devices, batch_per_device, seq_len)
        labels = labels.reshape(num_steps, num_devices, batch_per_device, seq_len)
        batches = [(sequences[i], labels[i]) for i in range(num_steps)]

elif mode == 'multi-server':
        # Each rank gets 1/world_size of the data
        local_batch_size = batch_size // world_size
        local_examples_needed = num_steps * local_batch_size

# Partition data by rank
        examples_per_rank = len(sequences) // world_size
        start_idx = rank * examples_per_rank
        end_idx = start_idx + examples_per_rank

rank_sequences = sequences[start_idx:end_idx]
        rank_labels = labels[start_idx:end_idx]

if len(rank_sequences) < local_examples_needed:
            repeats = (local_examples_needed // len(rank_sequences)) + 1
            rank_sequences = jnp.tile(rank_sequences, (repeats, 1))[:local_examples_needed]
            rank_labels = jnp.tile(rank_labels, (repeats, 1))[:local_examples_needed]
        else:
            rank_sequences = rank_sequences[:local_examples_needed]
            rank_labels = rank_labels[:local_examples_needed]

rank_sequences = rank_sequences.reshape(num_steps, local_batch_size, seq_len)
        rank_labels = rank_labels.reshape(num_steps, local_batch_size, seq_len)
        batches = [(rank_sequences[i], rank_labels[i]) for i in range(num_steps)]

print(f"📊 Data created: {len(batches)} batches (exactly {num_steps} steps)")
    print(f"   Total tokens this config will process: {num_steps * batch_size * seq_len:,}")

return batches, tokenizer

# =============================================================================
# MODEL ARCHITECTURE
# =============================================================================

class SimpleAttention(nn.Module):
    d_model: int
    num_heads: int

def setup(self):
        assert self.d_model % self.num_heads == 0
        self.head_dim = self.d_model // self.num_heads
        self.qkv_proj = nn.Dense(3 * self.d_model, use_bias=False)
        self.out_proj = nn.Dense(self.d_model, use_bias=False)

def __call__(self, x, training=False):
        batch_size, seq_len = x.shape[:2]

qkv = self.qkv_proj(x)
        qkv = qkv.reshape(batch_size, seq_len, 3, self.num_heads, self.head_dim)
        qkv = qkv.transpose(2, 0, 3, 1, 4)
        q, k, v = qkv[0], qkv[1], qkv[2]

scale = 1.0 / jnp.sqrt(self.head_dim)
        attn_scores = jnp.einsum('bhqd,bhkd->bhqk', q, k) * scale

mask = jnp.tril(jnp.ones((seq_len, seq_len)))
        attn_scores = jnp.where(mask, attn_scores, -1e9)

attn_weights = jax.nn.softmax(attn_scores, axis=-1)
        attn_out = jnp.einsum('bhqk,bhkd->bhqd', attn_weights, v)

attn_out = attn_out.transpose(0, 2, 1, 3).reshape(batch_size, seq_len, self.d_model)
        return self.out_proj(attn_out)

class SimpleFeedForward(nn.Module):
    d_model: int
    d_ff: int

def setup(self):
        self.w1 = nn.Dense(self.d_ff, use_bias=False)
        self.w2 = nn.Dense(self.d_model, use_bias=False)

def __call__(self, x, training=False):
        return self.w2(jax.nn.relu(self.w1(x)))

class TransformerBlock(nn.Module):
    d_model: int
    num_heads: int
    d_ff: int

def setup(self):
        self.ln1 = nn.LayerNorm()
        self.ln2 = nn.LayerNorm()
        self.attention = SimpleAttention(self.d_model, self.num_heads)
        self.feed_forward = SimpleFeedForward(self.d_model, self.d_ff)

def __call__(self, x, training=False):
        x = x + self.attention(self.ln1(x), training=training)
        x = x + self.feed_forward(self.ln2(x), training=training)
        return x

class SimpleWordLLM(nn.Module):
    vocab_size: int
    d_model: int = 512
    num_heads: int = 8
    num_layers: int = 8
    d_ff: int = 2048
    max_seq_len: int = 64

def setup(self):
        self.token_embedding = nn.Embed(self.vocab_size, self.d_model)
        self.position_embedding = nn.Embed(self.max_seq_len, self.d_model)

self.blocks = [\
            TransformerBlock(\
                d_model=self.d_model,\
                num_heads=self.num_heads,\
                d_ff=self.d_ff\
            ) for _ in range(self.num_layers)\
        ]

self.ln_final = nn.LayerNorm()
        self.output_proj = nn.Dense(self.vocab_size, use_bias=False)

def __call__(self, input_ids, training=False):
        batch_size, seq_len = input_ids.shape

token_emb = self.token_embedding(input_ids)
        pos_ids = jnp.arange(seq_len)[None, :]
        pos_emb = self.position_embedding(pos_ids)

x = token_emb + pos_emb

for block in self.blocks:
            x = block(x, training=training)

x = self.ln_final(x)
        logits = self.output_proj(x)

return logits

# =============================================================================
# TRAINING FUNCTIONS
# =============================================================================

def create_train_state(rng, model, input_shape, learning_rate=2e-4):
    print(f"🔧 Initializing model...")
    print(f"   Learning rate: {learning_rate:.2e}")

dummy_input = jnp.ones(input_shape, dtype=jnp.int32)
    params = model.init(rng, dummy_input, training=False)

param_count = sum(x.size for x in jax.tree_util.tree_leaves(params))
    print(f"✓ Model: {param_count:,} parameters ({param_count/1e6:.1f}M)")

optimizer = optax.chain(
        optax.clip_by_global_norm(1.0),
        optax.adamw(learning_rate, weight_decay=0.01, b1=0.9, b2=0.95)
    )

return train_state.TrainState.create(
        apply_fn=model.apply,
        params=params,
        tx=optimizer
    )

def compute_metrics(params, apply_fn, batch):
    inputs, labels = batch
    logits = apply_fn(params, inputs, training=True)

loss = optax.softmax_cross_entropy_with_integer_labels(
        logits.reshape(-1, logits.shape[-1]),
        labels.reshape(-1)
    )
    mean_loss = jnp.mean(loss)

predictions = jnp.argmax(logits, axis=-1)
    accuracy = jnp.mean(predictions == labels)
    perplexity = jnp.exp(mean_loss)

return {
        'loss': mean_loss,
        'accuracy': accuracy,
        'perplexity': perplexity
    }

@jit
def single_gpu_train_step(state, batch):
    def loss_fn(params):
        metrics = compute_metrics(params, state.apply_fn, batch)
        return metrics['loss']

loss, grads = jax.value_and_grad(loss_fn)(state.params)
    new_state = state.apply_gradients(grads=grads)
    metrics = compute_metrics(new_state.params, state.apply_fn, batch)

return new_state, metrics

@partial(pmap, axis_name='devices')
def multi_gpu_train_step(state, batch):
    def loss_fn(params):
        metrics = compute_metrics(params, state.apply_fn, batch)
        return metrics['loss']

loss, grads = jax.value_and_grad(loss_fn)(state.params)
    grads = jax.lax.pmean(grads, axis_name='devices')

new_state = state.apply_gradients(grads=grads)
    metrics = compute_metrics(new_state.params, state.apply_fn, batch)
    metrics = jax.lax.pmean(metrics, axis_name='devices')

return new_state, metrics

# =============================================================================
# TRAINING MODES WITH CONTROLLED TOKEN BUDGET AND LR SCALING
# =============================================================================

def run_single_gpu_training(text: str, target_tokens: int, seq_len: int, batch_per_gpu: int):
    """Run single GPU training with controlled token budget"""
    print(f"\n{'='*60}")
    print(f"SINGLE GPU TRAINING")
    print(f"{'='*60}")

device = jax.devices("gpu")[0] if jax.devices("gpu") else jax.devices()[0]
    print(f"🖥️  Device: {device}")

# Calculate training parameters
    global_batch_size = batch_per_gpu  # Only 1 GPU
    num_steps = calculate_training_steps(target_tokens, seq_len, global_batch_size)

# Base learning rate for single GPU
    base_lr = 2e-4
    learning_rate = base_lr

print(f"📊 Training configuration:")
    print(f"   Batch per GPU: {batch_per_gpu}")
    print(f"   Global batch size: {global_batch_size}")
    print(f"   Learning rate: {learning_rate:.2e} (base)")

# Create data
    train_batches, tokenizer = create_training_data(
        text, seq_len=seq_len, batch_size=global_batch_size,
        num_steps=num_steps, mode='single'
    )

# Create model
    model = SimpleWordLLM(
        vocab_size=tokenizer.vocab_size,
        d_model=512, num_heads=8, num_layers=8, d_ff=2048, max_seq_len=seq_len
    )

rng = random.PRNGKey(42)
    state = create_train_state(rng, model, (global_batch_size, seq_len), learning_rate=learning_rate)
    state = jax.device_put(state, device)

print(f"\n🚀 Starting training...")
    print(f"   Steps: {num_steps}")
    print(f"   Global batch size: {global_batch_size}")
    print(f"   Target tokens: {target_tokens:,}")

start_time = time.time()

# Training loop
    step_losses = []
    step_accuracies = []

for step in range(num_steps):
        batch = train_batches[step]
        batch = jax.device_put(batch, device)

state, metrics = single_gpu_train_step(state, batch)

step_losses.append(float(metrics['loss']))
        step_accuracies.append(float(metrics['accuracy']))

# Progress logging every 10% of steps
        if (step + 1) % max(1, num_steps // 10) == 0:
            avg_loss = sum(step_losses) / len(step_losses)
            avg_acc = sum(step_accuracies) / len(step_accuracies)
            elapsed = time.time() - start_time
            tokens_processed = (step + 1) * global_batch_size * seq_len
            tokens_per_sec = tokens_processed / elapsed if elapsed > 0 else 0
            print(f"   Step {step+1:4d}/{num_steps}: Loss={avg_loss:.4f}, Acc={avg_acc:.3f}, "
                  f"Tokens/sec={tokens_per_sec:,.0f}")

total_time = time.time() - start_time
    final_loss = sum(step_losses[-100:]) / min(100, len(step_losses))
    final_accuracy = sum(step_accuracies[-100:]) / min(100, len(step_accuracies))

tokens_per_sec = target_tokens / total_time

print(f"\n✅ Single GPU training complete!")
    print(f"   Total time: {total_time:.1f}s")
    print(f"   Final loss: {final_loss:.4f}")
    print(f"   Final accuracy: {final_accuracy:.3f}")
    print(f"   Throughput: {tokens_per_sec:,.0f} tokens/sec")

results = {
        'mode': 'single-gpu',
        'training_time': total_time,
        'final_loss': final_loss,
        'final_accuracy': final_accuracy,
        'gpus': 1,
        'global_batch_size': global_batch_size,
        'batch_per_gpu': batch_per_gpu,
        'steps': num_steps,
        'target_tokens': target_tokens,
        'tokens_per_sec': tokens_per_sec,
        'learning_rate': learning_rate,
        'speedup': 1.0,
        'efficiency': 1.0
    }

return results

def run_multi_gpu_training(text: str, num_gpus: int, target_tokens: int, seq_len: int, batch_per_gpu: int):
    """Run multi-GPU training with controlled token budget and LR scaling"""
    print(f"\n{'='*60}")
    print(f"MULTI-GPU TRAINING ({num_gpus} GPUs)")
    print(f"{'='*60}")

devices = jax.devices("gpu")[:num_gpus]
    print(f"🖥️  Devices: {devices}")

# Calculate training parameters
    global_batch_size = batch_per_gpu * num_gpus
    num_steps = calculate_training_steps(target_tokens, seq_len, global_batch_size)

# Scale learning rate with square root of batch size ratio
    base_lr = 2e-4
    batch_scale = global_batch_size / batch_per_gpu  # This is num_gpus
    learning_rate = base_lr * jnp.sqrt(batch_scale)

print(f"📊 Training configuration:")
    print(f"   Batch per GPU: {batch_per_gpu}")
    print(f"   Global batch size: {global_batch_size}")
    print(f"   Learning rate: {learning_rate:.2e} (base={base_lr:.2e} × sqrt({batch_scale:.1f}))")

# Create data
    train_batches, tokenizer = create_training_data(
        text, seq_len=seq_len, batch_size=global_batch_size,
        num_steps=num_steps, mode='multi-gpu', num_devices=num_gpus
    )

# Create model
    model = SimpleWordLLM(
        vocab_size=tokenizer.vocab_size,
        d_model=512, num_heads=8, num_layers=8, d_ff=2048, max_seq_len=seq_len
    )

rng = random.PRNGKey(42)
    state = create_train_state(rng, model, (batch_per_gpu, seq_len), learning_rate=float(learning_rate))
    state = jax.device_put_replicated(state, devices)

print(f"\n🚀 Starting training...")
    print(f"   Steps: {num_steps}")
    print(f"   Global batch size: {global_batch_size}")
    print(f"   Batch per GPU: {batch_per_gpu}")
    print(f"   Target tokens: {target_tokens:,}")

start_time = time.time()

# Training loop
    step_losses = []
    step_accuracies = []

for step in range(num_steps):
        batch = train_batches[step]

state, metrics = multi_gpu_train_step(state, batch)

step_losses.append(float(metrics['loss'][0]))
        step_accuracies.append(float(metrics['accuracy'][0]))

tokens_per_sec = target_tokens / total_time

print(f"\n✅ Multi-GPU training complete!")
    print(f"   Total time: {total_time:.1f}s")
    print(f"   Final loss: {final_loss:.4f}")
    print(f"   Final accuracy: {final_accuracy:.3f}")
    print(f"   Throughput: {tokens_per_sec:,.0f} tokens/sec")

results = {
        'mode': 'multi-gpu',
        'training_time': total_time,
        'final_loss': final_loss,
        'final_accuracy': final_accuracy,
        'gpus': num_gpus,
        'global_batch_size': global_batch_size,
        'batch_per_gpu': batch_per_gpu,
        'steps': num_steps,
        'target_tokens': target_tokens,
        'tokens_per_sec': tokens_per_sec,
        'learning_rate': float(learning_rate)
    }

return results

def run_multi_server_training(text: str, rank: int, world_size: int, target_tokens: int,
                              seq_len: int, batch_per_gpu: int):
    """Run multi-server distributed training with controlled token budget and LR scaling"""
    print(f"\n{'='*60}")
    print(f"MULTI-SERVER TRAINING (Rank {rank}/{world_size})")
    print(f"{'='*60}")

local_devices = jax.local_devices()
    global_devices = jax.devices()
    total_gpus = len(global_devices)

if rank == 0:
        print(f"🌐 Global cluster: {total_gpus} GPUs across {world_size} servers")

print(f"🖥️  [Rank {rank}] Local devices: {len(local_devices)}")

primary_device = local_devices[0]

# Calculate training parameters
    global_batch_size = batch_per_gpu * total_gpus
    num_steps = calculate_training_steps(target_tokens, seq_len, global_batch_size)

# Scale learning rate with square root of batch size ratio
    base_lr = 2e-4
    batch_scale = global_batch_size / batch_per_gpu  # This is total_gpus
    learning_rate = base_lr * jnp.sqrt(batch_scale)

if rank == 0:
        print(f"📊 Training configuration:")
        print(f"   Batch per GPU: {batch_per_gpu}")
        print(f"   Global batch size: {global_batch_size}")
        print(f"   Learning rate: {learning_rate:.2e} (base={base_lr:.2e} × sqrt({batch_scale:.1f}))")

# Create distributed training data
    train_batches, tokenizer = create_training_data(
        text, seq_len=seq_len, batch_size=global_batch_size,
        num_steps=num_steps, mode='multi-server',
        rank=rank, world_size=world_size
    )

# Create model (same architecture on all servers)
    model = SimpleWordLLM(
        vocab_size=tokenizer.vocab_size,
        d_model=512, num_heads=8, num_layers=8, d_ff=2048, max_seq_len=seq_len
    )

# Initialize with same random seed for consistency
    rng = random.PRNGKey(42)
    local_batch_size = global_batch_size // world_size
    state = create_train_state(rng, model, (local_batch_size, seq_len), learning_rate=float(learning_rate))
    state = jax.device_put(state, primary_device)

if rank == 0:
        print(f"\n🚀 Starting training...")
        print(f"   Steps: {num_steps}")
        print(f"   Global batch size: {global_batch_size}")
        print(f"   Batch per GPU: {batch_per_gpu}")
        print(f"   Local batch size per server: {local_batch_size}")
        print(f"   Target tokens: {target_tokens:,}")

# Distributed training function
    def simple_distributed_step(state, batch):
        def loss_fn(params):
            metrics = compute_metrics(params, state.apply_fn, batch)
            return metrics['loss']

loss, grads = jax.value_and_grad(loss_fn)(state.params)
        new_state = state.apply_gradients(grads=grads)
        metrics = compute_metrics(new_state.params, state.apply_fn, batch)
        return new_state, metrics

distributed_step_jit = jit(simple_distributed_step)

start_time = time.time()

# Training loop
    step_losses = []
    step_accuracies = []

for step in range(num_steps):
        batch = train_batches[step]
        batch = jax.device_put(batch, primary_device)

state, metrics = distributed_step_jit(state, batch)

step_losses.append(float(metrics['loss']))
        step_accuracies.append(float(metrics['accuracy']))

# Progress logging (only rank 0, every 10% of steps)
        if rank == 0 and (step + 1) % max(1, num_steps // 10) == 0:
            avg_loss = sum(step_losses) / len(step_losses)
            avg_acc = sum(step_accuracies) / len(step_accuracies)
            elapsed = time.time() - start_time
            tokens_processed = (step + 1) * global_batch_size * seq_len
            tokens_per_sec = tokens_processed / elapsed if elapsed > 0 else 0
            print(f"   Step {step+1:4d}/{num_steps}: Loss={avg_loss:.4f}, Acc={avg_acc:.3f}, "
                  f"Tokens/sec={tokens_per_sec:,.0f}")

tokens_per_sec = target_tokens / total_time

if rank == 0:
        print(f"\n✅ Multi-server training complete!")
        print(f"   Total time: {total_time:.1f}s")
        print(f"   Final loss: {final_loss:.4f}")
        print(f"   Final accuracy: {final_accuracy:.3f}")
        print(f"   Throughput: {tokens_per_sec:,.0f} tokens/sec")

results = {
        'mode': 'multi-server',
        'training_time': total_time,
        'final_loss': final_loss,
        'final_accuracy': final_accuracy,
        'gpus': total_gpus,
        'servers': world_size,
        'global_batch_size': global_batch_size,
        'batch_per_gpu': batch_per_gpu,
        'local_batch_size': local_batch_size,
        'steps': num_steps,
        'target_tokens': target_tokens,
        'tokens_per_sec': tokens_per_sec,
        'learning_rate': float(learning_rate)
    }

return results

# =============================================================================
# RESULTS MANAGEMENT
# =============================================================================

def save_results(results_dict: Dict[str, Any], filename: str):
    """Save results to JSON file"""
    print(f"\n💾 Saving results to {filename}")

def convert_types(obj):
        if isinstance(obj, (np.integer, jnp.integer)):
            return int(obj)
        elif isinstance(obj, (np.floating, jnp.floating)):
            return float(obj)
        elif isinstance(obj, (np.ndarray, jnp.ndarray)):
            return obj.tolist()
        elif isinstance(obj, dict):
            return {k: convert_types(v) for k, v in obj.items()}
        elif isinstance(obj, list):
            return [convert_types(item) for item in obj]
        else:
            return obj

serializable_results = convert_types(results_dict)

with open(filename, 'w') as f:
        json.dump(serializable_results, f, indent=2)

print(f"✓ Results saved")

def load_results(filename: str) -> Dict[str, Any]:
    """Load results from JSON file"""
    print(f"📂 Loading results from {filename}")

try:
        with open(filename, 'r') as f:
            results = json.load(f)
        print(f"✓ Results loaded")
        return results
    except FileNotFoundError:
        print(f"⚠️  File {filename} not found")
        return {}
    except Exception as e:
        print(f"❌ Error loading results: {e}")
        return {}

def print_scaling_comparison(all_results: Dict[str, Dict[str, Any]]):
    """Print scaling analysis with proper metrics"""
    print("\n" + "="*80)
    print("SCALING BENCHMARK RESULTS")
    print("="*80)

modes = ['single-gpu', 'multi-gpu', 'multi-server']

# Get baseline (single GPU)
    baseline = all_results.get('single-gpu', {})
    baseline_time = baseline.get('training_time', 1.0)
    baseline_tokens_per_sec = baseline.get('tokens_per_sec', 1.0)

print(f"\n📊 Configuration Comparison:")
    print("-" * 80)
    print(f"{'Mode':<15} {'GPUs':<6} {'Steps':<7} {'Batch':<8} {'LR':<10} {'Time(s)':<10} "
          f"{'Loss':<8} {'Accuracy':<10}")
    print("-" * 80)

for mode in modes:
        if mode in all_results:
            r = all_results[mode]
            mode_display = mode.replace('-', ' ').title()
            lr = r.get('learning_rate', 0)
            print(f"{mode_display:<15} {r.get('gpus', 1):<6} {r.get('steps', 0):<7} "
                  f"{r.get('global_batch_size', 0):<8} {lr:<10.2e} {r.get('training_time', 0):<10.1f} "
                  f"{r.get('final_loss', 0):<8.4f} {r.get('final_accuracy', 0):<10.3f}")

print(f"\n⚡ Scaling Efficiency Analysis:")
    print("-" * 80)
    print(f"{'Mode':<15} {'GPUs':<6} {'Speedup':<10} {'Efficiency':<12} {'Tokens/sec':<15}")
    print("-" * 80)

for mode in modes:
        if mode in all_results:
            r = all_results[mode]
            mode_display = mode.replace('-', ' ').title()

speedup = baseline_time / r.get('training_time', 1.0)
            efficiency = (speedup / r.get('gpus', 1)) * 100
            tokens_per_sec = r.get('tokens_per_sec', 0)

# Store computed metrics
            all_results[mode]['speedup'] = speedup
            all_results[mode]['efficiency'] = efficiency

print(f"{mode_display:<15} {r.get('gpus', 1):<6} {speedup:<10.2f}x "
                  f"{efficiency:<12.1f}% {tokens_per_sec:<15,.0f}")

print(f"\n✅ Validation Checks:")
    print("-" * 50)

# Check if accuracies are within acceptable range
    accuracies = [all_results[mode]['final_accuracy'] for mode in modes if mode in all_results]
    if accuracies:
        acc_min, acc_max = min(accuracies), max(accuracies)
        acc_range = acc_max - acc_min

if acc_range < 0.05:  # Within 5% is good
            print(f"✓ Accuracy consistency: {acc_min:.3f} - {acc_max:.3f} (range: {acc_range:.3f})")
            print(f"  All configurations converged to similar accuracy ✓")
        else:
            print(f"⚠️  Accuracy variance: {acc_min:.3f} - {acc_max:.3f} (range: {acc_range:.3f})")
            print(f"  Warning: Large accuracy differences may indicate issues")

# Check tokens processed
    target_tokens = baseline.get('target_tokens', 0)
    print(f"✓ All configurations processed {target_tokens:,} tokens")

# Check learning rate scaling
    print(f"✓ Learning rates scaled properly with batch size (sqrt scaling rule)")

print(f"\n💡 Key Insights:")
    print("-" * 50)

if 'multi-gpu' in all_results:
        mg = all_results['multi-gpu']
        print(f"• Multi-GPU ({mg['gpus']} GPUs): {mg.get('speedup', 0):.2f}x speedup, "
              f"{mg.get('efficiency', 0):.1f}% efficiency")
        if mg.get('efficiency', 0) > 85:
            print(f"  → Excellent single-node scaling!")
        elif mg.get('efficiency', 0) > 70:
            print(f"  → Good single-node scaling")
        else:
            print(f"  → Limited by model size (27M params too small for {mg['gpus']} GPUs)")
            print(f"  → Communication overhead exceeds compute benefit")

if 'multi-server' in all_results:
        ms = all_results['multi-server']
        print(f"• Multi-Server ({ms['gpus']} GPUs, {ms['servers']} nodes): "
              f"{ms.get('speedup', 0):.2f}x speedup, {ms.get('efficiency', 0):.1f}% efficiency")
        if ms.get('efficiency', 0) > 70:
            print(f"  → Excellent multi-node scaling!")
        elif ms.get('efficiency', 0) > 50:
            print(f"  → Good multi-node scaling")
        else:
            print(f"  → Inter-node communication overhead and small model size limit scaling")
            print(f"  → Note: 27M parameter model is too small to fully utilize {ms['gpus']} GPUs")

# =============================================================================
# MAIN EXECUTION
# =============================================================================

def main():
    print("="*80)
    print("JAX LLM SCALING BENCHMARK - Properly Controlled with LR Scaling")
    print("="*80)
    print(f"Mode: {args.mode}")
    print(f"Target tokens: {args.target_tokens:,}")
    print(f"Sequence length: {args.seq_len}")
    print(f"Batch per GPU: {args.batch_per_gpu}")

all_results = {}
    if args.load_results:
        all_results = load_results(args.load_results)

text = download_shakespeare()

try:
        if args.mode == 'single' or args.mode == 'all':
            print("\n" + "▶"*30)
            print("Running Single GPU Benchmark...")
            print("▶"*30)
            results = run_single_gpu_training(
                text, target_tokens=args.target_tokens,
                seq_len=args.seq_len, batch_per_gpu=args.batch_per_gpu
            )
            all_results['single-gpu'] = results

if args.mode == 'multi-gpu' or args.mode == 'all':
            print("\n" + "▶"*30)
            print(f"Running Multi-GPU Benchmark ({args.gpus} GPUs)...")
            print("▶"*30)
            results = run_multi_gpu_training(
                text, num_gpus=args.gpus, target_tokens=args.target_tokens,
                seq_len=args.seq_len, batch_per_gpu=args.batch_per_gpu
            )
            all_results['multi-gpu'] = results

if args.mode == 'multi-server':
            if args.rank is not None and args.world_size is not None:
                print("\n" + "▶"*30)
                print(f"Running Multi-Server Benchmark (Rank {args.rank})...")
                print("▶"*30)
                results = run_multi_server_training(
                    text, rank=args.rank, world_size=args.world_size,
                    target_tokens=args.target_tokens, seq_len=args.seq_len,
                    batch_per_gpu=args.batch_per_gpu
                )

if args.rank == 0:
                    all_results['multi-server'] = results
            else:
                print("❌ Multi-server mode requires --rank, --world-size, and --coordinator-ip")
                return

# Save results
        if args.mode != 'compare' and all_results:
            if args.mode == 'multi-server':
                if args.rank == 0:
                    save_results(all_results, args.save_results)
            else:
                save_results(all_results, args.save_results)

# Print comprehensive comparison
        if args.mode == 'all' or args.mode == 'compare':
            if len(all_results) >= 2:
                print_scaling_comparison(all_results)
            else:
                print("\n⚠️  Need at least 2 experiments to compare")
                print("   Run with --mode all or load previous results with --load-results")

elif args.mode in ['single', 'multi-gpu', 'multi-server']:
            mode_key = {
                'single': 'single-gpu',
                'multi-gpu': 'multi-gpu',
                'multi-server': 'multi-server'
            }[args.mode]

if mode_key in all_results:
                r = all_results[mode_key]
                print(f"\n{'='*60}")
                print(f"{args.mode.upper()} TRAINING SUMMARY")
                print(f"{'='*60}")
                print(f"Training time: {r['training_time']:.1f}s")
                print(f"Final loss: {r['final_loss']:.4f}")
                print(f"Final accuracy: {r['final_accuracy']:.3f}")
                print(f"GPUs used: {r['gpus']}")
                print(f"Learning rate: {r.get('learning_rate', 0):.2e}")
                print(f"Throughput: {r['tokens_per_sec']:,.0f} tokens/sec")

print(f"\n{'='*80}")
        print("✅ Benchmark completed successfully!")
        print(f"{'='*80}")

except Exception as e:
        print(f"\n❌ Benchmark failed: {e}")
        import traceback
        traceback.print_exc()
        sys.exit(1)

if __name__ == "__main__":
    main()

Bash Script

#!/bin/bash
# run_jax_benchmark.sh
# JAX Scaling Benchmark for 2-node B200 cluster

# Node configuration
NODE_1="localhost"
NODE_2="172.26.132.120"
COORDINATOR_IP="172.26.135.55"  # Use hostname instead of IP
SSH_CONFIG=""

# Benchmark parameters
TARGET_TOKENS=4915200
SEQ_LEN=64
BATCH_PER_GPU=8
SCRIPT_NAME="jax_scaling_benchmark.py"

echo "======================================"
echo "JAX Scaling Benchmark Suite"
echo "======================================"
echo "Cluster: 2x nodes with 8x B200 GPUs each (16 GPUs total)"
echo "Target tokens: $TARGET_TOKENS"
echo "Sequence length: $SEQ_LEN"
echo "Batch per GPU: $BATCH_PER_GPU"
echo ""

# Test connectivity
echo "🔍 Testing node connectivity..."
if ssh $SSH_CONFIG -o ConnectTimeout=5 $NODE_2 "echo 'OK'" >/dev/null 2>&1; then
    echo "✓ $NODE_2 reachable"
else
    echo "✗ $NODE_2 unreachable - multi-server benchmark will fail"
    echo "  Make sure you can: ssh $SSH_CONFIG $NODE_2"
fi
echo ""

# 1. Single GPU benchmark
echo "1️⃣  Single GPU Benchmark (1 GPU)"
echo "========================================"
python3 $SCRIPT_NAME \
  --mode single \
  --target-tokens $TARGET_TOKENS \
  --seq-len $SEQ_LEN \
  --batch-per-gpu $BATCH_PER_GPU \
  --save-results single_gpu_results.json

if [ $? -eq 0 ]; then
    echo "✅ Single GPU complete"
else
    echo "❌ Single GPU failed"
    exit 1
fi

echo ""
echo "2️⃣  Multi-GPU Benchmark (8 GPUs on 1 node)"
echo "========================================"
python3 $SCRIPT_NAME \
  --mode multi-gpu \
  --gpus 8 \
  --target-tokens $TARGET_TOKENS \
  --seq-len $SEQ_LEN \
  --batch-per-gpu $BATCH_PER_GPU \
  --save-results multi_gpu_results.json

if [ $? -eq 0 ]; then
    echo "✅ Multi-GPU complete"
else
    echo "❌ Multi-GPU failed"
    exit 1
fi

echo ""
echo "3️⃣  Multi-Node Benchmark (16 GPUs across 2 nodes)"
echo "========================================"
echo "Starting distributed training across 2 nodes..."

# Deploy script to node 2
echo "📦 Deploying script to $NODE_2..."
scp $SSH_CONFIG $SCRIPT_NAME $NODE_2:~/jax_blog/ 2>/dev/null
if [ $? -eq 0 ]; then
    echo "✓ Script deployed"
else
    echo "⚠️  Failed to deploy script - trying to continue anyway"
fi

# Start rank 1 (worker) on node 2 first
echo "🚀 Starting worker on $NODE_2 (rank 1)..."
ssh $SSH_CONFIG $NODE_2 "cd ~/jax_blog && source ~/jax_venv/bin/activate && python3 $SCRIPT_NAME \
  --mode multi-server \
  --rank 1 \
  --world-size 2 \
  --coordinator-ip $COORDINATOR_IP \
  --target-tokens $TARGET_TOKENS \
  --seq-len $SEQ_LEN \
  --batch-per-gpu $BATCH_PER_GPU" > worker_rank1.log 2>&1 &
WORKER_PID=$!

# Give worker time to initialize
sleep 10

# Start rank 0 (coordinator) locally
echo "🚀 Starting coordinator on $NODE_1 (rank 0)..."
python3 $SCRIPT_NAME \
  --mode multi-server \
  --rank 0 \
  --world-size 2 \
  --coordinator-ip $COORDINATOR_IP \
  --target-tokens $TARGET_TOKENS \
  --seq-len $SEQ_LEN \
  --batch-per-gpu $BATCH_PER_GPU \
  --save-results multi_server_results.json

COORDINATOR_EXIT=$?

# Wait for worker to finish
wait $WORKER_PID
WORKER_EXIT=$?

if [ $COORDINATOR_EXIT -eq 0 ] && [ $WORKER_EXIT -eq 0 ]; then
    echo "✅ Multi-server complete"
else
    echo "❌ Multi-server failed (coordinator: $COORDINATOR_EXIT, worker: $WORKER_EXIT)"
    echo "Worker logs:"
    cat worker_rank1.log
fi

echo ""
echo "4️⃣  Generating Comparison Report"
echo "========================================"

# Merge all results
python3 << 'EOF'
import json
import sys

results = {}

# Load all results
for filename in ['single_gpu_results.json', 'multi_gpu_results.json', 'multi_server_results.json']:
    try:
        with open(filename, 'r') as f:
            results.update(json.load(f))
        print(f"✓ Loaded {filename}")
    except FileNotFoundError:
        print(f"⚠️  {filename} not found")
    except Exception as e:
        print(f"❌ Error loading {filename}: {e}")

if results:
    with open('jax_scaling_results.json', 'w') as f:
        json.dump(results, f, indent=2)
    print("✓ Results merged to jax_scaling_results.json")
else:
    print("❌ No results to merge")
    sys.exit(1)
EOF

# Generate comparison report
if [ -f "jax_scaling_results.json" ]; then
    python3 $SCRIPT_NAME --mode compare --load-results jax_scaling_results.json
fi

echo ""
echo "======================================"
echo "✅ Benchmark Suite Complete!"
echo "======================================"
echo "Results saved in:"
echo "  - single_gpu_results.json"
echo "  - multi_gpu_results.json"
echo "  - multi_server_results.json"
echo "  - jax_scaling_results.json (merged)"
echo ""

Twitter Widget Iframe