在铁锈中写一篇制胜的4K简介

2020-07-06 07:30:04

我最近在“铁锈”中写了我的第一个4K简介,并在Nova2020上发布了它,它在新的学校简介比赛中获得了第一名。编写4K简介相当复杂,需要您同时掌握许多不同的领域。在这里,我将重点介绍我所学到的关于使Rust代码尽可能小的知识。

你可以在YouTube上观看演示,在Pouet下载可执行文件,或者从GitHub获得源代码。

4K简介是一个演示,其中整个程序(包括任何数据)有两个大小为4096字节或更少的数据,因此代码尽可能地节省空间是很重要的。Rust以创建臃肿的可执行文件而闻名,所以我想知道是否可以用它来创建非常节省空间的代码。

整个简介是用Rust和GLSL的组合编写的。GLSL用于渲染屏幕上的所有内容,而Rust完成其他所有工作:创建世界、相机和对象控制、创建乐器和播放音乐等。

我依赖的一些特性,比如xargo,还不是稳定锈的一部分,所以我使用夜间锈工具链。要默认安装和使用夜间工具链,您需要以下rustup命令。

我还使用了着色器微型器来预处理GLSL着色器,使其更小,更具皱纹友好性。着色器微型器不支持输出到.rs文件中,所以我最终使用它的原始输出,并手动将其复制到我的.rs文件中。(事后看来,我应该写一些东西来自动化这个阶段。甚至为着色器缩小器创建PR)。

起始点是我早先开发的概念代码证明(https://www.codeslow.com/2020/01/writing-4k-intro-in-rust.html)),我当时认为它非常简单。该文章还详细介绍了如何设置xtoml文件,以及如何使用xargo编译微型可执行文件。

许多最有效的大小优化与聪明的黑客没有任何关系,而是重新考虑设计的结果。

我最初的设计有一部分创建世界的代码,包括放置球体,另一部分负责移动球体。在某种程度上,我意识到球体放置和球体移动代码做的事情非常相似,我可以将它们合并到一个更复杂的函数中,这两个函数都能做这两件事。不幸的是,这种类型的优化会降低代码的美观性和可读性。

在某些情况下,您必须查看编译后的汇编代码,以了解代码编译成什么以及什么大小的优化是值得的。Rust编译器有一个非常有用的选项--emit=asm,用于输出汇编器代码。以下命令创建一个.s程序集文件;

不一定要是汇编语言专家才能从学习汇编器输出中获益,但是对汇编器语法有基本的理解肯定会有帮助。发布版本使用了opt-level=";z,这会使编译器针对尽可能小的大小进行优化。这使得找出汇编代码的哪一部分对应于铁锈代码的哪一部分有点棘手。

我发现Rust编译器在最小化代码、删除未使用的代码和不必要的参数以及折叠代码方面出人意料地出色。它还可以做一些奇怪的事情,这就是为什么偶尔研究一下生成的汇编代码是很重要的。

我使用了两个版本的代码;一个版本进行日志记录,并允许查看者操作用于创建有趣的相机路径的相机。铁锈允许您定义一些功能,您可以使用这些功能来有选择地包括一些功能。.toml文件有一个[Feature]部分,允许您声明可用的功能及其依赖项。我的4K简介在Tuml文件中有以下部分;

这两个可选功能都没有依赖关系,因此它们作为条件编译标志有效地工作。条件代码块前面有#[cfg(Feature)]语句。使用特性本身并不会使代码变得更小,但是当您在不同的特性集之间轻松切换时,它会使开发过程变得更好。

{//仅当选择了全屏功能时才编译此代码}{//仅在未选择全屏功能时编译此代码}。

检查了编译后的代码后,我确信只有选定的功能才会包含在编译后的代码中。

这些功能的主要用途之一是启用调试版本的日志记录和错误检查。代码加载和编译GLSL着色器经常失败,如果没有有用的错误消息,查找问题将非常痛苦。

当将代码放在不安全的{}块中时,我有点假设此块中的所有安全检查都将被禁用,但事实并非如此,所有常见的检查仍然应用,这些检查可能会很昂贵。

在查询表之前,编译器会插入代码来检查play_pos没有索引超过序列末尾,如果是这样的话会死机。这会增加相当大的代码大小,因为可能会有很多这样的表查找。

告诉编译器不执行任何范围检查,只执行表查找。这显然是一个潜在的危险操作,因此只能在非常不安全的代码块内执行。

最初,我的所有循环都使用惯用的锈蚀方式进行循环,在0..10的语法中使用For x,我只是假设它会被编译成尽可能紧密的循环。令人惊讶的是,事实并非如此。最简单的情况;

设置循环变量oop:如果循环结束,检查循环条件,跳转到末尾//do循环内代码无条件跳转到loopend:

设x=0;循环{//执行代码x+=1;如果i==10{Break;}}。

设置循环变量eloop://做循环内代码检查循环条件如果循环没有结束,跳转到loopend:

请注意,在每个循环结束时都会检查循环条件,这使得没有必要进行无条件跳转。这为一个循环节省了很小的空间,但是当程序中有30个循环时,它们确实会增加。

惯用Rust循环的另一个更难理解的问题是,在某些情况下,编译器会添加一些额外的迭代器设置代码,这确实会使代码变得臃肿。我从未完全理解是什么触发了这个额外的迭代器设置,因为用一个循环{}的构造替换{}的构造总是微不足道的。

我花了很多时间优化GLSL代码,最好的优化类别之一(通常也会使代码运行得更快)是一次对整个向量进行操作,而不是一次对一个组件进行操作。

例如,光线跟踪代码使用快速的网格遍历算法来检查每条光线访问地图的哪些部分。原始算法单独考虑每个轴,但是可以重写该算法,因此它同时考虑所有轴,并且不需要任何分支。Ruust实际上没有像GLSL这样的原生向量类型,但是您可以使用内部函数告诉它使用SIMD指令。

全局球体[CAMERA_ROT_IDX][0]+=CAMERA_ROT_SPEED[0]*CAMERA_SPEED;GLOBAL_SPOLES[CAMERA_ROT_IDX][1]+=CAMERA_ROT_SPEED[1]*CAMERA_SPEED。

让mut dst:x86::__m128=core::Arch::x86::_mm_load_ps(global_sphere[CAMERA_ROT_IDX]。as_mut_ptr());让mut src:x86::__m128=core::arch::x86::_mm_load_ps(CAMERA_ROT_SPEED。as_mut_ptr());dst=core::arch::x86::_mm_add_ps(dst,src);core::arch::x86::_mm_store_ss((&;mut global_sphere[Camera_rot_idx]))。as_mut_ptr(),dst);

它会小得多(但可读性要差得多)。遗憾的是,由于某些原因,这破坏了调试版本,而在发布版本上却完美工作。显然,这是我的内在知识的问题,而不是铁锈的问题。这是我会在下一次4K简介中花更多时间的事情,因为它节省了大量的空间。

有很多用于加载OpenGL函数的标准Rust板条箱,但默认情况下,它们都加载非常大的OpenGL函数集。每个加载的函数都会占用一些空间,因为加载器必须知道它的名称。Crinkler在压缩这类代码方面做得非常好,但它不能完全消除开销,所以我不得不创建自己的版本.gl.rs,其中只包括代码中使用的OpenGL函数。

我的第一个目标是编写一个有竞争力的4K简介,以证明语言适用于每个字节都很重要并且确实需要低级控制的场景。通常,这一直是汇编语言和C语言的唯一领域,次要目标是尽可能地使用惯用的Rust来编写它。

我认为我在第一个目标上相当成功。在开发过程中,我从未感觉到Rust以任何方式阻碍了我,或者我正在牺牲性能或功能,因为我使用的是Rust而不是C。

我在第二个目标上就没那么成功了。有太多不安全的代码并不真正需要在那里。不安全代码具有腐败效应;使用不安全代码快速完成一些事情(如使用可变静态)非常容易,但一旦不安全代码出现,它就会产生更多不安全代码,突然间它无处不在。在未来,我认为我会更加谨慎地使用不安全,只有在真的别无选择的情况下才使用它。