« June 2019 | Main | August 2019 »

July 24, 2019

程序员应该怎样提高自己

经常有小(我 20 岁左右的)朋友问我,作为一个程序员该怎样提高自己。每个人的经历不同,所处环境不错,其实这个问题很难具体回答。不如好好写一篇总结,以后就不必每封 email 都重新写一次了。

纵观我近 30 年的编程生涯,在每个时期,我看到的东西都不同。想必再过 10 年还会有变迁。我只能写写当下眼界所及之处。

引我爱上编程,并乐此不疲的学习,是“我能写出更高效的代码”这种乐趣。如果一个人在学习编程开始,不努力让自己的代码变得更高效,发现不了优化的乐趣,我想他很难爱上编程。Don Knuth 说,Premature optimization is the root of all evil ,这句话背后的道理,不必一开始就强行接受。evil 最能蛊惑人心,但是我们需要它引入门。

朝着优化这条路走下去,你会有自发的动力去了解计算机的本质架构,理解操作系统,理解内存模型,理解新的软硬件技术(它们大多数都是为了让程序跑的更快而发明出来)。否则,现在去学习这些东西,并不会体会到现实的意义。软件开发在今天,大部分的工作都在很高的层次了,大部分人的日常都在完成一些琐碎的业务,用不到这些。但实际上,你在融汇贯通后,可以在很高的层面凭经验就能从蛛丝马迹判断出底层发生的问题;可以从一些代码片段,判断出整个模块的设计意义。这些是无法作为单独的技能学会的。

精通一门语言是最基本的要求。所谓精通,就是要了解这门语言的各种阴暗角落。用每一样语言特性的背后的代价。知道在面临各种问题时用这门语言解决该问题的惯用法。大部分通用语言都会有设计缺陷,表现在具体方面就是面对某些问题,写起来直接了当,而另一类问题时却要绕很多弯弯,这些绕弯弯的部分就需要用某种模式去弥补。我认为,所谓编程的设计模式,并不是跨语言而独立存在的,它们是强烈依附于编程语言的。《设计模式》这本书,我读过的版本是基于 C++ 的,设计模式被谈论的更多的是在 Java 社区。这类模式都有很深的语言烙印。我们学习设计模式其实学的就是一门语言的惯用法。

初次学习设计模式时,肯定会有豁然开朗的感觉。但不应把自己陷入其中。为了提高一次层次,就必须精通至少第二门的语言。我的第一语言是 C ,第二语言是 Lua 。但对于许多人,肯定会有很多更好的选择。多看看不同的语言下解决问题的不同方式,有助于提高编程技能。

在我谈论程序员的编程技能的时候,我指的通常是两类能力:一是运用熟悉的编程语言,用在该语言下最高效的方法解决需求的能力;二是领域知识,尽量多的了解工作所处领域前人的积累,已存在的软件层次的接口,接口背后的代价。这两者缺一不可。不要用自己学习能力强为借口,认为随时可以进入一个新语言,一个新领域,而不会比别人差。不管是一门语言的使用,还是对一个领域的了解,都是需要长期的实践刻意积累的。

以上,都是我认为一个程序员对自己最基本的要求。这些东西多么精进都不为过。但还有一些更高层面的东西。

那就是分解问题,保持简洁的能力。也可以说是规划和构架的能力。这是超出编程语言,具体问题领域的,不过绝不能绕开它们。如果一个程序员编码很粗糙,或是对所在领域一知半解,我绝对不会信任他做的设计。

Keep It Simple, Stupid 谁都会说,但不受优化的蛊惑,我觉得很难。因为完全对优化代码免疫的人,我觉得他很难成为一名优秀的程序员。迈不过第一步,就谈不上在下一步有什么成就。大部分软件问题,本质上都是怎么把复杂问题分层次,分模块,化繁为简的过程。底层开发、基础设施建设为什么吸引了很多自命热爱编程的人,不是因为它们有挑战,相反,它们更纯粹,更简单,更容易做取舍。精益求精的人更容易做出成绩。

带着把各方各面都做到精益求精的心,跨越多个层次去看整个问题。奉着此心做取舍,知道封装和简化带来的代价,随时审视代价到底值不值。我很难总结出教条,这似乎真的只能用个人品味去解释:为什么这里要保持更朴素的数据结构,而牺牲高效的算法;那里为了少定义一个 api ,却让一件简单的任务变得更繁琐。

另一方面,规划不仅仅是针对代码,也包括了开发过程的一切。你不仅需要规划问题怎样划分,还要规划每个部分花多少时间和精力,区分轻重缓急。顺便谈谈超时工作的问题,我在前段谈 996 的时候就写过,超时工作其实反映的是规划的失误。找到正确的方法做事,最终需要投入的人力能有 10,100 倍的差距;而延长工作时间却无法超过 3 倍工时。提高自己的规划能力,应该先尽力减少超时工作。


前段时间有人问我,现在让你去面试程序员,你会考察些什么。我想了想,最近几年,我越发的不会做面试了。越来越不喜欢用具体技术问题考验面试者。

相比编写代码、调试代码、阅读代码、这些硬能力;我可能更为看重对各类工具掌握的软能力。比如有些人对 C++ 的犄角旮旯了解的一清二楚,却对 C++ 编译器的命令行参数一知半解,只会使用 IDE 开发,我觉得这样的技术栈是非常畸形的。软件的构建流程绝不仅仅是写好代码,就全部交给自动化工具去完成。即使仅局限于写代码,那么用代码生成代码,设计 DSL 解决领域问题,也是必备技能。这些在 meta 层面解决问题的方式,离不开你对构建工具的了解。

我们在日常工作中面临的很多琐碎,大部分都有现成的工具解放你的时间。分析日志 ,加工数据、收集信息,等等。都有很多途径去做。有笨拙的手工方法,有工具语言方便你编写脚本批处理,有现成的工具待你去发掘学习。看看你的工具箱里是不是只有一把锤子?


开源逐渐成为主流,我认为是现代软件开发方式的重大变迁。程序员群体可能是为数不多的,顶尖个体的生产力能够百倍千倍于平均水平的人群了。世界范围的开源开发,使得最顶尖的人可以把精力聚焦在不同的点,极大的放大他们的价值。所以造轮子固然有趣,会用轮子却更为重要。但大多数情况下,我们不能像搭乐高积木一样的组装软件——虽然那一定是每个开源模块的努力方向——理解和沟通的能力就显得格外重要。

有一段时间我在招聘应届毕业生时甚至觉得,考察写的代码好不好一点都不重要。真不如看看他写的文章,写的东西简单还是复杂不重要,就看有没有能力把事情说清楚。

在开源的世界中,不是你有能力读懂别人的代码就够了。如果你想使用,就必然面临你特有的需求,也有极大的可能性遇到别人没有遇到的问题。和作者沟通,和开发社区建立起良好的关系,说服维护者按你的想法推进这个软件的发展,或是吸纳别人的想法修正自己的设计,这是非常重要的技能。而弄个 fork 自己随心所欲的修改,甚至重起炉灶自造轮子,自己的层次就很难进一步突破了。

谈到这里,不得不提的是, git 绝对是软件行业近二十年最伟大的发明之一。它值得每个程序员正儿八经的学习,绝不应满足于会 commit push pull 就够了。它是进入开源世界的敲门砖。一个能力超强的程序员,如果不融入同样顶尖的团体,就是在浪费自己的人生。

July 17, 2019

法线贴图的压缩格式比较

这几天一直在忙着在 bgfx 上增加 ASTC 格式的法线贴图支持

法线贴图上的法线向量虽然有三个分量,但是它们是归一化的。而且,切线空间上的法线贴图的第三个向量 Z 总是正值,所以我们只需要用两个分量就可以保存下法线信息,在运行时再计算出第三个向量。

不过这种方法有一个问题,贴图采样的时候,由于是线性插值,所以计算出来的第三个向量可以和实际差的较远。通常的解决方法是保存球形投影的 X Y 分量,减少 Z 的偏差。具体可以看看 Nvidia 的这篇 Real-Time Normal Map DXT Compression

显卡硬件一开始并不支持的双通道压缩贴图,所以最早使用这个算法的 Id 是用 dxt5 模拟的,使用 Dxt5 的 RGB 中的 G 保存一个通道,再用 A 保存另一个通道。后来的 EAC_RG11 则直接支持了双通道压缩贴图。

ASTC 虽然没有特别支持双通道贴图,但是它的 encoder 可以把信息权重放在两个通道上,这样就可以在同样的 bpp 下,让双通道信息的误差更小。

bgfx 自带的贴图压缩工具没有支持这样的压缩参数,我最近的工作就是完善它。

bgfx 的 astc 支持是做足球经理项目的同学提的,我在几个月前提了个 issue 希望他能完善一下,可能是他工作很忙,一直没有回音。最近我们自己的项目向手机 port ,必须用到压缩法线贴图了(不然内存不够),只能自己动手。好在这样也就激活了这件事,现在那位同学已经自己开始干了。

btw, 这次发现我向 bimg 提的 pr ,居然可以跨仓库合并到 astc_encoder 项目上,而保留 patch 的作者和时间信息等。我一直不知道 git 还有跨仓库合并 patch 的功能 :)


这事我起了个头,不用自己做了,很开心。多出来的时间,我饶有兴趣的比较几种压缩格式的质量。

首先,图形有损压缩的质量衡量标准,业界一般采用 PSNR 峰值信噪比 。这个值越高,表示压缩质量越好。不过也不完全唯 PSNR 论。ASTC 和 ETC 的官方压缩器都提供了一个叫 percep 的选项,它的 psnr 差不多(略低)但号称对人的视觉上感觉更好。etcpack 更是把它作为默认选项。

我使用了 Nvidia 的贴图压缩工具 生成 DXT 格式贴图,ARM 的 astcencoder 生成 astc 格式贴图,Ericsson 的 etcpack 生成 etc 格式贴图。

astc encoder 有一个文档 专门说明法线贴图的压缩方案。

我今天使用了 Unity 官方公开的一个场景中使用的 2048x2048 的切线空间法线贴图。原始图片可以在这里找到

下表是我的比较结果:

encode toolsformatchannelsbppcommand optionsPSNR (dB) higher is better
nvcompress dxt1nm RGB 4 -nomips -normal -bc1n 27.484683
astcenc astc 6x5 RG 4.27 -t 4.0 -normal_psnr -thorough 29.670458
astcenc astc 4x4 RGB 8 -t 8.0 -thorough 33.238881
astcenc astc 5x4 RG 6.4 -t 6.0 -normal_psnr -thorough 33.839983
astcenc astc 5x4 RG 6.4 -t 6.0 -normal_psnr -thorough 33.839983
nvcompress dxt5nm RG 8 -nomips -normal -bc3n 34.518504
astcenc astc 4x4 RG 8 -t 8.0 -normal_percep -fast 35.779205
astcenc astc 4x4 RG 8 -t 8.0 -normal_psnr -fast 36.121064
astcenc astc 4x4 RG 8 -t 8.0 -normal_percep -thorough 37.030079
astcenc astc 4x4 RG 8 -t 8.0 -normal_psnr -thorough 37.403131
etcpack EAC_RG11 RG 8 -f RG 40.834371

PSNR 全部是用 astcenc 计算得到,因为 nvidia 的工具生成并还原的贴图只有两个通道,其它工具会还原成三通道。为了公平起见,我用 imagemagik 把结果中的 blue 通道全部清空再做计算。

我们可以发现,etcpack 生成的 EAC_RG11 质量最好,不过 etcpack 的算法实现实在是太糟糕了,慢的令人发指。同样一张 2048 的图,nvidia 压缩 dxt 只需要一秒,etcpack 需要跑几分钟。前段时间我自己实现过一个版本 ,可以把 O(n*n) 的时间复杂度降低到 O(n) 。

dxt 的质量最差,当然这个格式也最古老。

astc 最具弹性,选择 6bpp 就能到达到 dxt 8bpp 的质量。针对双通道的优化质量提升也很明显。而且在追求更小体积时,还可以选择更低质量(这点 EAC 做不到)。

July 08, 2019

用 skynet 实现 unity 的 cache server

我们公司的一些 Unity 项目,当 cache server 的数据上涨到几百 G 后,经常遇到问题。最近一次是 nodejs 内存使用太多导致进程挂掉。

我不太想的明白,一个几乎不需要在内存中保留什么状态的服务,为啥会吃掉那么多内存。简单看了一下 cache server 的官方实现 感觉实现的挺糟糕的。它的业务很简单,还不如按协议自己实现一个。

Cache server 解决的是 Unity 的 asset 打包问题,一台机器如果把资源从 A 转换为 B ,那么可以把结果 B 提交到 cache server 上;后来的人就不需要重新做这个转换流程,而直接下载 B 。

怎么定义 一个 B 是从 A 转换过来的呢?Unity 的每个资源都有一个唯一的 guid ,如果经过转换,那么转换过程,包括了原始数据的 hash 版本号,平台,转换程序,转换参数等等,可以计算出一个 hash ;任何参数的变更都会导致这个 hash 变化。所以 guid + hash 就可以代表一个唯一资源的唯一版本。在需要做转换时,算出 hash 去服务器上查询,如果不存在,就在本地转换然后上传;如果后来的人重新进行这个过程,就会发现前人上传的结果,直接下载即可。

这个过程中, cache server 对资源如何转换完全不需要了解。它就是一个大的 hash 表,客户端可以用 guid+hash 作 key 读写数据。

官方是用 nodejs 实现的。我前几年看过一次,原来只有几百行一个很小的程序,这次再看发现增加了不少代码,但是基本业务并没有变化。我认为它一直都很糟糕,现在甚至比以前更糟糕。

简单列举一下我认为的问题:

  1. 用 js 实现了一个非常复杂的 cleanup 过程,用于淘汰掉可能不再使用的 cache 文件。这个工作不应该在服务本身的进程做,因为 nodejs 的逻辑是跑在同一线程中,cleanup 的过程很可能影响正常的服务业务。而淘汰 cache ,用一个 crontab 加一个脚本就可以做的更好。实在没必要增加服务的复杂度。

  2. 同时支持了 file cache 和 mem cache 。这可能是内存爆掉的元凶。mem cache 是没有必要的,因为如果进程本身占用内存不多的话,操作系统会把所有闲置内存用于 IO 的 cache 。这个服务可以看成是一个普通的静态文件服务,无非是打开一个硬盘目录中的文件,发送给客户端。自己再做一层 mem cache 实属浪费,它不会提升什么性能,如果提升了,那么一定是原有的东西实现的有问题罢了。

  3. 文件散列在 cache 目录中做的不好。只简单做了一级目录的散列。导致我们的项目同一个目录下有几万个文件。虽然在 linux 下 ext4 对同一目录下放太多文件支持的不错,但是很多 cache server 还是跑在 windows 下,ntfs 支持的就糟糕一些。更重要的是,这对远程管理的负担太大。就算在本地,列目录也会卡住(因为默认的列目录还需要对文件名排序)。

  4. cache server 本身的协议设计的太草率,基本就是能用就凑合。比如我摘取一小段官方文档里对握手协议的描述就可见一斑。

The server reads eight bytes of data from the first package received. If the package contain less than eight bytes, only those are used. Only exception if getting a one-byte package, in which case it should wait for the next package in order go get at least two bytes worth of data.

居然在一个 tcp 流上设计的协议,谈 first package ,又没有怎样区分一个 package 的描述。貌似指 api 的读取调用收到的第一串数据?握手有可能是 8 字节,也有可能是 1 字节。


除了协议之外,我认为用 skynet 实现一个一样的替代物应该没太大工作量。上周花了几个小时试了一下,果然只需要 200 行左右的代码。核心功能 100 行代码就可以了,另外做了不少优化的工作,以更适合管理更大的项目(多人协作的高并发,海量文件)。

目前,我自己写的版本在公司内部测试。希望日后可以逐步取代官方 nodejs 版本的服务。这样方便根据实际使用情况做进一步的优化。