LeetLLM
My PlanLearnGlossaryTracksPracticeBlog
LeetLLM

Your go-to resource for mastering AI & LLM systems.

Product

  • Learn
  • Glossary
  • Tracks
  • Practice
  • Blog
  • RSS

Legal

  • Terms of Service
  • Privacy Policy

© 2026 LeetLLM. All rights reserved.

All Topics
Your Progress
0%

0 of 196 articles completed

🛠️Computing Foundations0/9
Git, Shell, Linux for AIDocker for Reproducible AIPython for AI EngineeringNumPy and Tensor ShapesCUDA for ML TrainingMPS & Metal for ML on MacData Structures for AISQL and Data ModelingAlgorithms for ML Engineers
📊Math & Statistics0/9
Gradients and BackpropVectors, Matrices & TensorsLinear Algebra for MLAdam, Momentum, SchedulersProbability for Machine LearningBayesian Inference from EvidenceStatistics and UncertaintyDistributions and SamplingHypothesis Tests, Intervals, and pass@k
📚Preparation & Prerequisites0/13
Neural Networks from ScratchCNNs from ScratchTraining & BackpropagationSoftmax, Cross-Entropy & OptimizationRNNs, LSTMs, GRUs, and Sequence ModelingAutoencoders and VAEsThe Transformer Architecture End-to-EndLanguage Modeling & Next TokensFrom GPT to Modern LLMsPrompt Engineering FundamentalsCalling LLM APIs in ProductionFirst AI App End-to-EndThe LLM Lifecycle
🧮ML Algorithms & Evaluation0/15
Linear Regression from ScratchLogistic Regression and MetricsDecision Trees, Forests, and BoostingReinforcement Learning BasicsValidation and LeakageStatistical Learning and GeneralizationConformal Prediction and UncertaintyClustering and PCAProbabilistic Generative ModelsCore Retrieval AlgorithmsDecoding AlgorithmsExperiment Design and A/B TestingCausal Inference and CounterfactualsPyTorch Training LoopsDataset Pipelines and Data Quality
📦Production ML Systems0/6
Feature Engineering for Production MLBatch and Streaming Feature PipelinesGradient Boosted Trees in ProductionRanking and Recommendation SystemsForecasting and Anomaly DetectionMonitoring Predictive Models
🧪Core LLM Foundations0/8
The Bitter Lesson & ComputeBPE, WordPiece, and SentencePieceStatic to Contextual EmbeddingsPerplexity & Model EvaluationFile Ingestion for AIChunking StrategiesLLM Benchmarks & LimitationsInstruction Tuning & Chat Templates
🧰Applied LLM Engineering0/25
Dimensionality Reduction for EmbeddingsCoT, ToT & Self-Consistency PromptingFunction Calling & Tool UseMCP & Tool Protocol StandardsContext EngineeringPrompt Injection DefenseResponsible AI GovernancePrivacy-Preserving Machine LearningData Labeling and Human FeedbackEvaluating AI AgentsProduction RAG PipelinesHybrid Search: Dense + SparseReranking and Cross-Encoders for RAGRAG Evaluation for Reliable AnswersLLM-as-a-Judge EvaluationBias & Fairness in LLMsHallucination Detection & MitigationLLM Observability & MonitoringExperiment Tracking with MLflow and W&BPrompt Optimization with DSPyModel Versioning & DeploymentSemantic Caching & Cost OptimizationLLM Cost Engineering & Token EconomicsModel Gateways, Routing, and FallbacksDesign an Automated Support Agent
🎓Portfolio Capstones0/9
Capstone: Delivery ETA PredictionCapstone: Product RankingCapstone: Demand ForecastingCapstone: Image Damage ClassifierCapstone: Production ML PipelineCapstone: Document QACapstone: Eval DashboardCapstone: Fine-Tuned ClassifierCapstone: Reproducible ML Study
🧠Transformer Deep Dives0/8
Sentence Embeddings & Contrastive LossEmbedding Similarity & QuantizationScaled Dot-Product AttentionVision Transformers and Image EncodersPositional Encoding: RoPE & ALiBiLayer Normalization: Pre-LN vs Post-LNMechanistic InterpretabilityDecoding Strategies: Greedy to Nucleus
🧬Advanced Training & Adaptation0/17
Scaling Laws & Compute-Optimal TrainingPre-training Data at ScaleBuild GPT from Scratch LabJAX for PyTorch ResearchersContinued Pretraining for Domain ShiftSynthetic Data PipelinesSupervised Fine-Tuning PipelineMixed Precision TrainingDistributed Training: FSDP & ZeROLoRA & Parameter-Efficient TuningTraining Run OperationsReward Modeling from Preference DataRLHF & DPO AlignmentConstitutional AI & Red TeamingRLVR & Verifiable RewardsKnowledge Distillation for LLMsModel Merging and Weight Interpolation
🤖Advanced Agents & Retrieval0/16
Vector DB Internals: HNSW & IVFAdvanced RAG: HyDE & Self-RAGGraphRAG & Knowledge GraphsRAG Security & Access ControlStructured Output GenerationReAct & Plan-and-ExecuteGuardrails & Safety FiltersCode Generation & SandboxingComputer-Use / GUI / Browser AgentsHuman-in-the-Loop Agent ArchitectureAI Coding Workflow with AgentsAgent Memory & PersistenceAgent Failure & RecoveryRecursive Language Models (RLM)Multi-Agent OrchestrationCapstone: Production Agent
⚡Inference & Production Scale0/31
Inference: TTFT, TPS & KV CacheMulti-Query & Grouped-Query AttentionKV Cache & PagedAttentionPrefix Caching and Prompt CachingFlashAttention & Memory EfficiencyContinuous Batching & SchedulingScaling LLM InferenceModel Parallelism for LLM InferenceAccelerator Architecture Field GuideCorrect GPU Profiling and BenchmarkingGPU Kernel Performance Engineering LabGPU Parallel Primitives LabGPU Compilers and Kernel LanguagesGEMM From Naive CUDA to Tensor CoresLow-Precision GPU ProgrammingGPU Runtime Execution LabAI-Generated Kernel EngineeringModel Quantization: GPTQ, AWQ & GGUFLocal LLM DeploymentSLM Specialization & Edge DeploymentSpeculative DecodingLong Context Window ManagementMixture of Experts ArchitectureMamba & State Space ModelsReasoning & Test-Time ComputeAdvanced MLOps & DevOps for AIGPU Serving & AutoscalingGPU Failure OperationsDistributed Inference Data PlaneServing Benchmark EngineeringA/B Testing for LLMs
🏗️System Design Capstones0/9
Content Moderation SystemCode Completion SystemMulti-Tenant LLM PlatformLLM-Powered Search EngineVision-Language Models & CLIPMultimodal LLM ArchitectureDiffusion Models: Images & TextReal-Time Voice AI AgentReasoning Agent System Design
🎤AI Lab Interviewing0/4
AI Lab Coding Interview: Python SystemsAI Lab System Design InterviewAI Lab Behavioral InterviewAI Lab Technical Presentation
🔬Project Deep Dives0/17
Deep Dive - vLLMDeep Dive - SkyRLDeep Dive - FlashAttentionDeep Dive - FlashInferDeep Dive - DeepGEMMDeep Dive - NCCLDeep Dive - MegatronDeep Dive - DeepSpeedDeep Dive - RayDeep Dive - MLflowDeep Dive - PyTorchDeep Dive - TransformersDeep Dive - SGLangDeep Dive - slimeDeep Dive - DeepEPDeep Dive - TinkerDeep Dive - Light-PEFT
Back to Topics
LearnAdvanced Training & AdaptationJAX for PyTorch Researchers
⚡HardFine-Tuning & Training

JAX for PyTorch Researchers

Read and modify JAX research code after the PyTorch GPT lab by making state, randomness, transformations, compilation, and timing explicit.

40 min read
Learning path
Step 106 of 196 in the full curriculum
Build GPT from Scratch LabContinued Pretraining for Domain Shift

Personalize this lesson

Adapt explanations and teaching visuals to your background and preferred voice.

You change a loss term in a paper's JAX repository. The numbers look right, but a short final batch triggers another compilation and dropout repeats the same mask. Those failures can come from shapes and random-key ownership rather than the model's mathematics.

You already know tensors, gradients, and training loops from PyTorch. We'll keep a four-row linear-regression example small enough to check by hand while making parameters, updates, and randomness explicit. The same checks help you modify a larger research implementation without replacing its framework.

Read the program before choosing a rewrite

JAX is an array-computing and program-transformation library. jax.numpy supplies a NumPy-shaped interface, while transformations such as grad, vmap, and jit derive new functions from functions you wrote.[1]Reference 1Quickstart: How to think in JAXhttps://docs.jax.dev/en/latest/quickstart.html The central unit isn't a model object. It's a numerical function with an explicit input and output contract.

Start by tracing one call, not by translating a whole framework. Find the batch entering the loss, the values returned by one step, and the point where a transformed function crosses the Python boundary. Once you can name those edges, later choices about vmap, jit, or sharding have somewhere concrete to attach.

PyTorch researchers often meet JAX through a larger stack such as Flax, Equinox, Optax, or a lab-specific framework. Those libraries may package state differently, but core JAX still determines how arrays are traced, differentiated, vectorized, compiled, and dispatched. Read through library wrappers until you find the pure numerical boundary.

While tracing that call, identify changing state, shape/static arguments, random-key ownership, and transformed functions. For our example, the model predicts weight[0] * x[0] + weight[1] * x[1] + bias. Its four rows are:

FeaturesTargetPrediction with zero parametersSquared error
[1, 0]204
[0, 1]-101
[1, 1]101
[2, 1]309

The initial mean squared error is (4 + 1 + 1 + 9) / 4 = 3.75. We know an exact solution too: weights [2, -1] and bias 0. Those numbers give us an independent check before compilation enters the story.

That inventory often reveals a narrow edit. Put a new regularizer in the loss, change sampling where a one-use key enters, or derive a per-example statistic with vmap; leave the surrounding research harness alone until evidence says it's the problem.

Neither framework has only one programming style. The table is a reading lens, not a law.

PyTorch habitJAX reading lensQuestion to ask
nn.Module owns parametersparameters often arrive as a pytreewhere is the new parameter tree returned?
loss.backward() fills .gradgrad returns a gradient treewhich argument is differentiated?
optimizer.step() updates stateupdate function returns statewho stores next optimizer state?
RNG state sits behind an APIa key crosses function boundaryis every subkey used once?
tensor indexing may mutate.at[...] returns updated valuedid caller keep returned array?
loop handles a batchvmap transforms one-example codewhich axis is mapped?
compiled module or functionjit specializes a pure functionwhat shapes and static values vary?

One step is a state transition

In the GPT lab, mutation was the whole loop: model.train(), optimizer.zero_grad(), loss.backward(), optimizer.step(). Parameters changed on the module, gradient buffers appeared on tensors, and a random generator advanced behind dropout or sampling. The call site can observe the result, but its return type doesn't tell you which pieces changed.

With Adam and dropout, the caller must retain updated parameters, updated optimizer state, a continuation key, and metrics. Stateful layers may add statistics such as a running mean. JAX transformations work cleanly when those changing values become ordinary inputs and outputs.[2]Reference 2Stateful computationshttps://docs.jax.dev/en/latest/stateful-computations.html

Read the figure as that caller's ledger. Every arrow marks ownership: the step consumes current state and hands back the next state.

One JAX training step as an explicit state transition. Parameters, optimizer state, PRNG key, and read-only batch enter train_step at time t. The key splits into a consumed step key and a retained key_t+1. value_and_grad computes loss and gradient pytrees, and tree.map updates parameters and optimizer state for step t+1.
Follow the state ledger across `train_step`: parameters, optimizer state, and the continuation PRNG key return to the caller. The batch is strictly read-only. `step_key` is consumed inside stochastic operations, and metrics are returned as fresh diagnostic data.

A useful signature looks like this:

state_transition_signature.py
1def train_step(params, optimizer_state, batch, key): 2 key, step_key = split(key) 3 loss, grads = differentiate(params, batch, step_key) 4 updates, optimizer_state = update(grads, optimizer_state, params) 5 params = apply_updates(params, updates) 6 metrics = {"loss": loss} 7 return params, optimizer_state, key, metrics

That snippet is structural pseudocode, not a promise about one optimizer library. Its value is the boundary. Nothing inside needs to reach into a global model, global key, or hidden optimizer object.

Pure means observable through arguments and returns

A pure function produces outputs from inputs without reading or writing hidden external state. JAX's transformation guides require this discipline because tracing may run Python code once, cache its numerical program, and skip Python side effects on later calls.[3]Reference 3JAX: The Sharp Bitshttps://docs.jax.dev/en/latest/notebooks/Common_Gotchas_in_JAX.html

Predict what happens if a jitted loss reads weight_decay from a module-level variable. The first trace can capture 0.01; changing the Python global to 0.1 doesn't guarantee the cached executable sees that edit. Pass research settings as arguments, then decide whether each one is an array value or a small static configuration.

Printing inside a jitted function exposes the same boundary. Python print runs while JAX traces, so it may display a tracer once instead of a device value on every execution. Return metrics, use jax.debug.print when you need a transformed-code debug print, or inspect values outside the compiled boundary.

Immutable arrays remove aliasing surprises

In NumPy and PyTorch, writing x[i] = value mutates the underlying memory buffer in place. That feels convenient in eager Python scripts, but in an optimizing compiler such as XLA, in-place mutation turns optimization into an alias-analysis nightmare: the compiler can't easily prove whether another concurrent thread or device stream is reading that same memory address. JAX enforces pure value semantics: ordinary jax.Array instances are immutable. Attempting x[i] = value raises TypeError immediately.[3]Reference 3JAX: The Sharp Bitshttps://docs.jax.dev/en/latest/notebooks/Common_Gotchas_in_JAX.html

To update an element, JAX provides the .at[...] indexing operator: updated = x.at[i].set(value). This expression returns a fresh array value representing the updated state; the original x remains completely untouched.

You might worry that returning a new array forces a full memory copy on every slice update. Under jax.jit, XLA analyzes buffer lifecycles. If the compiler proves the old buffer has no other live references (or if you explicitly donate the buffer via donate_argnums), XLA reuses the physical memory buffer in place without an extra allocation. You get the clean mental model of functional immutability in Python alongside the zero-copy speed of mutating memory on accelerator hardware. Controlled mutable Ref APIs exist for low-level kernel plumbing, but standard research training loops operate purely on immutable arrays.[4]Reference 4Ref: mutable arrays for data plumbing and memory controlhttps://docs.jax.dev/en/latest/array_refs.html

The examples were checked with JAX/jaxlib 0.11.1 on one CPU device using Python 3.12. Install the matching jax==0.11.1 package for these fixtures; accelerator setup is outside this lesson.

After updated = original.at[1].set(99), should original show 20 or 99? Compare both bindings in this example.

immutable_update.py
1import jax.numpy as jnp 2 3original = jnp.array([10, 20, 30]) 4updated = original.at[1].set(99) 5 6print("original", original.tolist()) 7print("updated ", updated.tolist()) 8print("separate values", bool(jnp.array_equal(original, jnp.array([10, 20, 30])))) 9 10assert original.tolist() == [10, 20, 30] 11assert updated.tolist() == [10, 99, 30]
Immutable update
1original [10, 20, 30] 2updated [10, 99, 30] 3separate values True

An easy porting mistake drops the return value:

dropped_update.py
1params["bias"].at[0].add(0.1) # computed, then discarded

The corrected version keeps the new leaf and then keeps the new tree:

kept_update.py
1new_bias = params["bias"].at[0].add(0.1) 2params = {**params, "bias": new_bias}

For updates across every parameter leaf, jax.tree.map is clearer than rebuilding dictionaries by hand. Later update examples use it after introducing pytrees.

Arrays aren't the only values that used to hide behind mutation. Randomness does too.

Randomness is explicit data

In PyTorch, calling torch.randn(...) reads and advances a hidden global generator state. That model breaks down on accelerator clusters: thousands of concurrent threads competing to update a single sequential state register cause lock contention, race conditions, or device-order nondeterminism. Worse, an optimizing compiler can't reorder or fuse operations without accidentally scrambling the random sequence.

JAX eliminates global RNG state entirely by using counter-based pseudorandom number generation, specifically the Threefry algorithm. In a counter-based PRNG, generating random numbers isn't a stateful mutation: it's a pure cryptographic permutation mapping an explicit key and counter to bits: bits=Threefry(K,C)\text{bits} = \text{Threefry}(K, C)bits=Threefry(K,C). Random generation becomes embarrassingly parallel across devices and cores, with zero synchronization overhead.

The golden rule to master: JAX random functions are pure mathematical functions. Passing the same key twice yields the exact same bits. Sampling doesn't advance or mutate the key. To draw independent samples, you explicitly split a key into one-use child keys and a continuation key using jax.random.split(key, num).[5]Reference 5Pseudorandom numbershttps://docs.jax.dev/en/latest/random-numbers.html

Split for independent operations, keep one output as future state, and consume the others as one-use subkeys. A loop, batch, device, or model layer can receive its own derived key without synchronizing a hidden global generator.

This runnable example makes that ownership rule visible: key reuse repeats a sample, while separate subkeys produce different samples.

prng_key_discipline.py
1import jax.numpy as jnp 2from jax import random 3 4root = random.key(7) 5next_key, key_a, key_b = random.split(root, 3) 6 7sample_a = random.normal(key_a, (4,)) 8sample_a_reused = random.normal(key_a, (4,)) 9sample_b = random.normal(key_b, (4,)) 10 11print("reuse repeats", bool(jnp.array_equal(sample_a, sample_a_reused))) 12print("split differs", bool(not jnp.array_equal(sample_a, sample_b))) 13print("next key retained", next_key.dtype) 14 15assert jnp.array_equal(sample_a, sample_a_reused) 16assert not jnp.array_equal(sample_a, sample_b)
PRNG key discipline
1reuse repeats True 2split differs True 3next key retained key<fry>

Typed keys from jax.random.key() carry their PRNG implementation in the dtype. The printed key<fry> is a scalar typed key for Threefry, not a length-two numeric array, although its underlying key data consists of two uint32 words. Older repositories may use jax.random.PRNGKey(), which returns that legacy numeric representation. Read the key format before changing checkpoint serialization; random.key_data and random.wrap_key_data provide explicit conversions.[6]Reference 6jax.random modulehttps://docs.jax.dev/en/latest/jax.random.html

Two PRNG ownership diagrams. Reusing key_t for two dropout draws produces identical masks and silent feature coupling. Splitting key_t once yields a retained continuation key plus independent one-use subkeys for separate pseudorandom streams.
JAX PRNG functions are pure mathematical mappings: passing the same key produces an identical pseudorandom bitstream. Splitting derives fresh Threefry subkeys and advances the continuation key for future training steps.

JAX doesn't promise sequential equivalence: drawing three scalars from split keys needn't give the same sequence as random.normal(key, (3,)) from the parent. This design leaves room for vectorization and parallel execution. To reproduce a sampler, preserve its key derivation, draw shapes, implementation, and software version, not only its seed.[5]Reference 5Pseudorandom numbershttps://docs.jax.dev/en/latest/random-numbers.html

prng_no_sequential_equivalence.py
1import jax.numpy as jnp 2from jax import random 3 4key = random.key(7) 5subkeys = random.split(key, 3) 6one_by_one = jnp.stack([random.normal(subkey) for subkey in subkeys]) 7all_at_once = random.normal(key, (3,)) 8 9print("one_by_one ", [round(float(value), 4) for value in one_by_one]) 10print("all_at_once", [round(float(value), 4) for value in all_at_once]) 11print("same sequence", bool(jnp.allclose(one_by_one, all_at_once))) 12 13assert not jnp.allclose(one_by_one, all_at_once)
No sequential equivalence
1one_by_one [1.114, 0.4541, -0.8204] 2all_at_once [0.4512, 1.9535, -0.5162] 3same sequence False

The comparison starts both programs from the same parent seed on purpose. Don't copy that reuse into a training step.

Split at ownership boundaries

Suppose one training step needs dropout and data augmentation. Split the step key into two subkeys, then pass each to one operation. Don't let both helpers split the same parent independently, since they'll derive matching child sequences.

Across devices, derive keys from stable identifiers such as process, device, step, or example indices when reproducibility requires that mapping. fold_in can combine an integer identifier with a key without managing a long manual split chain. Write the ownership rule beside the code so later vectorization doesn't duplicate random streams accidentally.

JAX also has an experimental stateful RNG backed by Ref objects. Its current documentation recommends explicit keys for performance-critical work and excludes returning the wrapper through transformations, carrying it through scan, or using it with remat. We use explicit keys throughout; this CPU lab doesn't exercise that experimental wrapper.[7]Reference 7jax.experimental.random.stateful_rnghttps://docs.jax.dev/en/latest/_autosummary/jax.experimental.random.stateful_rng.html

A dropout helper receives one key and applies two independent dropout masks. It calls random.bernoulli(key, ...) for both masks. Tests are reproducible, but both masks always match. What contract is broken, and where should you repair it?

Answer

The helper reused one key for two independent random events. Split its input key once at the helper's ownership boundary, consume one subkey per mask, and return or retain a separate continuation key if the caller needs future randomness. Changing the seed would only produce a different pair of matching masks.

Once keys and arrays are ordinary data, transformations can wrap the function without guessing hidden state. The next question is which artifact each wrapper should produce.

Transform one small function at a time

JAX's transformations become easier when the base function is boring. Start with a scalar loss over one parameter tree and one example, and check its output eagerly. Then decide what evidence you need: a derivative, one result per example, or a reusable compiled call. Add grad, vmap, or jit for that artifact, one boundary at a time.

The flow below shows function derivation, not runtime stages. Each box is still a callable function.

Diagram showing loss(params, example), grad(loss), vmap(per-example grad), and jit(batched grads).
loss(params, example), grad(loss), vmap(per-example grad), and jit(batched grads).

jax.grad(f) returns a new function that evaluates the gradient of scalar-valued f. jax.value_and_grad(f) returns both scalar value and gradient, which avoids expressing the forward loss twice in a training step.[8]Reference 8Automatic differentiationhttps://docs.jax.dev/en/latest/automatic-differentiation.html

jax.vmap(f) adds mapped array axes to a function. in_axes tells JAX which arguments carry the batch dimension and which stay shared. For a per-example loss, in_axes=(None, 0, 0) means one shared parameter tree plus batched inputs and targets.[9]Reference 9Automatic vectorizationhttps://docs.jax.dev/en/latest/automatic-vectorization.html

jax.jit(f) stages compatible array work for compilation and caches a specialized executable. It doesn't make arbitrary Python dynamic. Lists that grow, file reads, network calls, and Python branches on traced values need to stay outside or be expressed through JAX-supported operations.[10]Reference 10Just-in-time compilationhttps://docs.jax.dev/en/latest/jit-compilation.html

Transformation order changes the derived program

One composition preserves a gradient for each example; the other differentiates one aggregate loss. These expressions are sketches of the two derived functions, with complete definitions in the runnable cell below:

transformation_order.py
1batched_grad = jax.vmap(jax.grad(loss_one), in_axes=(None, 0, 0)) 2compiled_batched_grad = jax.jit(batched_grad) 3 4grad_of_batch_mean = jax.grad(batch_mean_loss) 5compiled_grad_of_mean = jax.jit(grad_of_batch_mean)

The first returns one gradient tree per example. The second returns the gradient of a scalar batch mean. Averaging the first set of gradients can match the second for a simple mean of independent examples, but memory layout and intermediate materialization may differ. Match the transformation to the artifact you need.

Per-example gradients help with clipping, influence analysis, and variance inspection. Ordinary training usually needs the gradient of an aggregated scalar loss. Don't materialize a batch of full parameter-sized gradients merely because vmap makes the expression short.

For the four-row regression example, the first weight's per-example gradients at zero are [-4, 0, -2, -12]. Their mean is -4.5. The second weight's mean gradient is -1.5, and the bias gradient is -2.5. One SGD step with learning rate 0.05 should therefore produce weights [0.225, 0.075] and bias 0.125.

Derive those quantities with JAX and check that averaging per-example gradients matches differentiating the mean. These linked cells share state; run them in order. They use CPU-sized float32 arrays and require JAX, with no accelerator installation assumed.

gradient_composition.py
1import jax 2import jax.numpy as jnp 3 4x = jnp.array([[1., 0.], [0., 1.], [1., 1.], [2., 1.]], dtype=jnp.float32) 5y = jnp.array([2., -1., 1., 3.], dtype=jnp.float32) 6zero_params = {"weight": jnp.zeros(2, dtype=jnp.float32), 7 "bias": jnp.array(0., dtype=jnp.float32)} 8 9def predict_one(params, features): 10 return jnp.dot(features, params["weight"]) + params["bias"] 11 12def loss_one(params, features, target): 13 return (predict_one(params, features) - target) ** 2 14 15def batch_loss(params, features, targets): 16 return jax.vmap(loss_one, in_axes=(None, 0, 0))(params, features, targets).mean() 17 18per_grad = jax.vmap(jax.grad(loss_one), in_axes=(None, 0, 0))(zero_params, x, y) 19mean_grad = jax.grad(batch_loss)(zero_params, x, y) 20averaged = jax.tree.map(lambda leaf: leaf.mean(axis=0), per_grad) 21assert all(bool(jnp.allclose(a, b)) for a, b in 22 zip(jax.tree.leaves(averaged), jax.tree.leaves(mean_grad))) 23assert jnp.allclose(mean_grad["weight"], jnp.array([-4.5, -1.5])) 24assert jnp.isclose(mean_grad["bias"], -2.5) 25print("per-example weight gradient shape:", per_grad["weight"].shape) 26print("mean gradient:", mean_grad["weight"].tolist(), float(mean_grad["bias"])) 27try: 28 jax.grad(lambda p: jax.vmap(loss_one, in_axes=(None, 0, 0))(p, x, y))(zero_params) 29except TypeError: 30 print("grad rejects a vector-valued loss") 31else: 32 raise AssertionError("Expected a scalar-output error")
Output
1per-example weight gradient shape: (4, 2) 2mean gradient: [-4.5, -1.5] -2.5 3grad rejects a vector-valued loss

The extra axis belongs to examples: the weight-gradient leaf is [4, 2], whereas the mean-gradient leaf is [2]. A length-one array isn't a scalar either; grad expects an output with shape () for this ordinary real-valued use.

Gradients need a place to live. That place is the same nested structure as the parameters.

Pytrees carry structured state

Real model state isn't one matrix. It can be a nested dictionary of layer weights, tuples of optimizer moments, and auxiliary statistics. JAX calls a nested container of leaves a pytree. Built-in containers such as dictionaries, tuples, and lists can form tree nodes, while arrays usually form leaves.[11]Reference 11Pytreeshttps://docs.jax.dev/en/latest/pytrees.html

Transformations flatten the structure at their boundary, operate on leaves, and rebuild matching output structure. If params is a pytree, jax.grad(loss_fn)(params, batch) returns gradients with the same tree structure. That structural agreement is a powerful debugging invariant.

Renaming one key from w to kernel can leave the leaf count unchanged while changing the state schema. A correct update must match paths and shapes, not merely the number of arrays.

Use jax.tree.map to apply the same update over matching leaves:

pytree_sgd_update.py
1new_params = jax.tree.map( 2 lambda parameter, gradient: parameter - learning_rate * gradient, 3 params, 4 grads, 5)

Two trees passed to tree.map need compatible structure. But matching structure doesn't validate leaf shapes: ordinary array arithmetic may silently broadcast an incorrect gradient. For parameter updates, check both the tree definition and exact leaf shapes before applying a leafwise update.

For example, a parameter tree keyed by {"layer": {"w": ..., "b": ...}} can't safely update from a gradient tree keyed by {"layer": {"kernel": ..., "b": ...}}. The leaf shapes may match, but the renamed key changes which state the update refers to.

Exercise both failure modes. A renamed key is a tree error. A scalar gradient under the correct key can broadcast without an error, so the explicit shape check below rejects it before an update.

check_gradient_schema.py
1def validate_gradient(params, grads): 2 if jax.tree.structure(params) != jax.tree.structure(grads): 3 raise ValueError("gradient tree mismatch") 4 if any(p.shape != g.shape for p, g in 5 zip(jax.tree.leaves(params), jax.tree.leaves(grads))): 6 raise ValueError("gradient shape mismatch") 7 8validate_gradient(zero_params, mean_grad) 9for bad in ({"kernel": mean_grad["weight"], "bias": mean_grad["bias"]}, 10 {"weight": jnp.array(-4.5), "bias": mean_grad["bias"]}): 11 try: 12 validate_gradient(zero_params, bad) 13 except ValueError as error: 14 print(error) 15 else: 16 raise AssertionError("Invalid gradient accepted") 17print("scalar broadcasting would have produced:", 18 [round(float(v), 3) for v in zero_params["weight"] - 0.05 * jnp.array(-4.5)])
Output
1gradient tree mismatch 2gradient shape mismatch 3scalar broadcasting would have produced: [0.225, 0.225]

Custom model classes require care. Unless a class is registered or supported by its library as a pytree node, JAX may treat the entire object as one leaf. Before editing serialization or transformation boundaries, inspect jax.tree.structure(params) and jax.tree.map(lambda x: x.shape, params).

Separate dynamic leaves from static metadata

Array leaves can change value without changing the tree definition. Layer names, activation choices, and architecture sizes are often static metadata. Frameworks may place them in dataclass fields, auxiliary pytree data, closures, or static arguments.

Changing tree structure or static metadata can trigger a new trace or compilation. Changing only values inside same-shaped array leaves usually reuses the existing compiled variant. Keep architecture decisions outside hot per-step data.

Checkpoint conversion must preserve tree paths, leaf shapes, dtypes, and semantic layout. A matching leaf count isn't enough. A transposed projection matrix has the same element count and still changes model behavior.

You need per-example gradient norms for clipping, but the current step computes the gradient of mean batch loss. Should you wrap the entire compiled training step in vmap, or derive a narrower function?

Answer

Derive grad(loss_one) from the scalar one-example loss, then vmap that gradient over example axes while sharing the parameter pytree with in_axes=None. Wrapping the whole stateful step would batch optimizer state and updates too, materialize more data than needed, and obscure whether aggregation happens before or after differentiation.

A training step is those four pieces: a pure loss, a gradient pytree, an explicit update, and optional vmap. The GPT lab's loss.backward(); optimizer.step() is the same state transition. The smallest version you can run doesn't need a Transformer.

Translate a tiny training step

The familiar PyTorch loop makes mutation explicit through method calls. This abbreviated comparison assumes an existing model, optimizer, and tensors; it isn't a standalone program.

pytorch_step.py
1model.train() 2optimizer.zero_grad(set_to_none=True) 3 4predictions = model(x) 5loss = ((predictions - y) ** 2).mean() 6loss.backward() 7optimizer.step() 8 9reported_loss = loss.detach()

The JAX translation starts by exposing model parameters as arrays. Prediction and loss are plain functions. The step asks value_and_grad for a gradient pytree, maps an update across leaves, and returns the new parameter tree.

Continue with the same four-row model. The code checks the first update against our hand calculation, trains for 80 steps, and evaluates the returned parameters. The loss returned by train_step is measured before that step's update, so compute a fresh final loss rather than relabeling the last returned loss.

tiny_jax_training_step.py
1params = zero_params 2 3@jax.jit 4def train_step(params, features, targets, learning_rate): 5 loss, grads = jax.value_and_grad(batch_loss)(params, features, targets) 6 new_params = jax.tree.map( 7 lambda parameter, gradient: parameter - learning_rate * gradient, 8 params, 9 grads, 10 ) 11 return new_params, loss 12 13initial_loss = batch_loss(params, x, y) 14first_params, first_loss = train_step(params, x, y, jnp.array(0.05)) 15assert jnp.allclose(first_params["weight"], jnp.array([0.225, 0.075])) 16assert jnp.isclose(first_params["bias"], 0.125) 17assert jnp.isclose(first_loss, 3.75) 18 19for _ in range(80): 20 params, loss = train_step(params, x, y, jnp.array(0.05)) 21 22final_loss = batch_loss(params, x, y).block_until_ready() 23per_example_loss = jax.vmap( 24 lambda features, target: (predict_one(params, features) - target) ** 2 25)(x, y) 26 27print("initial_loss", round(float(initial_loss), 4)) 28print("final_loss ", round(float(final_loss), 6)) 29print("weight ", [round(float(value), 3) for value in params["weight"]]) 30print("bias ", round(float(params["bias"]), 3)) 31print("examples ", [round(float(value), 6) for value in per_example_loss]) 32 33assert final_loss < initial_loss * 0.01 34assert jnp.array_equal(zero_params["weight"], jnp.zeros(2))
Tiny JAX training step
1initial_loss 3.75 2final_loss 0.007605 3weight [1.927, -0.839] 4bias -0.041 5examples [0.012935, 0.014567, 0.002254, 0.000663]

The update has no optimizer state because plain stochastic gradient descent (SGD) only needs a learning rate here. Adam would add first moments, second moments, and a step counter to the inputs and outputs. An optimizer library packages that state, but the state transition remains.

Notice what the example makes easy to test: one scalar loss, one parameter tree, one returned update, and one reported value. The same boundary survives when an optimizer adds state or a model adds dropout; only the tuple of explicit inputs and outputs grows.

Randomized training adds a key. Split it in train_step, give the one-use subkey to dropout or augmentation, and return the retained key beside parameters. Don't close over a module-level key.

Add two real pieces of state to the same regression model: momentum and input dropout. Momentum retains a moving update direction, new_velocity = 0.9 * velocity + gradient. Input dropout retains each feature with probability 0.75 and divides retained values by 0.75 so their expectation is unchanged. This is a state-ownership exercise, not a claim that dropout improves this tiny regression problem.

Replay identical inputs to check deterministic behavior. Then thread the returned state into a second step. A different continuation key is the invariant; two small dropout masks could coincide by chance.

momentum_and_key_state.py
1from jax import random 2 3@jax.jit 4def momentum_step(params, velocity, key, features, targets): 5 next_key, dropout_key = random.split(key) 6 mask = random.bernoulli(dropout_key, p=0.75, shape=features.shape) 7 dropped = features * mask / 0.75 8 loss, grads = jax.value_and_grad(batch_loss)(params, dropped, targets) 9 new_velocity = jax.tree.map(lambda v, g: 0.9 * v + g, velocity, grads) 10 new_params = jax.tree.map(lambda p, v: p - 0.05 * v, params, new_velocity) 11 return new_params, new_velocity, next_key, {"loss": loss, "mask": mask} 12 13velocity = jax.tree.map(jnp.zeros_like, zero_params) 14key = random.key(7, impl="threefry2x32") 15p1, v1, key1, metrics1 = momentum_step(zero_params, velocity, key, x, y) 16replay = momentum_step(zero_params, velocity, key, x, y) 17assert all(bool(jnp.array_equal(a, b)) for a, b in 18 zip(jax.tree.leaves((p1, v1, metrics1)), 19 jax.tree.leaves((replay[0], replay[1], replay[3])))) 20p2, v2, key2, metrics2 = momentum_step(p1, v1, key1, x, y) 21jax.block_until_ready((p2, v2, key2, metrics2)) 22assert not jnp.array_equal(random.key_data(key1), random.key_data(key2)) 23assert all(bool(jnp.all(v == 0)) for v in jax.tree.leaves(velocity)) 24assert jnp.array_equal(zero_params["weight"], jnp.zeros(2)) 25print("same-input replay matches:", True) 26print("continuation key advances:", True) 27print("returned velocity is nonzero:", 28 any(bool(jnp.any(v != 0)) for v in jax.tree.leaves(v1))) 29print("first mask:", metrics1["mask"].astype(jnp.int32).tolist())
Output
1same-input replay matches: True 2continuation key advances: True 3returned velocity is nonzero: True 4first mask: [[1, 0], [1, 1], [1, 1], [0, 0]]

The replay deliberately reuses an input key to test reproducibility. A real training loop keeps key1, then key2, rather than repeatedly submitting the original key. Resuming this step requires parameters, velocity, and the continuation key, together with the data-loader position.

The step works. Now ask whether the next call has the same shape, dtype, static settings, and device placement. Compilation is the next contract, because the first slow call isn't the same as the hundredth.

JIT compiles a shape-specific contract

Just-in-time (JIT) compilation has an up-front cost. When you call a @jax.jit-decorated function for the first time, JAX executes a two-stage compilation pipeline:

  1. Abstract Tracing: JAX runs your Python function once using abstract tracers (ShapedArray). These tracers track array shapes and dtypes without concrete numbers, recording every primitive operation into an intermediate representation graph called a Jaxpr (JAX expression).
  2. XLA Lowering & Compilation: JAX lowers the Jaxpr to StableHLO, and the XLA compiler generates an optimized machine-code binary for your accelerator. Kernel fusion, buffer reuse planning, and dead-code elimination happen here.

XLA caches the compiled executable in memory using a cache key formed by four attributes: the function's object identity, input array shapes, input dtypes, and the exact values of arguments marked static. Subsequent calls matching this exact signature bypass Python execution completely and run the cached binary directly on hardware.[10]Reference 10Just-in-time compilationhttps://docs.jax.dev/en/latest/jit-compilation.html

A [32, 512] token batch and a [16, 512] final batch don't share the same compiled variant. The shapes differ, producing a cache miss that forces a brand-new compilation. Float32 versus bfloat16 inputs split variants too, and changing a static argument recompiles by definition. A sudden multi-second pause at the end of an epoch is almost always compilation doing exactly what your changed batch shape demanded.

Diagram showing Same jitted callable, compatible cached input contract?, same shapes / dtypes / static values, and reuse compiled variant.
Same jitted callable, compatible cached input contract?, same shapes / dtypes / static values, and reuse compiled variant.

The short remainder isn't necessarily a data bug. It's a new input contract, so JAX may trace and compile again. If that remainder is expected, pad to a fixed shape and carry a mask, bucket sequence lengths into a small set, or handle the short batch on a deliberate side path.

Keep static option values bounded and construct the jitted callable once outside hot loops. Log compilation separately from execution so an intentional variant is visible rather than mistaken for kernel slowness.

Expose a captured-global bug alongside a shape change. The list below intentionally records Python tracing events for diagnosis; it isn't a device-side metric counter. A new shape retraces and captures the new global, so the bug can look intermittent.

trace_and_global_capture.py
1import jax 2import jax.numpy as jnp 3 4scale = 1.0 5traces = [] 6 7@jax.jit 8def captured(values): 9 traces.append(values.shape) # diagnostic Python trace, not runtime work 10 return values.sum() * scale 11 12first = float(captured(jnp.ones(4, dtype=jnp.float32))) 13scale = 3.0 14same_shape = float(captured(jnp.ones(4, dtype=jnp.float32))) 15new_shape = float(captured(jnp.ones(2, dtype=jnp.float32))) 16assert (first, same_shape, new_shape) == (4.0, 4.0, 6.0) 17assert traces == [(4,), (2,)] 18 19explicit = jax.jit(lambda values, scale: values.sum() * scale) 20fixed = float(explicit(jnp.ones(4), jnp.array(3.0))) 21assert fixed == 12.0 22print("captured global:", first, same_shape, new_shape) 23print("traced shapes:", traces) 24print("explicit scalar argument:", fixed)
Output
1captured global: 4.0 4.0 6.0 2traced shapes: [(4,), (2,)] 3explicit scalar argument: 12.0

Tracers aren't ordinary Python values

During tracing, an array argument may become a tracer that records abstract properties such as shape and dtype. Python can't always turn that tracer into a concrete bool, int, or array index. A branch such as if loss < threshold: can raise TracerBoolConversionError inside jit.[10]Reference 10Just-in-time compilationhttps://docs.jax.dev/en/latest/jit-compilation.html

Before choosing a fix, decide whether the branch belongs to data or configuration. A per-example mask is data and must stay in the array program; an activation choice from a small fixed set can be static and may justify bounded recompiles.

Choose a fix by semantics:

IntentBetter expression
select values elementwisejnp.where(condition, a, b)
execute one of two functionsjax.lax.cond(condition, true_fn, false_fn, operand)
loop for data-dependent countjax.lax.while_loop
scan a fixed sequence with statejax.lax.scan
branch on small configurationstatic argument, accepting bounded recompiles

Both branches of lax.cond are traced. A scalar condition selects one branch at execution, subject to compiler transformations; vmap over predicates can turn it into elementwise selection.[12]Reference 12jax.lax.condhttps://docs.jax.dev/en/latest/_autosummary/jax.lax.cond.html jnp.where isn't a lazy Python if: don't rely on it to make invalid arithmetic or NaN gradients in an unselected expression harmless.

Loop carries must retain fixed shapes and dtypes. Also, lax.while_loop doesn't support ordinary reverse-mode differentiation. A fixed-length lax.scan is often the appropriate training-loop form when you need reverse-mode gradients through iterations.[13]Reference 13jax.lax.while_loophttps://docs.jax.dev/en/latest/_autosummary/jax.lax.while_loop.html

Run both sides of a repaired scalar branch, then expose the reverse-mode limitation instead of assuming that every jittable program is differentiable.

tracer_safe_control_flow.py
1import jax 2import jax.numpy as jnp 3 4def bad_abs(value): 5 return value if value > 0 else -value 6 7try: 8 jax.jit(bad_abs)(jnp.array(-2.0)) 9except jax.errors.TracerBoolConversionError: 10 print("Python branch rejected a traced condition") 11else: 12 raise AssertionError("Expected a tracing failure") 13 14fixed_abs = jax.jit(lambda value: jax.lax.cond( 15 value > 0, lambda z: z, lambda z: -z, value)) 16assert float(fixed_abs(jnp.array(-2.0))) == 2.0 17assert float(fixed_abs(jnp.array(3.0))) == 3.0 18 19def dynamic_loop(value): 20 return jax.lax.while_loop(lambda z: z < 8., lambda z: 2. * z, value) 21 22assert float(dynamic_loop(jnp.array(1.))) == 8.0 23try: 24 jax.grad(dynamic_loop)(jnp.array(1.)) 25except ValueError: 26 print("while_loop executes, but reverse-mode gradient is unsupported") 27else: 28 raise AssertionError("Expected the reverse-mode limitation") 29 30def three_doublings(value): 31 return jax.lax.scan(lambda z, _: (2. * z, None), value, xs=None, length=3)[0] 32 33assert float(jax.grad(three_doublings)(jnp.array(1.))) == 8.0 34print("fixed-length scan gradient:", float(jax.grad(three_doublings)(jnp.array(1.))))
Output
1Python branch rejected a traced condition 2while_loop executes, but reverse-mode gradient is unsupported 3fixed-length scan gradient: 8.0

The fixed three-step loop has a different contract from "double until at least eight." The example compares their differentiation support; it doesn't claim the two functions agree for every input.

Marking every awkward value static isn't a repair. It moves values into compilation and can create one executable per value. Reserve static arguments for true configuration such as activation choice or a small fixed mode set.

Python side effects also happen during tracing. Appending metrics to a global list, incrementing a Python counter, or reading a changing file from a jitted function doesn't describe repeatable device computation. Return metrics and perform I/O in the outer loop.

Compilation and dispatch also need separate measurements.

Measure completed work, not dispatch

JAX dispatches array work asynchronously. Python can regain control before an accelerator finishes. Before trusting a timer, ask what it has observed: the Python enqueue call, or completed device work?

A timer around only result = compiled_fn(x) may measure enqueue overhead rather than execution.[14]Reference 14Benchmarking JAX codehttps://docs.jax.dev/en/latest/benchmarking.html

Use .block_until_ready() on a result when timing device execution. Place inputs on the target device before steady-state measurement, match dtypes, and separate transfer time when transfers are part of the real workload. JAX defaults to 32-bit dtypes, so a "JAX vs NumPy" comparison that leaves NumPy in float64 isn't a framework result.[14]Reference 14Benchmarking JAX codehttps://docs.jax.dev/en/latest/benchmarking.html

An array has a .block_until_ready() method; a tuple or dictionary doesn't. For the momentum step's structured return, jax.block_until_ready(result) waits on its array leaves, as the state example does above.

This runnable CPU microbenchmark uses a fixed [512, 64] float32 input and waits for every timed result. It checks shape and finiteness, not a machine-dependent speed threshold. Its printed timings will vary, so no timing snapshot is embedded here.

honest_jax_timing.py
1import jax 2import jax.numpy as jnp 3import numpy as np 4from time import perf_counter 5 6cpu = jax.devices("cpu")[0] 7x_host = np.arange(512 * 64, dtype=np.float32).reshape(512, 64) / 1024 8x_device = jax.device_put(x_host, cpu).block_until_ready() 9compiled_fn = jax.jit(lambda values: values.T @ (values - values.mean(axis=0))) 10 11start = perf_counter() 12cold = compiled_fn(x_device).block_until_ready() 13cold_seconds = perf_counter() - start 14 15for _ in range(5): 16 compiled_fn(x_device).block_until_ready() 17 18start = perf_counter() 19for _ in range(20): 20 warm = compiled_fn(x_device).block_until_ready() 21warm_seconds_per_call = (perf_counter() - start) / 20 22 23assert warm.shape == (64, 64) 24assert jnp.isfinite(warm).all() 25assert jnp.allclose(cold, warm) 26print("backend:", cpu.platform, "input:", x_device.shape, str(x_device.dtype)) 27print("cold milliseconds:", round(cold_seconds * 1000, 3)) 28print("warm milliseconds per synchronized call:", round(warm_seconds_per_call * 1000, 3))

Cold time includes tracing, compilation, dispatch, and execution for this fresh callable; a persistent compilation cache can change that breakdown. Warm time includes Python dispatch and a wait per call, so it's a synchronized per-call latency measurement. Enqueuing many independent calls and waiting once instead measures an amortized throughput-oriented boundary, not the same latency. Don't label one as the other.

For accelerator measurements, select and report the actual device, JAX/jaxlib versions, dtype, shapes, compilation-cache policy, transfer boundary, and synchronization rule. This lab verifies CPU numerical behavior and timing structure only. It doesn't establish GPU/TPU speed or multi-host performance. End-to-end measurements must also account for data loading, transfers, evaluation, and checkpoint work when those are in scope.

PyTorch accelerator work can also be asynchronous. Keep the general habit: synchronize according to framework and backend before comparing completed work. Framework names don't make a benchmark fair; matched workloads and boundaries do.

A benchmark reports JAX is 40 times faster than PyTorch. JAX timer covers one call after compilation but never reads result; PyTorch timer includes first accelerator call and synchronization. What must change before speed claim is meaningful?

Answer

Measure matched boundaries. Put inputs on target devices, synchronize both frameworks, report JAX cold compile-plus-execution separately from warm execution, warm PyTorch path too, and include transfers only when both measurements include them. Without those controls, comparison mixes dispatch latency, compilation, execution, and transfer costs.

Treat devices as another state boundary

The single-device path now has a measurable contract. Remember that vmap is local vectorization on one device: it doesn't distribute work across hardware accelerators.

When scaling across multiple GPUs or TPU pods, JAX provides two architectural generations of distributed execution:

  1. Historical SPMD with jax.pmap: Parallel map maps a function across device replicas by expecting a leading device dimension on input arrays (for example, shape (8, 32, 512) across 8 GPUs). Each device runs the identical compiled program on its local shard, synchronizing through collective primitives such as lax.psum.[15]Reference 15jax.pmaphttps://docs.jax.dev/en/latest/_autosummary/jax.pmap.html
  2. Modern Array Sharding with shard_map & NamedSharding: Current JAX treats multi-device distributed parallelism as an array property rather than a function wrapper. You define a logical device mesh (jax.sharding.Mesh(devices, ('data', 'model'))) and annotate PyTree leaves with a PartitionSpec. Inside jax.jit, XLA inspects the sharding specs and automatically inserts cross-device communication collectives (all-reduce, reduce-scatter, all-gather). When you need explicit, manual per-device SPMD logic, shard_map lets you write per-device kernels with explicit collectives without leaving jax.jit.

Explicit sharding adds another layer to debug. An array can have the expected global shape while its physical shards sit on an unexpected mesh or trigger silent communication transfers between operations.

Never copy a single PRNG key into every device lane: doing so makes every accelerator draw identical dropout masks, eliminating regularized diversity across data-parallel replicas. Use jax.random.fold_in(key, device_id) or split the key across the device axis so each replica derives an independent, reproducible random stream.

If a research repository already uses pmap or explicit sharding, preserve its version-specific wrappers until you've verified local shard shapes, device placement, numerical parity, and synchronized timing. A fast replicated call with the wrong local batch or duplicated randomness isn't a successful port. No multi-device collective, sharding strategy, or checkpoint migration is executed by this lesson's CPU tests.

Failure cases that look like model bugs

JAX contract violations often produce model symptoms: repeated keys make dropout masks identical, shape changes create compilation pauses that look like input stalls, and captured globals retain old coefficients after configuration changes. Start by classifying the symptom, then check the boundary that could produce it.

Use symptom, cause, and repair together:

SymptomLikely causeRepair
repeated random samplessame subkey reusedsplit at ownership boundary and consume once
original array unchanged.at result discardedbind returned array or returned tree
print runs oncePython side effect happened during tracereturn metric or use deliberate JAX debug tool
config edit has no effectglobal captured in cached tracepass value as array or bounded static argument
TracerBoolConversionErrorPython branch needs concrete array valueuse JAX control flow or true static config
recurring multi-second pausesshape, dtype, static value, or function identity changedstabilize contract and inspect compile logs
implausibly fast timingasynchronous result wasn't awaitedcall .block_until_ready()
tree-map structure errorparameter and gradient pytrees differcompare tree structures and leaf paths
checkpoint loads but outputs driftleaf layout or dtype conversion is wrongverify named paths, shapes, dtypes, and fixtures

Debug outside in

Remove jit temporarily and run the smallest failing function eagerly. Eager execution won't reproduce every compilation issue, but it gives clearer Python stack traces and confirms base numerical behavior.

Then inspect jax.make_jaxpr(function)(example_inputs) or a lowered representation when you need to see what JAX traced. Compare tree structures and leaf shapes before reading compiler output; those checks can isolate the boundary without requiring you to interpret generated machine code.

Reintroduce transformations one at a time: base function, then grad, then vmap, then jit. The first boundary that fails names the contract you need to fix. If the failure appears only after adding devices, repeat the same process with placement and collective boundaries visible.

Preserve JAX when its structure carries evidence

A paper repository contains more than equations. Its tests, checkpoint layout, data order, precision settings, sharding rules, and evaluation scripts define the reported experiment. Rewriting all of that changes many variables at once.

Keep the JAX path when the evidence already lives there. Reference checkpoints and evaluation may depend on its pytrees, or one local loss, update, sampler, or transformation may be the only thing that needs adjustment. vmap, higher-order differentiation, and vectorized simulation can express core research logic.

Proven TPU (Tensor Processing Unit) or multi-device infrastructure can make a rewrite especially costly. Paper reproduction may require the original execution path, and maintainers may already review and test JAX changes directly.

PyTorch may remain better for your production stack, team tooling, or supported operators. That doesn't make a research rewrite free. Keep the research implementation as oracle, add cross-framework fixtures, and port only the smallest boundary whose ownership is clear.

Use this decision table before replacing a working codebase:

SituationDefault moveEvidence before broader port
change one loss termedit pure JAX lossold and new loss fixtures plus gradients
add per-example metricvmap small metric functionshape and memory checks
load original checkpointpreserve pytree structurenamed leaf and output parity
unsupported deployment targetisolate export or port boundaryend-to-end numerical tolerance
organization owns PyTorch onlymaintain reference plus portcheckpoints, evals, dtypes, timing
framework preference alonedon't rewrite yetconcrete maintenance or capability gap

A clean port requires more than close loss curves. Compare preprocessing, parameter layouts, random seeds and streams, numerical precision, optimizer details, batch ordering, masks, evaluation aggregation, and generated artifacts. A mismatch in any one can dominate framework differences.

A focused modification workflow

Start by running one existing repository test and one tiny forward pass. Save inputs and outputs as a fixture if repository policy allows it. Record tree paths, leaf shapes, dtypes, device placement, and random-key ownership around the intended edit.

Change the smallest pure function and run it eagerly on the fixture. Compare scalar outputs and gradients, then apply the same transformations the repository uses. Benchmark the compiled path only after those checks pass.

If you're adding a regularizer, treat this sequence as an evidence ladder:

  1. add the term to the scalar loss;
  2. verify the term is zero on a known fixture;
  3. compare finite differences on a tiny float input, choosing a step and tolerance appropriate to its dtype;
  4. check the gradient pytree structure and look for non-finite values;
  5. run the jitted step twice with the same shapes;
  6. verify that one controlled key change affects only the stochastic lane;
  7. run a held-out training or evaluation slice;
  8. measure cold compile and warm execution separately.

That sequence preserves causal evidence. If the final metric changes, you can point to one numerical edit rather than a framework rewrite plus a new data loader plus a new optimizer plus a new checkpoint converter.

For practice, add 0.1 * sum(weight ** 2) to the regression loss without regularizing bias. At zero weights, both the extra loss and its gradient should be zero. At weights [2, -1], the extra loss should be 0.5 and its gradient [0.4, -0.2]. Check those values eagerly and under jit, then rerun the training fixture. This tests the intended numerical edit independently of its effect on convergence.

Complete the lesson

Mastery Check

Answer every question, then check your score. Score 75% or higher to mark this lesson complete.

1.A training step must update parameters, optimizer state, and RNG without hidden mutation. Which JAX boundary matches the article's reliable pattern?

Correct answer: Return new state, metrics, and next PRNG key from an explicit state transition function.

JAX transformations compose cleanly when the transition is explicit: inputs include params, optimizer state, key, and batch, while all updated values are returned. Hidden mutation or closed-over RNG state can make repeated calls silently reuse state.

2.A training step computes updated = params.at[idx].set(value) but later uses the old params. What is the decisive fix?

Correct answer: Bind the returned array, such as params = params.at[idx].set(value), before using it.

JAX arrays are immutable. The .at[...] expression builds an updated value and doesn't mutate the original binding, so dropping its return value drops the update.

3.A jitted dropout step gives identical masks on every call. Which RNG change follows the article's rule?

Correct answer: Split the incoming key into one-use children and return a retained continuation child.

JAX PRNG keys are explicit values, not mutable generators. Reusing a key repeats the same random draw; splitting gives independent subkeys while returning the next key preserves the caller's state.

4.A sampler keeps seed 7 but replaces three scalar draws from split keys with one vector draw from the parent key. What should the reviewer expect?

Correct answer: Potentially different samples; preserve and test the original split-versus-shape pattern when reproducibility matters.

JAX doesn't promise sequential equivalence between scalar draws from split keys and one shaped draw from a parent key. Keeping the seed alone doesn't preserve the sample stream.

5.You need one gradient norm per example for a stateless loss. Which composition expresses that computation?

Correct answer: Use vmap(grad(loss_one), in_axes=(None, 0)), then compute a norm for each returned gradient.

grad of a batch mean produces one aggregate gradient. Mapping a per-example gradient over the batch preserves individual gradients, which is the quantity needed for per-example norms.

6.A jax.tree.map parameter update fails after a refactor because parameter and gradient trees no longer align. What should be checked first?

Correct answer: Whether parameter and gradient pytrees have compatible structures and matching leaf shapes.

A leafwise parameter update requires gradients with the same treedef and compatible leaf shapes as the parameters. Optimizer state may wrap that tree in a library-specific structure, so you can't assume it matches the parameter treedef directly.

7.A jitted function branches with if mask.sum() > 0: and fails during tracing. Which repair matches the article?

Correct answer: Use jnp.where or lax.cond for data-dependent control flow, reserving Python branches for static metadata.

During tracing, array values are tracers rather than concrete Python booleans. JAX control-flow primitives express data-dependent branches; Python control flow is appropriate only when the condition is static.

8.A benchmark reports a fast jitted step, but the measured interval ends before device work finishes. What timing protocol is needed?

Correct answer: Warm up, synchronize with block_until_ready(), and report cold versus warm timing.

JAX dispatch is asynchronous, so a host timer can stop after enqueueing work. Waiting for every result measures synchronized per-call latency; enqueuing many calls before a wait is a different, throughput-oriented protocol.

9.A jitted function reads a global scale. After the global changes, old-shaped inputs keep the old scale but a new shape sees the new scale. What explains this?

Correct answer: The global is read during tracing, so a retrace can capture its new value.

The cached program can retain the old Python value. A new trace can capture the changed global, producing shape-dependent behavior. Pass a changing numerical scale as an explicit array argument.

10.A data-dependent lax.while_loop runs under jit, but jax.grad through it fails. Which diagnosis is correct?

Correct answer: Compilability doesn't guarantee reverse-mode support for a dynamic loop.

lax.while_loop doesn't support ordinary reverse-mode differentiation. A fixed-length scan can support it, but changing to a fixed iteration count changes the program's contract and must be justified.

10 questions remaining.

Next Step
Continue to Continued Pretraining for Domain Shift

You can now read a compiled research step without throwing away its checkpoints and tests. Next you'll keep that same discipline while adapting a base model on domain text: pin the data and starting checkpoint, then measure domain gain against general-capability regression.

PreviousBuild GPT from Scratch Lab
Share this article
XFacebookLinkedInBlueskyRedditHacker NewsEmail
References

Quickstart: How to think in JAX

JAX team · 2026

https://docs.jax.dev/en/latest/quickstart.html

Stateful computations

JAX team · 2026

https://docs.jax.dev/en/latest/stateful-computations.html

JAX: The Sharp Bits

JAX team · 2026

https://docs.jax.dev/en/latest/notebooks/Common_Gotchas_in_JAX.html

Ref: mutable arrays for data plumbing and memory control

JAX Authors · 2026

https://docs.jax.dev/en/latest/array_refs.html

Pseudorandom numbers

JAX team · 2026

https://docs.jax.dev/en/latest/random-numbers.html

jax.random module

JAX Authors · 2026

https://docs.jax.dev/en/latest/jax.random.html

jax.experimental.random.stateful_rng

JAX Authors · 2026

https://docs.jax.dev/en/latest/_autosummary/jax.experimental.random.stateful_rng.html

Automatic differentiation

JAX team · 2026

https://docs.jax.dev/en/latest/automatic-differentiation.html

Automatic vectorization

JAX team · 2026

https://docs.jax.dev/en/latest/automatic-vectorization.html

Just-in-time compilation

JAX team · 2026

https://docs.jax.dev/en/latest/jit-compilation.html

Pytrees

JAX team · 2026

https://docs.jax.dev/en/latest/pytrees.html

jax.lax.cond

JAX Authors · 2026

https://docs.jax.dev/en/latest/_autosummary/jax.lax.cond.html

jax.lax.while_loop

JAX Authors · 2026

https://docs.jax.dev/en/latest/_autosummary/jax.lax.while_loop.html

Benchmarking JAX code

JAX team · 2026

https://docs.jax.dev/en/latest/benchmarking.html

jax.pmap

JAX Authors · 2026

https://docs.jax.dev/en/latest/_autosummary/jax.pmap.html

Discussion

Questions and insights from fellow learners.

Discussion loads when you reach this section.