patrick-kidger / patrick-kidger/diffrax

Model is slower using diffrax, and even more when calling a function inside the vector_field

Open
#605 6 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

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

Description

Hello,

I am using diffrax to time-forward an ODE in a context of variational inversion.
Using equinox fitlers and abstract solver from diffrax is very powerful, and it could be very beneficial for my project down the line.

I coded a simple model in both equinox/diffrax and JAX pure but i see that the former is about 3 times slower.
For now this is negligible but later I will code a heavier model and it will become problematic.

The key aspect of this model is that I run many many timesteps (28 days at dt=60s, 40320 timesteps).
However, it seems that the diffrax model is always about x3 compared to JAX pure no matter the number of iterations.

For the gradient of the cost function, I use forward AD because of my very limited number of control parameters, but using diffrax should allow me to switch easily to reverse AD with automatic checkpointing if needed.

Description of my models

Note : the equation solved is the same for all

  • jmodel : JAX only code, my model is a Python class with a 'do_forward' method. Forcing is hard coded in the time loop.
  • eqx_model : my model is a eqx.Module class with a 'call' method. Forcing is hard coded in the time loop.
  • eqx_model_interpn : my model is a eqx.Module class with a 'call' method. Forcing calls a interpolation function.
  • eqx_dfx_model : my model is a eqx.Module class with a 'call' method, and I use diffrax to time step the model. Interpolation is hard coded in the vector_field definition.
Benchmark results (seconds)

forward pass:

jmodel : 0.18528
eqx_model : 0.22648
eqxmodel_interpn : 3.45376
eqx_dfx_model  : 0.53019

cost :

jmodel : 0.18697
eqx_model : 0.23840
eqxmodel_interpn : 3.25355
eqx_dfx_model  : 0.59816

grad :

jmodel : 0.26339
eqx_model : 0.29954
eqxmodel_interpn : 3.10121
eqx_dfx_model  : 1.02204
My questions
  • jmodel vs eqx_model: Equinox adds a bit of overhead for each timestep (about 1 microsec/timestep), I think this is normal but could you confirm ? (related to this)
  • eqx_model vs eqxmodel_interpn: Evaluating a function inside the timeloop seems to increase a lot the total time for the forward model (about x10 in my example). Is this expected ? I guess I have a workaround with my interpolation 'by hand' but it would be awesome to change the interpolation method with coding this part again, using diffrax.LinearInterpolation.
  • eqx_dfx_model vs eqx_model: Is this overhead induced by diffrax considered normal ? If no, what option in diffeqsolve could provide similar performance to pure JAX ? (I have had a look at #517 and #592 but had no success with modifying the diffeqsolve arguments)

Thank you for you help !

Hugo

config:
I run on a T400 mobile GPU
diffrax 0.7
jax 0.5.2
equinox 0.11.12

Code
import numpy as np
import os
import matplotlib.pyplot as plt
import time as clock
import jax
import jax.numpy as jnp
from jax import jit, lax
import jax.tree_util as jtu
from functools import partial
import equinox as eqx
import diffrax
from diffrax import Euler, diffeqsolve, ODETerm
os.environ["EQX_ON_ERROR"] = "nan"
jax.config.update("jax_enable_x64", True)

class classic_slab1D:
    def __init__(self, TAx, TAy, fc, t0, nt, dt, dt_forcing):
        self.TA = jnp.asarray(TAx) + 1j*jnp.asarray(TAy)
        self.fc = jnp.asarray(fc)  
        self.t0 = t0
        self.t1 = t0+nt*dt
        self.dt = dt
        self.dt_forcing = dt_forcing
        self.nt = nt
        self.do_forward_jit = jit(self.do_forward)
        
    def __one_step(self, X0, it):
        K, U = X0      
        # interpolation
        nsubsteps = self.dt_forcing // self.dt
        itf = jnp.array(it//nsubsteps, int)
        aa = jnp.mod(it,nsubsteps)/nsubsteps
        itsup = lax.select(itf+1>=self.nt, -1, itf+1) 
        TA = (1-aa)*self.TA[itf] + aa*self.TA[itsup]
        # 1 time forward
        U = U.at[it+1].set( U[it] + self.dt*(-1j*self.fc*U[it] 
                                           + K[0]*TA 
                                           - K[1]*U[it] ) )  
        X0 = K, U        
        return X0, X0

    def do_forward(self, pk):
        # initialisation
        U = jnp.zeros( self.nt, dtype='complex')
        K = jnp.exp(pk)
        # time loop
        X0 = K, U
        final, _ = lax.scan(self.__one_step, X0, jnp.arange(0,self.nt-1))
        _, U = final
        return jnp.real(U), jnp.imag(U)
   
class classic_slab1D_eqx(eqx.Module):
    
    pk : jnp.array # control vector
    TA : jnp.array          # parameters
    fc : jnp.array          # |
    dt_forcing : np.int32   # |
    t0 : np.int32           # run parameters
    t1 : np.int32           # |
    nt : np.int32           # | 
    dt : np.int32           # |
    
    is_difx : bool          # use diffrax or not
    interp_fn : bool        # use interoplation function or not
    
    def __init__(self, pk, TAx, TAy, fc, t0, nt, dt, dt_forcing, is_difx, interp_fn=False):
        self.fc = jnp.asarray(fc)  
        self.TA = jnp.asarray(TAx) + 1j*jnp.asarray(TAy)
        self.pk = pk
        self.t0 = t0
        self.t1 = t0+nt*dt
        self.dt = dt
        self.dt_forcing = dt_forcing
        self.nt = nt
        self.is_difx = is_difx
        self.interp_fn = interp_fn
        
    @eqx.filter_jit
    def __call__(self):
        K = jnp.exp(self.pk)
        nsubsteps = self.dt_forcing // self.dt
        
        if self.interp_fn:
                TAx_t, TAy_t = self.TA_at_t(jnp.real(self.TA), jnp.imag(self.TA), self.t0, self.t1, self.dt_forcing)
        
        if self.is_difx: # diffrax time forward

            def vector_field(t, C, args):
                U,V = C
                fc, K, TAx, TAy = args
                if self.interp_fn:
                    TAx,TAy = TAx_t.evaluate(t), TAy_t.evaluate(t)
                else:
                    # on the fly interpolation
                    it = jnp.array(t//dt, int)
                    itf = jnp.array(it//nsubsteps, int)
                    aa = jnp.mod(it,nsubsteps)/nsubsteps
                    itsup = lax.select(itf+1>=len(TAx), -1, itf+1) 
                    TAx = (1-aa)*TAx[itf] + aa*TAx[itsup]
                    TAy = (1-aa)*TAy[itf] + aa*TAy[itsup]
                # physic
                d_U = fc*V + K[0]*TAx - K[1]*U
                d_V = -fc*U + K[0]*TAy - K[1]*V
                d_y = d_U,d_V
                return d_y

            sol = diffeqsolve(terms=ODETerm(vector_field), 
                           solver=Euler(), 
                           t0=self.t0, 
                           t1=self.t1, 
                           y0=(0.0, 0.0), 
                           args=(self.fc, K, jnp.real(self.TA), jnp.imag(self.TA)), 
                           dt0=dt, 
                           saveat=diffrax.SaveAt(steps=True),
                           adjoint=diffrax.ForwardMode(),
                           max_steps=nt).ys
            U = sol[0]+1j*sol[1]
            
        else: # my time forward
            
            U = jnp.zeros( self.nt, dtype='complex')
            def __one_step(X0, it):
                K, U = X0  
                # interpolation
                if self.interp_fn:
                    t = self.t0 + it*self.dt
                    TA = TAx_t.evaluate(t) +1j*TAy_t.evaluate(t)
                else:
                    itf = jnp.array(it//nsubsteps, int)
                    aa = jnp.mod(it,nsubsteps)/nsubsteps
                    itsup = lax.select(itf+1>=self.nt, -1, itf+1) 
                    TA = (1-aa)*self.TA[itf] + aa*self.TA[itsup]
                # one time forward
                U = U.at[it+1].set( U[it] + self.dt*(-1j*self.fc*U[it] 
                                                + K[0]*TA 
                                                - K[1]*U[it] ) )  
                X0 = K, U        
                return X0, X0
            # time loop
            X0 = K, U
            final, _ = lax.scan(lambda carry, y: __one_step(carry, y), X0, jnp.arange(0,self.nt-1)) # 
            _, U = final
        return jnp.real(U),jnp.imag(U)
    def TA_at_t(self, TAx, TAy, t0, t1, dt_forcing):
        """
        return a function that interpolate at t the forcing
        """
        time_forcing = jnp.arange(t0, t1, dt_forcing, dtype=float)
        # print(len(time_forcing),len(TAx))
        TAx_t = diffrax.LinearInterpolation(time_forcing, TAx)
        TAy_t = diffrax.LinearInterpolation(time_forcing, TAy)
        return TAx_t, TAy_t

def benchmark(func, N=20):
    L = np.zeros(N)
    _ = func() # run once for compilation
    for k in range(N):
        time1=clock.time()
        result = func()
        L[k] = clock.time()-time1
    return L.mean(), L.std()

def cost(sol, obs): return jnp.nanmean( (sol[0]-obs[0])**2 + (sol[0]-obs[0])**2)
    
def cost_j(pk, jmodel, obs):
    sol = jmodel.do_forward_jit(pk)
    return cost(sol, obs)

def cost_eqx(dynamic_model, static_model, obs):
    mymodel = eqx.combine(dynamic_model, static_model)
    sol = mymodel()
    return cost(sol, obs)

def dcost_j(pk, jmodel, obs): return jax.jacfwd(cost_j)(pk, jmodel, obs)
    
def dcost_eqx(dynamic_model, static_model, obs): return eqx.filter_jacfwd(cost_eqx)(dynamic_model, static_model, obs)
    
def my_partition(mymodel):
    filter_spec = jtu.tree_map(lambda arr: False, mymodel) # keep nothing
    filter_spec = eqx.tree_at( lambda tree: tree.pk, filter_spec, replace=True) # keep only pk
    return eqx.partition(mymodel, filter_spec)
    
# MY PARAMETERS
t0 = 0
nt = 28*86400//60
dt = 60
dt_forcing = 3600.
time_forcing = jnp.arange(t0,nt*dt,dt_forcing)
TAx = 0.2*np.ones(len(time_forcing)) # step forcing
TAy = 0.0*np.ones(len(time_forcing))
fc = 1e-4 

pktarget = jnp.asarray([-8.,-13.])
pkini = jnp.array([-9.,-11.])
 

jmodel = classic_slab1D(TAx, TAy, fc, t0, nt, dt, dt_forcing)
eqxmodel = classic_slab1D_eqx(pktarget, TAx, TAy, fc, t0, nt, dt, dt_forcing, is_difx=False)
eqxmodel_dfx = classic_slab1D_eqx(pktarget, TAx, TAy, fc, t0, nt, dt, dt_forcing, is_difx=True)
eqxmodel_fninterp = classic_slab1D_eqx(pktarget, TAx, TAy, fc, t0, nt, dt, dt_forcing, is_difx=False, interp_fn=True)
N = 10

# make some observations
dt_obs = 86400 # 1 per day
obs = np.nan*jnp.zeros((2,nt),dtype=np.float64)
truth = jmodel.do_forward_jit(pktarget)
for k in range(nt):
    if k%(dt_obs//dt)==0:
        obs = obs.at[0,k].set(truth[0][k]) # U
        obs = obs.at[1,k].set(truth[1][k]) # V

# new models with new control parameter pk
eqxmodel_2 = classic_slab1D_eqx(pkini, TAx, TAy, fc, t0, nt, dt, dt_forcing, is_difx=False)
eqxmodel_fninterp_2 = classic_slab1D_eqx(pktarget, TAx, TAy, fc, t0, nt, dt, dt_forcing, is_difx=False, interp_fn=True)
eqxmodel_dfx_2 = classic_slab1D_eqx(pkini, TAx, TAy, fc, t0, nt, dt, dt_forcing, is_difx=True)
dyn_eqx, stat_eqx = my_partition(eqxmodel_2)
dyn_eqx_fni, stat_eqx_fni = my_partition(eqxmodel_fninterp_2)
dyn_eqx_dfx, stat_eqx_dfx = my_partition(eqxmodel_dfx_2)

print('Forward model:')
print('     jmodel:           mean, std (s)', benchmark(partial(jmodel.do_forward_jit,pk=pktarget),N=N))
print('     eqxmodel:         mean, std (s)', benchmark(eqxmodel,N=N))
print('     eqxmodel_interpn: mean, std (s)', benchmark(eqxmodel_fninterp,N=N))
print('     eqx_dfx_model:    mean, std (s)', benchmark(eqxmodel_dfx,N=N))

print('Cost:')
print('     jmodel:           mean, std (s)', benchmark(partial(cost_j,pk=pkini,jmodel=jmodel,obs=obs),N=N))
print('     eqxmodel:         mean, std (s)', benchmark(partial(cost_eqx,dyn_eqx,stat_eqx,obs),N=N))
print('     eqxmodel_interpn: mean, std (s)', benchmark(partial(cost_eqx,dyn_eqx_fni,stat_eqx_fni,obs),N=N))
print('     eqx_dfx_model:    mean, std (s)', benchmark(partial(cost_eqx,dyn_eqx_dfx,stat_eqx_dfx,obs),N=N))

print('gradient (forward):')
print('     jmodel:           mean, std (s)', benchmark(partial(dcost_j,pk=pkini,jmodel=jmodel,obs=obs),N=N))
print('     eqxmodel:         mean, std (s)', benchmark(partial(dcost_eqx,dyn_eqx,stat_eqx,obs),N=N))
print('     eqxmodel_interpn: mean, std (s)', benchmark(partial(dcost_eqx,dyn_eqx_fni,stat_eqx_fni,obs),N=N))
print('     eqx_dfx_model:    mean, std (s)', benchmark(partial(dcost_eqx,dyn_eqx_dfx,stat_eqx_dfx,obs),N=N))

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

Reproduce the supplied benchmark using the shown diffeqsolve, ODETerm, Euler, and LinearInterpolation entry points under the listed Python, JAX, Equinox, and diffrax versions. Compare the pure JAX, Equinox, interpolation, and diffrax variants on the stated GPU; done means the overhead is explained and any relevant configuration or limitation is documented.

Written by the indexing model from the issue text.

Assessment

Tech stack
python
Domain
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.