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 177 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/8
Gradients and BackpropVectors, Matrices & TensorsLinear Algebra for MLAdam, Momentum, SchedulersProbability for Machine LearningStatistics 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/11
Linear Regression from ScratchLogistic Regression and MetricsDecision Trees, Forests, and BoostingReinforcement Learning BasicsValidation and LeakageClustering and PCACore Retrieval AlgorithmsDecoding AlgorithmsExperiment Design and A/B TestingPyTorch 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/24
Dimensionality Reduction for EmbeddingsCoT, ToT & Self-Consistency PromptingFunction Calling & Tool UseMCP & Tool Protocol StandardsContext EngineeringPrompt Injection DefenseResponsible AI GovernanceData 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/16
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 TuningReward 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/19
Inference: TTFT, TPS & KV CacheMulti-Query & Grouped-Query AttentionKV Cache & PagedAttentionPrefix Caching and Prompt CachingFlashAttention & Memory EfficiencyContinuous Batching & SchedulingScaling LLM InferenceModel Parallelism for LLM InferenceModel 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 & AutoscalingA/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 & Test-Time Compute
🎤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 from a PyTorch foundation by making state, randomness, transformations, compilation, and timing explicit.

23 min read
Learning path
Step 100 of 177 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 open a paper's reference repository to change one loss term. The code looks like NumPy, but parameters live in nested dictionaries, random-number keys move through every function, and decorators wrap decorators. Rewriting the project in PyTorch feels tempting. A smaller move is usually safer: learn the execution contract, change the local function, and keep the paper's tests and checkpoints intact.

You already know tensors, gradients, and training loops from PyTorch. JAX keeps that mathematics, but moves ownership into function inputs and outputs. Once state and shapes become visible, unfamiliar code starts reading like a precise dataflow program instead of implicit framework behavior.

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.

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.

The first review pass should answer four questions:

  1. Which values change after one step?
  2. Which arguments control shapes or Python branches?
  3. Where does each random key come from, split, and leave?
  4. Which functions are transformed by grad, vmap, or jit?

That inventory often reveals a narrow edit: place a new regularizer in loss function, change sampling where a one-use key enters, or derive a per-example statistic with vmap instead of porting frameworks.

The comparison below maps familiar habits without pretending that either framework has only one programming style.

PyTorch habitJAX reading lensQuestion to ask
nn.Module owns parametersparameters often arrive as a pytreewhere is 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

A PyTorch training loop often expresses state through object mutation. Model parameters change when the optimizer steps. Gradient buffers appear on tensors. A random generator advances behind dropout or sampling calls. You can inspect each object, but its changing state isn't written in the step's return type.

JAX transformations work best when changing values become ordinary data. A training step accepts current parameters, optimizer state, batch, and random key. It returns updated parameters, updated optimizer state, a retained next key, and metrics. Official JAX guidance uses this state-in, state-out pattern for model parameters, optimizer state, and stateful layer statistics.[2]Reference 2Stateful computationshttps://docs.jax.dev/en/latest/stateful-computations.html

The figure makes ownership visible across one call.

State transition table for one JAX training step. Parameter and optimizer pytrees enter at time t and return updated at time t plus one, a PRNG key enters once and splits into a retained next key plus a one-use step key, while the batch is read without becoming hidden state. State transition table for one JAX training step. Parameter and optimizer pytrees enter at time t and return updated at time t plus one, a PRNG key enters once and splits into a retained next key plus a one-use step key, while the batch is read without becoming hidden state.
Read each row from left to right: changing training values cross the pure step boundary and return under caller ownership, while the batch remains an external input and a random subkey is consumed only once.

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

Printing inside a jitted function illustrates the mismatch. Python print runs while JAX traces, so it may display a tracer once instead of a device value on every execution. Use returned metrics, jax.debug.print for deliberate transformed-code debugging, or inspect values outside the compiled boundary.

Globals are more dangerous. If a loss reads weight_decay from a module-level variable, a trace can capture its old value. Updating the Python global doesn't guarantee the cached executable changes. Pass research settings as arguments, then decide whether each one is an array value or a small static configuration.

Immutable arrays remove aliasing surprises

NumPy and PyTorch let code assign into an array or tensor. JAX's ordinary arrays use immutable value semantics. x[i] = value raises an error, and x.at[i].set(value) describes an updated array value instead.[3]Reference 3JAX: The Sharp Bitshttps://docs.jax.dev/en/latest/notebooks/Common_Gotchas_in_JAX.html

The old value remains valid from Python's perspective. The caller must bind the returned value. Inside compiled code, the compiler may reuse buffers when safe, but that optimization doesn't change the program's value semantics.

This runnable check updates one element and proves that the original array didn't change.

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.

Randomness is explicit data

PyTorch code can seed a generator and let calls advance its internal state. JAX's standard random API passes a pseudo-random number generator (PRNG) key into each random function. Sampling doesn't mutate the key. Calling a random function twice with the same key produces the same sample and may create unwanted correlations.[4]Reference 4Pseudorandom numbershttps://docs.jax.dev/en/latest/random-numbers.html

Split once for each independent use. 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 verifies both properties: 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() are the current standard interface. You may still see older repositories call jax.random.PRNGKey(). Read the repository's key format before changing checkpoint or serialization code.

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.

Current JAX also has experimental stateful RNG support. Its documentation still recommends explicit key semantics for performance-sensitive applications and names transformation limitations. This lesson keeps explicit keys because their dataflow remains visible under jit, vmap, checkpointing, and review.

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. Check its output. Differentiate it. Add a batch axis with vmap. Compile the stable outer call with jit only after eager checks pass.

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

Diagram showing loss(params, example), grad(loss), vmap(per-example grad), and jit(batched grads). 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.[5]Reference 5Automatic 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.[6]Reference 6Automatic 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.[7]Reference 7Just-in-time compilationhttps://docs.jax.dev/en/latest/jit-compilation.html

Transformation order changes the derived program

These expressions are both legal but ask for different derived functions:

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.

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.[8]Reference 8Pytreeshttps://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.

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. A missing dictionary key, tuple with different length, or leaf shape mismatch should be treated as a state-schema bug, not patched with position-dependent indexing.

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 same element count and still changes model behavior.

Translate a tiny training step

The familiar PyTorch loop makes mutation explicit through method calls. The snippet below performs one linear-regression step and shows where gradients and optimizer state live.

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.

The complete CPU-sized example trains a two-feature linear model. It also uses vmap to inspect final per-example losses and blocks on the reported value before printing.

tiny_jax_training_step.py
1import jax 2import jax.numpy as jnp 3 4x = jnp.array([ 5 [1.0, 0.0], 6 [0.0, 1.0], 7 [1.0, 1.0], 8 [2.0, 1.0], 9]) 10y = jnp.array([2.0, -1.0, 1.0, 3.0]) 11 12params = { 13 "weight": jnp.zeros((2,)), 14 "bias": jnp.array(0.0), 15} 16 17def predict_one(params, features): 18 return jnp.dot(features, params["weight"]) + params["bias"] 19 20def batch_loss(params, features, targets): 21 predictions = jax.vmap(predict_one, in_axes=(None, 0))(params, features) 22 return jnp.mean((predictions - targets) ** 2) 23 24@jax.jit 25def train_step(params, features, targets, learning_rate): 26 loss, grads = jax.value_and_grad(batch_loss)(params, features, targets) 27 new_params = jax.tree.map( 28 lambda parameter, gradient: parameter - learning_rate * gradient, 29 params, 30 grads, 31 ) 32 return new_params, loss 33 34initial_loss = batch_loss(params, x, y) 35 36for _ in range(80): 37 params, loss = train_step(params, x, y, jnp.array(0.05)) 38 39final_loss = batch_loss(params, x, y).block_until_ready() 40per_example_loss = jax.vmap( 41 lambda features, target: (predict_one(params, features) - target) ** 2 42)(x, y) 43 44print("initial_loss", round(float(initial_loss), 4)) 45print("final_loss ", round(float(final_loss), 6)) 46print("weight ", [round(float(value), 3) for value in params["weight"]]) 47print("bias ", round(float(params["bias"]), 3)) 48print("examples ", [round(float(value), 6) for value in per_example_loss]) 49 50assert final_loss < initial_loss * 0.01
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 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.

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.

JIT compiles a shape-specific contract

Just-in-time (JIT) compilation has an up-front cost. On the first compatible call, JAX traces the Python function, lowers array operations, compiles an executable, and runs it. Later calls can reuse that executable when the relevant input contract matches.[7]Reference 7Just-in-time compilationhttps://docs.jax.dev/en/latest/jit-compilation.html

Input shapes and dtypes commonly participate in specialization. A [32, 512] token batch and a [16, 512] final batch can produce different compiled variants. So can float32 versus bfloat16 leaves. Static arguments add their concrete values to the cache key and recompile when those values change.

Control shape churn before optimizing kernels:

  • pad batches to a fixed shape and carry a mask;
  • bucket sequence lengths into a small set;
  • drop or separately handle short remainder batches;
  • keep static option values bounded;
  • construct the jitted callable once outside hot loops;
  • log or profile compilation separately from execution.

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.[7]Reference 7Just-in-time compilationhttps://docs.jax.dev/en/latest/jit-compilation.html

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

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.

Measure completed work, not dispatch

JAX dispatches array work asynchronously. Python can regain control before an accelerator finishes. A timer around only result = compiled_fn(x) may measure enqueue overhead rather than completed computation.[9]Reference 9Benchmarking JAX codehttps://docs.jax.dev/en/latest/benchmarking.html

Use .block_until_ready() on a result when timing device execution. Also place inputs on target device before steady-state measurement, match dtypes, and separate transfer time when transfers are part of the real workload.

The benchmark skeleton below distinguishes cold and warm calls. It isn't marked runnable because timing assertions depend on machine load and backend.

honest_jax_timing.py
1from time import perf_counter 2 3x_device = jax.device_put(x).block_until_ready() 4 5start = perf_counter() 6cold = compiled_fn(x_device).block_until_ready() 7cold_seconds = perf_counter() - start 8 9for _ in range(5): 10 compiled_fn(x_device).block_until_ready() 11 12start = perf_counter() 13for _ in range(100): 14 warm = compiled_fn(x_device) 15warm.block_until_ready() 16warm_seconds_per_call = (perf_counter() - start) / 100

Cold time includes compilation and execution. Warm time reuses a compatible executable. Report both when research iteration latency matters. For end-to-end systems, include data loading, host-to-device transfer, evaluation, and checkpoint work rather than quoting only a kernel microbenchmark.

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.

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.

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. Most failures happen at the Python-to-array boundary, not inside 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.

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.

Modify JAX in place when:

  • reference checkpoints and evaluation code already use its pytrees;
  • one local loss, update, sampler, or transformation needs adjustment;
  • vmap, higher-order differentiation, or vectorized simulation expresses core research logic;
  • TPU or multi-device infrastructure is already proven;
  • paper reproduction requires matching original execution path;
  • maintainers 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 intended edit.

Make change in smallest pure function. Run it eagerly on fixture. Compare scalar outputs and gradients, then apply same transformations used by repository. Only after correctness passes should you benchmark compiled path.

For a new regularizer, workflow could be:

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

That sequence preserves causal evidence. If 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.

Keep the execution contract visible

Trace one batch through a JAX repository and name every state boundary. Parameters and optimizer state arrive as pytrees. Randomness arrives as keys. Array updates return values. Transformations derive gradient, batch, and compiled functions from a small numerical core.

Reject misleading diagnoses by checking contract first. Compile pauses may indicate shape churn, repeated dropout masks can expose key reuse, and stale coefficients can come from captured globals. Inspect shape, static values, key ownership, side effects, and synchronization before blaming model math.

Keep these checks close during research edits:

  • inspect function inputs and returned state;
  • compare pytree structures before leaf values;
  • split each random subkey once;
  • retain every immutable update result;
  • stabilize hot-path shapes and static options;
  • add transformations one boundary at a time;
  • block before timing completed device work;
  • preserve reference fixtures before considering a port.
Complete the lesson

Mastery Check

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

1.A jitted training step reads a module-level weight-decay value. You change the Python global, but later same-shaped batches still use old behavior. What is the best repair?
2.Two dropout layers receive same JAX PRNG subkey. What failure should you expect?
3.A researcher calls params['bias'].at[0].add(0.1) but discards returned value. What happens?
4.You need one gradient pytree for scalar mean loss over batch. Which construction is direct?
5.A final batch has shape [7, 512] while normal batches have shape [32, 512]. Job pauses before final update. What is likely cause?
6.Timer wraps result = compiled_fn(x) on GPU and reports implausibly tiny latency. Which measurement change is required?
7.A jitted function uses if loss < threshold with traced array scalar and raises TracerBoolConversionError. Threshold is data-dependent. What is suitable fix?
8.Paper repository already has JAX checkpoints, sharding, tests, and evals. You only need change one regularizer. Which default minimizes experimental drift?
9.Parameter pytree and gradient pytree have different dictionary keys. What should happen before optimizer update?

9 questions remaining.

Next Step
Continue to Continued Pretraining for Domain Shift

You can now read and modify a compiled research training step without discarding its original evidence. Next, apply that discipline to adapting a base model on domain text while measuring domain gain and general-capability regression.

PreviousBuild GPT from Scratch Lab
Share this article
XFacebookLinkedInBlueskyRedditHacker NewsEmail
References

Quickstart: How to think in JAX

JAX team · 2026

Stateful computations

JAX team · 2026

JAX: The Sharp Bits

JAX team · 2026

Pseudorandom numbers

JAX team · 2026

Automatic differentiation

JAX team · 2026

Automatic vectorization

JAX team · 2026

Just-in-time compilation

JAX team · 2026

Pytrees

JAX team · 2026

Benchmarking JAX code

JAX team · 2026

Discussion

Questions and insights from fellow learners.

Discussion loads when you reach this section.