From Atoms to DevicesNanoelectronic modeling, lecture by lecture

Lecture 12

The recursive Green function algorithm (RGF)

In the lecture

  • the two NEGF quantities: the retarded Green function \(G^R\) (where electrons can be: the states) and the lesser Green function \(G^<\) (where electrons are: the occupation)
  • the three simulation domains (left reservoir, central device, right reservoir) and the boundary self-energies obtained from the left- and right-connected Green functions of the semi-infinite leads
  • why \(G^R = (E - H - \Sigma^R)^{-1}\) is a full matrix although \(E - H - \Sigma^R\) is tridiagonal, and what a full inversion costs
  • which elements of \(G^R\) are needed: one element for the current, the diagonal (and nearest off-diagonals) for the charge
  • the forward recursion for left-connected Green functions, the backward recursion for the diagonal of \(G^R\), and the recursion for off-diagonal elements; cost \(O(N B^3)\) instead of \(O(N^3 B^3)\)

In this notebook

implements the recursive Green function algorithm in NumPy for the single-band RTD of lecture 11.1, checks the diagonal and corner elements of \(G^R\) against a dense inverse and the transmission against nemo.Transport, maps the local density of states and the left- and right-injected spectral functions, times the forward-only, full RGF, banded-LU and dense-inverse solutions against the number of sites, and extends the algorithm to block-tridiagonal matrices of a 2D strip to show the \(N B^3\) scaling.

Run the notebookEvery number and figure on this page comes from it. Change it, rerun it, take it further.
Run it on your own machine

The notebook needs open-nemo (Python ≥ 3.10 and a C++20 compiler; BLAS/LAPACK).

git clone https://github.com/denphi/open-nemo.git && cd open-nemo
python3 -m venv .venv && source .venv/bin/activate
pip install -e ".[notebooks]"
jupyter lab From_Atoms_to_Devices/06_resonant_tunneling_diodes/12_0_recursive_green_function.ipynb
Code8 lines · cell 1
import numpy as np
import matplotlib.pyplot as plt
import opennemo as nemo
from opennemo.visualization import style

style.use(notebook=True)
H2M0 = nemo.core.units.HBAR2_2M0          # ħ²/2m₀ = 0.0381 eV nm²
EM = nemo.structures.materials.EffectiveMassMaterial

1. What NEGF has to compute#

This lecture is about numerics: how the Green functions used in all RTD calculations of this section are actually obtained. Two quantities are needed.

Dynamics, the states of the system. The retarded Green function solves

\[\left(E - H - \Sigma^R_\text{bound} - \Sigma^R_\text{scatt}\right) G^R = 1 ,\]

where \(E\) is the energy (eV), \(H\) the device Hamiltonian, \(\Sigma^R_\text{bound} = \Sigma^R_\text{left} + \Sigma^R_\text{right}\) the self-energies that describe escape into the contacts and \(\Sigma^R_\text{scatt}\) the escape into other channels by scattering. \(G^R\) is the impulse response of the device: it tells where electrons can be.

Kinetics, the occupation. The lesser Green function counts the electrons,

\[G^< = G^R\left(\Sigma^<_\text{bound} + \Sigma^<_\text{scatt}\right) G^{R\dagger}, \qquad \Sigma^<_\text{bound} = i\,\Gamma_\text{left} f_\text{left} + i\,\Gamma_\text{right} f_\text{right},\]

with \(\Gamma = i(\Sigma^R - \Sigma^{R\dagger})\) the broadening of a contact and \(f\) its Fermi function. The lecture's analogy: \(G^R\) is the set of apartments, \(G^<\) tells which apartments are occupied. Here scattering is left out (\(\Sigma_\text{scatt} = 0\)); it is the subject of lecture 13. For a full course on NEGF the lecture recommends Supriyo Datta's lectures on nanoHUB.

The device is split into three domains: a left reservoir, the central device (sites \(0 \dots N-1\)) and a right reservoir. Each reservoir is a semi-infinite lead. Its left-connected Green function \(g^R_{-1,-1}\) on the last lead site knows everything about the lead from site \(-1\) to \(-\infty\); coupled into the device by the hopping \(t_{-1,0}\) it gives the boundary self-energy on site 0,

\[\Sigma^R_{0,0} = t_{0,-1}\, g^R_{-1,-1}\, t_{-1,0},\]

and the same on site \(N-1\) from the right lead. For a single-band lead with hopping \(t\) and band bottom \(E_c\) this is analytic, \(\Sigma^R = -t\,e^{ika}\) with \(\cos ka = 1 - (E - E_c)/2t\); for multiband leads it is computed numerically, for example with the Sancho–Rubio iteration.

The test device is the RTD of lecture 11.1: GaAs contacts, two 5 nm Al${0.3}$Ga${0.7}$As barriers and a 7 nm GaAs well, discretised with \(a = 0.1\) nm. Its Hamiltonian is a tridiagonal matrix with on-site energies \(\varepsilon_i\) and hoppings \(-t_i\), \(t = \hbar^2/(2 m^* a^2)\); rtd.lead_self_energy returns the analytic lead self-energy.

Code20 lines · cell 4
import time

from threadpoolctl import threadpool_limits

x_al = 0.3
gaas = EM("GaAs", 0.067, 12.9, conduction_band_edge=0.0)
algaas = EM("Al0.3Ga0.7As", 0.067 + 0.083 * x_al, 12.9 - 2.84 * x_al, conduction_band_edge=0.324)
layers = [
    nemo.Layer(gaas, 10),
    nemo.Layer(algaas, 5),
    nemo.Layer(gaas, 7),
    nemo.Layer(algaas, 5),
    nemo.Layer(gaas, 10),
]
rtd = nemo.Heterostructure1D(layers, spacing=0.1)
H = rtd.hamiltonian.sparse.dense()
N = rtd.num_sites
eps, hop = np.real(np.diag(H)), np.real(np.diag(H, 1))  # on-site energies and H_{i,i+1} (eV)
print(f"N = {N} sites, hopping H_i,i+1 = {hop[0]:.2f} eV in GaAs and {hop[110]:.2f} eV in AlGaAs")
print(f"nonzeros of H: {np.count_nonzero(H)} of {N * N} ({np.count_nonzero(H) / N**2:.2%})")
N = 370 sites, hopping H_i,i+1 = -56.87 eV in GaAs and -41.46 eV in AlGaAs
nonzeros of H: 1108 of 136900 (0.81%)

2. \(G^R\) is a full matrix, and inverting it is expensive#

\(E - H - \Sigma^R\) is tridiagonal; only the two corner sites are modified by the self-energies. Its inverse, however, is a full matrix: an impulse on any site reaches every other site. The figure shows \(|G^R_{ij}|\) at an energy just above the first resonance.

Code17 lines · cell 6
def dense_green(H, E, potential=None):
    u = np.zeros(len(H)) if potential is None else potential
    A = (E + 0j) * np.eye(len(H)) - H - np.diag(u)
    A[0, 0] -= rtd.lead_self_energy(E, "left", potential=u[0])
    A[-1, -1] -= rtd.lead_self_energy(E, "right", potential=u[-1])
    return np.linalg.inv(A)


E0 = 0.06
G = dense_green(H, E0)
fig, axs = plt.subplots(1, 2, figsize=(10, 4.2))
axs[0].spy(np.abs(H) > 0, markersize=0.3, color=style.CONTEXT)
axs[0].set_title("E − H − Σ: tridiagonal")
im = axs[1].imshow(np.log10(np.abs(G)), cmap="viridis")
axs[1].set_title(f"log10 |G_ij| at E = {E0} eV: full")
fig.colorbar(im, ax=axs[1], shrink=0.8, label="log10 |G| (1/eV)")
print(f"smallest |G_ij| = {np.abs(G).min():.1e} 1/eV: no element of G is zero")
smallest |G_ij| = 6.2e-04 1/eV: no element of G is zero
Figure computed by the notebook with open-nemo

The lecture counts the cost for a realistic device. An RTD with 100 nm contacts and 5 nm barriers and well has 500 to 1000 sites; in a multiband tight-binding model each site is a \(10\times10\) or \(20\times20\) block, so the matrix order is between 500 and 10 000. A full inversion costs \(\sim N^3\) operations, a tridiagonal LU factorisation that produces the whole inverse \(\sim N^2\), and the recursive algorithm \(\sim N\). Storage follows the same logic: the sparse matrix needs about \(3N\) complex numbers, the full \(G^R\) needs \(N^2\).

Code10 lines · cell 8
n = 10_000
ops = {"full inversion, N^3": n**3, "tridiagonal LU, N^2": n**2, "recursive Green function, N": n}
rate = n / 1.0  # operations per second that make the RGF take one second, as on the slide
for name, k in ops.items():
    t = k / rate
    print(f"{name:27s}: {k:.0e} operations -> {t:7.0e} s = {t / 3600:9.3g} h = {t / 3.156e7:.3g} years")
print(
    f"storage: sparse matrix 3N x 16 bytes = {3 * n * 16 / 1e6:.2f} MB, full G = N^2 x 16 bytes = "
    f"{n**2 * 16 / 1e6:.0f} MB"
)
full inversion, N^3        : 1e+12 operations ->   1e+08 s =  2.78e+04 h = 3.17 years
tridiagonal LU, N^2        : 1e+08 operations ->   1e+04 s =      2.78 h = 0.000317 years
recursive Green function, N: 1e+04 operations ->   1e+00 s =  0.000278 h = 3.17e-08 years
storage: sparse matrix 3N x 16 bytes = 0.48 MB, full G = N^2 x 16 bytes = 1600 MB
Slide 10: cost of the full inversion, the tridiagonal LU and the recursive Green function
Slide 10. For a matrix of order \(10^4\): \(10^{12}\) operations for the full inversion (3.2 years if \(10^4\) operations take a second), \(10^8\) for the tridiagonal LU (2.7 hours), \(10^4\) for the recursive Green function (1 s). One inversion is not enough: 100–1000 energies, 5–7 self-consistent iterations and 10–100 bias points need about 165 000 of them. The full \(G^R\) of order \(10^4\) needs 1600 MB, the sparse matrix 0.5 MB.

3. Which elements are needed?#

Without incoherent scattering, only a few elements of \(G^R\) enter the observables:

  • Current only (the charge comes from a semiclassical model, as in the Thomas–Fermi calculations of lectures 11.1–11.3): the transmission is \(T(E) = \Gamma_L \Gamma_R |G^R_{0,N-1}|^2\), one single element.
  • Current and charge (Hartree, lecture 11.4): the electron density on site \(i\) needs the diagonal of \(G^< = G^R \Sigma^< G^{R\dagger}\), that is \(\Gamma_L |G^R_{i,0}|^2 f_L + \Gamma_R |G^R_{i,N-1}|^2 f_R\) in the non-equilibrium region, the first and last columns of \(G^R\); in the reservoirs, where the occupation is an equilibrium one, the diagonal \(G^R_{i,i}\) suffices.

The two columns have a direct meaning: \(\Gamma_L |G^R_{i,0}|^2/2\pi\) is the probability density of finding on site \(i\) an electron injected from the left contact, \(\Gamma_R |G^R_{i,N-1}|^2/2\pi\) the same for an electron from the right contact. Their sum is the local density of states \(-\operatorname{Im} G^R_{ii}/\pi\).

Slide 22: only a few elements of the Green function are needed; scattering needs the full matrix
Slide 22. Without scattering only a few elements are needed: the diagonals in the reservoirs (equilibrium density of states) and the off-diagonal strips that carry the coherent transport of electrons injected from the left and from the right contact. Non-equilibrium scattering connects different sites and requires the full matrix (red box).

4. The recursions#

Write the matrix to be inverted as \(A = E - H - \Sigma^R\) with diagonal blocks \(D_i\) (here numbers, \(D_i = E - \varepsilon_i - \Sigma_i\)) and off-diagonal blocks \(t_{i,i\pm1}\), the off-diagonal entries of \(A\) (here \(t_{i,i+1} = -H_{i,i+1}\)).

Forward recursion (left-connected Green functions). \(g^{L}_{i,i}\) is the Green function of site \(i\) with everything to its left connected and everything to its right cut off:

\[g^{L}_{0,0} = D_0^{-1}, \qquad g^{L}_{i,i} = \left(D_i - t_{i,i-1}\, g^{L}_{i-1,i-1}\, t_{i-1,i}\right)^{-1}.\]

The last one, \(g^L_{N-1,N-1}\), includes the right lead through \(D_{N-1}\) and is therefore the exact element \(G^R_{N-1,N-1}\): left-connected and right-connected meet at the last site.

Backward recursion (diagonal of \(G^R\)):

\[G^R_{i,i} = g^{L}_{i,i}\left(1 + t_{i,i+1}\, G^R_{i+1,i+1}\, t_{i+1,i}\, g^{L}_{i,i}\right).\]

Off-diagonal elements, by walking from the diagonal one step at a time:

\[G^R_{i,j} = -\,g^{L}_{i,i}\, t_{i,i+1}\, G^R_{i+1,j} \quad (i < j),\]

which gives the last column \(G^R_{i,N-1}\) from \(G^R_{N-1,N-1}\). For the current alone, the corner element can be carried along in the forward pass: the left-connected corner element obeys \(g^{L}_{0,i} = -\,g^{L}_{0,i-1}\, t_{i-1,i}\, g^{L}_{i,i}\), and \(G^R_{0,N-1} = g^L_{0,N-1}\). The first column follows from the same recursions run from the right (right-connected Green functions).

Code50 lines · cell 13
def rgf(d, tu, tl, current_only=False):
    """Recursive Green function for a scalar tridiagonal A = E - H - Sigma.

    d: diagonal D_i, tu: A_{i,i+1}, tl: A_{i+1,i}. Returns G_{0,N-1} if current_only, else
    (diagonal G_ii, last column G_{i,N-1}).
    """
    n = len(d)
    g = np.empty(n, complex)
    g[0] = 1 / d[0]
    corner = g[0]
    for i in range(1, n):  # forward: left-connected g
        g[i] = 1 / (d[i] - tl[i - 1] * g[i - 1] * tu[i - 1])
        corner = -corner * tu[i - 1] * g[i]
    if current_only:
        return corner
    G, col = np.empty(n, complex), np.empty(n, complex)
    G[-1] = col[-1] = g[-1]
    for i in range(n - 2, -1, -1):  # backward: diagonal and last column
        G[i] = g[i] * (1 + tu[i] * G[i + 1] * tl[i] * g[i])
        col[i] = -g[i] * tu[i] * col[i + 1]
    return G, col


def rgf_device(E, u):
    """Diagonal, first column, last column of G^R and the broadenings, for potential u (eV)."""
    sl = rtd.lead_self_energy(E, "left", potential=u[0])
    sr = rtd.lead_self_energy(E, "right", potential=u[-1])
    d = E - eps - u + 0j
    d[0] -= sl
    d[-1] -= sr
    Gd, col_last = rgf(d, -hop, -hop)
    _, col_first = rgf(d[::-1], -hop[::-1], -hop[::-1])  # right-connected pass
    return Gd, col_first[::-1], col_last, -2 * sl.imag, -2 * sr.imag


u0 = np.zeros(N)
Gd, c0, cN, gl, gr = rgf_device(E0, u0)
print(
    f"max |G_ii(RGF) - G_ii(dense)|       = {np.abs(Gd - np.diag(G)).max():.1e} 1/eV "
    f"(largest |G_ii| {np.abs(np.diag(G)).max():.1f})"
)
print(f"max |G_i,N-1(RGF) - dense|          = {np.abs(cN - G[:, -1]).max():.1e} 1/eV")
print(f"max |G_i,0(RGF) - dense|            = {np.abs(c0 - G[:, 0]).max():.1e} 1/eV")
d = E0 - eps + 0j
d[0] -= rtd.lead_self_energy(E0, "left")
d[-1] -= rtd.lead_self_energy(E0, "right")
print(
    f"|G_0,N-1| forward pass only: {abs(rgf(d, -hop, -hop, current_only=True)):.6e}, dense: "
    f"{abs(G[0, -1]):.6e}"
)
max |G_ii(RGF) - G_ii(dense)|       = 1.0e-12 1/eV (largest |G_ii| 3.6)
max |G_i,N-1(RGF) - dense|          = 4.4e-14 1/eV
max |G_i,0(RGF) - dense|            = 3.0e-14 1/eV
|G_0,N-1| forward pass only: 8.908818e-04, dense: 8.908818e-04
Slide 35: forward and backward recursion, and why the forward recursion is the bottleneck
Slide 35. The forward recursion inverts one block per layer and scales as \(O(N B^3)\); the backward recursion only multiplies blocks, \(O(N B^2)\). With block sizes \(B\) of 10–40 the forward recursion is the bottleneck.

The RGF elements agree with the dense inverse to round-off. Now the transmission through the RTD, \(T(E) = \Gamma_L \Gamma_R |G^R_{0,N-1}|^2\) from the forward pass alone, against nemo.Transport (open-nemo's own RGF implementation), at zero bias and with a linear drop of 0.1 V across the barriers and the well.

Code34 lines · cell 16
x = rtd.x
ramp = np.clip((x - 10) / 17, 0, 1)
E = np.linspace(0.001, 0.4, 1200)
tr = nemo.Transport(rtd.hamiltonian)


def transmission_rgf(E, u):
    out = np.empty(len(E))
    for k, e in enumerate(E):
        sl = rtd.lead_self_energy(e, "left", potential=u[0])
        sr = rtd.lead_self_energy(e, "right", potential=u[-1])
        d = e - eps - u + 0j
        d[0] -= sl
        d[-1] -= sr
        out[k] = 4 * sl.imag * sr.imag * abs(rgf(d, -hop, -hop, current_only=True)) ** 2
    return out


fig, ax = plt.subplots(figsize=(7, 3.8))
for v, color in ((0.0, style.CONTEXT), (0.1, style.OPEN_NEMO)):
    u = -v * ramp
    t0 = time.perf_counter()
    T_rgf = transmission_rgf(E, u)
    t_py = (time.perf_counter() - t0) / len(E)
    T_lib = tr.transmission(E, potential=u)
    ax.semilogy(E, T_lib, color=color, lw=2.5, alpha=0.5, label=f"nemo.Transport, {v:.1f} V")
    ax.semilogy(E, T_rgf, "--", color="k", lw=0.8)
    print(
        f"{v:.1f} V: max |T_RGF - T_nemo| = {np.abs(T_rgf - T_lib).max():.1e}, "
        f"NumPy forward pass {t_py * 1e3:.2f} ms per energy"
    )
ax.plot([], [], "k--", lw=0.8, label="NumPy RGF (forward pass)")
ax.set(xlabel="Energy (eV)", ylabel="T(E)", ylim=(1e-8, 2))
ax.legend(fontsize=8);
0.0 V: max |T_RGF - T_nemo| = 1.3e-08, NumPy forward pass 0.20 ms per energy
0.1 V: max |T_RGF - T_nemo| = 5.7e-09, NumPy forward pass 0.20 ms per energy
Figure computed by the notebook with open-nemo

5. What the matrix elements mean#

With the diagonal and the two columns, the local density of states and the left- and right-injected spectral functions follow on every site:

\[\text{LDOS}_i(E) = -\frac{1}{\pi}\operatorname{Im} G^R_{ii}, \qquad A^L_i = \Gamma_L |G^R_{i,0}|^2, \quad A^R_i = \Gamma_R |G^R_{i,N-1}|^2, \qquad A^L_i + A^R_i = -2\operatorname{Im}G^R_{ii}.\]

The map shows the LDOS of the unbiased RTD; the line plots the left- and right-injected spectral functions at the first resonance with the 0.1 V drop of lecture 11.1 applied (from 10 to 27 nm).

Code26 lines · cell 18
Em = np.linspace(0.002, 0.45, 300)
ldos = np.array([-rgf_device(e, u0)[0].imag / np.pi for e in Em])
u1 = -0.1 * ramp
r = nemo.resonances(rtd, potential=u1, emax=0.3)
e1 = r["energy"][0]
Gd1, c01, cN1, gl1, gr1 = rgf_device(e1, u1)
AL, AR = gl1 * np.abs(c01) ** 2, gr1 * np.abs(cN1) ** 2
print(
    "sum rule: max |A_L + A_R + 2 Im G_ii| / max(A_L + A_R) = "
    f"{np.abs(AL + AR + 2 * Gd1.imag).max() / (AL + AR).max():.1e}"
)
iw = np.argmin(abs(x - 18.5))
print(f"C1 at 0.1 V: {e1 * 1e3:.2f} meV; in the middle of the well A_R / A_L = {AR[iw] / AL[iw]:.1f}")

fig, axs = plt.subplots(1, 2, figsize=(11, 4))
axs[0].pcolormesh(x, Em, np.log10(ldos + 1e-6), cmap="viridis", vmin=-3, shading="auto")
axs[0].plot(x, rtd.band_edge, color="w", lw=1)
axs[0].set(xlabel="Position (nm)", ylabel="Energy (eV)", title="log10 LDOS (1/eV per site), 0 V")
axs[1].semilogy(x, AL, **style.series(lw=2), label="A_L: injected from the left")
axs[1].semilogy(x, AR, **style.series("reference"), label="A_R: injected from the right")
axs[1].set(
    xlabel="Position (nm)",
    ylabel="spectral function (1/eV per site)",
    title=f"E = C1 = {e1 * 1e3:.1f} meV, 0.1 V",
)
axs[1].legend(fontsize=8);
sum rule: max |A_L + A_R + 2 Im G_ii| / max(A_L + A_R) = 5.6e-14
C1 at 0.1 V: 3.85 meV; in the middle of the well A_R / A_L = 16.6
Figure computed by the notebook with open-nemo

The LDOS map shows the quasi-bound states in the well as bright lines and the standing-wave patterns of the contact states reflected by the barriers. The sum rule holds to round-off. At 0.1 V the resonance C1 lies only 3.9 meV above the emitter band edge: electrons arriving from the left have very little kinetic energy there and couple weakly to the well, while from the collector side, more than 0.1 eV above its band edge, the coupling is much stronger, so \(A^R\) is 16 times larger than \(A^L\) in the well. Which of the two actually holds electrons is decided by the Fermi functions: the collector states at this energy are empty, its Fermi level being 0.1 eV lower, so the charge in the well comes from \(A^L f_L\) alone. This is the physics behind the off-diagonal strips of slide 22: the first column of \(G^R\) carries what the left contact injects, the last column what the right contact injects, and each is weighted with its own Fermi function.

6. Cost against the number of sites#

The following cell times one energy point for chains of increasing length: the dense inverse (\(N^3\)), the banded (tridiagonal) LU solve for the whole inverse (\(N^2\)), the NumPy RGF with the full backward pass and with the forward pass only (\(N\)), and nemo.Transport. All timings use one BLAS thread.

Code48 lines · cell 20
from scipy.linalg import solve_banded

sizes = [100, 200, 400, 800, 1600]
times = {
    "dense inverse": [],
    "banded LU, full inverse": [],
    "RGF, diagonal + column": [],
    "RGF, forward pass only": [],
    "nemo.Transport (per energy)": [],
}


def best_of(f, k=3):
    best = np.inf
    for _ in range(k):
        t0 = time.perf_counter()
        f()
        best = min(best, time.perf_counter() - t0)
    return best


with threadpool_limits(1):
    for n in sizes:
        chain = nemo.Heterostructure1D([nemo.Layer(gaas, n * 0.05), nemo.Layer(gaas, n * 0.05)], spacing=0.1)
        h = chain.hamiltonian.sparse.dense()
        e = 0.05
        A = (e + 0j) * np.eye(n) - h
        A[0, 0] -= chain.lead_self_energy(e, "left")
        A[-1, -1] -= chain.lead_self_energy(e, "right")
        ab = np.zeros((3, n), complex)
        ab[0, 1:], ab[1], ab[2, :-1] = np.diag(A, 1), np.diag(A), np.diag(A, -1)
        dd, tt = np.diag(A).copy(), np.diag(A, 1).copy()
        times["dense inverse"].append(best_of(lambda: np.linalg.inv(A)))
        times["banded LU, full inverse"].append(best_of(lambda: solve_banded((1, 1), ab, np.eye(n))))
        times["RGF, diagonal + column"].append(best_of(lambda: rgf(dd, tt, tt)))
        times["RGF, forward pass only"].append(best_of(lambda: rgf(dd, tt, tt, current_only=True)))
        trn = nemo.Transport(chain.hamiltonian, num_threads=1)
        Es = np.linspace(0.02, 0.2, 200)
        times["nemo.Transport (per energy)"].append(best_of(lambda: trn.transmission(Es)) / len(Es))

fig, ax = plt.subplots(figsize=(6.5, 4.2))
colors = style.sweep(len(times))
for (name, tl), c in zip(times.items(), colors):
    ax.loglog(sizes, tl, "o-", color=c, label=name)
    slope = np.polyfit(np.log(sizes[2:]), np.log(tl[2:]), 1)[0]
    print(f"{name:28s}: " + "  ".join(f"{t * 1e3:8.3f}" for t in tl) + f"  ms;  slope {slope:.2f}")
ax.set(xlabel="number of sites N", ylabel="time per energy (s)")
ax.legend(fontsize=8);
dense inverse               :    0.187     0.771     3.198    17.168   101.294  ms;  slope 2.49
banded LU, full inverse     :    0.195     0.787     3.040    12.666    56.949  ms;  slope 2.11
RGF, diagonal + column      :    0.085     0.176     0.357     0.727     1.455  ms;  slope 1.01
RGF, forward pass only      :    0.039     0.078     0.157     0.323     0.639  ms;  slope 1.01
nemo.Transport (per energy) :    0.002     0.003     0.005     0.009     0.018  ms;  slope 0.87
Figure computed by the notebook with open-nemo

The fitted slopes of the log–log curves over the three largest sizes are printed (timings vary by a few per cent from run to run). The dense inverse grows with a slope of about 2.5 (LAPACK is very efficient at these moderate sizes, so the slope is still below the asymptotic 3), the banded LU solve that produces all \(N^2\) elements with a slope of about 2.1, and both RGF variants linearly. At 1600 sites the dense inverse takes about 100 ms per energy and the NumPy forward pass about 0.65 ms, more than 100 times less. The NumPy loop has a large constant cost per site from the Python interpreter; the compiled implementation in nemo.Transport needs about 0.016 ms per energy at the same size (its slope of about 0.8 still contains a fixed cost per call). The ratio between dense and recursive solutions grows without bound with \(N\), which is the payoff the lecture describes: years of computing reduced to seconds.

7. Block tridiagonal matrices: \(O(N B^3)\)#

In a multiband or multi-dimensional model each site (or layer) is a block of size \(B\): \(D_i\) and \(t_{i,i+1}\) become \(B\times B\) matrices and the scalar divisions become block inversions. The same code with matrix operations handles, for example, a 2D strip of width \(B\) sites on a square lattice (hopping 1 eV, arbitrary units), whose leads are strips of the same width. The lead self-energies come from open-nemo's Sancho–Rubio solver, nemo.solvers.transport.self_energy. In a perfect strip the transmission must count the open transverse modes, an integer staircase.

Code44 lines · cell 22
def rgf_block(Ds, tu, tl):
    """Block RGF: returns the corner block G_{0,N-1} (current) and the diagonal blocks G_ii."""
    n = len(Ds)
    g = [np.linalg.inv(Ds[0])]
    corner = g[0]
    for i in range(1, n):
        g.append(np.linalg.inv(Ds[i] - tl[i - 1] @ g[i - 1] @ tu[i - 1]))
        corner = -corner @ tu[i - 1] @ g[i]
    G = [None] * n
    G[-1] = g[-1]
    for i in range(n - 2, -1, -1):
        G[i] = g[i] @ (np.eye(len(g[i])) + tu[i] @ G[i + 1] @ tl[i] @ g[i])
    return corner, G


def strip(B, n_slabs, e, t=1.0):
    h0 = 4 * t * np.eye(B) - t * (np.eye(B, k=1) + np.eye(B, k=-1))
    h1 = -t * np.eye(B)
    lead = nemo.Lead(h0 + 0j, h1 + 0j)
    sl = nemo.solvers.transport.self_energy(lead, e + 1e-9j, "left")
    sr = nemo.solvers.transport.self_energy(lead, e + 1e-9j, "right")
    D = [(e + 0j) * np.eye(B) - h0 for _ in range(n_slabs)]
    D[0] = D[0] - sl
    D[-1] = D[-1] - sr
    off = [-h1 + 0j for _ in range(n_slabs - 1)]
    return D, off, sl, sr


B = 10
Es = np.linspace(0.05, 2.0, 120)
Tstrip = []
for e in Es:
    D, off, sl, sr = strip(B, 30, e)
    corner, _ = rgf_block(D, off, off)
    gL, gR = 1j * (sl - sl.conj().T), 1j * (sr - sr.conj().T)
    Tstrip.append(np.trace(gL @ corner @ gR @ corner.conj().T).real)
modes = [np.sum(e > 2 - 2 * np.cos(np.pi * np.arange(1, B + 1) / (B + 1))) for e in Es]
print(f"max |T - number of open modes| = {np.max(np.abs(np.array(Tstrip) - modes)):.1e}")

fig, ax = plt.subplots(figsize=(6, 3.5))
ax.plot(Es, Tstrip, color=style.OPEN_NEMO, lw=2, label="block RGF")
ax.step(Es, modes, "k--", lw=0.8, where="mid", label="open transverse modes")
ax.set(xlabel="Energy (eV)", ylabel="T(E)", title=f"perfect strip, width B = {B}")
ax.legend(fontsize=8);
max |T - number of open modes| = 2.0e-14
Figure computed by the notebook with open-nemo

The cost of the block recursion is \(N\) block inversions and multiplications of \(B\times B\) matrices, \(O(N B^3)\), against \(O((NB)^3)\) for a dense inversion of the whole matrix of order \(NB\). The timing below uses 40 slabs and widths from 5 to 80.

Code28 lines · cell 24
n_slabs, e = 40, 0.5
widths = [5, 10, 20, 40, 80]
t_blk, t_den = [], []
with threadpool_limits(1):
    for B in widths:
        D, off, sl, sr = strip(B, n_slabs, e)
        Afull = np.zeros((n_slabs * B, n_slabs * B), complex)
        for i in range(n_slabs):
            Afull[i * B : (i + 1) * B, i * B : (i + 1) * B] = D[i]
            if i < n_slabs - 1:
                Afull[i * B : (i + 1) * B, (i + 1) * B : (i + 2) * B] = off[i]
                Afull[(i + 1) * B : (i + 2) * B, i * B : (i + 1) * B] = off[i]
        corner, Gblk = rgf_block(D, off, off)
        Ginv = np.linalg.inv(Afull)
        err = max(
            np.abs(Gblk[i] - Ginv[i * B : (i + 1) * B, i * B : (i + 1) * B]).max() for i in range(n_slabs)
        )
        t_blk.append(best_of(lambda: rgf_block(D, off, off)))
        t_den.append(best_of(lambda: np.linalg.inv(Afull)))
        print(
            f"B = {B:2d}: matrix order {n_slabs * B:5d}, max |G_ii(block RGF) - dense| = {err:.1e}, "
            f"block RGF {t_blk[-1] * 1e3:7.2f} ms, dense {t_den[-1] * 1e3:8.2f} ms"
        )
for name, tl in (("block RGF", t_blk), ("dense inverse", t_den)):
    print(
        f"{name:13s}: log-log slope against B between B = 40 and 80: "
        f"{np.log(tl[-1] / tl[-2]) / np.log(2):.2f}"
    )
B =  5: matrix order   200, max |G_ii(block RGF) - dense| = 6.8e-16, block RGF    0.37 ms, dense     0.77 ms
B = 10: matrix order   400, max |G_ii(block RGF) - dense| = 1.0e-15, block RGF    0.46 ms, dense     3.23 ms
B = 20: matrix order   800, max |G_ii(block RGF) - dense| = 1.4e-15, block RGF    1.01 ms, dense    17.56 ms
B = 40: matrix order  1600, max |G_ii(block RGF) - dense| = 1.7e-15, block RGF    2.89 ms, dense   101.78 ms
B = 80: matrix order  3200, max |G_ii(block RGF) - dense| = 2.1e-15, block RGF   10.93 ms, dense   681.91 ms
block RGF    : log-log slope against B between B = 40 and 80: 1.92
dense inverse: log-log slope against B between B = 40 and 80: 2.74

Both methods give the same diagonal blocks to round-off. At small \(B\) the block recursion is dominated by Python overhead per slab, so its time hardly grows; at large \(B\) the block operations dominate and the growth steepens (a slope of about 2 between \(B = 40\) and 80, where optimised small-matrix BLAS still hides part of the \(B^3\)), while the dense inverse grows with a slope near 2.8 and at \(B = 80\) is about 60 times slower. In operation count the dense inversion of the order-\(NB\) matrix is \(N^2 = 40^2\) times more expensive than the \(N\) block inversions. This is the \(O(N B^3)\) scaling of slide 35, and the reason the lecture names the forward recursion, which contains the block inversions, as the bottleneck.

8. Scattering fills the matrix#

Everything above relied on \(E - H - \Sigma\) being (block) tridiagonal. A boundary self-energy only touches the two end blocks. Incoherent scattering adds \(\Sigma^R_\text{scatt} = D \times G^R\): if the scattering is local (an interaction that only couples a site to itself, such as a deformation-potential or Büttiker-probe model), \(\Sigma_\text{scatt}\) is diagonal and the recursions still apply, inside a self-consistent loop. A non-local interaction, in which an electron on one site feels a lattice vibration or a charge further away (polar optical phonons, Coulomb scattering), couples distant sites, makes \(\Sigma_\text{scatt}\) and therefore \(E - H - \Sigma\) full, and the recursive algorithm loses its advantage. Practical codes then restrict the full treatment to a small central region, which is the strategy of the NEMO "central region with reservoirs" partitioning used in lectures 11.3 and 13.

Slide 44: the recursion for off-diagonal blocks of the Green function
Slide 44. General expressions for off-diagonal blocks: starting from the diagonal, each step multiplies by a left-connected (upper part) or right-connected (lower part) Green function and a hopping block, \(G_{i,j} = -g^{LR}_{i,i} t_{i,i+1} G_{i+1,j}\), so that any needed element can be reached without inverting the full matrix.

Summary#

  • NEGF needs the retarded Green function \(G^R = (E - H - \Sigma^R)^{-1}\) (the states) and the lesser Green function \(G^< = G^R \Sigma^< G^{R\dagger}\) (their occupation). The leads enter through boundary self-energies on the first and last site, computed from left- and right-connected lead Green functions.
  • \(E - H - \Sigma\) is tridiagonal but \(G^R\) is full. A full inversion costs \(N^3\) operations, a tridiagonal LU for the whole inverse \(N^2\), and the recursive algorithm \(N\) (for blocks of size \(B\): \(N B^3\)).
  • The current needs a single element \(G^R_{0,N-1}\), available after the forward pass; the charge needs the diagonal (equilibrium reservoirs) and the first and last columns (non-equilibrium region).
  • The forward recursion builds left-connected Green functions, the backward recursion the diagonal of \(G^R\), and a further recursion walks to off-diagonal elements. In the NumPy implementation here all elements agree with the dense inverse to round-off and the transmission agrees with nemo.Transport.
  • Local scattering keeps the matrix tridiagonal; non-local scattering fills it and requires full-matrix methods on a reduced central region.

Exercises#

  1. Modify rgf to return the nearest off-diagonal elements \(G^R_{i,i+1}\) and use them to compute the current density between sites \(i\) and \(i+1\) at the resonance energy. Check that it is the same on every bond (current conservation).
  2. Integrate \(A^L_i f_L + A^R_i f_R\) over energy at 0.1 V and 77 K to obtain the electron density along the RTD, and compare it with the semiclassical density of the contacts.
  3. Time rgf and rgf_block with B = 1 for the same chain. How much of the per-site cost is Python overhead, and how would you reduce it?
  4. Add a constant imaginary part \(-i\eta\) to all \(D_i\) (a crude local scattering model, \(\eta = 1\) meV) and check that the RGF still agrees with the dense inverse. What happens to the sum rule \(A^L + A^R = -2\operatorname{Im} G^R_{ii}\), and why?

Further reading#