3
\$\begingroup\$

This is a program to solve a differential equation numerically using Euler method. As of now, it is very slow, and I need to run 10000 Monte Carlo simulations.

The differential equation is called stochastic Landau-Lifschitz-Gilbert equation with Spin Transfer Torque effect, basically a differential equation having vector cross products.

I cannot use scipy because there is random noise added in each simulation step (ASFAIK) scipy cannot do that. And I also need to normalize the resultant vector in each step.

import numpy as np
from matplotlib import pyplot as plt

class Mtj:
    def __init__(self, step_size, t_max):
        #Physical constants
        self.mu0 = 4 * np.pi * 1e-7
        self.gamma = 2.2127e5
        self.kb = 1.38e-23
        self.e = 1.6e-19
        self.me = 9.1e-31
        self.hbar = 1.054e-34
        self.alpha = 0.05
        self.gamma_eff = self.gamma / (1 + self.alpha**2)
        #MTJ Dimensions
        self.D = 50e-9
        self.tf = 1.1e-9
        self.tox = 1.4e-9
        self.AMTJ = np.pi * (self.D**2) / 4
        self.Vf = self.AMTJ * self.tf
        #Demag tensors
        self.Nx = (np.pi * self.tf) / (4 * self.D)
        self.Ny = self.Nx
        self.Nz = 1 - (2 * self.Nx)
        #Mag field constants
        self.HEX = -(5 / 1e4) / self.mu0
        self.Hex = np.array([0, self.HEX, 0])
        self.HPMA = None
        self.HVCMA = None
        self.HDEMAG = None

        self.temperature = None
        #Magnetic Parameters
        self.Ms0 = 0.786e6
        self.Ki0 = 5.274e-4
        self.Beta0 = 97.3e-15
        #Electrical
        self.VMTJ = -0.2
        self.Istt = None
        self.Jstt = None
        self.Hstt = None
        #Temperature dependent parameters
        self.Msx = 1.73
        self.Tc = 875
        self.Ms = None   
        self.gammai = 2.18
        self.Ki = None 
        self.gammab = 2.83
        self.Beta = None
        self.P0 = 0.58
        self.asp = 2e-5
        self.P = None 
        #MTJ resistance
        self.phi = 0.4
        self.RA = 5e3
        self.F = 33141
        self.Rp = (self.tox / (self.F * self.phi**0.5 * self.AMTJ)) * np.exp((1.025 * self.tox * (2 * self.e * self.me * self.phi)**0.5) / self.hbar)
        self.Vh = 0.5
        self.TMR = 1.0        
        #simulation initialization
        self.step_size = step_size
        self.t_max = t_max
        self.num_steps = round(self.t_max / self.step_size)
        self.m_initial = np.array([0, 0, 1])
        self.magnetization = np.zeros((self.num_steps, 3))
        self.time = np.zeros(self.num_steps)
        self.m = self.m_initial
        self.m_r = np.array([0, 0, 1])

    def Set_temp_params(self, temperature):
        self.temperature = temperature
        self.Ms = self.Ms0 * (1 - (self.temperature / self.Tc)**self.Msx)
        self.Ki = self.Ki0 * (self.Ms / self.Ms0)**self.gammai
        self.Beta = self.Beta0 * (self.Ms / self.Ms0)**self.gammab
        self.P = self.P0 * (1 - self.asp * self.temperature**1.5 )
        self.Hstt = (self.hbar * self.P) / (2 * self.e * self.mu0 * self.Ms * self.tf) * self.gamma

    def Set_mag_fields(self):
        self.HPMA = (2 * self.Ki) / (self.mu0 * self.Ms * self.tf)
        self.HVCMA = - (2 * self.Beta * abs(self.VMTJ)) / (self.mu0 * self.Ms * self.tox * self.tf)
        self.HDEMAG = -self.Ms * np.array([self.Nx, self.Ny, self.Nz])

    def calculate_Rmtj(self):
        self.Rmtj = (self.Rp * (1 + (self.VMTJ/self.Vh)**2 + self.TMR)) \
            / (1 + (self.VMTJ**2/self.Vh**2) + self.TMR * (0.5 * (1 + self.m[2])))

    def llg(self):
        self.calculate_Rmtj()
        self.Istt = self.VMTJ / self.Rmtj
        self.Jstt = self.Istt / self.AMTJ
        sigmath = np.random.randn(3)
        sigmath = sigmath / np.linalg.norm(sigmath)
        Hthermal = np.sqrt((2 * self.kb * self.temperature * self.alpha) / ( self.mu0 * self.Ms * self.gamma * self.Vf * self.step_size)) * sigmath
        Hpma = np.array([0, 0, self.HPMA * self.m[2]])
        Hvcma = np.array([0, 0, self.HVCMA * self.m[2]])
        Hdemag = self.HDEMAG * self.m
        Heff = Hpma + Hdemag + self.Hex + Hvcma + Hthermal
        dmdt = -self.gamma_eff * np.cross(self.m, Heff) - self.alpha * self.gamma_eff * np.cross(self.m, np.cross(self.m, Heff)) - \
                self.Hstt * self.Jstt * np.cross(self.m, np.cross(self.m, self.m_r))
        return dmdt

    def run(self):
        for i in range(0,self.num_steps):
            dmdt = self.llg()
            self.m = self.m + self.step_size * dmdt
            self.m /= np.linalg.norm(self.m)
            self.magnetization[i] = self.m
            self.time[i] = i * self.step_size
            

    def plot(self):
        fig, ax = plt.subplots()
        ax.plot(self.time, self.magnetization[:, 0], label='Mx')
        ax.plot(self.time, self.magnetization[:, 1], label='My')
        ax.plot(self.time, self.magnetization[:, 2], label='Mz')
        ax.set_xlabel('Time (s)')
        ax.set_ylabel('Magnetization')
        ax.legend()
        plt.show()
        
mtj = Mtj(1e-12, 10e-9)
mtj.Set_temp_params(450)
mtj.Set_mag_fields()
mtj.run()
mtj.plot()

I know Matlab would be faster, but python is convenient. Please give me suggestion if there is any way to speed up my code.

\$\endgroup\$
9
  • 3
    \$\begingroup\$ Have you tried using the Pypy JIT to run your code? It might speed things up. \$\endgroup\$
    – Dair
    Commented May 15 at 16:40
  • \$\begingroup\$ I don't see anything stochastic here. \$\endgroup\$
    – vnp
    Commented May 15 at 18:15
  • \$\begingroup\$ @vnp, you're looking for that np.random.randn(3) call. 8^) \$\endgroup\$
    – J_H
    Commented May 15 at 18:40
  • \$\begingroup\$ Like J_H mentioned in the answer, @numba.jit is a good start, but if you really want performance, write it in C or C++. That's the depressing truth about python performance: there isn't any. The good news is your code is mainly numeric calculations so translating it should be fairly straightforward. \$\endgroup\$
    – Passer By
    Commented May 16 at 8:29
  • \$\begingroup\$ numpy is for the mass processing of data, there is an overhead for type and range checking. Atomic calls make it slow, using numpy.sin for a single argument is slower than using math.sin. Thus it might be helpful to generate all the random numbers that are used at once instead of only 3 in every loop. \$\endgroup\$ Commented May 16 at 9:35

5 Answers 5

7
\$\begingroup\$

This was very difficult for a quantitative analyst to examine because the separation between variables and constants was not distinct. It has already been observed in another answer that your naming convention is odd. Attaching constants to the class is a bad choice and makes it more difficult to understand what is updated on the instance and what is not.

Specifically indicating what are constants.

The first thing I did was rename all of your constants so that I try to observe any optimisations in functions:

import numpy as np
from matplotlib import pyplot as plt

# Physical constants
C_mu0 = 4 * np.pi * 1e-7
C_gamma = 2.2127e5
C_kb = 1.38e-23
C_e = 1.6e-19
C_me = 9.1e-31
C_hbar = 1.054e-34
C_alpha = 0.05
C_gamma_eff = C_gamma / (1 + C_alpha ** 2)
# MTJ dimensions
C_D = 50e-9
C_tf = 1.1e-9
C_tox = 1.4e-9
C_AMTJ = np.pi * (C_D ** 2) / 4
C_vf = C_AMTJ * C_tf
# Demag tensors
C_Nx = (np.pi * C_tf) / (4 * C_D)
C_Ny = C_Nx
C_Nz = 1 - (2 * C_Nx)
# Mag field constants
C_HEX = -(5 / 1e4) / C_mu0
C_Hex = np.array([0, C_HEX, 0])
# C_HPMA = None
# C_HVCMA = None
# C_HDEMAG = None

# Magnetic Parameters
C_Ms0 = 0.786e6
C_Ki0 = 5.274e-4
C_Beta0 = 97.3e-15

# Electrical
C_VMTJ = -0.2

# Temperature dependent parameters
C_Msx = 1.73
C_Tc = 875
C_gammai = 2.18
C_gammab = 2.83
C_P0 = 0.58
C_asp = 2e-5

# MTJ resistance
C_phi = 0.4
C_RA = 5e3
C_F = 33141
C_Rp = (C_tox / (C_F * C_phi ** 0.5 * C_AMTJ)) * np.exp(
    (1.025 * C_tox * (2 * C_e * C_me * C_phi) ** 0.5) / C_hbar)
C_Vh = 0.5
C_TMR = 1.0


class Mtj:
    def __init__(self, step_size, t_max):

        # Mag field constants
        self.HPMA = None
        self.HVCMA = None
        self.HDEMAG = None

        self.temperature = None

        # Electrical
        self.Istt = None
        self.Jstt = None
        self.Hstt = None
        # Temperature dependent parameters
        self.Ms = None
        self.Ki = None
        self.Beta = None
        self.P = None

        # simulation initialization
        self.step_size = step_size
        self.t_max = t_max
        self.num_steps = round(self.t_max / self.step_size)
        self.m_initial = np.array([0, 0, 1])
        self.magnetization = np.zeros((self.num_steps, 3))
        self.time = np.zeros(self.num_steps)
        self.m = self.m_initial
        self.m_r = np.array([0, 0, 1])

    def Set_temp_params(self, temperature):
        self.temperature = temperature
        self.Ms = C_Ms0 * (1 - (self.temperature / C_Tc) ** C_Msx)
        self.Ki = C_Ki0 * (self.Ms / C_Ms0) ** C_gammai
        self.Beta = C_Beta0 * (self.Ms / C_Ms0) ** C_gammab
        self.P = C_P0 * (1 - C_asp * self.temperature ** 1.5)
        self.Hstt = (C_hbar * self.P) / (2 * C_e * C_mu0 * self.Ms * C_tf) * C_gamma

    def Set_mag_fields(self):
        self.HPMA = (2 * self.Ki) / (C_mu0 * self.Ms * C_tf)
        self.HVCMA = - (2 * self.Beta * abs(C_VMTJ)) / (C_mu0 * self.Ms * C_tox * C_tf)
        self.HDEMAG = -self.Ms * np.array([C_Nx, C_Ny, C_Nz])

    def calculate_Rmtj(self):
        self.Rmtj = (C_Rp * (1 + (C_VMTJ / C_Vh) ** 2 + C_TMR)) \
                    / (1 + (C_VMTJ ** 2 / C_Vh ** 2) + C_TMR * (0.5 * (1 + self.m[2])))

    def llg(self):
        self.calculate_Rmtj()
        self.Istt = C_VMTJ / self.Rmtj
        self.Jstt = self.Istt / C_AMTJ
        sigmath = np.random.randn(3)
        sigmath = sigmath / np.linalg.norm(sigmath)
        Hthermal = np.sqrt((2 * C_kb * self.temperature * C_alpha) / (
                    C_mu0 * self.Ms * C_gamma * C_vf * self.step_size)) * sigmath
        Hpma = np.array([0, 0, self.HPMA * self.m[2]])
        Hvcma = np.array([0, 0, self.HVCMA * self.m[2]])
        Hdemag = self.HDEMAG * self.m
        Heff = Hpma + Hdemag + C_Hex + Hvcma + Hthermal
        dmdt = -C_gamma_eff * np.cross(self.m, Heff) - C_alpha * C_gamma_eff * np.cross(self.m,
                                                                                                 np.cross(self.m,
                                                                                                          Heff)) - \
               self.Hstt * self.Jstt * np.cross(self.m, np.cross(self.m, self.m_r))
        return dmdt

    def run(self):
        for i in range(0, self.num_steps):
            dmdt = self.llg()
            self.m = self.m + self.step_size * dmdt
            self.m /= np.linalg.norm(self.m)
            self.magnetization[i] = self.m
            self.time[i] = i * self.step_size

    def plot(self):
        fig, ax = plt.subplots()
        ax.plot(self.time, self.magnetization[:, 0], label='Mx')
        ax.plot(self.time, self.magnetization[:, 1], label='My')
        ax.plot(self.time, self.magnetization[:, 2], label='Mz')
        ax.set_xlabel('Time (s)')
        ax.set_ylabel('Magnetization')
        ax.legend()
        plt.show()

Mixing initialisation and execution

Secondly you have a separate process for initialisating step sizes, which you do on the instance and a separate method for setting temperature parameters. You should set the temperature parameters at initialisation of the class.

Then I relabelled temperature names as constants internal to the class so that it was further obvious what was updated during a run.

class Mtj:
    def __init__(self, step_size, t_max, temperature):

        # Mag field constants
        self.HPMA = None
        self.HVCMA = None
        self.HDEMAG = None

        # Temperature
        self.temperature = temperature
        self.C_Ms = C_Ms0 * (1 - (self.temperature / C_Tc) ** C_Msx)
        self.C_Ki = C_Ki0 * (self.C_Ms / C_Ms0) ** C_gammai
        self.C_Beta = C_Beta0 * (self.C_Ms / C_Ms0) ** C_gammab
        self.C_P = C_P0 * (1 - C_asp * self.temperature ** 1.5)
        self.C_Hstt = (C_hbar * self.C_P) / (2 * C_e * C_mu0 * self.C_Ms * C_tf) * C_gamma

        # Electrical
        self.Istt = None
        self.Jstt = None

        # simulation initialization
        self.step_size = step_size
        self.t_max = t_max
        self.num_steps = round(self.t_max / self.step_size)
        self.m_initial = np.array([0, 0, 1])
        self.magnetization = np.zeros((self.num_steps, 3))
        self.time = np.zeros(self.num_steps)
        self.m = self.m_initial
        self.m_r = np.array([0, 0, 1])

    def Set_mag_fields(self):
        self.HPMA = (2 * self.C_Ki) / (C_mu0 * self.C_Ms * C_tf)
        self.HVCMA = - (2 * self.C_Beta * abs(C_VMTJ)) / (C_mu0 * self.C_Ms * C_tox * C_tf)
        self.HDEMAG = -self.C_Ms * np.array([C_Nx, C_Ny, C_Nz])

    def calculate_Rmtj(self):
        self.Rmtj = (C_Rp * (1 + (C_VMTJ / C_Vh) ** 2 + C_TMR)) \
                    / (1 + (C_VMTJ ** 2 / C_Vh ** 2) + C_TMR * (0.5 * (1 + self.m[2])))

    def llg(self):
        self.calculate_Rmtj()
        self.Istt = C_VMTJ / self.Rmtj
        self.Jstt = self.Istt / C_AMTJ
        sigmath = np.random.randn(3)
        sigmath = sigmath / np.linalg.norm(sigmath)
        Hthermal = np.sqrt((2 * C_kb * self.temperature * C_alpha) / (
                    C_mu0 * self.C_Ms * C_gamma * C_vf * self.step_size)) * sigmath
        Hpma = np.array([0, 0, self.HPMA * self.m[2]])
        Hvcma = np.array([0, 0, self.HVCMA * self.m[2]])
        Hdemag = self.HDEMAG * self.m
        Heff = Hpma + Hdemag + C_Hex + Hvcma + Hthermal
        dmdt = -C_gamma_eff * np.cross(self.m, Heff) - C_alpha * C_gamma_eff * np.cross(self.m,
                                                                                                 np.cross(self.m,
                                                                                                          Heff)) - \
               self.C_Hstt * self.Jstt * np.cross(self.m, np.cross(self.m, self.m_r))
        return dmdt

After doing this I could observe that set_mag_fields was just setting constants as well. So this was restructured into the initialisation.

class Mtj:
    def __init__(self, step_size, t_max, temperature):

        # Temperature
        self.temperature = temperature
        self.C_Ms = C_Ms0 * (1 - (self.temperature / C_Tc) ** C_Msx)
        self.C_Ki = C_Ki0 * (self.C_Ms / C_Ms0) ** C_gammai
        self.C_Beta = C_Beta0 * (self.C_Ms / C_Ms0) ** C_gammab
        self.C_P = C_P0 * (1 - C_asp * self.temperature ** 1.5)
        self.C_Hstt = (C_hbar * self.C_P) / (2 * C_e * C_mu0 * self.C_Ms * C_tf) * C_gamma

        # Mag field constants
        self.C_HPMA = (2 * self.C_Ki) / (C_mu0 * self.C_Ms * C_tf)
        self.C_HVCMA = - (2 * self.C_Beta * abs(C_VMTJ)) / (C_mu0 * self.C_Ms * C_tox * C_tf)
        self.C_HDEMAG = -self.C_Ms * np.array([C_Nx, C_Ny, C_Nz])

Standardising routines

I removed calculate_Rmtj. A better name for it would have been set_Rmtj but it was a one line function out of context with the rest of your code style.

Unfortunately I have to get back to work now.. But with the increased visibility it might be easier to look for further optimisations and any possible vectorisations. I am sure this can be improved further for performance gains.

Full Updated code

import numpy as np
from matplotlib import pyplot as plt

# Physical constants
C_mu0 = 4 * np.pi * 1e-7
C_gamma = 2.2127e5
C_kb = 1.38e-23
C_e = 1.6e-19
C_me = 9.1e-31
C_hbar = 1.054e-34
C_alpha = 0.05
C_gamma_eff = C_gamma / (1 + C_alpha ** 2)
# MTJ dimensions
C_D = 50e-9
C_tf = 1.1e-9
C_tox = 1.4e-9
C_AMTJ = np.pi * (C_D ** 2) / 4
C_vf = C_AMTJ * C_tf
# Demag tensors
C_Nx = (np.pi * C_tf) / (4 * C_D)
C_Ny = C_Nx
C_Nz = 1 - (2 * C_Nx)
# Mag field constants
C_HEX = -(5 / 1e4) / C_mu0
C_Hex = np.array([0, C_HEX, 0])
# C_HPMA = None
# C_HVCMA = None
# C_HDEMAG = None

# Magnetic Parameters
C_Ms0 = 0.786e6
C_Ki0 = 5.274e-4
C_Beta0 = 97.3e-15

# Electrical
C_VMTJ = -0.2

# Temperature dependent parameters
C_Msx = 1.73
C_Tc = 875
C_gammai = 2.18
C_gammab = 2.83
C_P0 = 0.58
C_asp = 2e-5

# MTJ resistance
C_phi = 0.4
C_RA = 5e3
C_F = 33141
C_Rp = (C_tox / (C_F * C_phi ** 0.5 * C_AMTJ)) * np.exp(
    (1.025 * C_tox * (2 * C_e * C_me * C_phi) ** 0.5) / C_hbar)
C_Vh = 0.5
C_TMR = 1.0


class Mtj:
    def __init__(self, step_size, t_max, temperature):

        # Temperature
        self.temperature = temperature
        self.C_Ms = C_Ms0 * (1 - (self.temperature / C_Tc) ** C_Msx)
        self.C_Ki = C_Ki0 * (self.C_Ms / C_Ms0) ** C_gammai
        self.C_Beta = C_Beta0 * (self.C_Ms / C_Ms0) ** C_gammab
        self.C_P = C_P0 * (1 - C_asp * self.temperature ** 1.5)
        self.C_Hstt = (C_hbar * self.C_P) / (2 * C_e * C_mu0 * self.C_Ms * C_tf) * C_gamma

        # Mag field constants
        self.C_HPMA = (2 * self.C_Ki) / (C_mu0 * self.C_Ms * C_tf)
        self.C_HVCMA = - (2 * self.C_Beta * abs(C_VMTJ)) / (C_mu0 * self.C_Ms * C_tox * C_tf)
        self.C_HDEMAG = -self.C_Ms * np.array([C_Nx, C_Ny, C_Nz])

        # Electrical
        self.Istt = None
        self.Jstt = None

        # simulation initialization
        self.step_size = step_size
        self.t_max = t_max
        self.num_steps = round(self.t_max / self.step_size)
        self.m_initial = np.array([0, 0, 1])
        self.magnetization = np.zeros((self.num_steps, 3))
        self.time = np.zeros(self.num_steps)
        self.m = self.m_initial
        self.m_r = np.array([0, 0, 1])

    def llg(self):
        self.Rmtj = (C_Rp * (1 + (C_VMTJ / C_Vh) ** 2 + C_TMR)) \
                    / (1 + (C_VMTJ ** 2 / C_Vh ** 2) + C_TMR * (0.5 * (1 + self.m[2])))
        self.Istt = C_VMTJ / self.Rmtj
        self.Jstt = self.Istt / C_AMTJ
        sigmath = np.random.randn(3)
        sigmath = sigmath / np.linalg.norm(sigmath)
        Hthermal = np.sqrt((2 * C_kb * self.temperature * C_alpha) / (
                    C_mu0 * self.C_Ms * C_gamma * C_vf * self.step_size)) * sigmath
        Hpma = np.array([0, 0, self.C_HPMA * self.m[2]])
        Hvcma = np.array([0, 0, self.C_HVCMA * self.m[2]])
        Hdemag = self.C_HDEMAG * self.m
        Heff = Hpma + Hdemag + C_Hex + Hvcma + Hthermal
        dmdt = -C_gamma_eff * np.cross(self.m, Heff) - C_alpha * C_gamma_eff * np.cross(self.m,
                                                                                                 np.cross(self.m,
                                                                                                          Heff)) - \
               self.C_Hstt * self.Jstt * np.cross(self.m, np.cross(self.m, self.m_r))
        return dmdt

    def run(self):
        for i in range(0, self.num_steps):
            dmdt = self.llg()
            self.m = self.m + self.step_size * dmdt
            self.m /= np.linalg.norm(self.m)
            self.magnetization[i] = self.m
            self.time[i] = i * self.step_size

    def plot(self):
        fig, ax = plt.subplots()
        ax.plot(self.time, self.magnetization[:, 0], label='Mx')
        ax.plot(self.time, self.magnetization[:, 1], label='My')
        ax.plot(self.time, self.magnetization[:, 2], label='Mz')
        ax.set_xlabel('Time (s)')
        ax.set_ylabel('Magnetization')
        ax.legend()
        plt.show()


mtj = Mtj(1e-12, 10e-9, 450)
mtj.run()
mtj.plot()

Actually you can now observe that these lines:

sigmath = np.random.randn(3)
sigmath = sigmath / np.linalg.norm(sigmath)
Hthermal = np.sqrt(
    (2 * C_kb * self.temperature * C_alpha) /
    (C_mu0 * self.C_Ms * C_gamma * C_vf * self.step_size)
) * sigmath

contain purely constants and random variables. You can extract this outside the class and create one large vectorised array rather than calculating it repeatedly in each loop.

Profiling

np.cross accounts for 60% of the profiling speed.

enter image description here

It runs 50,000 times. You can observe that one of the equations does not need to be calculated twice: np.cross(self.m, Heff) can be precalculated and substitued in, which will reduce to 40,000 times.

You may also find that your triple vector cross products are more rapidly solved by the alternative formulation. But for this you would have to experiment.

enter image description here

Edited with suggestions

I took the constants and random variables to initialisation and also reduced the cross product computations to 40,000. Speed improved:

enter image description here

I also used the alternative formulation for the triple cross product as follows:

        m_dot_m = np.dot(self.m, self.m)
        m_dot_Heff = np.dot(self.m, Heff)
        m_dot_r = np.dot(self.m, self.m_r)
        m_x_m_x_Heff = self.m * m_dot_Heff - Heff * m_dot_m
        m_x_m_x_r = self.m * m_dot_r - self.m_r * m_dot_m
        cross_m_Heff = np.cross(self.m, Heff)
        dmdt = -C_gamma_eff * cross_m_Heff - C_alpha * C_gamma_eff * \
               m_x_m_x_Heff - \
               self.C_Hstt * self.Jstt * m_x_m_x_r

This was very effective, notice the time for llg

enter image description here

The real gains

You mentioned that you need to do this 10,000 times. You cannot vectorize or parallelize the above because the operations need to be performed in sequence.

However you can, and should, vectorize the run of 10,000 of these simulations. For example the array magnetization has dimension (num_steps, 3), but you aim to create a 3D tensor which is of dimension (num_steps, 3, 10000).

That is why I reformulated the equation using dot products, so that later you can vectorize all of these formulae to obtain the speed from numpy's C implementation.

Thus far


import numpy as np
from matplotlib import pyplot as plt

# Physical constants
C_mu0 = 4 * np.pi * 1e-7
C_gamma = 2.2127e5
C_kb = 1.38e-23
C_e = 1.6e-19
C_me = 9.1e-31
C_hbar = 1.054e-34
C_alpha = 0.05
C_gamma_eff = C_gamma / (1 + C_alpha ** 2)
# MTJ dimensions
C_D = 50e-9
C_tf = 1.1e-9
C_tox = 1.4e-9
C_AMTJ = np.pi * (C_D ** 2) / 4
C_vf = C_AMTJ * C_tf
# Demag tensors
C_Nx = (np.pi * C_tf) / (4 * C_D)
C_Ny = C_Nx
C_Nz = 1 - (2 * C_Nx)
# Mag field constants
C_HEX = -(5 / 1e4) / C_mu0
C_Hex = np.array([0, C_HEX, 0])
# C_HPMA = None
# C_HVCMA = None
# C_HDEMAG = None

# Magnetic Parameters
C_Ms0 = 0.786e6
C_Ki0 = 5.274e-4
C_Beta0 = 97.3e-15

# Electrical
C_VMTJ = -0.2

# Temperature dependent parameters
C_Msx = 1.73
C_Tc = 875
C_gammai = 2.18
C_gammab = 2.83
C_P0 = 0.58
C_asp = 2e-5

# MTJ resistance
C_phi = 0.4
C_RA = 5e3
C_F = 33141
C_Rp = (C_tox / (C_F * C_phi ** 0.5 * C_AMTJ)) * np.exp(
    (1.025 * C_tox * (2 * C_e * C_me * C_phi) ** 0.5) / C_hbar)
C_Vh = 0.5
C_TMR = 1.0


class Mtj:
    def __init__(self, step_size, t_max, temperature):

        # Temperature
        self.temperature = temperature
        self.C_Ms = C_Ms0 * (1 - (self.temperature / C_Tc) ** C_Msx)
        self.C_Ki = C_Ki0 * (self.C_Ms / C_Ms0) ** C_gammai
        self.C_Beta = C_Beta0 * (self.C_Ms / C_Ms0) ** C_gammab
        self.C_P = C_P0 * (1 - C_asp * self.temperature ** 1.5)
        self.C_Hstt = (C_hbar * self.C_P) / (2 * C_e * C_mu0 * self.C_Ms * C_tf) * C_gamma

        # Mag field constants
        self.C_HPMA = (2 * self.C_Ki) / (C_mu0 * self.C_Ms * C_tf)
        self.C_HVCMA = - (2 * self.C_Beta * abs(C_VMTJ)) / (C_mu0 * self.C_Ms * C_tox * C_tf)
        self.C_HDEMAG = -self.C_Ms * np.array([C_Nx, C_Ny, C_Nz])

        # Electrical
        self.Istt = None
        self.Jstt = None

        # simulation initialization
        self.step_size = step_size
        self.t_max = t_max
        self.num_steps = round(self.t_max / self.step_size)
        self.m_initial = np.array([0, 0, 1])
        self.magnetization = np.zeros((self.num_steps, 3))
        self.time = np.zeros(self.num_steps)
        self.m = self.m_initial
        self.m_r = np.array([0, 0, 1])

        sigmath = np.random.randn(3, self.num_steps)
        sigmath = sigmath / np.linalg.norm(sigmath, axis=0)
        self.C_Hthermal = np.sqrt((2 * C_kb * self.temperature * C_alpha) / (
                    C_mu0 * self.C_Ms * C_gamma * C_vf * self.step_size)) * sigmath

    def llg(self, i):
        self.Rmtj = (C_Rp * (1 + (C_VMTJ / C_Vh) ** 2 + C_TMR)) \
                    / (1 + (C_VMTJ ** 2 / C_Vh ** 2) + C_TMR * (0.5 * (1 + self.m[2])))
        self.Istt = C_VMTJ / self.Rmtj
        self.Jstt = self.Istt / C_AMTJ

        Hpma = np.array([0, 0, self.C_HPMA * self.m[2]])
        Hvcma = np.array([0, 0, self.C_HVCMA * self.m[2]])
        Hdemag = self.C_HDEMAG * self.m
        Heff = Hpma + Hdemag + C_Hex + Hvcma + self.C_Hthermal[:, i]

        m_dot_m = np.dot(self.m, self.m)
        m_dot_Heff = np.dot(self.m, Heff)
        m_dot_r = np.dot(self.m, self.m_r)
        m_x_m_x_Heff = self.m * m_dot_Heff - Heff * m_dot_m
        m_x_m_x_r = self.m * m_dot_r - self.m_r * m_dot_m
        cross_m_Heff = np.cross(self.m, Heff)
        dmdt = -C_gamma_eff * cross_m_Heff - C_alpha * C_gamma_eff * \
               m_x_m_x_Heff - \
               self.C_Hstt * self.Jstt * m_x_m_x_r
        return dmdt

    def run(self):
        for i in range(0, self.num_steps):
            dmdt = self.llg(i)
            self.m = self.m + self.step_size * dmdt
            self.m /= np.linalg.norm(self.m)
            self.magnetization[i] = self.m
            self.time[i] = i * self.step_size

    def plot(self):
        fig, ax = plt.subplots()
        ax.plot(self.time, self.magnetization[:, 0], label='Mx')
        ax.plot(self.time, self.magnetization[:, 1], label='My')
        ax.plot(self.time, self.magnetization[:, 2], label='Mz')
        ax.set_xlabel('Time (s)')
        ax.set_ylabel('Magnetization')
        ax.legend()
        plt.show()


mtj = Mtj(1e-12, 10e-9, 450)
mtj.run()
mtj.plot()
\$\endgroup\$
5
  • \$\begingroup\$ Takes less than one second to run on my end. A significant improvement in terms of speed. \$\endgroup\$
    – Kate
    Commented May 16 at 15:01
  • \$\begingroup\$ Thanks! Actually using explcit cross product made it even faster, I precalculated the random number array, now it runs is about 0.5 seconds. Could you tell me how you got that screenshot of benchmark statistics? \$\endgroup\$ Commented May 16 at 15:45
  • \$\begingroup\$ I used the profiling tool in the professional version of Pycharm, which uses the cProfile profiler. \$\endgroup\$
    – Attack68
    Commented May 16 at 16:19
  • \$\begingroup\$ Choosing a “this is a constant” prefix is a design choice, that’s cool. But please prefer lowercase “c_” over the proposed “C_”, out of respect for PEP-8. (I gave the OP a pass on most naming details, due to very helpful adherence to math conventions which are a bit different from python conventions.) Yeah, yeah, I know, manifest constants are typically upcased. But here, mixed case sometimes makes an identifier more readable, more familiar. \$\endgroup\$
    – J_H
    Commented May 16 at 20:51
  • \$\begingroup\$ Pep8 module level constants are usually all caps. The only reason I didnt use all caps is it was easier to stick with the OP original naming for comparison and when editing to avoid errors. I prefer the capital C because it is more obvious and it better suited the task presented here and made the answer clearer to me and, in my opinion, to most. \$\endgroup\$
    – Attack68
    Commented May 16 at 21:12
4
\$\begingroup\$

I'm not an expert but let me raise very common tips that make a calculation faster and that you might be already aware of.

Decreasing the number of function calls

You call llg() 10000 times in the loop, which then calls calculate_Rmtj() in it. Calling a function is slow. Did you try expanding llg() and calculate_Rmtj() directly in the loop? (yes that will make your code messy). Also, you call randn(), cross() and norm() in the loop. Some of them can be slow. It looks like randn() can be called before the loop, i.e., building (10000, 3) array by randn(10000, 3) and then you can access elements in the loop.

Using a JIT compiler

If you want to make your function work faster, a JIT compiler might be the thing. numba is a very popular package for this purpose. By using it, you can for example make llg() run a lot faster (by orders of magnitude). But you need to modify your code.

Using local variables

Using local variables are generally faster way. I'm not very sure about this but accessing self.something from a function scope over and over looks not economical. You can try to pass them to a function as arguments.

\$\endgroup\$
3
\$\begingroup\$

physical constants

Thank you for using MKS, it's all very clear.

        self.e = 1.6e-19
        self.me = 9.1e-31

Number of coulombs is correct to three decimal places. But it's just too easy to specify it to five, in keeping with the γ sig figs: 1.6022 Similarly 9.1094 for mass, which had just two sig figs.

I can see some small attraction to always having self.this and self.that, but still, choosing to allocate new storage for these on each object instance is an odd modeling choice. Consider putting such constants at top-level of this or another module. Consider relying on a pypi package which already offers them, such as astropy or scipy:

from scipy.constants import e, m_e, mu_0

When we examine a particular "constant", this (modern) OP code has the curious artifact of relying on the value of mu_0 that was used from 1948 through 2018. The redefinition of 2019 altered its value to the slightly different one we see in the scipy package. This was due to ditching the platinum kilogram prototype, which regrettably was starting to exhibit discrepancies of ~ 50 μg.

tl;dr: These details are boring and not part of the core contribution of this work; outsource them so you needn't worry about them.

naming

These are distinct identifiers:

        self.HEX = -(5 / 1e4) / self.mu0
        self.Hex = np.array([0, self.HEX, 0])

But I do wish you'd pick a slightly different name for that first one. Sometimes colleagues discuss code over the telephone. Try to choose names that will sound distinct when recited aloud, or when recited mentally during quiet code reading.

    def Set_temp_params(self, temperature):
        ...
    def Set_mag_fields(self):

Pep-8 asked you nicely to please use a lowercase set_ prefix for such methods.

cite a reference

    def llg(self):

The Review Context made it clear enough that this is the Landau-Lifschitz-Gilbert equation, and I thank you for that. But the source code really needs a """docstring""" or at least a # comment to that effect.

Citing some author would also shed a great deal of light on F, Rmtj, and the various other expressions. As written this code is on the opaque side. I do appreciate that it is trying hard to conform to math notation conventions.

profiling

This submission would have benefited from the inclusion of profiling measurements.

speed

We pick a random vector in 3-space, good.

    def llg(self):
        ...
        sigmath = np.random.randn(3)

But then we run() it \$10^4\$ times. There's an opportunity to roll all the random numbers at once here, and let numpy / BLAS worry about looping.

Or if you wish to conserve memory, pick some compromise, perhaps by rolling 300 numbers at a time and doing a hundred runs through this.

If you don't go that route, consider adding type annotation which mypy lints clean on, and then giving this method numba's @njit decorator.

You're computing nine temp vars, each of them changing slightly on every iteration. It's not clear to me how big the partial derivative is on each of them. If some of them are fairly stable, changing by some tiny epsilon with every iteration, consider hoisting them out of this function, that is, hoisting them out of the loop. So if we do ten loop unrolls, we compute just once at top of unroll a smallish change which is 10x bigger than what OP code computes, rather than computing order-of-magnitude smaller values ten times. Then we still need to loop back \$10^3\$ times, an improvement over \$10^4\$ times.

\$\endgroup\$
3
  • 1
    \$\begingroup\$ Thank you I will try to incorporate them. Another thing I noticed is that the code became about 5-6x faster when I used an excplicit cross product instead of the numpy builtin one. Would you happen to know why? \$\endgroup\$ Commented May 16 at 8:40
  • \$\begingroup\$ Is there a way I can put my new code somehwere and we can continue this discussion? Should I post the new code in this question itself? \$\endgroup\$ Commented May 16 at 8:51
  • \$\begingroup\$ When you take the tour, it describes a couple of possible outcomes. For example, you might choose to post a self Answer, or create a related new Question. \$\endgroup\$
    – J_H
    Commented May 16 at 12:52
3
\$\begingroup\$
        self.HPMA = (2 * self.Ki) / (self.mu0 * self.Ms * self.tf)
        self.HVCMA = - (2 * self.Beta * abs(self.VMTJ)) / (self.mu0 * self.Ms * self.tox * self.tf)

With interpreted languages, I'm never sure how much common subexpression elimination to expect.

    def Set_temp_params(self, temperature):
        self.temperature = temperature
        temperature_coefficient = 1 - (self.temperature / self.Tc) ** self.Msx
        self.Ms = self.Ms0 * temperature_coefficient 
        self.Ki = self.Ki0 * temperature_coefficient ** self.gamma_i
        self.Beta = self.Beta0 * temperature_coefficient ** self.gamma_b
        …
    def Set_mag_fields(self):
        fudge = 2 / (self.mu0 * self.Ms * self.tf)
        self.HPMA = self.Ki * fudge
        self.HVCMA = -(self.Beta * abs(self.VMTJ)) / self.tox * fudge
        …

should be algebraically equivalent, and the expression for HVCMA isn't overly long any more.
(I noticed another instance of self.mu0 * self.Ms in llg().)

\$\endgroup\$
1
  • 1
    \$\begingroup\$ "I'm never sure how much common subexpression elimination" last I checked python doesn't have it, period. Which makes sense when you consider all the types are statically unknown so operators can be arbitrary functions. \$\endgroup\$
    – Passer By
    Commented May 16 at 8:34
2
\$\begingroup\$

I am not very qualified to comment on your algorithm, so this is naive review from the POV of developer, but here is some generic advice that can be used in any situation where you want to optimize slow code, or identify bottlenecks.

I often use the codetiming package for this purpose.

So I have applied decorators on each function like:

@Timer(name="llg", text="Ran in {:0.8f} seconds", logger=logger.debug)
def llg(self):
    ....

And then this code to produce a breakdown by function:

logger.info("Timing summary")
logger.info("--------------")
total_time = sum(Timer.timers.values())
logger.info(f"Total time: {total_time:.8f}")
logger.info("name                           time       cost ")
logger.info("------------------------------ ---------- ------")
for k, v in Timer.timers.items():
    logger.info(f"{k:30} {v:.8f} {(v / total_time):.2%}")

Result:

2024-05-15 18:30:44,361 - INFO - L163 - Timing summary
2024-05-15 18:30:44,361 - INFO - L164 - --------------
2024-05-15 18:30:44,361 - INFO - L166 - Total time: 7.78605968
2024-05-15 18:30:44,361 - INFO - L167 - name                           time       cost 
2024-05-15 18:30:44,361 - INFO - L168 - ------------------------------ ---------- ------
2024-05-15 18:30:44,361 - INFO - L170 - __init__                       0.00012388 0.00%
2024-05-15 18:30:44,361 - INFO - L170 - Set_temp_params                0.00000414 0.00%
2024-05-15 18:30:44,361 - INFO - L170 - Set_mag_fields                 0.00001162 0.00%
2024-05-15 18:30:44,361 - INFO - L170 - calculate_Rmtj                 0.02407876 0.31%
2024-05-15 18:30:44,361 - INFO - L170 - llg                            3.64657636 46.83%
2024-05-15 18:30:44,361 - INFO - L170 - run                            4.11526491 52.85%

This is a quick and convenient way to pinpoint slow code. And then you can dig further by wrapping selected blocks of code like this:

with Timer(name="some code", text="Ran in {:0.8f} seconds", logger=logger.info):
    # do something
    ...

Check for example the block in function llg that has np.cross...

The second strategy is that you can cache the results of expensive operations. The docs have a basic example about it:

@cache
def factorial(n):
    return n * factorial(n-1) if n else 1

>>> factorial(10)      # no previously cached result, makes 11 recursive calls
3628800
>>> factorial(5)       # just looks up cached value result
120
>>> factorial(12)      # makes two new recursive calls, the other 10 are cached
479001600

As long as the arguments are "hashable" you should be able to apply that logic to any purpose. Some variables do not change over the course of program execution, so if you find yourself repeatedly calculating the same values over and over, this "trick" will save a lot of precious (CPU) time.

You will quite likely have to refactor your code to benefit from this approach. You would need to pass arguments to your functions rather than rely on self.whatever. I find that approach bug-prone, because it's not immediately clear where and how the values are instantiated. Or you could use setters and getters.

But this is not my code and I have my own way of doing things so...

In functions like Set_mag_fields where you set three class attributes:

def Set_mag_fields(self):
    self.HPMA = (2 * self.Ki) / (self.mu0 * self.Ms * self.tf)
    self.HVCMA = - (2 * self.Beta * abs(self.VMTJ)) / (self.mu0 * self.Ms * self.tox * self.tf)
    self.HDEMAG = -self.Ms * np.array([self.Nx, self.Ny, self.Nz])

You could return a namedtuple instead, if that makes sense.

Misc: as per PEP8 constants and magic numbers should be uppercase.

The class could perfectly be written as dataclass.

\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.