#array #element-wise #no-alloc #stack #low-memory #expressions #compatible

无std strobe

快速、低内存、逐元素数组表达式在栈上。与无std(和无分配)环境兼容。

5 个版本

0.2.0 2023年12月10日
0.1.3 2023年9月23日
0.1.2 2023年9月20日
0.1.1 2023年9月17日
0.1.0 2023年9月17日

#258 in 硬件支持

MIT/Apache

70KB
1.5K SLoC

Strobe

快速、低内存、逐元素数组表达式在栈上。与无std(和无分配)环境兼容。

此crate提供任意深度的数组表达式,并且执行时不进行分配(如果提供了输出数组)或仅对输出数组进行一次分配。

只要内部函数与编译器的循环向量化器兼容,并且与目标CPU上可用的(和启用的)SIMD指令集匹配,就可以在数据段上实现向量化。

适合谁使用?

如果你

  • 进行大于64个元素的数组多步操作,
  • 可以将你的数组表达式表示为树,
  • 并且在分配上遇到瓶颈或根本无法分配,
  • 或需要具体受限的内存使用,
  • 或内存或CPU使用受限,
  • 或没有标准库,
  • 或想要不手动向量化而获得速度提升,

那么这可能会很有帮助。

不适合谁使用?

然而,如果你

  • 进行单步或小规模数组操作,
  • 或无法合理地将表达式表示为树,
  • 或可以从具有出色人体工学的库(如 ndarray)中获得所需性能,
  • 或需要绝对最快和最低内存的方法,并且愿意且能够手动向量化你的应用程序,

那么这可能根本不会有所帮助。

示例:(A - B)*(C + D)进行一次分配

use strobe::{array, add, sub, mul};

// Generate some arrays to operate on
const NT: usize = 10_000;
let a = vec![1.25_f64; NT];
let b = vec![-5.32; NT];
let c = vec![1e-3; NT];
let d = vec![3.14; NT];

// Associate those arrays with inputs to the expression
let an = &mut array(&a);
let bn = &mut array(&b);
let cn = &mut array(&c);
let dn = &mut array(&d);

// Build the expression tree, then evaluate,
// allocating once for the output array purely for convenience
let y = mul(&mut sub(an, bn), &mut add(cn, dn)).eval();

// Check results for consistency
(0..NT).for_each(|i| { assert_eq!(y[i], (a[i] - b[i]) * (c[i] + d[i]) ) });

示例:零分配评估

虽然这里我们使用了简单的示例,但任何strobe表达式都可以以这种方式评估到现有存储中。

use strobe::{array, mul};

// Generate some arrays to operate on
const NT: usize = 10_000;
let a = vec![1.25_f64; NT];
let an0 = &mut array(&a);  // Two input nodes from `a`, for brevity
let an1 = &mut array(&a);

// Pre-allocate storage
let mut y = vec![0.0; NT];

// Build the expression tree, then evaluate into preallocated storage.
mul(an0, an1).eval_into(&mut y);

// Check results for consistency
(0..NT).for_each(|i| { assert_eq!(y[i], a[i] * a[i] ) });

示例:自定义表达式节点

许多常用函数已经实现。尚未实现的函数可以使用unarybinaryternaryaccumulator函数以及匹配的函数指针或闭包组装。

use strobe::{array, unary};

let x = [0.0_f64, 1.0, 2.0];
let mut xn = array(&x);

let sq_func = |a: &[f64], out: &mut [f64]| { (0..a.len()).for_each(|i| {out[i] = x[i].powi(2)}) };
let xsq = unary(&mut xn, &sq_func).eval();

(0..x.len()).for_each(|i| {assert_eq!(x[i] * x[i], xsq[i])});

显著的设计决策和UAQ(未提出的问题)

  • 为什么不实现标准的num ops?
    • 因为我们无法保证返回节点的生命周期与它所拥有的引用的生命周期相匹配,除非外部作用域保证拥有输入和输出。
  • 为什么在接口上放弃更符合习惯的函数式编程方式呢?
    • 同样的原因,我们没有标准的数操作符 - 不幸的是,这种用法破坏了生命周期保证。
  • 为什么它不是 panic-never 兼容的?
    • 为了保证数据的长度并消除切片操作中的 panic 分支,我们需要使用固定长度的输入数组,并使用 const generics 传播这些长度。这将导致不愉快的用户体验,并且因为数组长度需要在编译时确定,这也会阻止任何有用的跨语言绑定。
  • 为什么表达式需要是严格的树结构?
    • 因为我们严格位于堆栈上,我们需要对每个节点有可变引用以便使用中间存储,这是我们允许向量化的原因,因为我们只能对任何东西有一个可变引用,这自然产生了树结构。
    • 然而 由于输入数组不需要是可变的,多个表达式节点可以引用同一个输入数组,这解决了许多(但不是所有)可能导致严格树结构不足的潜在情况。
  • 我可以手工向量化我的数组操作,并且使用更少的内存以更快的速度执行它们!
    • 酷!尽情享受吧。

许可协议

许可协议为以下之一

任选其一。

依赖关系

~560KB
~11K SLoC