Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 7

Functional In-Place Array Updates with .at[].set/add

Recommended resources

Hello! Let's dive back into JAX.

In our last lesson, we surveyed the landscape of "gotchas" that often trip up developers coming from imperative frameworks. The core theme was that JAX's functional principles, especially immutability, require a new way of thinking. We briefly introduced the array.at[...] syntax as the JAX-native solution for what would be an in-place update in NumPy or PyTorch.

Today, we will master this crucial API. Our entire focus is on using .at[].set() and its variants to perform array modifications in a clean, functional, and efficient manner. This is not just a syntactic curiosity; it's a fundamental pattern for building and training models in JAX, and understanding its nuances will pay dividends, particularly in how it interacts with compilation and automatic differentiation.

1. The Functional Update Pattern

As we established, JAX arrays are immutable. Attempting a direct item assignment, a cornerstone of your PyTorch and NumPy workflows, results in a TypeError.

Machine Learning with JAX - From Zero to Hero | Tutorial #1

This clip from Aleksa Gordić's "Machine Learning with JAX" tutorial provides a clear, practical demonstration of this concept.

First, watch from what that means to see the familiar NumPy behavior contrasted with the JAX error. Then, watch the solution from exception here. This introduces the .at[].set() syntax and, crucially, touches on the role of the JIT compiler in optimizing this pattern.

The fundamental pattern you just saw is:

new_array = old_array.at[indices].set(values)

It's essential to internalize that this operation does not change old_array. It returns a completely new JAX array that is a copy of the original with the specified update applied. This purity is what allows JAX to trace, compile, and transform functions without worrying about hidden state changes.

You might be thinking that creating a full copy of an array just to change one element sounds incredibly inefficient. This is a very reasonable concern.

Understanding JAX: JIT, XLA, and Pure Functions Explained

This short explanation from the Google for Developers channel directly addresses this performance question.

Watch the clip from JAX arrays cannot. It succinctly reinforces the concept and highlights that while the code looks less performant, it unlocks deep compiler optimizations.

As the video explains, this functional style is a contract with the compiler. By promising that you won't modify the original array, you allow the XLA compiler, particularly within a jax.jit context, to perform aggressive optimizations. If it can prove that the original array is not used again, it can often fuse the operations and perform the update in-place in the compiled machine code, giving you the best of both worlds: safe, functional semantics and optimal performance.


2. The .at[] Toolbox: Beyond set

The .at[] object provides more than just set. It exposes a family of methods that correspond to common update operations, mirroring NumPy's ufunc.at functions. The most common of these is add, but many others are available.

The general syntax is array.at[indices].operation(value). Here are some key operations:

  • .set(val): Replaces indexed elements with val.
  • .add(val): Adds val to indexed elements (equivalent to x[idx] += val).
  • .multiply(val): Multiplies indexed elements by val (equivalent to x[idx] *= val).
  • .min(val): Takes the element-wise minimum with val.
  • .max(val): Takes the element-wise maximum with val.

Let's see a practical example of using add with more complex indexing.

Machine Learning with JAX - From Zero to Hero | Tutorial #1

Returning to Aleksa Gordić's tutorial, this later section demonstrates more advanced usage.

Watch the segment from Gotcha number two. Notice how he uses .set() to modify a whole row and then uses .add() with more complex slicing (.at[1::2, 2:]) to update specific sub-regions of the array.

This demonstrates that the indexing used inside .at[] is just as powerful as standard NumPy indexing. You can use:

  • Integers and slices: x.at[0, 2:5]
  • Integer arrays (fancy indexing): x.at[jnp.array([0, 2]), jnp.array([1, 3])]
  • Boolean masks: x.at[x > 5]

A particularly elegant feature is the ability to chain these operations, as each one returns a new array.

import jax.numpy as jnp




# Create a 5x5 matrix of zeros
x = jnp.zeros((5, 5))




# Chained functional updates to create a frame
framed_x = x.at[0, :].set(1)   # Top row
             .at[-1, :].set(1)  # Bottom row
             .at[:, 0].set(1)   # Left column
             .at[:, -1].set(1)  # Right column

print(framed_x)



# [[1. 1. 1. 1. 1.]
#  [1. 0. 0. 0. 1.]
#  [1. 0. 0. 0. 1.]
#  [1. 0. 0. 0. 1.]
#  [1. 1. 1. 1. 1.]]

3. Interaction with Autodifferentiation: A PyTorch Comparison

Here is where your PyTorch expertise provides a valuable point of contrast. Consider the implications of this functional update style for automatic differentiation.

Learning JAX as a PyTorch developer - Patrick Kidger

Patrick Kidger's article, written for PyTorch developers, provides an excellent, nuanced take on this topic.

Please read the section titled Gotcha: in-place updates. Kidger makes a crucial point: JAX sometimes must make a copy to preserve information needed for the backward pass.

This is a profound design difference. In PyTorch, you might do:
some_tensor[2:5] = 0
PyTorch tracks that some_tensor has been modified in-place. If you later call .backward() and the original values at indices 2:5 were needed to compute a gradient, PyTorch will raise a runtime error because you've overwritten the necessary data.

JAX, by design, avoids this entire class of errors. When you write some_array.at[2:5].set(0), JAX's gradient logic knows that the original some_array is still available and pristine. If the backward pass needs the original values, they are there. If it doesn't, the XLA compiler may still optimize the copy away. The price is a potential memory copy, but the gain is the complete elimination of a tricky category of gradient-related runtime errors.


Conclusion

You have now mastered the JAX-idiomatic way to handle array modifications. This pattern is fundamental to everything that follows.

Let's summarize the key takeaways:

  • Functional Updates: All array "modifications" in JAX are done by creating a new array via the array.at[indices].operation(values) pattern.
  • Rich API: The .at[] object provides a suite of operations like .set(), .add(), .multiply(), etc., that can be combined with advanced NumPy-style indexing.
  • Compiler Optimization: The functional style allows the XLA compiler to perform powerful optimizations, often eliding the copy operation and performing true in-place updates in the compiled code for maximum performance.
  • Gradient Safety: This design guarantees that values needed for the backward pass are never accidentally overwritten, avoiding a class of runtime errors common in imperative frameworks like PyTorch.

With a firm grasp of JAX's data model (immutable arrays, pure functions, and functional updates), you are now ready to unleash JAX's true power. In our next lesson, we will begin Module 2 by taking a deep dive into the first and most fundamental function transformation: jax.jit. We will explore how it works, what tracing is, and how to use it to dramatically accelerate your code.