Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

RI-RCCSD(T):极简到高效率

实现内容:RI-RCCSD(T) (CCSD with triplet perturbation correction)

  • 程序:ri_rccsdt_naive.rs (gitee, github)
  • 最简实现,无视性能
  • 该程序总共约 60 行 (其中调用的函数 prepare_intermediates 复用了程序 ri_rccsdt_slow.rs 的实现)

  • 程序:ri_rccsdt_slow.rs (gitee, github)
  • 与高性能的 RI-RCCSD(T) 具有相同的算法实现与程序结构,但没有引入一些关键的程序技巧
  • 该程序总共约 150 行,其中多个程序复用的函数 prepare_intermediates 约 60 行

  • 程序:ri_rccsdt.rs (gitee, github)
  • 性能较高
    • 体系 (H2O)10 cc-pVDZ (, ,无冻结轨道)
    • 计算设备 Ryzen HX7945,16 cores,算力约 1.1 TFLOP/sec
    • CCSD(T) 的三次微扰部分耗时约 1010 sec,CPU 性能利用率不低于 34%,可能还有提升空间
    • 本文档的所有性能分析均基于 OpenBLAS 后端讨论
  • 该程序总共约 150 行 (其中调用的函数 prepare_intermediates 复用了程序 ri_rccsdt_slow.rs 的实现)
  • 本程序的算法参考了 PySCF 在 CCSD(T) 上的实现

目录

1. RI-RCCSD(T) 实现前言

CCSD(T) 方法是通过微扰的方法,在仅有基于 CCSD 得到的 的一次、二次激发振幅基础上,给出三次激发的能量。微扰的三次激发能量的定义并不唯一,CCSD(T) 仅仅是多种三次微扰方法的其中一种;由于其具有较好的计算量与计算精度平衡,因此作为小分子计算化学方法广为接受与使用,常被称为“黄金标准”。至于这个“黄金”是否名不副实,或者是否是一种 overkill,这因具体的计算化学问题而异。

我们在此不讨论 CCSD(T) 的理论本身。

尽管我们将实现的是闭壳层 CCSD(T) 在 RI 下的近似,但若没有进一步近似,那么 RI 近似本身并不会真的节省 CCSD(T) 的计算量。因此,下述程序的实现,基本上与普通的 CCSD(T) 一致。内存上,我们要求非常大的 大小的张量;这会比 CCSD 单次迭代的内存量要大得多 (如果不考虑到 incore DIIS 对内存的额外消耗)。

2. RCCSD(T) 中间量

对于 CCSD(T) 的程序实现,我们需要一些中间张量。这部分的计算相对于 CCSD(T) 本身并不大,因此就在这里作讨论。

2.1 记号与公式

从公式上,CCSD(T) 涉及到 6-D 张量的计算,其指标是 ,即 3 个非占轨道与 3 个占据轨道。在迭代时,我们的主基调是对非占轨道 优先迭代1;因此会希望程序用到的张量中,非占轨道往尽量放在最不连续的维度上 (row-major 下是靠前的维度),占据轨道尽量放在最连续的维度上靠 (col-major 下是靠后的维度)。

为此,我们在使用分子轨道下的双电子积分、以及激发张量时,其维度顺序都与 CCSD 时稍有不同。仅针对 CCSD(T) 的实现,定义

需要留意,上式中的 的转置顺序与 不同。其中最大的张量是 浮点数的 4-D 张量。

2.2 程序

这里的程序都是比较标准的做法。

#![allow(unused)]
fn main() {
// ri_rccsdt_slow.rs, in fn `prepare_intermediates`

let t1_t = t1.t().into_contig(RowMajor);
let t2_t = t2.transpose((2, 3, 1, 0)).into_contig(RowMajor);
let d_ooo = eo.i((.., None, None)) + eo.i((None, .., None)) + eo.i((None, None, ..));

let eri_vvov_t = unsafe { rt::empty(([nvir, nvir, nocc, nvir], &device)) };
(0..nvir).into_par_iter().for_each(|a| {
    (0..nvir).into_par_iter().for_each(|c| {
        let mut eri_vvov_t = unsafe { eri_vvov_t.force_mut() };
        eri_vvov_t.i_mut([a, c]).matmul_from(&b_ov.i((.., a)), &b_vv.i((.., c)).t(), 1.0, 0.0);
    });
});

let eri_vooo_t = unsafe { rt::empty(([nvir, nocc, nocc, nocc], &device)) };
(0..nvir).into_par_iter().for_each(|a| {
    (0..nocc).into_par_iter().for_each(|l| {
        let mut eri_vooo_t = unsafe { eri_vooo_t.force_mut() };
        eri_vooo_t.i_mut([a, l]).matmul_from(&b_oo.i(l), &b_ov.i((.., a)).t(), 1.0, 0.0);
    });
});

let eri_vvoo_t = unsafe { rt::empty(([nvir, nvir, nocc, nocc], &device)) };
(0..nvir).into_par_iter().for_each(|a| {
    (0..nvir).into_par_iter().for_each(|b| {
        let mut eri_vvoo_t = unsafe { eri_vvoo_t.force_mut() };
        eri_vvoo_t.i_mut([a, b]).matmul_from(&b_ov.i((.., a)), &b_ov.i((.., b)).t(), 1.0, 0.0);
    });
});
}

2.3 性能评价

计算变量FLOPs 解析式FLOPs 实际数值
0.52 TFLOPs
0.04 TFLOPs
0.14 TFLOPs
总计0.70 TFLOPs
  • 计算耗时约 1.4 sec,实际运行效率为 0.50 TFLOP/sec;
  • CPU 性能利用率为 45%;
  • 在 CCSD(T) 计算中,这部分 计算量相对而言是可以忽略不计的。

3. RI-RCCSD(T) 最简实现

RI-RCCSD(T) 的公式尽管不算最少 (相比于 MP2 方法),但也相比于 RI-RCCSD 要少许多了。其表达式的复杂性与 Hartree-Fock 相当。因此,我们可以用较少的代码实现。

我们可以参考 Shen JCTC 2019 公式 (10) 附近的表达式,不过在约定俗成上有所区别:

上述 6-D 张量,以 为例,在程序中其张量对应的指标顺序是


上式中,计算 使用到转置后的分子轨道下电子积分与 CCSD 激发张量;因此为了与程序作对照,我们需要将公式改写为

的计算过程中,在指标 下作外循环,其索引得到的子张量都是连续的;并且可以通过维度更改 (reshape),不需要复制到新的张量,就可以通过矩阵乘法给出结果。这解释了我们为何要如此设计 CCSD 激发张量与分子轨道下双电子积分的指标顺序的原因。


RCCSD(T) 最简程序实现代码如下,核心代码总共 25 行:

#![allow(unused)]
fn main() {
// ri_rccsdt_naive.rs

let wp: Tsr = unsafe { rt::empty(([nvir, nvir, nvir, nocc, nocc, nocc], &device)) };
(0..nvir).into_par_iter().for_each(|a| {
    (0..nvir).into_par_iter().for_each(|b| {
        (0..nvir).into_par_iter().for_each(|c| unsafe {
            let mut wp = wp.force_mut();
            let wp_1 = eri_vvov_t.i((a, b)) % t2_t.i(c).reshape((nvir, -1));
            let wp_2 = t2_t.i((a, b)).t() % eri_vooo_t.i(c).reshape((nocc, -1));
            wp.i_mut([a, b, c]).assign(wp_1.reshape((nocc, nocc, nocc)) - wp_2.reshape((nocc, nocc, nocc)));
        });
    });
});
let w = wp.transpose((0, 1, 2, 3, 4, 5))
    + wp.transpose((0, 2, 1, 3, 5, 4))
    + wp.transpose((1, 0, 2, 4, 3, 5))
    + wp.transpose((1, 2, 0, 4, 5, 3))
    + wp.transpose((2, 0, 1, 5, 3, 4))
    + wp.transpose((2, 1, 0, 5, 4, 3));
let v = &w
    + t1_t.i((.., None, None, .., None, None)) * eri_vvoo_t.i((None, .., .., None, .., ..))
    + t1_t.i((None, .., None, None, .., None)) * eri_vvoo_t.i((.., None, .., .., None, ..))
    + t1_t.i((None, None, .., None, None, ..)) * eri_vvoo_t.i((.., .., None, .., .., None));
let (so, sv) = (slice!(0, nocc), slice!(nocc, nmo));
let d = -mo_energy.i((sv, None, None, None, None, None))
    - mo_energy.i((None, sv, None, None, None, None))
    - mo_energy.i((None, None, sv, None, None, None))
    + mo_energy.i((None, None, None, so, None, None))
    + mo_energy.i((None, None, None, None, so, None))
    + mo_energy.i((None, None, None, None, None, so));
let z: Tsr = 4 * &w + w.transpose((0, 1, 2, 4, 5, 3)) + w.transpose((0, 1, 2, 5, 3, 4))
    - 2 * w.transpose((0, 1, 2, 5, 4, 3))
    - 2 * w.transpose((0, 1, 2, 3, 5, 4))
    - 2 * w.transpose((0, 1, 2, 4, 3, 5));
let e_corr_pt = (z * v / &d).sum() / 3.0;
}

但显然,这样的代码距离能用还有不少问题:

  • 内存用量庞大。这是最主要的问题。该程序需要引入多个 大小的张量;对于 (H2O)4 cc-pVDZ 基组,一个这样的张量就要 26 GB;对于 (H2O)5 甚至要 100 GB。这显然是不能接受的。

  • Elementwise 操作的优化不足。上述程序还用到了很多的 broadcast 与 transpose;这些 elementwise 运算会占用 的内存带宽。单个内存中 elementwise 操作的时间代价通常比矩阵乘法中单步的乘加融合 (FMA) 代价要更大。考虑到 CCSD(T) 的主要计算量是 即较小的 的矩阵乘法 FLOPs,内存带宽占用只少一个数量级,因此 CCSD(T) 的 elementwise 操作最好有较高效率的实现。

    • 这与 CCSD 的情形不同。CCSD 是较大的 的矩阵乘法 FLOPs,与 的 elementwise 操作。因此,CCSD 的实现一般只需要注重矩阵乘法本身的实现即可。

4. RI-RCCSD(T) 初步实现

4.1 固定外循环指标的 实现

作为初步实现,我们首先要解决内存用量庞大的问题。

作为解决方法,我们对指标 作并行的外循环,使得循环内部最大的张量只有 3-D 的大小,如 等等。

为此,我们将计算 的部分单独拉出来成为一个函数:

#![allow(unused)]
fn main() {
// ri_rccsdt_slow.rs

fn get_w(abc: [usize; 3], intermediates: &RCCSDTIntermediates) -> Tsr {
    let t2_t = intermediates.t2_t.as_ref().unwrap();
    let eri_vvov_t = intermediates.eri_vvov_t.as_ref().unwrap();
    let eri_vooo_t = intermediates.eri_vooo_t.as_ref().unwrap();
    let nvir = t2_t.shape()[0];
    let nocc = t2_t.shape()[2];

    let [a, b, c] = abc;

    let mut w = eri_vvov_t.i([a, b]) % t2_t.i(c).reshape([nvir, nocc * nocc]);
    w.matmul_from(&t2_t.i([a, b]).t(), &eri_vooo_t.i(c).reshape([nocc, nocc * nocc]), -1.0, 1.0);
    w.into_shape([nocc, nocc, nocc])
}
}

4.2 处理 的置换求和

我们注意到 是经过置换求和后得到的结果:

这意味着,指标 下的结果 依赖于其他指标

为了破除这种依赖关系,使得外循环指标 内的任务相互独立,我们的做法是

  • 只对 上超三角部分作迭代 (迭代次数是 或约 次)。
  • 在循环内部现场生成置换的 等张量,即对外循环中特定的 指标,我们会生成 6 次 的张量。
#![allow(unused)]
fn main() {
// ri_rccsdt_slow.rs

fn ccsd_t_energy_contribution(abc: [usize; 3], mol_info: &RCCSDInfo, intermediates: &RCCSDTIntermediates) -> f64 {
    // ... //
    let [a, b, c] = abc;
    let w = get_w([a, b, c], intermediates)
        + get_w([a, c, b], intermediates).transpose([0, 2, 1])
        + get_w([b, c, a], intermediates).transpose([2, 0, 1])
        + get_w([b, a, c], intermediates).transpose([1, 0, 2])
        + get_w([c, a, b], intermediates).transpose([1, 2, 0])
        + get_w([c, b, a], intermediates).transpose([2, 1, 0]);
    // ... //
}
}

4.3 其余的计算

其余部分的张量计算就没有非常特别的部分了。

但在能量求和时,需要注意到,我们使用了上超三角的 的迭代,每个被迭代到的指标 的权重并不相同。

其中,权重 的数值取决于它在哪个对角线上:

#![allow(unused)]
fn main() {
// ri_rccsdt_slow.rs

fn ccsd_t_energy_contribution(abc: [usize; 3], mol_info: &RCCSDInfo, intermediates: &RCCSDTIntermediates) -> f64 {
    // ... //
    let v = &w
        + t1_t.i((a, .., None, None)) * eri_vvoo_t.i([b, c]).i((None, .., ..))
        + t1_t.i((b, None, .., None)) * eri_vvoo_t.i([a, c]).i((.., None, ..))
        + t1_t.i((c, None, None, ..)) * eri_vvoo_t.i([a, b]).i((.., .., None));
    let d = -(ev[[a]] + ev[[b]] + ev[[c]]) + d_ooo;
    let z = 4.0 * &w + w.transpose([1, 2, 0]) + w.transpose([2, 0, 1])
        - 2.0 * w.transpose([2, 1, 0])
        - 2.0 * w.transpose([0, 2, 1])
        - 2.0 * w.transpose([1, 0, 2]);
    let e_tsr: Tsr = (z * v) / d;

    let fac = if a == c {
        1.0 / 3.0
    } else if a == b || b == c {
        1.0
    } else {
        2.0
    };
    
    fac * e_tsr.sum()
}
}

4.4 性能评价

计算表达式FLOPs 解析式FLOPs 实际数值
296 TFLOPs
78 TFLOPs
总计374 TFLOPs
  • 这里我们没有统计其他 的计算步骤;
  • 计算耗时约 1603 sec,实际运行效率不低于 0.23 TFLOP/sec;
  • CPU 性能利用率不低于 21%。

5. RI-RCCSD(T) 高性能实现

5.1 效率改进方案与实现

上述程序的程序性能主要受制于下面几个因素:

  • 低效的张量索引;
  • 较为低效的 elementwise 运算;
  • 过分频繁的内存分配;

作为解决方案,

A. 整个程序经常处理张量转置;注意到每次循环时,张量的转置顺序都是固定的,那么可以在外循环前先将转置顺序储存为数组,以避免每次循环内再对转置的位置作计算。

#![allow(unused)]
fn main() {
// ri_rccsdt.rs

pub struct TransposedIndices {
    pub tr_012: Vec<usize>,
    pub tr_021: Vec<usize>,
    pub tr_102: Vec<usize>,
    pub tr_120: Vec<usize>,
    pub tr_201: Vec<usize>,
    pub tr_210: Vec<usize>,
}

fn prepare_transposed_indices(nocc: usize) -> TransposedIndices {
    let device = DeviceTsr::default();
    let base = rt::arange((nocc * nocc * nocc, &device)).into_shape([nocc, nocc, nocc]);
    let tr_012 = base.transpose([0, 1, 2]).reshape(-1).to_vec();
    let tr_021 = base.transpose([0, 2, 1]).reshape(-1).to_vec();
    let tr_102 = base.transpose([1, 0, 2]).reshape(-1).to_vec();
    let tr_120 = base.transpose([1, 2, 0]).reshape(-1).to_vec();
    let tr_201 = base.transpose([2, 0, 1]).reshape(-1).to_vec();
    let tr_210 = base.transpose([2, 1, 0]).reshape(-1).to_vec();

    TransposedIndices { tr_012, tr_021, tr_102, tr_120, tr_201, tr_210 }
}
}

B. 为了避免频繁的内存分配,在计算 时,应在调用函数 get_w 时重复使用先前的内存 buffer,输出张量应尽量避免新分配内存。下述代码也引入了 tr_indices,这就是上面提到的预先存储好的转置顺序。

#![allow(unused)]
fn main() {
// ri_rccsdt.rs

fn get_w(abc: [usize; 3], mut w: TsrMut, mut wbuf: TsrMut, intermediates: &RCCSDTIntermediates, tr_indices: &[usize]) {
    let t2_t = intermediates.t2_t.as_ref().unwrap();
    let eri_vvov_t = intermediates.eri_vvov_t.as_ref().unwrap();
    let eri_vooo_t = intermediates.eri_vooo_t.as_ref().unwrap();
    let device = t2_t.device().clone();
    let nvir = t2_t.shape()[0];
    let nocc = t2_t.shape()[2];

    let [a, b, c] = abc;

    // hack for TsrMut to reshape to nocc, nocc * nocc
    let mut wbuf = rt::asarray((wbuf.raw_mut(), [nocc, nocc * nocc].c(), &device));
    wbuf.matmul_from(&eri_vvov_t.i([a, b]), &t2_t.i(c).reshape([nvir, nocc * nocc]), 1.0, 0.0);
    wbuf.matmul_from(&t2_t.i([a, b]).t(), &eri_vooo_t.i(c).reshape([nocc, nocc * nocc]), -1.0, 1.0);

    // add to w with transposed indices
    let w_raw = w.raw_mut();
    let wbuf_raw = wbuf.raw();
    w_raw.iter_mut().zip(tr_indices).for_each(|(w_elem, &tr_idx)| {
        *w_elem += wbuf_raw[tr_idx];
    });
}
}
#![allow(unused)]
fn main() {
// ri_rccsdt.rs, in fn `ccsd_t_energy_contribution`

let mut w = rt::zeros(([nocc, nocc, nocc], &device));
let mut wbuf = unsafe { rt::empty(([nocc, nocc, nocc], &device)) };

get_w([a, b, c], w.view_mut(), wbuf.view_mut(), intermediates, &tr_indices.tr_012);
get_w([a, c, b], w.view_mut(), wbuf.view_mut(), intermediates, &tr_indices.tr_021);
get_w([b, c, a], w.view_mut(), wbuf.view_mut(), intermediates, &tr_indices.tr_201);
get_w([b, a, c], w.view_mut(), wbuf.view_mut(), intermediates, &tr_indices.tr_102);
get_w([c, a, b], w.view_mut(), wbuf.view_mut(), intermediates, &tr_indices.tr_120);
get_w([c, b, a], w.view_mut(), wbuf.view_mut(), intermediates, &tr_indices.tr_210);
}

C. 涉及到的 elementwise 运算中,计算 时使用到了 broadcast 数乘;但目前 RSTSR 的 broadcast elementwise 运算的实现效率仅仅满足一般需求,其性能没有达到极限。如果确实是 broadcast elementwise 导致性能低下,建议手动展开 for 循环。

D. 用于计算 的 broadcast elementwise 运算,若手动展开 for 循环,则需要对张量进行索引。为更高效地进行张量索引,一般需要做三件事:

  • 在确保索引不会越界的情况下,使用 index_uncheck 以避免越界检查 (这是决定性因素);
  • 低维度的索引代价总是小于高维度;尽可能先取出子张量,对更低维度的子张量进行索引;
  • 若在索引前能确保张量维度的大小,则尽量通过 into_dim 转换到静态维度。

E. 如果是多步计算,譬如计算 时涉及到多个张量的求和,对于这种情形最好使用迭代器以避免多次写入:对于特定的指标 多次读入张量并求和、但只写入一次到 2

F. 实际上,当 计算完毕后,后面的计算中,内循环的指标 之间其实没有关联。这意味着不需要对张量 作新的内存分配或存储;他们的数值随内循环 的迭代生成求得能量的贡献 (通过 fold 函数实现能量贡献的累加),就可以直接消亡。

#![allow(unused)]
fn main() {
// ri_rccsdt.rs, in fn `ccsd_t_energy_contribution`

let eri_vvoo_t_ab = eri_vvoo_t.i([a, b]).into_dim::<Ix2>();
let eri_vvoo_t_bc = eri_vvoo_t.i([b, c]).into_dim::<Ix2>();
let eri_vvoo_t_ca = eri_vvoo_t.i([c, a]).into_dim::<Ix2>();
let t1_t_a = t1_t.i(a).into_dim::<Ix1>();
let t1_t_b = t1_t.i(b).into_dim::<Ix1>();
let t1_t_c = t1_t.i(c).into_dim::<Ix1>();
let iter_ijk = (0..nocc).cartesian_product(0..nocc).cartesian_product(0..nocc);
let d_abc = -(ev[[a]] + ev[[b]] + ev[[c]]);
let w_raw = w.raw();

let e_sum = izip!(
    iter_ijk,
    w.raw().iter(),
    d_ooo.raw().iter(),
    tr_indices.tr_012.iter(),
    tr_indices.tr_120.iter(),
    tr_indices.tr_201.iter(),
    tr_indices.tr_210.iter(),
    tr_indices.tr_021.iter(),
    tr_indices.tr_102.iter()
)
.fold(0.0, |acc, (((i, j), k), &w_val, &d_ijk, &tr_012, &tr_120, &tr_201, &tr_210, &tr_021, &tr_102)| unsafe {
    let v_val = w_val
        + t1_t_a.raw()[i] * eri_vvoo_t_bc.index_uncheck([j, k])
        + t1_t_b.raw()[j] * eri_vvoo_t_ca.index_uncheck([k, i])
        + t1_t_c.raw()[k] * eri_vvoo_t_ab.index_uncheck([i, j]);
    let z_val =
        4.0 * w_raw[tr_012] + w_raw[tr_120] + w_raw[tr_201] - 2.0 * (w_raw[tr_210] + w_raw[tr_021] + w_raw[tr_102]);
    let d_val = d_abc + d_ijk;
    acc + z_val * v_val / d_val
});

let fac = if a == c {
    1.0 / 3.0
} else if a == b || b == c {
    1.0
} else {
    2.0
};

fac * e_sum
}

5.2 性能评价

计算表达式FLOPs 解析式FLOPs 实际数值
296 TFLOPs
78 TFLOPs
总计374 TFLOPs
  • 这里我们没有统计其他 的计算步骤;
  • 计算耗时约 1003 sec,实际运行效率不低于 0.37 TFLOP/sec;
  • CPU 性能利用率不低于 34%。

  1. 这里我们对非占轨道 作外循环优先迭代,而不是对占据轨道 优先迭代。

    在实现对占据轨道 作外循环、非占轨道 作内循环的程序后,对于当前关心的体系 (5-10 个水分子),程序效率会有显著下降。我们认为这比较可能是因为循环中,生成的 大小的张量会频繁读写、占用了较多的内存带宽,且其大小显著大于 L3 缓存、带宽效率也较低。同时,其计算过程中涉及到一步 得到 的矩阵乘法,即两个较小的矩阵相乘得到更大的矩阵;这类矩阵乘法不太容易达到理想浮点效率。

    相对地,对非占轨道 作外循环时,中间张量是 浮点数大小;对于 (H2O)10 体系,这也只有 2 MB。在单个线程内部,较小的张量的 elementwise 操作与矩阵乘法,其缓存命中率较高,容易达到更高的计算效率。

  2. 这也是 RSTSR 目前不擅长的部分,即它不支持延迟计算 (lazy evaluation)。不引入延迟计算,既是出于使用者方便的角度,也可以减少 RSTSR 开发者维护的难度。对于 C++ 中支持延迟计算的库,可以参考 Eigen。RSTSR 的张量支持各种迭代器 (依元素迭代或依特定维度迭代),并支持并行迭代。利用这些迭代器,也可以提升计算效率,避免多次写入;当然,这里高性能的 RI-RCCSD(T) 的实现是依预先存储的指针位置进行迭代,这个实现策略的性能更高但技巧性太强。