我今年第二次做了《代码的出现》。对于那些没有';我没听说过,它';这是一组简短的编程谜题,在12月的前25天一次发布两个。在解决问题时,人们往往有不同的目标——一些人竞相快速完成并进入排行榜,一些人试图学习新的语言,还有一些人做一些可笑的事情(比如一个人在Scratch中解决了大部分难题!)。我没有专注于快速提交解决方案,而是尝试优化解决方案的运行时间,最初的目标是在800毫秒内解决所有难题(大约是JVM冷启动所需的时间)。正如你可能从这篇文章中猜到的';在AMD5950X上,我不用多线程就在65毫秒内解决了所有49个难题。这大大超出了我的预期,结果比python的启动速度还要快(在同一台机器上大约70毫秒)!此外,三天(23天、25天和15天)占据了绝大多数时间。如果没有这些日子,剩下的解决方案只需几毫秒就可以运行。
注意:本文的标题有点点击诱饵——由于python不驻留在页面缓存中,因此需要基于5950x的系统~70ms才能运行时间为python3-c";退出()";,但使用热页缓存只需10毫秒。
我想尝试使用rustdoc作为一种展示我的代码的方式,并解释我对这些问题感兴趣的地方,以及我是如何优化它们的。这种格式应该允许您浏览我的问题解决方案,查看我使用的类型,阅读我的想法,并查看我编写的实际代码。它';这是我第一次';我试过像这样使用rustdoc,所以我';感谢您的反馈。你可以在这里找到我的写作和解决方案,在继续之前,你可能应该阅读它们。你';如果你';我已经解决了AOC 2021,但是你也可以跟随每天的第一部分的问题描述。现在你';我做到了。。。
编写高效的AoC解决方案时,一个意想不到的问题是无法有效地对它们进行基准测试,这使得很难就改进内容做出明智的决定。许多性能分析工具使用类似于perf的方法,每几毫秒对程序运行时进行一次采样(FunctionTrace是一个非常棒的非采样Python分析器,完全没有偏见)。当最慢的谜题(第23天)在约31毫秒内运行时,它是';很难收集足够的数据来做出优化决策。因此,我通常会基于一些启发式方法进行优化,并使用cargo准则来验证我的假设。我使用了两种主要的启发式方法,当您关心性能时,应始终牢记这两种方法:
我最初的目标一直是找到一个更快的算法,让我避免一些计算。例如,对于第17天第2部分,我们对计算弹丸运动的步数所做的任何优化,仍然比避免通过三角形数计算大多数步数要慢。请注意,这并不意味着';为了减少工作量,不需要更快的算法——编写代码,这样编译器就可以省去边界检查,或者记忆某些计算的结果通常是非常有效的。在找到一个高效的算法后,我专注于最小化等待读取主内存的时间。这通常通过处理器&39;s缓存,它将透明地缓存对主内存的访问。然而,缓存有一个固定的大小,往往比系统上的RAM量小得多——我的开发机器有32GB的RAM,只有~1MB的缓存!为了有效地使用缓存,它';重要的是尽量减少数据的大小';以及使用可以利用缓存的模式。第23天是最小化数据大小的好例子。我们需要表示许多不同的游戏状态并对它们进行迭代,但一个游戏通常由27个不同的空间组成,每个空间都需要存储一个字节,然后由编译器填充到32个字节。通过消除无法到达的状态并将一些信息压缩成比特,我们';我们可以将其减少到16字节,这样我们就可以在长时间访问主内存之前,将缓存中可以容纳的游戏状态数量增加一倍。如果我们只删除这个16字节的优化,第23天的速度会慢30%以上!虽然优化数据结构的大小有助于将更多数据放入缓存,并允许CPU花费时间进行计算,而不是等待内存,但它';确保程序有效地使用缓存也很重要。为了在硬件中实现,CPU缓存存储行(通常每个行64字节),而不是单个字节。这意味着,在访问内存中的某个位置后,在它很可能命中缓存后不久访问任何内容,从而避免了对内存的往返访问。作为一个例子,我们在第9天通过按顺序迭代每一行来利用这一点,而不是使用BFS之类的东西。正如我们';重新迭代每个u16(占用2字节的空间),我们';我们将加载32个条目到缓存中,以便进行内存访问。如果我们迭代每一列,我们';d最终会将许多不同的行加载到缓存中,但不会立即使用它们,这会导致我们更频繁地访问主内存。第15天是一个很好的例子,我们可以';不一定要做好这件事;与Djikstra';s下一个邻居可能位于网格中的任何位置(内存为512KB),因此我们';我们很少能够通过缓存共享内存访问,而是将大部分时间花在等待从主存加载数据上。
我仍然很喜欢生锈。我的许多早期解决方案看起来与Python解决方案非常相似,除了强制的错误检查,不用担心它们';我错了,而且表现得非常快(如果我不在某处使用这个短语,它就不会是一篇生锈的文章)。确保我';我几乎不需要调试运行时错误,而且仍然具有本机性能,Rust已经成为我最喜欢的实用语言。我的大多数解决方案都有两个阶段,首先我解决了问题,而不特别关心性能,然后我优化它,直到它有令人满意的性能。在第一阶段中,我经常使用一些奇特的数据结构,比如向量和哈希表,然后将它们转换成更简单的数据结构,比如固定大小的数组。锈蚀#39;s强大的类型系统使我能够自信地进行重构,因为我知道我不能';不要错过一些东西,并在运行时遇到恼人的问题。就性能而言,它';通过特别选择我的数据类型来控制内存使用,同时也不需要通过malloc()和free()手动管理内存,这真是太好了。我特别选择了整数大小来优化内存布局和缓存效率,但几乎从来没有考虑过分配(甚至没有想到它们只占我解决方案的flamegraph的一小部分)。生命周期是新的Rust程序员似乎感到困惑的事情之一,但Rust团队在这方面做得很好,我在代码中使用了所有引用,但只需要使用显式生命周期注释两次。与1.0版本之前的版本相比,编译器在解决问题方面做得更好。我没有';根本不需要触碰不安全的东西,从VTune中的一些快速评测来看,我不';我不觉得自己因此错过了任何表演。这继续增强了我的信念:考虑不安全的普通程序员应该考虑重构他们的代码来使用不同的模式。我没有';根本不用夜间生锈。这基本上是可行的,但有一些不稳定的API,我会#39;我喜欢拥有。在我脑海中,BtreeMap::pop#u首先会';我简化了我的hacky priority queue实现,并且有许多与const泛型相关的不稳定特性,我希望这些特性能够存在。Const泛型看起来是个不错的主意,这是我第一次将它们应用到一个Rust程序中。我在一些地方使用了它们,但由于一些限制,我从来没有觉得它们比通过额外的论证有很大的改进。尤其是,不能在数据结构中使用const泛型函数参数意味着我不能';t围绕内存布局进行优化,我会';我预料到了。整数运算在Rust中似乎非常烦人。如果你关心整数使用的内存量,你';最后,我会在usize到索引集合、isize到处理潜在的负面情况,以及任何你真正想要的积分类型之间来回转换。这是一个合理的设计决策,但它';当我知道转换是安全的时候,把代码乱扔给as usize之类的东西会让我非常沮丧。这太棒了。通过生成的文档浏览生态系统中的所有内容的能力,包括按返回类型搜索函数等有用功能,使新库(甚至标准库)的安装变得轻而易举。像Hoogle这样的东西仍然很好,但我发现Rust docs比标准Python或Java docs更令人愉快。
我认为使用锈迹来解决代码2021的出现是令人愉快的,用它编写一些高性能的解决方案是令人满意的。我觉得我在与Python这样的动态语言相似的时间内得到了问题的初始解决方案';我需要(尽管优化显然需要更长的时间),而且它';很高兴我的CPU得到了有效利用。我';我喜欢看到更多的人试图用最少的运行时间来解决AOC,并且认为与其他高性能语言如C或C++相比是有趣的。通过快速浏览Code subreddit的出现,我相信我拥有了迄今为止最快的公共实现。如果有人相信';这是错误的(特别是如果以我的一个可用系统为基准),请告诉我!