REST-DIIS 使用说明
1. 安装说明
该程序是库,没有主程序。若要在其他项目中使用该库,直接在其他库的 Cargo.toml 的 dependencies 中引入该库即可。
目前该程序没有发布到 crates.io 的计划。请将该库下载到本地。
该库不需要额外的编译;但在使用时,请作以下准备工作:
-
准备 hdf5。这是 cargo crate hdf5-metno 的要求;在 Ubuntu 系统中请执行
sudo apt install libhdf5-dev
对于不适合 apt install 的操作系统或集群环境,需要自行尝试解决。
-
准备 OpenBLAS。我们要求使用编译了 CBLAS、LAPACKE 的并行 OpenBLAS 后端 (pthread 或 OpenMP 均可)。并且您需要在项目中实现下述两者其一 (目的是将 libopenblas.so 和 OpenMP runtime 链入您的程序):
build.rs
中引入如下语句:#![allow(unused)] fn main() { println!("cargo:rustc-link-lib=openblas"); println!("cargo:rustc-link-lib=gomp"); // or other valid omp runtime library }
RUSTFLAGS = -l openblas -l gomp
这是 RSTSR 本身还有待发展导致的情况
必须要使用 OpenBLAS,是因为 RSTSR 目前还没有实现 Faer 后端的线性代数接口、或者 OpenBLAS 以外 BLAS 后端。
DIIS 需要用到线性代数功能 (求本征值),但计算性能需求并不大,所以理想的情况是不需要借助任何 FFI 后端实现 DIIS,即未来我们希望默认使用 Faer 后端。
Github Action 脚本不是理想的安装方式
以 Github Action 为代表的持续集成/持续部署脚本,是用户说明文档之外的另一种重要资料,让用户学习如何安装和测试一个程序。
但对于 REST-DIIS 的 Github Action 而言,我们必须指出,这不是最好的安装方式。这是因为 Ubuntu 默认安装的 OpenBLAS (libopenblas-openmp-dev),它并没有将 LAPACKE 同时编译;而 RSTSR 的线性代数功能需要使用到 LAPACKE。作为解决方案,链入 liblapacke-dev 不是好的选择。
我们不建议使用 RSTSR DeviceOpenBLAS
后端的同时,链接的 OpenBLAS 不具有 LAPACKE 的编译。
2. 使用说明
2.1 HDF5 临时文件及其路径
本项目的 DIISSemiIncore
将会在 DIIS 外推过程中使用 HDF5 硬盘存储空间。该空间是临时存储,一般来说用户是不需要获得的 (以后也许我们会实现 DIIS 文件存读,但目前这不是亟需实现的功能)。
DIIS 的临时文件路径由环境变量 REST_TMPDIR
或 TMPDIR
指定。REST_TMPDIR
优先级更高。
对于临时文件的声明周期问题:
- 如果程序正常退出,那么 DIIS 临时文件会自然地删除。
- 如果程序被用户终止或崩溃,DIIS 临时文件很有可能会存留在硬盘中。用户需要手动删除这些文件。
2.2 程序使用大致说明
下述不完整的代码是程序演示。该 DIIS 使用 DIISIncore
,即所有存储均在内存进行。
若想查看一个可以工作的完整代码,我们也在一个 简单的玩具问题示例 中展示了 DIIS 的应用 (参考 后一节)。
#![allow(unused)] fn main() { // 简化的张量类型记号 type Tsr<T> = Tensor<T, DeviceOpenBLAS, IxD>; // 默认的 DIIS 选项 let diis_flags = rstsr_diis::DIISIncoreFlagsBuilder::default().build().unwrap(); // 生成 DIIS 实例 let mut diis_driver = rstsr_diis::DIISIncore::<f64>::new(diis_flags, &device); // 初猜向量 (譬如 SCF 下 Fock 矩阵 F_uv;CCSD 下的振幅 t1、t2) let vec_init: Tsr<f64> = ...; // 误差向量 (譬如某种定义下的 F_ai) // 如果误差不好定义可以直接传 None (譬如 CCSD 的情形) let err_init: Option<Tsr<f64>> = ...; // 向 DIIS 实例插入初猜向量 // 参数表: // - 初猜 (或待迭代) 向量 // - 初猜 (或待迭代) 误差 (有最好,没有可以传 None) // - 迭代步数标记 (这里没有,建议传 None) diis_driver.update(vec_init, err_init, None) // 实际迭代 for niter in 0..max_iter { ... // 更新的待外推向量 let vec_new: Tsr<f64> = ...; // 当前向量的误差 let err_new: Option<Tsr<f64>> = ...; // 向 DIIS 实例插入待外推向量,并得到外推后的向量 let vec_extrapolated = diis_driver.update(vec_new, err_new, None) ... } }
2.3 程序功能
-
同时支持全内存 (incore) 与半内存 (semi-incore) 功能。其中,半内存是指输入的、以及 DIIS 外推输出的向量,都是完全存于内存的;但 DIIS 内部的存储空间使用了硬盘作为媒介。
- 全内存的 DIIS 类型是
DIISIncore
,设置参数的构造程序类型是DIISIncoreFlagsBuilder
。 - 半内存的 DIIS 类型是
DIISSemiIncore
,设置参数的构造程序类型是DIISSemiIncoreFlagsBuilder
。 - 目前我们还没有实现全磁盘 (outcore) 的功能。
- 由于目前数学库 RSTSR 还不支持 GPU 等设备,因此目前还不具备异构功能。
- 全内存的 DIIS 类型是
-
重要的设置参数 (flags):
- 不同的 DIIS 示例具有不同的设置参数类型,这些参数类型目前还互不相通。
space
:DIIS 空间大小。该设置一方面并非越大越好,另一方面该数值越大、DIIS 内部存储的向量也会越多,对内存或磁盘的需求会越紧张。特别是对 CCSD 等参数量较大的问题,不建议该值设置地太大。min_space
:DIIS 初始外推空间大小。该数值需要不小于 1。pop_strategy
:DIIS 弹出策略。DIIS 可以看作是一种队列 (queue),其队列大小是space
参数所决定的。一般来说 DIIS 是作为双向队列 (dqueue) 使用的,即先入先出 (DIISPopStrategy::Iteration
);但既然 DIIS 同时还需要计算输入向量的误差,那么我们也可以每次弹出误差最大的向量 (DIISPopStrategy::ErrDiagonal
)。但由于如果最后一次输入的向量误差较大 (这种情况一般来说是收敛不太成功的情况),弹出该向量容易导致外推出来的向量总是有较大误差;因而这种情形下,我们会退回到先入先出的策略上。chunk
:对于较长的向量,其计算是分批次进行的。该值对走硬盘的 DIIS 更为关键。需要注意,该值的设置并非越大越好,特别是硬盘占用空间小于磁盘的缓冲区域的情景。我们认为一个 chunk 的大小小于 1 MB (128 k FP64) 是比较合适的。scratch_path
:DIIS 缓存文件的文件夹路径。该选项仅对走硬盘的 DIIS 提供。
-
Cargo feature
sequential_io
:DIIS 计算误差重叠矩阵与外推时,不使用异步 I/O。我们在实现 DIIS 时,默认是使用异步 I/O 的,因此该选项一般不建议开启。
2.4 Undefined Behavior 注意事项
我们在使用 DIIS 时,一般仅使用其 diis.update(vec, err, iter) -> extrapolated_vec
函数。在使用该函数时,有一些情况我们不会仔细检查,有可能会导致 UB。这需要用户自己保证,包括:
- 每次传入的
vec
,err
的向量最好是一维 (动态维度IxD
) 向量,且必须保证每次传入的向量长度一致。 - 如果你的计算中,有任何一次
err
无法给出 (即需要传 None),那么请自始至终都在err
参数中传入 None。err
传入有效值或 None 在整个程序中是两种处理逻辑。 - 一般来说
iter
传入 None 即可,除非有特殊需求。DIIS 是双向队列时,具体要弹出哪个向量是通过迭代步数给出的;如果用户传入不合理的iter
,那么弹出的过程可能会出现问题。
玩具模型上的示例
该文档是针对示例文件 toy_model.rs
的补充说明。
1. 问题描述
该问题是一个简单的 4x4 矩阵求解问题:
一般来说,该问题是 复杂度的,因为该问题需要先对 分解为上三角、对角、下三角矩阵的乘积 (Cholesky 若对称半正定,SVD 或 QR 若非对称,等等),将问题化为若干 三角矩阵求解问题;但矩阵的分解本身是 的。
现在,我们注意到 矩阵本身具有特征:其矩阵数值上,对角矩阵元相比于非对角元大许多。譬如示例中用到的矩阵
#![allow(unused)] fn main() { [ [1.0, 0.1, 0.3, 0.2], [0.1, 1.5, 0.2, 0.1], [0.3, 0.2, 1.8, 0.2], [0.2, 0.1, 0.2, 1.3], ] }
在这种情况下,我们记矩阵 是矩阵 的对角元构成的矩阵;其对角值向量是 。
这种具有对角值大特性的矩阵,是否有办法避免 复杂度的计算?DIIS 提供一种 (不保证一定求解成功,但在计算化学中屡试不爽) 的策略。
2. 简单迭代策略
在具体应用中,DIIS 迭代需要以简单迭代算法为基础。对于当前问题,简单迭代算法设计如下。
首先,初猜向量可以通过下述表达式给出: 这里的除号是依元素除以的意思。因此,我们就以 作为初猜。
其次,对于迭代过程中的向量 ,为了得到下一次的向量 ,我们注意并联立下述两式: 得到 我们就将该近似式定义为 。这样就得到了简单迭代策略。
3. 简单迭代程序实现
程序实现如下。
#![allow(unused)] fn main() { let f = |x: &Tsr<f64>| &a % x - &b; // the initial guess let mut x = &b / &d; let mut x0; // the number of iterations and the tolerance let mut niter = 0; let maxiter = 20; let tol = 1e-7; while niter < maxiter && f(&x).l2_norm_all() > tol { niter += 1; x0 = x; let b0 = &a % &x0; x = (&b - b0) / &d + &x0; } }
最终实际迭代次数是 19 次。
4. DIIS 实现
DIIS 相对于简单迭代情景,所需要增加的代码很少:
- 初始化 DIIS 实例需要 1--2 行;
- 将初猜提供给 DIIS 需要 1 行;
- 迭代过程中 DIIS 外推更新需要 1 行。
#![allow(unused)] fn main() { let f = |x: &Tsr<f64>| &a % x - &b; // the initial guess let mut x = &b / &d; let mut x0; // DIIS incore driver let diis_flags = DIISIncoreFlagsBuilder::default().build().unwrap(); // <== let mut diis = DIISIncore::<f64>::new(diis_flags, &device); // <== // initial guess to DIIS diis.update(x.to_owned(), None, None); // <== // the number of iterations and the tolerance let mut niter = 0; let maxiter = 20; let tol = 1e-7; while niter < maxiter && f(&x).l2_norm_all() > tol { niter += 1; x0 = x; let b0 = &a % &x0; x = (&b - b0) / &d + &x0; x = diis.update(x, None, None); // <== } }
经过 DIIS 加速,该问题可以在 5 次迭代中完成。
5. 同时允许 semi-incore (disk I/O) 或 fully incore 实现的 DIIS
在实际的化学问题中,我们可能需要同时处理 semi-incore 与 fully incore 两种 DIIS:在大量计算的较小体系的情景下,我们希望用 fully incore DIIS 以增加效率;在计算较大体系时,则使用 semi-incore DIIS 以保证内存空间充足。
在这种情形下,DIISAPI
作为 dyn-compatible trait 提供了一种方式,以使得 semi-incore 与 fully incore 能同时处理:
#![allow(unused)] fn main() { // DIIS incore driver let semi_incore = x.size() < 3; // assuming x > 3 requires semi-incore (disk I/O) let mut diis: Box<dyn DIISAPI<Tsr<f64>>> = match semi_incore { false => Box::new({ let diis_flags = DIISIncoreFlagsBuilder::default().build().unwrap(); DIISIncore::<f64>::new(diis_flags, &device) }), true => Box::new({ let diis_flags = DIISSemiIncoreFlagsBuilder::default().build().unwrap(); DIISSemiIncore::<f64>::new(diis_flags, &device) }), }; // initial guess to DIIS diis.update(x.to_owned(), None, None); }
所涉及到的编程实践
1. 异步 I/O
RSTSR-DIIS 在处理 semi-incore 情景时,使用标准库实现异步 I/O (以 semi_incore_inner_dot 函数为例)。
首先,由于我们用到的变量通常有生命周期,因此不能直接使用 std::thread::spawn
函数开辟新的线程。其解决方案是,需要使用 std::thread::scope
先圈住声明周期,随后进行双线程。
以下述伪代码为例。假设我们要执行 func_a
与 func_b
(两者是 FnOnce,不返回任何结果)
#![allow(unused)] fn main() { for i in (0..niter) { func_a(); func_b(); } }
实际情景中,func_b
会依赖于 func_a
,即不能乱序执行;但 func_b
不依赖于 func_a
。若现在要进行异步问题,我们可以这么操作:
#![allow(unused)] fn main() { std::thread::scope(|scope| { // 圈住生命周期 let mut task = scope.spawn(|| {}); for i in (0..niter) { func_a(); // 此时 i 步的 func_a 与 i-1 步的 func_b 同时进行 task.join().unwrap(); // 相当于 barrier,保证现在执行 func_b 时已经完成 func_a task = scope.spawn(move || { func_b(); // func_b 并不在 master thread 上生成,而是平行进行的 }) } }) }
很可能我们在 func_b
时需要用到一些可变引用;碰到这种情况会比较麻烦,需要在 spawn 前传入不可变引用,然后在 spawn 内部用一些 unsafe 技巧弄成可变的。这其实有点像 OpenMP 并行时,一些变量是 shared 即所有线程可见的;这在 Rust 中其实是 unsafe 的,但这样的程序非常好写,而且大多数时候不会真的造成 race,那我的看法是不如就大方地用 unsafe (= trust me)。
同时,我们指出上面的做法是 post-process 的异步。另一种异步策略是 prefetch:
# 伪代码
func_a(0)
for i in range(1, niter)
barrier()
if i < niter - 1:
task = spawn(func_a(i + 1)) # prefetch
func_b(i)
但 prefetch 的策略我感觉不是非常好写,因为会对第一次循环读取需要在循环外进行、会增加一些判断语句去确定是否有下一轮循环。
被异步到分支线程的任务不一定必须是 I/O,也可以是计算过程本身。