介绍
这是第一篇 Autograd 帖子的延续。在 v2 中,我们带来了一些优化,我将向您介绍我为大幅加速程序所做的事情。
随机数
在此 v2 中,我们将使用随机数生成器来输入程序。之前我们使用固定浮点数(1.0 和 2.0)来检查程序是否输出正确的解决方案。然而,即使我们使用相似的模式,每种语言的编译器也会进行一些不同级别的优化:-O3 用于 C,-O ReleaseFast 用于 Zig,–release 用于 Rust。
之前的 v1 基准测试 – 标准化
为了使工作台标准化,我修改了 v1 以使用随机数生成器,特别是因为我知道所有程序都会输出正确的解决方案。
v2 基准测试 – 标准化
让我们换个角度,让我们从头开始:下面是优化的迭代 v2 基准数,也使用随机数生成器进行标准化。
在这种格式中,可以更轻松地比较 v1 和 v2 基准数字。另外,由于这次是C王在上面,所以我们将使用C代码来讨论。
请注意,编译后的 Python (PyCodon) 比 Rust 更快,几乎与 Zig 一样快。这表明 Python 代码一旦经过适当的优化和编译,就能够与这些超高性能的编程语言实现相同的竞争环境……为什么? Python深处埋藏着一个强大的C引擎,我们只需要知道如何解锁它!
优化级别
- v0级:不启动优化,只是让程序正确运行。 (完毕)
- 级别 v1:优化的第一次迭代。 (完毕)
- 级别 v2:第二次迭代,在本文中讨论。 (完毕)
- 级别 v3:SIMD。 (开发中)
- 级别 v4:多线程。 (未来的WIP)
性能改进
1.存储运算结果并复用
–> 减少冗余工作,减少计算开销。
==–注意–==:在 PC 上,您需要“在新选项卡中打开图像”,然后双击图像才能放大。在手机上,您只需捏合缩放即可放大图像。
在v1(左分割)中,程序不存储运算结果。相反,它会在每次需要时在前向传播过程中重新计算值。例如:
-
sum_xy
的值在prod_sum_xy
中使用时会重新计算。 -
prod_sum_xy
的值在softplus_prod
中使用时会重新计算。
在 v2(右分割)中,程序将操作结果存储在 NaiveTape 结构中,并在需要时重用它们。这消除了重新计算的需要:
-
sum_xy
的值存储在tape.vars[sum_xy_id]
中并在prod_sum_xy
中重用。 -
prod_sum_xy
的值存储在tape.vars[prod_sum_xy_id]
中并在softplus_prod
中重用。
==–改进–==
- 结果计算一次并存储以供重复使用,从而减少了计算开销。
- 由于消除了冗余计算,性能更快。
2.1 将结果存储在数组中可以提高缓存效率。
在 v2(右分割)中,NaiveTape 结构将所有变量 (vars) 和操作 (ops) 存储在连续数组中:
- 每个变量(NaiveVar)存储其值(val)和梯度(grad)。
- 每个操作 (MyOperation) 都存储其类型、输入 id 和输出 id。
深潜: \
- 减少内存占用:
v1:大型且低效的数据结构。使用指针会消耗额外的内存。
v2:紧凑的数据结构。使用整数索引,省略指针的需要。 - 更好的缓存局部性:
v1:NaiveVar 和 MyOperation 对象由于动态分配而分散在内存中。
v2:所有数据都存储在 NaiveTape 结构内的连续数组中。 - 消除内存池:
v1:固定大小内存池,引入越界风险。
v2:没有内存池,对象直接存储在 NaiveTape 结构内的数组中。
我写了一篇文章,介绍为什么使用索引比 ArrayList 中的指针更好。 ==TLDR:指针使用更多内存,并且更容易出现内存错误。== 在此用例中,数组索引更好。
2.2 重用结果。
执行操作时,其结果存储在 vars 数组中,稍后可以使用其索引进行访问。例如:
- 求和运算:
- Tape_sum的结果存储在tape.vars[sum_xy_id]中。
- 该结果在 Tape_prod 操作中重复使用。
- 产品运营:
- Tape_prod的结果存储在tape.vars[prod_sum_xy_id]中。
- 该结果在tape_softplus 操作中重复使用。
参考代码:
2.3 执行速度更快。
冗余计算的消除和缓存效率的提高导致 v2 中的执行速度更快。这在程序执行数百万次迭代的主循环中尤其明显。
3 简化的向后传递。
v1:基于指针访问 NaiveVar 对象 -> 内存间接,速度慢。
v2:基于索引 -> 快速消除指针间接。
结论
正如您所看到的,应用这些优化可以实现巨大的加速,特别是存储和重用结果,从而消除冗余处理。
请注意,我们还没有触及优化级别,我们深入(是的,这太深了!)SIMD,然后是多线程。但这需要等到下周末。