RHF Gradient#
原理简述#
大体原理#
RHF 梯度包含下述五部分:
Hamiltonian core
库伦
交换
原子核
重叠
上述五项中,从公式推演的角度,较为特别的项是重叠部分。依据当前文档的推导方式,重叠积分本质上不是能量的导数,而更接近 Lagrangian 乘子的导数。
从计算耗时的角度,较为特别的项是交换部分;其次是库伦部分。剩余的部分耗时大体上是可以忽略的。
RHF 梯度的计算如下:
其中,记号 \(\partial_{A_t}^\mathrm{S}\) 是指 Skeleton 导数,即对核坐标或电子积分的导数、但不对轨道系数作导数。
程序实现策略#
从程序实现的角度来说,我们将梯度的贡献分为四大类:
原子核能量贡献
Hamiltonian Core 贡献
可约化到原子轨道的贡献 (包括重叠积分、部分 J、部分 K 贡献)
可约化到辅助基的贡献 (包括部分 J、部分 K 贡献)
程序实现约定#
在我们对 RHF 实现中,有如下约定 (convention):
除了最后与 REST 主程序的交互部分外,我们统一使用 col-major。因此,很多张量的定义、以及 broadcasting 规则与 NumPy 是反者来的,在调试程序与使用 Python 作性能初步实现与测评时必须要多留意。
轨道系数 \(C_{\mu p}\) 的储存是 \((\mu, p)\),亦是 col-major。这与 REST 和 PySCF 相同。
在变量名中,记号简称有
o: occupiedv: virtualb: basis (atomic orbital)a: all (molecular orbital, occ + vir, not used in HF gradient)u: auxiliarytp: triangular-packed
迭代指标变量的记号简称是
i: occupiedu: basisp: auxiliary
电子积分的指标顺序依照 libcint 给出的积分下,不作任何额外内存分配与转置的情况下,col-major 的顺序。这与 PySCF 的记号约定有较大差别 (PySCF 是既非 row 也非 col major)。
具体实现#
原子核贡献#
该计算已经在 REST 有所实现。
重叠积分贡献#
需要注意,这里被求和的指标是 \(\nu\),而该指标在 int1e_ipovlp (指标顺序 \((t, \nu, \mu)\)) 是倒数第二位的,因此需要用 sum(axis=-2)。这里 int1e_ipovlp 的指标顺序与通常的习惯少许不同。
函数 get_dme0
变量名 |
变量意义 |
指标顺序 |
维度大小 |
其他说明 |
|---|---|---|---|---|
|
\(C_{\mu p}\) |
\((\mu, p)\) |
\((n_\mathrm{basis}, n_\mathrm{orb})\) |
|
|
\(n_p\) |
\((p)\) |
\((n_\mathrm{orb})\) |
|
|
\(\varepsilon_p\) |
\((p)\) |
\((n_\mathrm{orb})\) |
|
|
\(E_{\mu \nu}\) |
\((\mu, \nu)\) |
\((n_\mathrm{basis}, n_\mathrm{basis})\) |
sym |
函数 get_grad_dao_ovlp
变量名 |
变量意义 |
指标顺序 |
维度大小 |
其他说明 |
|---|---|---|---|---|
|
\((\partial_t \mu \vert \nu)\) |
\((\mu, \nu, t)\) |
\((n_\mathrm{basis}, n_\mathrm{basis}, 3)\) |
anti-sym |
|
\(E_{\mu \nu}\) |
\((\mu, \nu)\) |
\((n_\mathrm{basis}, n_\mathrm{basis})\) |
sym |
|
\(\Delta_{t \mu}\) 贡献项 |
\((\mu, t)\) |
\((n_\mathrm{basis}, 3)\) |
Hamiltonian Core 贡献#
首先,从程序的角度,\(\partial_{A_t} h_{\mu \nu}\) 是依赖于原子核 \(A\) 的量;由于它不是计算瓶颈问题,浪费一些内存与计算资源并非不可接受。因此,我们可以对每个原子 \(A\) 进行迭代,生成其对应的 \(\partial_{A_t} h_{\mu \nu}\)。这个迭代生成器定义在 generator_deriv_hcore 中;该迭代生成器读入分子信息,输出内部的函数;该生成器需要在内部存储一些变量以避免重复计算 (这里是变量 h1)。在 Python 中,这可以通过类实现,也可以通过内部函数实现。在 Rust 中,我们就用 closure 实现。
其次,我们需要给出 \(\partial_{A_t} h_{\mu \nu}\) 的具体计算过程。首先定义动能与势能算符的表示
其导数则为
上式中,\(\mu_{\in A}\) 是只有原子轨道中心在 \(A\) 原子上的基组 \(\mu\) 才被纳入统计。
对于 \(\partial_{A_t} v_{\mu \nu}\) 的第三项,由于它只处理原子 \(A\) 而不作求和,因此其计算可以通过更换规范原点位置 (gauge origin) 以进行计算;记号 \(\boldsymbol{r}_{\rightarrow A}\) 是指将 \(\boldsymbol{r}\) 的规范原点变更为 \(\boldsymbol{A}\)。需要注意,这里我们利用了一次类似于 Stokes 定理的做法,将电子积分作如下变换:
因此,整理上述式子可得
函数 generator_deriv_hcore
变量名 |
变量意义 |
指标顺序 |
维度大小 |
其他说明 |
|---|---|---|---|---|
|
\(\langle \partial_t \mu \vert \hat k \vert \nu \rangle\) |
\((\mu, \nu, t)\) |
\((n_\mathrm{basis}, n_\mathrm{basis}, 3)\) |
anti-sym |
|
\(\langle \partial_t \mu \vert \hat v \vert \nu \rangle\) |
\((\mu, \nu, t)\) |
\((n_\mathrm{basis}, n_\mathrm{basis}, 3)\) |
non-sym |
|
\(\langle \partial_t \mu \vert \frac{1}{\vert \boldsymbol{r}_{\rightarrow A} \vert} \vert \nu \rangle\) |
\((\mu, \nu, t)\) |
\((n_\mathrm{basis}, n_\mathrm{basis}, 3)\) |
non-sym, gauge-dependent |
|
\(\texttt{h1}_{\mu \nu}^t\) |
\((\mu, \nu, t)\) |
\((n_\mathrm{basis}, n_\mathrm{basis}, 3)\) |
non-sym |
|
\(\partial_{A_t} h_{\mu \nu}\) |
\((\mu, \nu, t)\) |
\((n_\mathrm{basis}, n_\mathrm{basis}, 3)\) |
sym |
双电子积分分解矩阵 \(\mathbf{J}^{-1/2}\)#
在目前的 REST 中,双电子积分分解矩阵 \(\mathbf{J}^{-1/2}\) 是严格的矩阵分数幂次。
需要指出的是,Cholesky 分解可能在一些缩并问题上有更高的效率。因此如果以后 REST 要更改分解策略,下面的代码和算法需要作一定调整。
变量名 |
变量意义 |
指标顺序 |
维度大小 |
其他说明 |
|---|---|---|---|---|
|
\((\mathbf{J}^{-1/2})_{PQ}\) |
\((P, Q)\) |
\((n_\mathrm{aux}, n_\mathrm{aux})\) |
sym |
密度矩阵的特殊上三角压缩#
在我们的算法中,偶尔会出现对称矩阵的乘法与求和计算:
其中,张量 \(b_{t, \mu \nu}\) 与矩阵 \(c_{\mu \nu}\) 都是关于 \((\mu, \nu)\) 指标对称的。
这种情况下,求和计算不一定要对完整的 \((\mu, \nu)\) 求和,而只对其上三角部分 \((\mu, \nu \geqslant \nu)\) 求和。为此,定义
其中,\(\mathrm{tp}(\mu \nu)\) 是一维指标,长度 \(\frac{1}{2} n_\mathrm{basis} (n_\mathrm{basis} + 1)\)。映射关系 \(\bowtie\) 与 \(\mathop{\tilde{\bowtie}}\) 分别指代的是
在当前的梯度计算问题中,我们令密度矩阵的特殊上三角压缩为
关于到底选用下三角还是上三角#
这里涉及到 row-major 与 col-major 其中一个具有根本性分歧的问题。
我们考虑下述对称矩阵
该矩阵是 \(3 \times 3 = 9\) 大小的矩阵;它作为演示的示例不大,但在电子结构计算中可能就是内存的存储瓶颈。在这种情况下,储存其三角部分的 6 个元素就可以省出差不多一半内存。
但问题是,这 6 个元素到底要如何存储?
这里 Row-major 与 Col-major 就会产生分歧。
Row-major 情形下,它将存储为 \((0, 1, 2, 3, 4, 5)\)
Col-major 情形下,它将存储为 \((0, 1, 3, 2, 4, 5)\)
但是有意思的是,如果存储上三角矩阵,Col-major 将会给出 \((0, 1, 2, 3, 4, 5)\),与 Row-major 的下三角是相同的:
那么我们应该选择下三角、还是上三角?我们指出,在已有程序的使用习惯上,
Row-major 应选择下三角
PySCF 中经常用函数 pack_tril 与 unpack_tril,但你会发现根本没有 pack_triu 与 unpack_triu 函数的定义;
NumPy 中,像 Cholesky 分解函数 (np.linalg.cholesky) 的默认选项
upper=False,也是因为 NumPy 是 row-major,因此默认是下三角;
Col-major 应选择上三角
SciPy 中,Cholesky 分解函数 (scipy.linalg.cholesky) 的默认选项反而是
lower=False,是因为 SciPy 主要使用 f2py 接口,而 f2py 遵循的是 Fortran 习惯使用 col-major,其三角矩阵的定义风格就与 col-major 有所绑定。
一些程序通过定义上下三角矩阵的类型,从根源上规避这个问题 (这里是指 Julia;我们的数学库没有 Julia 那样的野心,所以决定将一些使用上的责任让渡给用户)。
我相信 row-major 选择下三角、col-major 选择上三角是有其考虑的。以 row-major 为例,我们可以不需要事先知道矩阵的大小,也能判断下三角矩阵压缩后元素的位置、在压缩前的索引到底是多少,参考下述 Python 程序:
def get_unpacked(idx):
if idx == 0:
return (0, 0)
import numpy as np
i = int(np.floor(np.sqrt(2 * idx)))
j = idx - i * (i + 1) // 2
if j < 0:
return (i - 1, j + i)
else:
return (i, j)
for idx in range(6):
print(get_unpacked(idx))
# output
# (0, 0)
# (1, 0)
# (1, 1)
# (2, 0)
# (2, 1)
# (2, 2)
但如果是 row-major 的上三角矩阵,那么就必须事先知道矩阵维度,才能给出压缩前的索引位置。
出于同样的原因,col-major 则倾向于使用上三角,而不使用下三角。
函数 pack_triu_tilde
变量名 |
变量意义 |
指标顺序 |
维度大小 |
其他说明 |
|---|---|---|---|---|
|
\(D_{\mu \nu}\) |
\((\mu, \nu)\) |
\((n_\mathrm{basis}, n_\mathrm{basis})\) |
sym |
|
\(D_{\mathrm{tp} (\mu \nu)}\) |
\((\mathrm{tp} (\mu \nu))\) |
\((\frac{1}{2} n_\mathrm{basis} (n_\mathrm{basis} + 1))\) |
\(\mathop{\tilde{\bowtie}}\) |
J 贡献#
以下计算给出的是 J 贡献计算的中间量 (intermediate)
函数 get_itm_j
变量名 |
变量意义 |
指标顺序 |
维度大小 |
其他说明 |
|---|---|---|---|---|
|
\((\mathbf{J}^{-1/2})_{PQ}\) |
\((P, Q)\) |
\((n_\mathrm{aux}, n_\mathrm{aux})\) |
sym |
|
\(Y_{Q, \mathrm{tp} (\mu \nu)}\) |
\((\mathrm{tp} (\mu \nu), Q)\) |
\((\frac{1}{2} n_\mathrm{basis} (n_\mathrm{basis} + 1), n_\mathrm{aux})\) |
\(\bowtie\) |
|
\(D_{\mathrm{tp} (\mu \nu)}\) |
\((\mathrm{tp} (\mu \nu))\) |
\((\frac{1}{2} n_\mathrm{basis} (n_\mathrm{basis} + 1))\) |
\(\mathop{\tilde{\bowtie}}\) |
|
\(\mathscr{J}_P\) |
\((P)\) |
\((n_\mathrm{aux})\) |
以下是 J 贡献在原子轨道基组导数上的结果:
函数 get_grad_dao_j_int3c2e_ip1
变量名 |
变量意义 |
指标顺序 |
维度大小 |
其他说明 |
|---|---|---|---|---|
|
\((\partial_t \mu \nu \vert P)\) |
\((\mu, \nu, P, t)\) |
\((n_\mathrm{basis}, n_\mathrm{basis}, n_\mathrm{aux}, 3)\) |
non-sym |
|
\(D_{\mu \nu}\) |
\((\mu, \nu)\) |
\((n_\mathrm{basis}, n_\mathrm{basis})\) |
sym |
|
\(\mathscr{J}_P\) |
\((P)\) |
\((n_\mathrm{aux})\) |
|
|
\(\Delta_{t \mu}\) 贡献项 |
\((\mu, t)\) |
\((n_\mathrm{basis}, 3)\) |
以下是 J 贡献在 3c-2e ERI 辅助基组导数上的结果:
函数 get_grad_daux_j_int3c2e_ip2
变量名 |
变量意义 |
指标顺序 |
维度大小 |
其他说明 |
|---|---|---|---|---|
|
\((\mu \nu \vert \partial_t P)\) |
\((\mathrm{tp} (\mu \nu), P, t)\) |
\((\frac{1}{2} n_\mathrm{basis} (n_\mathrm{basis} + 1), n_\mathrm{aux}, 3)\) |
\(\bowtie\) |
|
\(D_{\mathrm{tp} (\mu \nu)}\) |
\((\mathrm{tp} (\mu \nu))\) |
\((\frac{1}{2} n_\mathrm{basis} (n_\mathrm{basis} + 1))\) |
\(\mathop{\tilde{\bowtie}}\) |
|
\(\mathscr{J}_P\) |
\((P)\) |
\((n_\mathrm{aux})\) |
|
|
\(\Delta_{t P}\) 贡献项 |
\((P, t)\) |
\((n_\mathrm{aux}, 3)\) |
以下是 J 贡献在 2c-2e ERI 辅助基组导数上的结果:
函数 get_grad_daux_j_int2c2e_ip1
变量名 |
变量意义 |
指标顺序 |
维度大小 |
其他说明 |
|---|---|---|---|---|
|
\((\partial_t P \vert Q)\) |
\((P, Q, t)\) |
\((n_\mathrm{aux}, n_\mathrm{aux}, 3)\) |
anti-sym |
|
\(\mathscr{J}_P\) |
\((P)\) |
\((n_\mathrm{aux})\) |
|
|
\(\Delta_{t P}\) 贡献项 |
\((P, t)\) |
\((n_\mathrm{aux}, 3)\) |
K 贡献#
以下计算给出的是 K 贡献计算的中间量 (intermediate)
需要注意,该中间量是本计算过程唯一需要用到 \(O(N^3)\) 大小的存储;其他的计算都可以利用分批方法给出 \(O(N^2)\) 大小的存储。但由于两个指标是占据轨道,并且该矩阵是对称的,因此实际上储存空间很小,甚至大幅地小于很多分批计算的电子积分。
同时需要留意的是,与 J 贡献中使用密度矩阵而非轨道系数呼应地,在 K 贡献中,我们使用“加权轨道系数”进行计算:
函数 get_itm_k_occtp
变量名 |
变量意义 |
指标顺序 |
维度大小 |
其他说明 |
|---|---|---|---|---|
|
\((\mathbf{J}^{-1/2})_{PQ}\) |
\((P, Q)\) |
\((n_\mathrm{aux}, n_\mathrm{aux})\) |
sym |
|
\(Y_{Q, \mathrm{tp} (\mu \nu)}\) |
\((\mathrm{tp} (\mu \nu), Q)\) |
\((\frac{1}{2} n_\mathrm{basis} (n_\mathrm{basis} + 1), n_\mathrm{aux})\) |
\(\bowtie\) |
|
\(\tilde C_{\mu i}\) |
\((\mu, i)\) |
\((n_\mathrm{basis}, n_\mathrm{occ})\) |
|
|
\(\mathscr{K}_{P, \mathrm{tp} (ij)}\) |
\((\mathrm{tp} (ij), P)\) |
\((\frac{1}{2} n_\mathrm{occ} (n_\mathrm{occ} + 1), n_\mathrm{aux})\) |
\(\bowtie\) |
以下计算给出的是 K 贡献计算的中间量 (intermediate)
该贡献中间量的内存需求较大;它尽管是对称的,但我们有意选择不将其压缩,因为后面有一次使用情景是不压缩的。压缩与否对性能的影响其实不大,但代码写起来还是不压缩的比较方便。
函数 get_itm_k_ao
变量名 |
变量意义 |
指标顺序 |
维度大小 |
其他说明 |
|---|---|---|---|---|
|
\(\mathscr{K}_{P, \mathrm{tp} (ij)}\) |
\((\mathrm{tp} (ij), P)\) |
\((\frac{1}{2} n_\mathrm{occ} (n_\mathrm{occ} + 1), n_\mathrm{aux})\) |
\(\bowtie\) |
|
\(\tilde C_{\mu i}\) |
\((\mu, i)\) |
\((n_\mathrm{basis}, n_\mathrm{occ})\) |
|
|
\(\mathscr{K}_{P, \mu \nu}\) |
\((\mu, \nu, P)\) |
\((n_\mathrm{basis}, n_\mathrm{basis}, n_\mathrm{aux})\) |
sym |
以下计算给出的是 K 贡献计算的中间量 (intermediate)
需要注意到,在程序实现中,\(\mathscr{K}_{P, ij}\) 是以下三角矩阵存储的:
因此,实际程序实现中,我们需要先对对角元缩放 \(\sqrt{0.5}\) 倍,并将乘法的结果缩放 \(2\) 倍;计算结束后,对角元缩放 \(\sqrt{2}\) 倍。
函数 get_itm_k_aux
变量名 |
变量意义 |
指标顺序 |
维度大小 |
其他说明 |
|---|---|---|---|---|
|
\(\mathscr{K}_{P, \mathrm{tp} (ij)}\) |
\((\mathrm{tp} (ij), P)\) |
\((\frac{1}{2} n_\mathrm{occ} (n_\mathrm{occ} + 1), n_\mathrm{aux})\) |
\(\bowtie\) |
|
\(\mathscr{K}_{PQ}\) |
\((P, Q)\) |
\((n_\mathrm{aux}, n_\mathrm{aux})\) |
sym |
以下是 K 贡献在原子轨道基组导数上的结果:
函数 hget_grad_dao_k_int3c2e_ip1
变量名 |
变量意义 |
指标顺序 |
维度大小 |
其他说明 |
|---|---|---|---|---|
|
\((\partial_t \mu \nu \vert P)\) |
\((\mu, \nu, P, t)\) |
\((n_\mathrm{basis}, n_\mathrm{basis}, n_\mathrm{aux}, 3)\) |
non-sym |
|
\(\mathscr{K}_{P, \mu \nu}\) |
\((\mu, \nu, P)\) |
\((n_\mathrm{basis}, n_\mathrm{basis}, n_\mathrm{aux})\) |
sym |
|
\(\Delta_{t \mu}\) 贡献项 |
\((\mu, t)\) |
\((n_\mathrm{basis}, 3)\) |
以下是 K 贡献在 3c-2e ERI 辅助基组导数上的结果:
函数 get_grad_daux_k_int3c2e_ip2
变量名 |
变量意义 |
指标顺序 |
维度大小 |
其他说明 |
|---|---|---|---|---|
|
\((\mu \nu \vert \partial_t P)\) |
\((\mathrm{tp} (\mu \nu), P, t)\) |
\((\frac{1}{2} n_\mathrm{basis} (n_\mathrm{basis} + 1), n_\mathrm{aux}, 3)\) |
\(\bowtie\) |
|
\(\mathscr{K}_{P, \mu \nu}\) |
\((\mu, \nu, P)\) |
\((n_\mathrm{basis}, n_\mathrm{basis}, n_\mathrm{aux})\) |
sym |
|
\(\Delta_{t P}\) 贡献项 |
\((P, t)\) |
\((n_\mathrm{aux}, 3)\) |
以下是 K 贡献在 2c-2e ERI 辅助基组导数上的结果:
函数 get_grad_daux_k_int2c2e_ip1
变量名 |
变量意义 |
指标顺序 |
维度大小 |
其他说明 |
|---|---|---|---|---|
|
\((\partial_t P \vert Q)\) |
\((P, Q, t)\) |
\((n_\mathrm{aux}, n_\mathrm{aux}, 3)\) |
anti-sym |
|
\(\mathscr{K}_{PQ}\) |
\((P, Q)\) |
\((n_\mathrm{aux}, n_\mathrm{aux})\) |
sym |
|
\(\Delta_{t P}\) 贡献项 |
\((P, t)\) |
\((n_\mathrm{aux}, 3)\) |
计算需求简要分析#
内存用量需求#
首先,整个计算中唯一一次使用到 \(O(N^3)\) 的量是 \(\mathscr{K}_{P, \mathrm{tp} (i j)}\)。其占用浮点数约是 \(\frac{1}{2} n_\mathrm{occ}^2 n_\mathrm{aux}\)。大部分情况下,这个量是可以接受的:我们在计算 RI-HF 本身就需要占用大得多的 3c-2e ERI。
其次,其余计算中看起来涉及到 \(O(N^3)\) 内存量的部分,都是可以对辅助基进行分解的。这包括
\((\partial_t \mu \nu | P)\) (\(3 \times n_\mathrm{basis}^2 n_\mathrm{aux}\))
\((\mu \nu | \partial_t P)\) (\(1.5 \times n_\mathrm{basis}^2 n_\mathrm{aux}\))
\(\mathscr{K}_{P, \mu \nu}\) (\(1 \times n_\mathrm{basis}^2 n_\mathrm{aux}\))
为了保险起见,我们可以设置 \(10 \times n_\mathrm{basis}^2\) 作为一个内存分配单元,确定每个分批次中辅助基的数量。
计算量需求#
该问题比较微妙地,并不是受 \(O(N^4)\) 复杂度的计算量影响,而是 \(O(N^3)\) 电子积分和内存瓶颈。其中电子积分的计算耗时最大。
目前有尚未解决的疑问。这是关于电子积分耗时的问题。
在每个单步的效率评估时,我发现打印出来的结果有令人疑惑的地方。在对 auxiliary basis batch size 不设上限的前提下 (即 aux_batch_size 不设 216 的上限),仅仅是变更 OpenBLAS 从 OpenBLAS 到 pthreads,就会产生很大的性能影响。
下述计算在 Ryzen 7945HX (16 cores) 下,运行 C12H26/def2-TZVP 的梯度计算耗时情况。最理想的情况是能进 3.4 sec,但这需要在张量 reduce 运算上引入高效的 einsum,而不能仅仅使用 RSTSR 目前实现的、类 NumPy 一般用途的标准算子。
OpenBLAS with OpenMP
Detailed time report:
de-jk preparation 1|: 0.432 s for de-jk preparation 1
de-jk preparation power|: 0.336 s for de-jk preparation power
de-jk preparation 2|: 0.130 s for de-jk preparation 2
de-jk batch int|: 1.538 s for de-jk batch int
de-jk batch 1|: 0.149 s for de-jk batch 1
de-jk batch 2|: 0.534 s for de-jk batch 2
de-jk batch 3|: 0.664 s for de-jk batch 3
de-jk batch 4|: 0.098 s for de-jk batch 4
de-jk batch 5|: 0.113 s for de-jk batch 5
Detailed time report:
rhf grad |: 3.809 s for rhf grad
OpenBLAS with pthread
Detailed time report:
de-jk preparation 1|: 0.402 s for de-jk preparation 1
de-jk preparation power|: 0.290 s for de-jk preparation power
de-jk preparation 2|: 0.163 s for de-jk preparation 2
de-jk batch int|: 2.863 s for de-jk batch int
de-jk batch 1|: 0.150 s for de-jk batch 1
de-jk batch 2|: 0.546 s for de-jk batch 2
de-jk batch 3|: 0.663 s for de-jk batch 3
de-jk batch 4|: 0.087 s for de-jk batch 4
de-jk batch 5|: 0.115 s for de-jk batch 5
Detailed time report:
rhf grad |: 5.218 s for rhf grad
我们写的 rest-libcint 接口没有真的引入 GEMM 函数或其他 BLAS,但在 batch int 即 3c-2e ERI 导数积分的耗时上差距巨大。对于这个耗时结果我确实无法理解。