Skip to content

[JAX] Accelerate multi-step training loops using on-device jax.lax.scan for steps_per_execution > 1 - #23527

Open
gaga1313 wants to merge 7 commits into
keras-team:masterfrom
gaga1313:JAX-Steps_Per_Execution-Fix
Open

[JAX] Accelerate multi-step training loops using on-device jax.lax.scan for steps_per_execution > 1#23527
gaga1313 wants to merge 7 commits into
keras-team:masterfrom
gaga1313:JAX-Steps_Per_Execution-Fix

Conversation

@gaga1313

@gaga1313 gaga1313 commented Aug 29, 2026

Copy link
Copy Markdown
Collaborator

Description

Context & Problem

In Keras 3 with the JAX backend, setting steps_per_execution > 1 previously executed a Python host-level loop around single-step compiled executables. On accelerator hardware (GPU/TPU) as well as multi-core CPUs, this incurred Python host dispatch overhead, buffer allocation round-trips, and device-host synchronization barriers on every single step ($N$ dispatches for $N$ steps).

As shown in the baseline benchmarks below, increasing steps_per_execution on master yielded virtually flat performance (~1.1x to 1.25x on GPU) because host-side dispatch latency was the primary bottleneck.

Proposed Solution

This PR introduces true on-device multi-step execution using jax.lax.scan:

  1. On-Device jax.lax.scan Compilation: When steps_per_execution > 1 and jit_compile=True, batches are stacked into super-batches along axis 0 and executed continuously in hardware memory (GPU HBM) with only 1 host dispatch per $N$ steps.
  2. Remainder & Uneven Batch Handling: Partial remainder batches (< SPE) and uneven batch shapes fall back gracefully to sequential step execution via PartialBatchList without shape crashes or dropped samples.
  3. Data Adapter Integration:
    • tf.data.Dataset: Automatically batches by steps_per_execution on the host (.batch(SPE).prefetch(AUTOTUNE)) with preserved _super_batched lifecycle state across epochs.
    • NumPy & PyDataset: Batches are stacked on host via _get_host_stacked_iterator with clean fallbacks.
  4. Zero Overhead for steps_per_execution=1: The default single-step execution path remains completely untouched.

Detailed Performance Benchmarks

Benchmark Setup: Deep Feedforward Model (input_dim=128, hidden_dim=256, 4 layers, num_classes=10, batch_size=64, num_samples=64000).


1. Infra: CPU

A. tf.data.Dataset (CPU)

SPE Previous Throughput (samples/s) Previous Latency (ms/step) Previous Speedup (vs. SPE=1) PR Throughput (samples/s) PR Latency (ms/step) PR Speedup (vs. SPE=1) Net Speedup (PR vs. Previous)
1 13,475.9 4.75 ms 1.00x 14,529.4 4.40 ms 1.00x 1.08x
2 20,178.0 3.17 ms 1.50x 12,440.3 5.14 ms 0.86x 0.62x
4 20,935.0 3.06 ms 1.55x 66,027.7 0.97 ms 4.54x 3.15x
8 14,596.8 4.38 ms 1.08x 136,964.7 0.47 ms 9.43x 9.38x
16 21,327.3 3.00 ms 1.58x 133,007.3 0.48 ms 9.15x 6.24x
32 21,997.2 2.91 ms 1.63x 486,462.9 0.13 ms 33.48x 22.11x
64 15,646.7 4.09 ms 1.16x 291,062.9 0.22 ms 20.03x 18.60x

B. NumPy Dataset / Generator (CPU)

SPE Previous Throughput (samples/s) Previous Latency (ms/step) Previous Speedup (vs. SPE=1) PR Throughput (samples/s) PR Latency (ms/step) PR Speedup (vs. SPE=1) Net Speedup (PR vs. Previous)
1 20,183.3 3.17 ms 1.00x 8,754.2 7.31 ms 1.00x 0.43x
2 18,963.6 3.37 ms 0.94x 16,641.2 3.85 ms 1.90x 0.88x
4 14,081.5 4.54 ms 0.70x 13,484.0 4.75 ms 1.54x 0.96x
8 21,239.2 3.01 ms 1.05x 11,800.4 5.42 ms 1.35x 0.56x
16 18,201.8 3.52 ms 0.90x 16,450.4 3.89 ms 1.88x 0.90x
32 21,039.2 3.04 ms 1.04x 19,953.0 3.21 ms 2.28x 0.95x
64 20,784.5 3.08 ms 1.03x 20,430.2 3.13 ms 2.33x 0.98x

2. Infra: GPU

A. tf.data.Dataset (GPU)

SPE Previous Throughput (samples/s) Previous Latency (ms/step) Previous Speedup (vs. SPE=1) PR Throughput (samples/s) PR Latency (ms/step) PR Speedup (vs. SPE=1) Net Speedup (PR vs. Previous)
1 33,727.6 1.90 ms 1.00x 33,231.1 1.93 ms 1.00x 0.99x
2 37,388.7 1.71 ms 1.11x 120,060.0 0.53 ms 3.61x 3.21x
4 41,181.3 1.55 ms 1.22x 413,578.4 0.15 ms 12.45x 10.04x
8 41,940.8 1.53 ms 1.24x 942,298.0 0.068 ms 28.36x 22.47x
16 41,100.1 1.56 ms 1.22x 1,200,796.0 0.053 ms 36.13x 29.22x
32 42,007.5 1.52 ms 1.25x 1,202,540.0 0.053 ms 36.19x 28.63x
64 42,217.2 1.52 ms 1.25x 714,004.9 0.090 ms 21.49x 16.91x

B. NumPy Dataset / Generator (GPU)

SPE Previous Throughput (samples/s) Previous Latency (ms/step) Previous Speedup (vs. SPE=1) PR Throughput (samples/s) PR Latency (ms/step) PR Speedup (vs. SPE=1) Net Speedup (PR vs. Previous)
1 37,841.3 1.69 ms 1.00x 40,766.6 1.57 ms 1.00x 1.08x
2 41,473.7 1.54 ms 1.10x 60,651.2 1.06 ms 1.49x 1.46x
4 32,311.3 1.98 ms 0.85x 69,622.4 0.92 ms 1.71x 2.15x
8 44,423.5 1.44 ms 1.17x 128,850.5 0.50 ms 3.16x 2.90x
16 45,988.7 1.39 ms 1.22x 152,346.3 0.42 ms 3.74x 3.31x
32 40,931.1 1.56 ms 1.08x 170,306.7 0.38 ms 4.18x 4.16x
64 43,434.7 1.47 ms 1.15x 169,052.3 0.38 ms 4.15x 3.89x

Visualizations & Screenshots


Key Takeaways & Conclusions

  1. Previous Implementation Bottleneck (Flat Scaling):
    • On master, increasing steps_per_execution from 1 to 64 produced virtually no throughput improvement on GPU (~1.25x on tf.data, ~1.15x on NumPy).
    • Because the multi-step loop executed in Python on the host, each step incurred a full PJRT launch barrier and buffer dispatch round-trip, keeping step latency locked at ~1.5–1.9 ms/step.
  2. Dramatic GPU Acceleration with tf.data.Dataset:
    • By fusing multi-step execution on-device via jax.lax.scan and pre-batching $N$ steps in C++ (.batch(SPE).prefetch(AUTOTUNE)), host dispatch stalls are completely eliminated.
    • GPU throughput scales from 33,231 samples/s (SPE=1) to 1,202,540 samples/s (SPE=32) — achieving a 36.2x internal speedup and a 29.2x net speedup over the previous implementation.
    • Per-step latency drops from 1.90 ms/step down to 0.053 ms/step.
  3. NumPy Generator GPU Scaling:
    • For Python generators on GPU, throughput scales by 4.18x (from 40,766 to 170,307 samples/s), with step latency dropping from 1.57 ms to 0.38 ms. Because batching happens in Python host memory via np.stack, tf.data is recommended for maximum throughput.
  4. Optimal steps_per_execution Range:
    • Peak throughput is achieved around SPE=16 to SPE=32, balancing maximum accelerator saturation against memory footprint and compilation time.

Testing

Added comprehensive unit tests in keras/src/backend/jax/trainer_test.py:

  • test_steps_per_execution_numeric_equivalence: Verifies strict numerical equivalence of weights and losses between steps_per_execution=1 and steps_per_execution > 1.
  • test_steps_per_execution_remainder_batches: Validates that datasets with non-divisible remainder batches execute seamlessly across fit, predict, and evaluate.
  • test_steps_per_execution_tf_dataset_functional_model: Verifies end-to-end multi-step training with tf.data.Dataset pipelines on Functional models across multiple epochs.

Ran full test suites:

  • pytest keras/src/backend/jax/trainer_test.py (12/12 passed)
  • pytest keras/src/trainers/trainer_test.py (162/162 passed)
  • Formatted and linted with ruff.

Contributor Agreement

  • I am a human, and not a bot.
  • I will be responsible for responding to review comments in a timely manner.
  • I will work with the maintainers to push this PR forward until submission.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request implements support for multi-step execution (steps_per_execution > 1) in the JAX backend, introducing host-side super-batching, JAX-native scanning via jax.lax.scan, and fallback unrolling for partial batches. The review feedback highlights several critical robustness issues where the implementation assumes non-null values in nested batch structures. Specifically, the reviewer pointed out that module-level @jit on _concatenate_outputs bypasses eager execution settings, and multiple utility functions (such as tree.map_structure and layout mapping) will crash with TypeError or AttributeError if optional batch elements (like sample_weight) are None. Adding proper guards for None values is highly recommended to ensure stability.

Comment thread keras/src/backend/jax/trainer.py Outdated
Comment on lines +37 to +46
@jit
def _concatenate_outputs(outputs):
if not outputs:
return []
if len(outputs) == 1:
return outputs[0]
return tree.map_structure(
lambda *args: jax.numpy.concatenate(args, axis=0),
*outputs,
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Unconditionally decorating _concatenate_outputs with @jit at the module level violates run_eagerly=True (or jit_compile=False) and can cause unexpected compilation overhead. Additionally, if any of the outputs are None (e.g., optional outputs or state), jax.numpy.concatenate will raise a TypeError. We should remove the module-level @jit decorator and handle None values safely.

def _concatenate_outputs(outputs):
    if not outputs:
        return []
    if len(outputs) == 1:
        return outputs[0]
    return tree.map_structure(
        lambda *args: jax.numpy.concatenate(args, axis=0) if args[0] is not None else None,
        *outputs,
    )

Comment on lines +300 to +306
leaf = tree.flatten(batch)[0]
if leaf.shape[0] < self.steps_per_execution:
sliced_batches = [
tree.map_structure(lambda x, i=i: x[i], batch)
for i in range(leaf.shape[0])
]
return _unroll_steps(state, sliced_batches)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

In Case 2, tree.flatten(batch)[0] is fragile because the first element of the flattened batch could be None (e.g., if the batch structure starts with an optional element). Using next(x for x in tree.flatten(batch) if x is not None) is much more robust. Furthermore, tree.map_structure(lambda x, i=i: x[i], batch) will crash with a TypeError if any element in batch is None (such as sample_weight=None or y=None during prediction). We should check if x is not None before indexing.

Suggested change
leaf = tree.flatten(batch)[0]
if leaf.shape[0] < self.steps_per_execution:
sliced_batches = [
tree.map_structure(lambda x, i=i: x[i], batch)
for i in range(leaf.shape[0])
]
return _unroll_steps(state, sliced_batches)
leaf = next((x for x in tree.flatten(batch) if x is not None), None)
if leaf is not None and leaf.shape[0] < self.steps_per_execution:
sliced_batches = [
tree.map_structure(
lambda x, i=i: x[i] if x is not None else None,
batch,
)
for i in range(leaf.shape[0])
]
return _unroll_steps(state, sliced_batches)

Comment on lines +313 to +317
sliced_batches = [
tree.map_structure(lambda x, i=i: x[i], batch)
for i in range(leaf.shape[0])
]
return _unroll_steps(state, sliced_batches)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

In Case 4, tree.map_structure(lambda x, i=i: x[i], batch) will crash with a TypeError if any element in batch is None (such as sample_weight=None or y=None during prediction). We should check if x is not None before indexing.

Suggested change
sliced_batches = [
tree.map_structure(lambda x, i=i: x[i], batch)
for i in range(leaf.shape[0])
]
return _unroll_steps(state, sliced_batches)
if leaf is not None:
sliced_batches = [
tree.map_structure(
lambda x, i=i: x[i] if x is not None else None,
batch,
)
for i in range(leaf.shape[0])
]
return _unroll_steps(state, sliced_batches)

Comment on lines +1272 to +1273
def get_single_layout(d):
return distribution.get_data_layout(d.shape).backend_layout

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

If batches[0] contains any None elements (e.g., optional sample_weight or y), get_single_layout will crash with an AttributeError when accessing d.shape. We should add a guard to return None if d is None.

                def get_single_layout(d):
                    if d is None:
                        return None
                    return distribution.get_data_layout(d.shape).backend_layout

Comment on lines +1308 to +1311
def get_layout(_, d_orig):
base_layout = distribution.get_data_layout(
d_orig.shape
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

If batches[0] contains any None elements, get_layout will crash with an AttributeError when accessing d_orig.shape. We should add a guard to return None if d_orig is None.

Suggested change
def get_layout(_, d_orig):
base_layout = distribution.get_data_layout(
d_orig.shape
)
def get_layout(_, d_orig):
if d_orig is None:
return None
base_layout = distribution.get_data_layout(
d_orig.shape
)

@codecov-commenter

codecov-commenter commented Aug 29, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 62.25166% with 57 lines in your changes missing coverage. Please review.
✅ Project coverage is 84.22%. Comparing base (83320b5) to head (9d802a5).

Files with missing lines Patch % Lines
keras/src/backend/jax/trainer.py 62.25% 43 Missing and 14 partials ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master   #23527      +/-   ##
==========================================
- Coverage   84.29%   84.22%   -0.07%     
==========================================
  Files         468      468              
  Lines       71096    71191      +95     
  Branches    11788    11820      +32     
==========================================
+ Hits        59931    59962      +31     
- Misses       8164     8215      +51     
- Partials     3001     3014      +13     
Flag Coverage Δ
keras 84.05% <62.25%> (-0.07%) ⬇️
keras-cpu 84.05% <62.25%> (-0.06%) ⬇️
keras-jax 58.28% <62.25%> (-0.04%) ⬇️
keras-numpy 53.88% <3.31%> (-0.07%) ⬇️
keras-openvino 59.58% <3.31%> (-0.08%) ⬇️
keras-tensorflow 59.86% <3.31%> (-0.08%) ⬇️
keras-torch 59.27% <3.31%> (-0.08%) ⬇️
keras-tpu ?

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@gaga1313 gaga1313 changed the title [Don't Merge] [JAX] Accelerate multi-step training loops using on-device jax.lax.scan for steps_per_execution > 1 [JAX] Accelerate multi-step training loops using on-device jax.lax.scan for steps_per_execution > 1 Aug 30, 2026
Comment on lines +42 to +45
return tree.map_structure(
lambda *args: jax.numpy.concatenate(args, axis=0),
*outputs,
)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not just return ax.numpy.concatenate(outputs, axis=0)?

Line 41 assumes it's a list or tuple already.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This function was already in the library. The line 41 only checks if the nested output is list of length 1 then return the first outputs[0].

jax.numpy.concatenate will not work with the nested outputs.

Comment thread keras/src/backend/jax/trainer.py Outdated
Comment on lines +31 to +34
class PartialBatchList(list):
"""Wrapper to distinguish a list of batches from a batch of lists/tuples."""

pass

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this needed?

Doesn't the existing if on the batch size cover this?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you are talking about if statements on line 300 and 304, they are different. I have removed the PartialBatchList, and simply replaced the if condition with to check if batch is a list.

Comment thread keras/src/backend/jax/trainer.py Outdated
Comment on lines +229 to +230
step_function,
raw_step_function=None,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The contract for _make_function was basically to add support for steps_per_execution. The jitting support was added beforehand and step_function was already jitted if applicable.

Now _make_function has this weird contract that takes two functions, one maybe jitted and one not jitted. _make_function jits the loop case but doesn't jit the non-loop case. _make_function uses both step_function and raw_step_function, which is confusing.

Let's clean up the contract and do:

  • only step_function is passed and it's the non-jitted function
  • _make_function takes care of the jitting (if jitting is requested) in all cases, and there is no pre-jitting before calling _make_function

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for highlighting this. Fixed!

Comment thread keras/src/backend/jax/trainer.py Outdated
step_function,
raw_step_function=None,
out_shardings=None,
donate_argnums=0,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

donate_argnums: do we need this? Isn't it always 0?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for highlighting, I will remove it.

Comment on lines +455 to +460
if isinstance(data_batch, list):
data_batch = data_batch[0]
else:
data_batch = tree.map_structure(
lambda x: x[0] if x is not None else None, data_batch
)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you should always do the tree.map_structure. It will work in the list case too, and it will handle the case of nested lists, which I don't think is handled correctly right now.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we always use tree.map_structure then in the case of PartialBatchList [b1, b2], the tree.map_structure will recursively slice into the leaves of each batch, which strips the batch dimension.

data_batch = next(data_or_iterator)
break

if data_batch is not None and self.steps_per_execution > 1:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't you need to also check if super-batching was enabled?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If steps_per_execution > 1 then super-batching is enabled by default. Either iterator would return a Super_batch (pytree) or list through get_host_stack_terator or super-batch via tf.data iterator (pytree).

Comment thread keras/src/backend/jax/trainer_test.py Outdated
Comment on lines +154 to +158
@parameterized.named_parameters(
{"testcase_name": "spe_2", "steps_per_execution": 2},
{"testcase_name": "spe_4", "steps_per_execution": 4},
)
def test_steps_per_execution_numeric_equivalence(self, steps_per_execution):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is nothing JAX specific about the tests you added, they should pass with all backends that support steps_per_execution. So, at the very least, they should move to https://github.com/keras-team/keras/blob/master/keras/src/trainers/trainer_test.py

In fact there are already tests for steps_per_execution in https://github.com/keras-team/keras/blob/master/keras/src/trainers/trainer_test.py

So make sure they cover the same and remove these, or augment them if something is missing.

@gaga1313 gaga1313 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed thanks! I have also removed the test from jax/trainer_test.py.

Comment thread keras/src/backend/jax/trainer.py Outdated
Comment on lines +31 to +34
class PartialBatchList(list):
"""Wrapper to distinguish a list of batches from a batch of lists/tuples."""

pass

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you are talking about if statements on line 300 and 304, they are different. I have removed the PartialBatchList, and simply replaced the if condition with to check if batch is a list.

Comment thread keras/src/backend/jax/trainer.py Outdated
step_function,
raw_step_function=None,
out_shardings=None,
donate_argnums=0,

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for highlighting, I will remove it.

Comment thread keras/src/backend/jax/trainer.py Outdated
Comment on lines +229 to +230
step_function,
raw_step_function=None,

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for highlighting this. Fixed!

data_batch = next(data_or_iterator)
break

if data_batch is not None and self.steps_per_execution > 1:

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If steps_per_execution > 1 then super-batching is enabled by default. Either iterator would return a Super_batch (pytree) or list through get_host_stack_terator or super-batch via tf.data iterator (pytree).

Comment on lines +455 to +460
if isinstance(data_batch, list):
data_batch = data_batch[0]
else:
data_batch = tree.map_structure(
lambda x: x[0] if x is not None else None, data_batch
)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we always use tree.map_structure then in the case of PartialBatchList [b1, b2], the tree.map_structure will recursively slice into the leaves of each batch, which strips the batch dimension.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants