Await-Tree Async Rust 可观测性的灵丹妙药 - 赵梓淇
Await-Tree Async Rust 可观测性的灵丹妙药 赵梓淇 Bugen Zhao Await-Tree Async Rust 可观测性的灵丹妙药 Await-Tree 的 设计原理与实现 2 回顾 Async Rust 的设计与痛点 1 Await-Tree 的 应用与真实案例 3 Await-Tree Async Rust 可观测性的灵丹妙药 Await-Tree 的 设计原理与实现 poll 驱动的状态机 • 组合嵌套为调度单元: Task • async fn 语法糖 Async Rust 观测与调试的痛点 Async Rust 回顾 • 特性: Future 灵活的可组合性 • 任意定制 Poll 的执行逻辑 (Join / Select / Timeout) • 动态的调用关系 • 痛点:观测与调试工具无法理解灵活的执行逻辑 • Backtrace 不够直观 ( 痛点:观测与调试工具无法还原 Pending Task 的执行状态 • 难以得知 Task 阻塞的位置和原因 • 难以调试 Async Stuck • ? 如何解决? Await-Tree Async Rust 可观测性的灵丹妙药 Await-Tree 的 设计原理与实现 2 回顾 Async Rust 的设计与痛点 1 Await-Tree 的 应用与真实案例 3 设计目标 Await Tree 的设计原理与实现0 码力 | 37 页 | 8.60 MB | 1 年前3C++高性能并行编程与优化 - 课件 - 05 C++11 开始的多线程编程
:符合 RAII 思想,解构时自动 join() • C++20 引入了 std::jthread 类,和 std::thread 不同在于:他的解构函数里会 自动调用 join() 函数,从而保证 pool 解 构时会自动等待全部线程执行完毕。 小彭老师快乐吐槽时间 • 多线程、异步、无阻塞、并发,能提升程序响应速度,对现实世界中的软件工程至关重要 。 • 反面教材: blender 如果没有锁定,则对 mutex 进行上锁。 • 如果已经锁定,则陷入等待,直到 mutex 被 另一个线程解锁后,才再次上锁。 • 而调用 unlock() 则会进行解锁操作。 • 这样,就可以保证 mtx.lock() 和 mtx.unlock() 之间的代码段,同一时间只有一个线程在执行 ,从而避免数据竞争。 通俗的说: mutex 是个厕所, A 同学在用了, B 同学就不能进去,要等 方的同时持有了对方等着的锁的情况。 解决 2 :保证双方上锁顺序一致 • 其实,只需保证双方上锁的顺序一致,即 可避免死锁。因此这里调整 t2 也变为先 锁 mtx1 ,再锁 mtx2 。 • 这时,无论实际执行顺序是怎样,都不会 出现一方等着对方的同时持有了对方等着 的锁的情况。 解决 3 :用 std::lock 同时对多个上锁 • 如果没办法保证上锁顺序一致,可以用标 准库的 std::lock(mtx10 码力 | 79 页 | 14.11 MB | 1 年前3C++高性能并行编程与优化 - 课件 - 08 CUDA 开启的 GPU 编程
函数还是 GPU 都可以使 用,只要你用的 CUDA 编译器。 GCC 编译器相应的私货则 是 __attribute__((“inline”)) 。 • 注意声明为 __inline__ 不一定就保证内联了,如果函数太大编 译器可能会放弃内联化。因此 CUDA 还提供 __forceinline__ 这个关键字来强制一个函数为内联。 GCC 也有相应的 __attribute__((“always_inline”)) CUDA 的函数,如 cudaDeviceSynchronize() 。 • 他们出错时,并不会直接终止程序,也不会抛出 C++ 的异常,而是返回一个错误代码,告诉你出的具体什么 错误,这是出于通用性考虑。 • 这个错误代码的类型是 cudaError_t ,其实就是个 enum 类型,相当于 int 。 • 可以通过 cudaGetErrorName 获取该 enum 的具体名 字。这里显示错误号为 (managed) 上分配。 • 实际上这种“骗”来魔改类内部行为的操作,正是现代 C++ 的 concept 思想所在。因此替换 allocator 实际上是标准库允许的 ,因为他提升了标准库的泛用性。 进一步:避免初始化为 0 • vector 在初始化的时候(或是之后 resize 的时候)会调用所 有元素的无参构造函数,对 int 类型来说就是零初始化。然而 这个初始化会是在 CPU0 码力 | 142 页 | 13.52 MB | 1 年前3C++高性能并行编程与优化 - 课件 - 10 从稀疏数据结构到量化数据类型
基于哈希表,不保证顺序但更高效,需要键值能被哈希,复杂度 O(1) 用 unordered_map 按 16x16 分块存储 分块能减少 unordered_map 中存储的表项数量,从而减轻哈 希的压力。但意味着键值在空间上需要具有一定的局域性,否 则 会浪费分块中一 部分空间。 然而我们这里是 要用他记录粒子 经过的点,因此 具有一定空间局 域性,能够被分 块优化。 实际上空间局域 性正是稀疏网格 。 这些被写入的部分被称为激活元素 (active element) ,反之则是未激活 (inactive) 。 这就是稀疏的好处,按需分配,自动扩容。 分块则是利用了我们存储的数据常常有着空间局域性的特点,减轻哈希表的压 力,同时在每个块内部也可以快乐地 SIMD 矢量化, CPU 自动预取之类的。 第 2 章:位运算 稀疏的好处:坐标可以是负数 这样即使坐标为负数,或者可以是任意大的坐标,都不会产生越界错误。 signed 类型的 >> 会生成 sar 指令。 我们需要负方向无限延伸的稀疏数据结果,那就只 要 signed 那个就行。 >> 2 = 没有重合时可以用高效的加法:位运算 | • 如果可以保证 a 和 b 满足 a & b = 0 , 如: • 1011000 和 0000110 • 则: a + b = a | b | = 位运算 << :快速计算 2 的幂次方 • pow(20 码力 | 102 页 | 9.50 MB | 1 年前3C++高性能并行编程与优化 - 课件 - 02 现代 C++ 入门:RAII 内存管理
糊(有好处也有坏处,对高性能计算而言利大于 弊) 如果没有解构函数,则每个带有返回的分 支都要手动释放所有之前的资源 : RAII :异常安全( exception-safe ) C++ 标准保证当异常发生时,会调用已创建对象的解构函数 。 因此 C++ 中没有(也不需要) finally 语句。 如果此处不关闭,则可等 待稍后垃圾回收时关闭。 虽然最后还是关了,但如 果对时序有要求或对性能 int{3.14f} 会出错,因为 {} 是非强制转换。 2. Pig(“ 佩奇” , 3.14f) 不会出错,但是 Pig{“ 佩奇” , 3.14f} 会出错,原因同上,更安全。 3. 可读性: Pig(1, 2) 则 Pig 有可能是个函数, Pig{1, 2} 看起来更明确。 • 其实谷歌在其 Code Style 中也明确提出别再通过 () 调用构造函数,需要类型转换时应该 用: 等基础类型 2. void *, Object * 等指针类型 3. 完全由这些类型组成的类 • 这些类型被称为 POD ( plain-old-data )。 • POD 的存在是出于兼容性和性能的考虑。 << 取决于内存的随机值 编译器默认生成的构造函数:无参数( POD 陷阱解决方案) • 不过我们可以手动指定初始化 weight 为 0 。 • 通过 {} 语法指定的初始化值,会在编译器自0 码力 | 96 页 | 16.28 MB | 1 年前3C++高性能并行编程与优化 - 课件 - 06 TBB 开启的并行编程之旅
的芯片? • 结论:狭义的摩尔定律没有失效。但晶体管数 量的增加,不再用于继续提升单核频率,转而 用于增加核心数量。单核性能不再指数增长! 你醒啦?免费午餐结束了! 指望靠单核性能的增长带来程序性 能提升的时代一去不复返了,现在 要我们动动手为多核优化一下老的 程序,才能搭上摩尔定律的顺风车 。 神话与现实: 2 * 3GHz < 6GHz • 一个由双核组成的 3GHz 的 CPU 实际上提供了 显然不是。甚至在两个处理器上同时运行两个线程也不见得可以获得两倍的性能。相似的 ,大多数多线程的应用不会比双核处理器的两倍快。他们应该比单核处理器运行的快,但 是性能毕竟不是线性增长。 • 为什么无法做到呢?首先,为了保证缓存一致性以及其他握手协议需要运行时间开销。在 今天,双核或者四核机器在多线程应用方面,其性能不见得的是单核机器的两倍或者四倍。 这一问题一直伴随 CPU 发展至今。 并发和并行的区别 • 运用多线程的方式和动机,一般分为两种。 缩并。 • 这种常用于核心数量很多,比如 GPU 上 的缩并。 结论:改进后的并行缩并的时间复杂度为 O(logn) ,工作复杂度为 O(n) 。 封装好了: parallel_reduce 保证每次运行结果一致: parallel_deterministic_reduce 并行缩并的额外好处:能避免浮点误差,例如求平均值 扫描( scan ) 如图所示,扫描和缩并差不多,只不过他会把求和的中间结果存到数组里去0 码力 | 116 页 | 15.85 MB | 1 年前3C++高性能并行编程与优化 - 课件 - Zeno 中的现代 C++ 最佳实践
来在语句块内使用外部的局部变量。 带有构造函数和解构函数的类 • 实际上,只需定义一个带有构造函数和解构函 数的类(这里的 Helper ),然后一个声明该类 的全局变量( helper ),就可以保证: • 1. 该类的构造函数一定在 main 之前执行 • 2. 该类的解构函数一定在 main 之后执行 • 该技巧可用于在程序退出时删除某些文件之类 。 • 这就是小彭老师的静态初始化 变量,是一个带有构造函 数和解构函数的类,则 C++ 标准保证: 1. 构造函数会在第一次进入函数的时候调用。 2. 解构函数依然会在 main 退出的时候调用。 3. 如果从未进入过函数(构造函数从未调用过)则 main 退出时也不会调用解构函数。 • 并且即使多个线程同时调用了 func ,这个变量的 初始化依然保证是原子的( C++11 起)。 • 这就是函数静态初始化 (func-static-init) getMyClassInstance() 会在第一次调用时创 建 MyClass 对象,并返回指向他的引用。 • 根据 C++ 函数静态变量初始化的规则,之后 的调用不会再重复创建。 • 并且 C++11 也保证了不会多线程的危险, 不需要手动写 if 去判断是否已经初始化过, 非常方便! 函数静态初始化和全局静态初始化的配合 • 如果在全局静态初始化( before_main )里 使用了函数静态初始化(0 码力 | 54 页 | 3.94 MB | 1 年前3C++高性能并行编程与优化 - 课件 - 性能优化之无分支编程 Branchless Programming
的一个拓展特性(像 sse 一样),使用前需 要先用 cpuid 指令检测是否支持,如果在不支持 cmov 的 CPU 上使用会产生 SIGILL 错误。不过现在 64 位的 x86 CPU 都保证自带了 cmov 和 sse 拓展,所以不需要手动 开启什么开关编译器就会自动生成利用 cmov 和 sse 指令的高效代码,这也是 x86-64 的优点之一。 https://www.felixcloutier 时是需要连续两次条件跳转指令的。 但是在 -O3 的淫威下,编译器把其中一个条件跳转自动优化掉了( cmovle 和 cmovl )。 可惜另一个 if-else 的条件跳转指令( js )没有被成功优化掉(编译器具有短视性)。 可以看到“摆烂”版本的三目运算符 ?: 和 if-else 其实是一样的,也只优化掉了其中一个条件跳转。 但是在“妙用加减乘”的版本里,两次比较依然都是高效的无分支指令( setg 和 cmovbe 交给编译器自动优化掉。 • 一般只需要把 if-else 改成三目运算符 ?: 编 译器就能成功识别了(见开头的例子)。 • 建议只有当性能遇到瓶颈时,再去针对性对 “热代码”优化,而不是一股脑儿全部改成无分 支,影响可读性。 “ 妙用加减乘”的无分支优化是万能的吗? • return x >= 0 ? sqrt(x) : 0; • 能不能优化成: • return (x >= 0)0 码力 | 47 页 | 8.45 MB | 1 年前3C++高性能并行编程与优化 - 课件 - 07 深入浅出访存优化
2667*16*2=42672 MB/s • 那么,频率相同的情况下,可以考虑插两块 8GB 的内存, 比插一块 16GB 的内存更快,不过价格可能还是翻倍的。 • 系统会自动在两者之间均匀分配内存,保证读写均匀分配 到两个内存上,实现内存的并行读写,这和磁盘 RAID 有 一定相似之处。 验证一下刚刚的 parallel_add 是不是用足了全部带宽 • 刚刚 a 数组的大小是 1024 MB 2048 MB 的数据。 • 花费了 0.0656 秒。 • 因此带宽是 31198 MB/s 。 • 和理论带宽 42672 MB/s 相差不多,符合我的预期 。 第 2 章:缓存与局域性 针对不同数据量大小的带宽测试 • 我们试试看 a 不同的大小,对带宽有什么影响。 针对不同数据量大小的带宽测试(续) • 可见数据量较小时,实际带宽甚至超过了 理论带宽极限 42672 MB/s 跨步,则中间的缓存行没有被读取,从而变快了。 缓存行决定数据的粒度 • 结论:访问内存的用时,和访问的字节数 量无关,和访问的每个字节所在的缓存行 数量有关。 • 可见,能否很好的利用缓存,和程序访问 内存的空间局域性有关。 缓存行决定数据的粒度(续) • 所以我们设计数据结构时,应该把数据存 储的尽可能紧凑,不要松散排列。最好每 个缓存行里要么有数据,要么没数据,避 免读取缓存行时浪费一部分空间没用。0 码力 | 147 页 | 18.88 MB | 1 年前3C++高性能并行编程与优化 - 课件 - 04 从汇编角度看编译器优化
引用同一个头文件造成冲突,并不是必须 static 才内 联 如果你不确定某修改是否能提升性能,那你最好实际测一下,不要脑内模拟 inline 在现代 C++ 中有其他含义,但和内联没有关系,他是一个迷惑性的名字 “ 大厂面试官”笑话 • 同样沦为笑柄的还有 register 关键字,号称:可以让一个变量使用寄存器存储,更高效。 • 都能把等差数列求和优化成 5050 的编译器笑着看着你,说道:还要你提醒吗? b = b; 最后 b 没有改变。 导致优化后结果不一样,这就是 编译器放弃优化的原因。 告诉编译器别怕指针别名: __restrict 关键字 __restrict 是一个提示性的关键字,是程序员向 编译器保证:这些指针之间不会发生重叠! 从而他可以放心地优化成功: __restrict 关键字:只需加在非 const 的即可 实际上, __restrict 只需要加在所有具有写入 访问的指针(这里是 的地址不一定对齐到 16 字 节 movaps : move aligned packed single SIMD 指令:敢不敢再宽一点? 为什么编译器没有用 256 位的 ymm0 ? 因为他不敢保证运行这个程序的电脑支持 AVX 指令集…… 两个 int32 可以合并为一个 int64 四个 int32 可以合并为一个 __m128 八个 int32 可以合并为一个 __m256 让编译器自动检测当前硬件支持的指令集0 码力 | 108 页 | 9.47 MB | 1 年前3
共 28 条
- 1
- 2
- 3