patrick-kidger / patrick-kidger/diffrax

Significant performance difference: diffeqsolve vs. lax.scan - Expected Behavior?

Open
#592 4 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
Python
Stars
2.1k
Forks
189
Avg merge
3d 18h
Merged PRs (30d)
1

Description

Hi,

I appreciate all the work that has gone into Diffrax! I'm trying to use diffrax to simulate the interaction between an NN policy and an ode system model for a given horizon length. Specifically, the policy takes the system state as input at each step and generate the control action that is to be applied to the system. Then the ode is solved for obtaining the evolution of the state. For that I have two implementations. The first one is by using lax.scan, and in each iteration the ode is solved for one step using solver.init() and solver.step(). In the second implementation, I use Diffrax.diffeqsolve() which takes as arguments the final simulation time and saveAt(ts = jnp.linespace(t0,t1, num_sim_steps) in addition to term, solver, ..etc. For both implementations I use 'Diffrax.Euler' as solver and I augment the ODE with the policy network inside the vector-field. However, I noticed that the simulation with Diffrax.diffeqsolve() is almost 3 times slower than with lax.scan and single steps, and this difference gets even bigger when comparing Diffrax.diffeqsolve() with 'Adaptive steps' and lax.scan with 'fixed single steps'. The idea behind using Diffrax.diffeqsolve() is that I wanted to investigate if reducing the number of steps, by adapting step sizes, would improve the simulation speed even in the presence of the overheads resulted from intermediate solver-related calculations and rejected steps when exceeding tolerances. But what I don't understand is that when using Euler I would expect both implementations to have similar simulation speeds, which is not the case. My second question would be is there a way to utilize adaptive solver to make the simulation faster for this application.

Here is a benchmark example for this comparison in which I replaced the actual system with an arbitrary first-order ODE. I use Python (3.12.7), Jax (0.4.35), equinox (0.11.10), and diffrax (0.5.1). I run the code on CPU 13th Gen Intel i7-1355U.

Imports

import jax
import jax.numpy as jnp
import numpy as np

import equinox as eqx
import optax
import diffrax

import timeit

Define policy network class

class MLP(eqx.Module):
    """ class for a policy for providing control actions.
    """
    layers: list[eqx.nn.Linear]

    def __init__(self, layer_sizes, key):
        self.layers = []
        for fan_in, fan_out in zip(layer_sizes[:-1], layer_sizes[1:]):
            key, subkey = jax.random.split(key)
            self.layers.append(eqx.nn.Linear(fan_in, fan_out, use_bias=True, key=subkey))

    def __call__(self, x):
        for layer in self.layers[:-1]:
            x = jax.nn.leaky_relu(layer(x))
        return self.layers[-1](x)

case 1: lax.scan and single-steps

def ode_step(init_state, ref, policy):
     """Method for simulating the policy-environment interaction and solving the ode for one step
 
     Args:
         init_state (jax.Array): state at the current step (y0)
         ref (jax.Array): reference state at the current step
         policy (MLP): for predicting the control action 
 
     Returns:
         jax.Array: updated state after one step simulation
     """
     args = (ref)
 
     # ode, including policy (MLP) and system: tau*dy/dt + y = u, aumming tau=1
     d_y = lambda t,y,args: (policy(jnp.concatenate([y, args]))-y)                                        
 
     term = diffrax.ODETerm(d_y)
     solver = diffrax.Euler()
     t0 = 0
     t1 = 1e-4
     y0 = init_state
     env_state = solver.init(term, t0, t1, y0, args)
     y, _, _, env_state, _ = solver.step(term, t0, t1, y0, args, env_state, made_jump=False)
     return y

def rollout_traj_scan(policy, init_states, ref_states, horizon_length):
     """rollout policy-environment interaction for 'horizon_length' for single sample using lax.scan
 
     Args:
         policy (MLP): predicting control actions
         init_states (jax.Array): initial environment state before rollout
         ref_states (jax.Array): reference to be tracked
         horizon_length (int): length of future predictions
 
     Returns:
         jax.Array: MSE tracking loss
     """
     # extending ref_states to horizon length
     ref_o = jnp.repeat(ref_states[None, :], horizon_length, axis=0)                                      
     
     def body_fun(carry, ref):
         state = carry
         state = ode_step(carry, ref, policy)
         return (state), (state)
 
     _, (states) = jax.lax.scan(body_fun, (init_states), ref_o, horizon_length)
 
     # error between the simulation state and reference
     error= states-ref_states                                                                           
 
     loss=jnp.mean((error)**2)                                                                               
     return jnp.clip(loss, max=1e5)

Case 2: Diffrax.diffeqsolve

def ode_diffeqsolve(policy, init_state, ref_state,  horizon_length):
      """Method for rollout, simulating the policy-environment interaction, and solving the ode for 'horizon_length' usin diffrax.diffeqsolve.
  
      Args:
          policy (MLP): predicting control actions
          init_state (jax.Array): initial environment state before rollout
          ref (jax.Array): reference to be tracked
          horizon_length (int): length of future predictions
  
      Returns:
          jax.Array: state trajectory at predefined time steps
      """
      args = (ref_state)
  
      # ode, including policy (MLP) and system: tau*dy/dt + y = u, assuming tau=1
      d_y = lambda t,y,args: (policy(jnp.concatenate([y, args]))-y)                                        
                                                                                                           
      return diffrax.diffeqsolve(
          terms = diffrax.ODETerm(d_y), 
          solver = diffrax.Euler(), 
          t0 = 0, 
          t1 = horizon_length*1e-4, 
          dt0=1e-4, 
          y0=init_state, 
          args=args, 
          saveat=diffrax.SaveAt(ts=jnp.linspace(1e-4, horizon_length*1e-4, horizon_length)), 
          stepsize_controller=diffrax.ConstantStepSize()
      ).ys 

#Roll out using diffrax.diffeqsolve until t1
def rollout_traj_diffeqsolve(policy, init_states, ref_states, horizon_length):
      """Calls 'ode_diffeqsolve' for the rollout and calculate MSE tracking loss
  
      Args:
          policy (MLP): for predicting control actions
          init_states (jax.Array): initial environment state before rollout
          ref_states (jax.Array): reference to be tracked
          horizon_length (int): length of future predictions
  
      Returns:
          jax.Array: MSE tracking loss
      """
  
      # get state trajectory through horizon_length
      states = ode_diffeqsolve(policy, init_states, ref_states, horizon_length)                          
  
      # error between the simulation state and reference
      error= states-ref_states                                                                           
  
      loss=jnp.mean((error)**2)
      return jnp.clip(loss, max=1e5)
      

Setup for the benchmark

jax_key = jax.random.PRNGKey(np.random.randint(0, 2**31))
jax_key, policy_key = jax.random.split(jax_key)

policy=MLP([2,20,20,20,1],key=jax_key)  # initialize policy network

state_key, ref_key = jax.random.split(jax_key)
init_states= jax.random.uniform(state_key, minval=0.0, maxval=30.0, shape=(1,))  # generate random initial state
ref_states =  jax.random.uniform(ref_key, minval=0.0, maxval=30.0, shape=(1,))  # generate random reference state

horizon_length = 25  # rollout length
train_steps = 5000  # number of iterations to be measured

Function for testing the speed of fwd and bwd propagations of the system

def speedtest(fcn, name):
    
    fwd = eqx.filter_jit(fcn)
    bwd = eqx.filter_jit(eqx.filter_grad(fcn))

    #Measure fwd time for train_steps iterations
    fwd_times = timeit.repeat(
        lambda: jax.block_until_ready(fwd(
        policy,
        init_states,
        ref_states,
        horizon_length,
    )), number=train_steps, repeat=10
    )                                                                                                      
    print(f"{name} fwd: {min(fwd_times)}")

    #Measure fwd+Bwd time for train_steps iterations
    bwd_times = timeit.repeat(
        lambda: jax.block_until_ready(bwd(
        policy,
        init_states,
        ref_states,
        horizon_length,
    )), number=train_steps, repeat=10
    )                                                                                                      
    print(f"{name} fwd+bwd: {min(bwd_times)}")

Run tests

speedtest(rollout_traj_scan, "scan")
speedtest(rollout_traj_diffeqsolve, "diffeqsolve")

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start by reproducing the benchmark comparing lax.scan with solver.init()/solver.step() against diffrax.diffeqsolve using Euler and the supplied speedtest. Read the diffrax.diffeqsolve and solver-step entry points, then determine whether the observed overhead is expected and whether adaptive stepping can improve this policy-and-ODE workload; done means documenting the cause and guidance for this use case.

Written by the indexing model from the issue text.

Assessment

Tech stack
python
Domain
machine-learning, performance
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Stale
Clarity
Needs clarification
Newbie friendliness
30/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.