Chaofan

For the next train

作者: qcf

  • 为什么不推荐使用SIMD intrinsic

    许多程序员会从各种角落听到SIMD的概念,并视其为某种黑暗法术,甚至叫嚣SIMD是「真正的难点」,C++的奇技淫巧都无法与之相提并论。并且,这些第一次听说SIMD指令的人,往往会兴奋地用那一堆名字都看不明白的函数改写自己的所有代码。如果运气不好,程序会发生段错误 (segment fault),然后得花上半天时间调试。如果运气好能跑起来,他们也可能发现,自己的程序并没得到预期的性能提升,代码还变乱了。

    当然了,自己菜能怪谁呢?肯定是哪里还没改好,程序员失落地想。可问题是,有没有一种可能,这种重写根本没必要,SIMD也不是全世界只有你知道的黑魔法?之所以描绘这种心态,是因为以上就是我当年的心路历程。不过也用不着批评,因为了解编译器的人并不多。悲剧的是,至少对C和C++程序员来说,对编译器这个巨兽多了解一些,比从道听途说的优化方法重要太多。

    本文将会阐述:为什么(大多数时候)你不该用系统的SIMD函数,如何在不用这些函数的情况下享受SIMD的性能,什么时候SIMD函数才是必要的,以及在使用它们时应该注意什么。

    通常我们将这些特殊的函数称作intrinsic,意为内置函数,指你无法自己实现,只能由编译器提供支持的函数。后文会混用SIMD函数和SIMD intrinsic两个概念表达同一意思。

    如果你不了解SIMD为何义,可以参考此前一篇介绍SIMD的文章

    SIMD函数难以移植

    大部分人使用x86电脑,因此SIMD函数对他们而言就是immintrin.h或者类似头文件带来的类似_mm_add_epi32的函数,比如这样:

    #include <immintrin.h>
    __m128i some_add(__m128i a, __m128i b) {
      return _mm_add_epi32(a, b);
    }

    在x86平台它可以正常编译,但在arm上就会遇到若干奇怪的错误,然后告诉你:

    This header is only meant to be used on x86 and x64 architecture

    所以在arm平台要写成这样:

    #include <arm_neon.h>
    int32x4_t some_add(int32x4_t a, int32x4_t b) {
      return vaddq_s32(a, b);
    }

    在PowerPC平台又要写成这样:

    #include <altivec.h>
    vector int some_add(vector int a, vector int b) {
      return vec_add(a, b);
    }

    这还只是最简单的一类整数加法!

    即使你说,好,我不知道什么PowerPC,我也憎恨天天碰瓷伟大Wintel联盟的MacBook,我这辈子的代码就和x86绑死了。直到有一天,你听说了SIMD可以有512位那么长,然后复制粘贴下这样的代码:

    #include <immintrin.h>
    __m512i some_add(__m512i a, __m512i b) {
      return _mm512_add_epi32(a, b);
    }

    结果发现你的i7-6820HQ无法运行这个程序,指令不合法 (illegal instruction) 了!然后你还是要为不同版本的Intel/AMD CPU写不同的#ifdef。甚至AVX512还分为若干个小的指令集,某些CPU只能支持其中一部分,而Intel还在不断更新这套指令集 (如最新的AVX10)。

    这样看来,你需要一套机制,帮你自动处理不同平台支持的SIMD接口、长度、类型,否则生命就白白浪费在此,太令人崩溃了。

    SIMD函数难以写对

    面向SSE指令集,如果你想用intrinsic写一个简单的数组求和,需要写成这样:

    #include <immintrin.h>
    int sum(int *arr, int size) {
      const int step = sizeof(__m128i) / sizeof(int);
      int buf[step] = {};
      __m128i result = _mm_load_si128((__m128i*)buf);
      for (int i = 0; i < size / step; ++i) {
        __m128i addend = _mm_load_si128(
          (__m128i*)(arr + i * step));
        result = _mm_add_epi32(result, addend);
      }
      _mm_store_si128((__m128i*)buf, result);
      for (int i = 1; i < step; ++i)
        buf[0] += buf[i];
      for (int i = size / step * step; i < size; ++i)
        buf[0] += arr[i];
      return buf[0];
    }

    你需要在一个buf和SIMD类型里来回操作以初始化和获得单个元素 (SSE没有broadcast指令来用int初始化__m128i),时刻牢记SIMD长度,load和store时要强制转换指针类型,不把epi32写成epi16 (写错了不会有任何编译报错),非对齐的状态要记得用_mm_loadu_si128而不是load,最后的部分要手动加上。

    ……这实在不是什么好玩的东西。

    SIMD函数会阻碍编译优化

    再次以上面的代码为例。简单粗暴的版本写成这样:

    int sum(int *arr, int size) {
      int result = 0;
      for (int i = 0; i < size; ++i)
        result += arr[i];
      return result;
    }

    和以上的版本相比,哪个更快呢?以clang编译器为例,在O2和O3优化下,这个naive版本出乎意料地更快;而在O0和O1,这个手写SIMD的版本更快。因为O2下,编译器会启用「自动向量化」,生成的代码中依然使用了SIMD指令。

    要注意,x86平台部分CPU支持AVX2或AVX512指令集,支持32或64字节的整数SIMD,在这种情况下,naive版本的代码会被编译器自动优化到最优的长度,而手写版本依然只能使用16字节的指令。

    而即使同样采用16字节SIMD,编译器优化的版本依然比手写版本更快。

    SIMD函数破坏代码可读性

    经过前面两个小节后,这部分就不用多说了。互联网上有非常多关于编译优化的讨论,有人认为编译器比人聪明,有人认为人比编译器聪明。我的看法是:大多数情况下,编译器比程序员更了解硬件,但程序员也比编译器更了解程序意图。因此最重要的是尽量告知编译器程序意图,而不是尝试代替编译器做优化。即使需要亲手优化的时候,也要尽量保持软件工程的原则。

    人需要做「抽象」而不是「具体」层次的优化。如果某个层次的优化也逐渐变得机械化,此时应该加入新一层编译器,而让人处理更高层次的问题。现在流行的AI编译器就是这个思路。

    更好的方法

    对于类似以上sum的函数,编译器的自动向量化相当可靠,要注意的就是设置好目标平台,比如大部分编译器在x86上不会默认启用AVX512特性。

    C++环境和C有点不一样。因为C++有复杂的语义,某些常见的操作因为可能抛异常或者内部实现复杂的原因,并不能被编译器优化为简单的内存操作。比如:

    std::vector<int> sum2(int *arr1, int *arr2, int size) {
      std::vector<int> result;
      for (int i = 0; i < size; ++i)
        result.push_back(arr1[i] + arr2[i]);
      return result;
    }

    这个函数看似简单,但编译器无法为此生成向量化指令,因为push_back实际是个复杂的操作,会涉及到扩容和内存移动,还有可能抛出异常。写成下面这样就可以了:

    std::vector<int> sum2(int *arr1, int *arr2, int size) {
      std::vector<int> result(size);
      for (int i = 0; i < size; ++i)
        result[i] = arr1[i] + arr2[i];
      return result;
    }

    SIMD的本质是CPU额外提供的一组加速指令,编译器做的事情也是将高级语言转换成汇编指令。所以越贴近高级语言,越能和代码融合的写法,对编译器越友好。

    GCC和Clang都提供了向量类型扩展,这些扩展支持主要的算术、比较和读取写入操作。尽管向量长度需要作为常量指定,但通过宏的方式依然可以做到可扩展。这类扩展可以跨平台。实际上,打开immintrin.h或者arm_neon.h可以发现,它很可能就是利用编译器扩展实现的。

    此外,编译器还支持特殊的的pragma命令,对特定循环配置向量化属性。

    C++方面,从C++26开始引入了std::simd,可以以C++的风格实现一些SIMD操作。但不用等到C++26全面启用,较新版的libstdc++ (GNU)和libc++ (LLVM)已经率先支持std::experimental::simd。如果你是想用特殊的指令加速一些科学计算算法,直接用Eigen这类现成库也许更适合。

    此外,同样不要试图用SIMD手工实现一些标准库函数(如memmove、memcmp)来加速,因为它们已经这样实现了,并且标准库运行时会根据平台支持的特性自动选择最优版本。

    什么时候需要用intrinsic

    以上提到的向量化思路适用于多数单纯想用SIMD加速简单运算的场景。某些指令集支持一些复杂的加速指令,比如带mask的移动或者矩阵运算。编译器很难识别同样意图的C/C++代码并转换到对应指令,这种时刻就需要手写intrinsic了。而且不只是intrinsic,编译器本身也提供一组builtin函数。不过最好还是慎用,并且将用到的部分集中在一两处。

    利用各平台的intrinsic,可以高效地实现一些意想不到的操作。最典型的例子是simdjson,其中UTF-8验证和查找token等都利用了SIMD指令做加速。有机会可以在以后的文章里讨论某类场景的实现。

    除非在写操作系统内核这样的项目,或者有特殊的调试需求,否则尽量用编译器builtin而不是内联汇编。内联汇编会破坏可读性,还会阻碍寄存器分配和指令调度这类编译器优化。

    总结

    编译器向量化可以涵盖最基本的场景,而且会比手写intrinsic表现得更好。在C++里要注意容器类型的隐藏语义。

    需要手动向量化的场景,使用经过考验的第三方库,或者编译器的语言扩展。

    只有在实现非常取巧的操作时,才适合直接调用intrinsic,即使是这样也应该把相关代码集中到少数几个函数里便于移植。

    不要使用内联汇编。

  • 2024年终总结

    看上去,我没有坚持写年终总结的习惯,去年的所谓年终总结,其实是逛完公园即兴组织的一条长微博。但我其实是写过的,只是不常写完并发表而已,草稿箱里躺着2017年的总结,然后打算和2018的合并,再和2019的合并……还是算了吧。

    好在,如果你能看到这篇文章,那意味着至少对2024年度,我终于完成这个任务了。2024是我上大学以来的第十年,工作的第五年,这样规整的年份,也成了许多人生侧面的转折点。

    先看去年的若干目标完成得如何。只能说……惨烈。不过除了2019,我也没有多少回完成过大部分年度目标。读书方面倒是完成了,预计20本,实际光微信读书都快30本。创作上,没有在B站发过视频(随手拍的不算),目标12篇博客实际完成一半(已经是里程碑!),没有上架应用。学习上,没有学会新乐器,对投资也没有深入学习,就是年初开始定投纳指,收益率是不错,但因为有定投限额,所以总金额也不高,Lisp也是没没没坚持下来。健身从下半年开始几乎荒废。想玩的几个游戏都没玩通,2077甚至还停在2022年底的进度,说好的看一些电影和动画也烂尾了。

    去年转卖一台新iPhone赚了500,今年两台才只赚100,黄牛说现在256G好卖,1T卖不出去。果真是风水轮流转,苹果用户也要考虑经济形势了。

    生活

    从什么说起呢?最明显的改变是离开住了五年的张江,搬到离市区更近的地方。其实原因是这里离女友的地方近,和工作没有关系,甚至每天早上还要早起一个小时坐地铁,到后面摆烂就直接花近百块打车。搬过来不到一个月已失眠了好多次,甚至有天半夜快三点在备忘录里留了一条「记住今晚,以后一定要找个一周5天WFH的工作」。

    另一个变化是,以前因为离得远,常觉得母校附近的地方是繁华胜地,吃不完逛不完。现在搬到不远处以后,没过两月也腻了。新的住处抽油烟机能用,没上班那段时间还做过不少菜。

    其他的谈不上什么根本性的变化,像睡眠时间晚这类事早是顽疾了,试图纠正并失败的次数可能比成功被Rust重写的C++项目还多。

    阅读

    今年彻底和Kindle说再见,迎来了微信读书。不得不说微信读书是个非常好的产品——即使名字带微信两个字。

    读了很多书,如果只能挑一本说,就是《反脆弱》。这本书的核心观点可以概括为三句话:

    • 世界的日常运转建立在无数「这件事情不会发生」的假设之上;
    • 但因为现实世界的复杂性,这些以为不可能发生的事完全可能发生,时间拉长甚至一定会发生;
    • 生活需要策略以冲抵小概率事件发生带来的损失,甚至利用其获利。

    顺带一提,作者塔勒布另外两部出名的作品是《黑天鹅》和《随机漫步的傻瓜》,前者也是2021年对我影响最大的书。

    除了意识到意外事件可能发生,还要在它们到来时立刻承认发生。这是现实主义者的标配。是的,2024年开始,我意识到自己需要做现实主义者。理想和wishful thinking不是一回事,如果坏事已经发生或者预料到它会发生,应该想的是「那怎么做」而不是「我本可以」。这很难,这也就是为何现实主义者如此稀少。

    《反脆弱》让我一度每天睡前思考「最近可能发生什么」,以至在四月底写下一条备忘录「如果还有三月裁员」。

    其他相对印象深刻的书还有:

    • 《肥胖代码》,内容绕了一大圈,举了大量例子,其实只是在说明一件事:肥胖和胰岛素抵抗有关,胰岛素抵抗和身体分泌胰岛素的时间有关,人只要一吃就会分泌胰岛素,所以吃东西的时间甚至比量和吃的内容更重要。因此作者也是断食理念的推崇者
    • 《李光耀观天下》,现实主义者很珍贵,从这本书可以一窥现实主义者是如何看待世界的
    • 《涛动周期论》,是已故周金涛先生若干演讲的合集,读完一段时间后只记得自己的理解:周期是人类活动和自然因素共同造成的,大周期包含若干小周期,周期的本质是现实世界随机性在宏观上的体现(结合塔勒布和达里奥的作品)。如果世界经济长波周期真是一甲子,而上一个周期的开端是1970年代的石油危机,那么我们这代人面临的低谷可能要30年代末才能结束——好好锻炼身体吧
    • 《潮起:中国创新型企业的诞生》
    • 《中文打字机》
    • 《置身事内》和《以利为利》
    • 《那瓦尔宝典》,人需要利用信息技术给自己创造复利,收入不等于财富,倒是很适合AI时代的人再读读
    • 《波之塔》,读的第一本松本清张,是很符合对日本文学刻板印象的作品,写作于1960年代,富士山自杀圣地的初步印象就来自于此(《富士山下》灵感也多半从这里来)

    如果电影也算阅读的话,今年印象最深的电影应该是《你的名字》,是的,我此前没有看过,所以后来把《天气之子》《铃芽之旅》《秒速五厘米》过了遍。

    数字世界

    开始尝试Obsidian,一开始感觉很好,但没有坚持下来。Emacs也一样。总的来说这么多年自己最习惯的还是苹果备忘录。但备忘录总归不能作为最终目的地,所以还需要写完发布出来,待在一个喜欢写文档的公司就可以养成这种习惯。现在也能理解把博客当笔记本用的人了。

    开始使用折叠屏手机,vivo X Fold 3 Pro,感觉良好,如果摄像头不硌手就更好。但我确实又对拍照能力有期待,而且非Pro款没有无线充电。vivo系统用起来的感觉比小米更好(几年前的记忆了,如有错请指正)。

    买了想要很久的联想P3 Ultra工作站,但买来就是在吃灰,因为它的噪音和体积并不适合当作桌面PC(更何况已经有一台),只能当Home Lab用,但我还没部署任何东西。

    很想换USB-C接口的iPhone,但又没有什么动力换。对Belkin的双C口插头很满意。请不要使用杂牌充电宝,除非你想像我一样体验刚起床卧室发生爆炸。

    GitLab清退中国用户,把一些不开源的个人项目转移到了自建Gitea上。数据确实要放在自己手上才放心。

    职业

    跟我有点现实交情,或者常看我Mastodon的朋友都知道,今年我的职业生涯发生了重大转折,说更直白一点就是——我被裁员了。但我想先解释一点背景,毕竟我好像没写过2018和2019的年度总结,而且我也不是那种直到被裁才惊觉世界为何对我如此不公的作死中年人,顺道也想梳理这几年工作的心路历程。

    一点回顾

    从2019年毕业开始,我就在IBM编译器团队工作,团队的主要职责是给POWER这个CPU架构开发和维护一个基于LLVM的C/C++/Fortran编译器。2018年9月,我通过校招录取,但当时本来的计划是「考研」,且想抓住宝贵的一学期休息一会(学院整个大四无课),所以拖延到次年再去实习。这期间的生活有两个关键字——「星际争霸」和「海底捞」。

    回头看,当时推迟实习的决定非常正确,因为人生中再不会有这么无忧无虑的一段躺平时光了,即使现在辞掉工作也不可能有那个心态。

    校招过程非常顺利,顺利到难以想象。因为编译器在普通计算机学生眼中属于要求很高的黑科技,而且IBM编译组声名在外(感谢知乎用户「蓝色」),甚至17年就有过申请实习的想法,可惜没有名额。当时的我因为2018上半年找实习受挫,计划考研但内心并不认同考研这件事,给中科院计算所编译组的导师发邮件甚至没得到任何回复,正在为出路发愁,所以在知乎上看到找人信息后,纠结了10分钟就果断发了邮件(其中9分钟都是担心会不会面不过导致期望变失望)。

    在COVID之初的几年内,我的工作都是给LLVM的PowerPC后端添加各种支持或者修复问题,所以人们能看到我给LLVM交过很多Patch,实际上那是我工作的一部分。直到2022年,大约就是上海封城那段时间,我开始负责一个试验性质的项目:移植Rust语言的工具链到AIX操作系统。最早这个项目只有我一个人,后来陆续有几位同事加入(和退出)。5月29日,宣布解封日期的前一天,我在凌晨三点穿越这座无人的城市,坐高铁回家,还在郊区隔离点蹭了一星期饭。

    解释一下,这个项目的「技术含量」没有想象的那么高,工具链移植多数时候都是杂活,比如各种crate和二进制格式、链接器选项的工作,但不妨碍整个项目的成就感。早期那会,入睡了都会能梦到进度。最重要的两个里程碑,rustc和cargo都是在家里完成的。当时每天晚饭后都会到附近的街上走走,走在熟悉的商业街,路上多是附近的老人(衰败的地方就是这样),对比想到自己在参与一个全世界仅此而已的项目,反差实在难以描述。由于确实充满热情,外加估期保守,项目进展大大超出预期。到八月初,最早的原始测试版就发布了。

    最早的用起来并不方便,因为对很多crate做的移植还没被上游接受。但测试版还是引发了很多用户的兴趣,而且随着很多Python包选择用Rust取代C/C++写原生扩展,在AIX上使用Rust工具链不再是nice to have,而是必需。

    好像Rust项目说得有些多,因为它是过去几年职业生涯里一段有趣的记忆,有机会可以再写一篇文章聊聊。还有件小事可以在这里提出来当备忘,而且它直到我离职也没有结果(后面应该也不会有结果)。

    简言之,就是Rust官方存在一个管理工具叫rustup,用户可以用rustup注册本地的Rust工具链,或者从某个服务器下载(默认是官方)。官方服务器上的Rust工具链,是由官方使用GitHub Actions CI编译的。问题来了:如果我们想让AIX上的Rust也能通过rustup安装,那就需要GitHub Actions能编译出AIX上的Rust工具链,而GitHub Actions只有Linux,自建服务器也不支持AIX,而AIX也不像Solaris/Illumos一样可以交叉编译。

    一开始,我们想出了几种打擦边球的方案(内容不提,但它们既不优雅也不实用,我很反对),基本都未获社区认可。最后还剩一条路:因为Rust不依赖C库头文件(libc crate已经封装完了),整个LLVM天然也是交叉编译的,AIX交叉编译最大的障碍就是没一个其他平台可用的链接器,所以如果能移植LLVM的lld到AIX,问题自然解决。

    内部讨论中,有人提出过这个方案,但最后没有被严肃对待。我可以理解,管理者本能偏向保守(「我们没有人有链接器的经验!」),但我对这个项目的潜在热情不亚于22年的Rust,所以已经做过一些早期探索,就此搁置真的非常可惜。随着我的离开,我想可能不会有人再有兴趣做lld到AIX的移植了。

    Rust项目给我们带来了回报,让我用一个极其有热情的项目对冲了22年动荡的负面情绪,我也在入职不到四年半的时候第二次升职(对普通应届生这个时间也许是六七年)。

    一锅端

    被裁的过程也相当戏剧化。前面提到,夏天我搬离了张江。被裁前一周,和几个朋友吃饭,其中一位来自B站,闲逛到字节楼下的时候问我:住得不远,有想法去字节吗?

    次周周四,女朋友来上海,我去机场接机。半夜的顺风车普遍吓人,司机在高架上几乎睡着。这趟来回让我第一次感觉到,「买车」好像不是一件毫无意义的事。因为到家很晚,第二天在家办公的时候非常困,到五点已经没精力写代码了,模糊到折腾vscode主题。下班后,逛了附近的盒马,还在畅想后面怎么WLB。

    次日中午,一同事微信上问我,公司的账号还能登录吗。我本能反应:是不是服务又中断了?对方强调,不是网络问题,是权限停止。我尝试后得到一样的结果。对面答曰:「看来我们在一条船上」。这让我疑惑,但也不焦虑,因为过于突然以致难以往那个方向想。下午的计划是去逛宜家,车上刷微博时看到关注的科技博主说「IBM会裁掉整个中国的研发团队,包括CSL和CDL」,我终于开始好奇事情会如何尘埃落定。刚进宜家商场片刻,发现自己被拉进一个神秘的微信群,第一句话是「这一天来了」。

    起初,局势并不明朗。如何赔偿,缓冲期多久,中间如何交接,都不清楚。当时我的心态甚至都不是石头落地,而是兴奋,因为我不用纠结要走的那天怎么提离职了。当时的我还有八天年假,还在想有没有必要请掉。两周之前我去了珠海和澳门,还觉得应该多留几天假给年底旅游。

    我并没有急着将这事告诉家里人,而是在周日找了家KTV好好唱了一下午,前台还好心地升级到了大包间。看了《刺猬》,就记得一句话:「自此,我们再不会被万事万物卡住。」

    周一是全员大会,也是我最后一次赶着时间打车到公司。有人以为会长篇大论,实际就是几个高管在WebEx上念了两分钟的稿子就下线了。潦草得甚至上了新闻。下午草草离场。

    求职记

    就在正式通报裁员消息的周一,A团队就联系上了我(当然还有其他同事),介绍了他们的情况。我本着来者不拒的原则,也想面试看看。结果没过几天同一家公司的B团队也找了过来,我和其中几位之前就认识,他们非常热情,而且做的事情也更合我的兴趣。此时得知这家公司即使不同部门,招聘流程也是互斥的,我只能二选一,于是尴尬地拒绝了A。

    和B团队的面试非常顺利,聊了很多平时没什么人有热情和我聊的东西,甚至有点「酒逢知己」之感,我对面试官的印象也非常好。终面那天是新iPhone发售,我约了中午取货,所以面完就要匆匆冒雨出去坐地铁。前一年iPhone发售那天我也是急匆匆早上取货,因为完事后还要赶去朱家角团建,当时的我能想到第二年的情景吗?

    虽然B团队给我留下的印象非常好,但有一些其他原因使得我并不打算加盟。也许是巧合,B的招聘流程要求录用前要经历笔试。题目不难,但时间非常尴尬,和我另一场面试有重合,而那天刚好临近国庆假期,很多招聘人员联系不上,无法错开时间,所以我没有通过笔试。因为这样的原因没有收到Offer,多少有些遗憾。

    早在刚宣布裁员那会,我联系上了半导体相关的一家公司C。因为他们规模较小,HR动作很快,实际上我这两个月的第一场面试就是他们家。除了问工作经历和编译器的知识外,面试官给我出了一道数据结构的题,看着风格就很LeetCode。起初我以为很简单,但写着发现有个操作总是达不到要求的O(1)时间复杂度,在搏斗快一个小时后,果断选择放弃完美主义,交一个O(n)答案。面试官出乎意料地满意。后来搜了一下,是LeetCode hard原题,晚上自己又实现了正确的版本。

    因为缺人,C公司的招聘流程推进非常快,半个多月的时间就发了口头Offer。他们待遇不错,做的事情也比较贴合我的技能树。但站在当时的时间点,自己对创业公司还是多少有顾虑,另外也想换个细分方向练练级,只能先拒绝了。中途还面了公司D,总的来说乏善可陈,第一个跟我提996的就是他家的HR。

    找工作最初想的是什么都面一面,所以投了公司E的一个和编译器半点关系没有的部门。没想到他们HR非常专业,联系上我给并介绍了一番情况。鉴于这家公司在行业内的光环,其实如果真的能去,能留下一段经历也挺值得。一面开始前非常紧张,好在过程还算顺利,但一面通过后,HR告知没有人头了,招聘流程只能冻结。

    F也是在业内鼎鼎大名的公司,但它的岗位招聘流程非常奇怪,官网上看不到这个岗位,甚至没留一个邮箱,只能通过一个员工的微信发简历过去。(当年面试IBM的时候,虽然官网上也还没开放,但至少领导留了邮箱联系)时值中秋假期,投完几天后我忍不住微信问了两句,因为对面在休假,得到了不太积极的回复。后来再无下文,后来我从其他路径了解到,他们是已经招到人了。实话实说,对于这事我不是很开心,因为该岗位要求的编译器开发背景和我非常契合,再怎么说给个面试机会并不过分。不过预期管理是生活必修课,没办法。

    国庆前,我在网上联系到了另一个F公司的员工,想要投一个测试岗位看看,结果到10月下旬也无下文。本着破罐子破摔的原则,我又自己在这家公司官网投递了相同岗位,没想到当天下午HR就电话过来了解情况了。面试官是个印度人,我因为对印度客户做了半年技术支持,对印度英语抵抗力还可以,但中途面试官一直在灵魂拷问我一个问题——你之前是做编译器开发的,为什么要来做测试?我说了很多冠冕堂皇的理由,他似乎并不满意。他又提了一个问题:如果现在同公司有一个编译器开发的机会,我会选择它还是继续选择这个岗位?妈妈说过,做人要诚实,这次我直接说,选前者。面试官很高兴,对我的坦诚大家赞赏。然后,邮件结果是不合适。

    接下来是公司G,他们几年前有招过语言开发的岗位,所以我对它还有些好印象。这次没有发现,但也顺手投了编译器相关的机会,说是编译器工程师,但职位描述说得很清楚,强调「调编译器」而不是「开发编译器」。面试官说提前看过我的博客和GitHub,看到我在开源社区有很多贡献。这让我很感动,面试这么多回只有极少几个面试官会提前留意我的额外信息。交流一下之后,让我手写一道和图遍历有关的题目,难度不高,但当时的我对图就是懵的状态,最后没写对。面试官最后略显尴尬的说,其实手写代码也不是惟一的考核因素,别紧张。没过多久,结果就显示「不合适」。

    H团队和前面的F一样,都是我求职之初期望值非常高的机会。我给F投简历非常早,甚至还是某天睡不着半夜决心起来提交的,但他们硬生生在简历关卡了我半个月,不过最后HR还是联系我了。面试约在晚上,面试官迟到了小会,全程未开摄像头,对我的简历经历呈现出毫无兴趣的样子(其实和职位要求非常符合),我甚至怀疑这个人是不是以前被外企的人欺负过。整个面试过程也有些缺乏尊重,持续抛出一堆非常基本的名词概念问我知道这是什么吗,最后写代码环节更是直接下线,让我写好了叫他一声。不出意外,第二天就收到了流程中止的反馈。让我更无语的是,在H团队面试不通过的一小时内,我向同一公司其他无关部门投递的所有简历同时被终止流程。因此,在九月底那段时间,我被冲击得有些慌不择路了。

    然后是I和J两个团队。最初我给I投递过简历,卡了一周后回复简历关没过,问了自称熟悉情况的猎头,说是对面希望要的是更资深一些的工程师。没想到又过了一周,I又把我简历捞了起来。J是猎头帮我内推的,过程中两位面试官在我这的印象可以和B的并列最佳,为了他们的Offer我还推迟了其他一些机会,没想到最后远不及预期,只能拒绝。也从其他地方听到,J因为在公司中缺少盈利能力,经费不足,项目风险也大。在I的HR面中,对面问我拿到了哪些Offer,我强调了我的预期,HR问如果给和J类似的Offer呢,答曰难以接受。果然,最后I的Offer没批下来。

    在这期间同事还帮我介绍了团队K,他们的风格偏传统,中年员工比较多,所以不会太「奋斗」,但企业文化也不那么适合我这样自由散漫的家伙。技术面只有一面,但有两个小时,七个面试官在线上轮流发问,一开始还很紧张,后来车轮战越战越有激情,对面也算满意。他们的Offer比J强一些,但正式Offer内容比口头的有缩水,这点不太好。最后综合考虑,拒绝。

    还有一家公司L,早在五月就被我纳入了潜在下家列表。他们一开始也把我简历拒了,后来在BOSS直聘上联系到了团队负责人,对方邀约周六面试。整个面试过程算平平无奇,但其中一个面试官真是字面意义上的没睡醒,看得出加班非常严重,也和之前听到的一致。后面的HR面也强调了这一点。其实我对L公司印象很好,但确实无法接受这种强度,拒绝。

    还有一家和编译器没什么关系的公司M,算是猎头硬推的,我想着可以面面看。问了我一些C++和软件测试的问题,这位和H是惟二对我简历背景一点不问的,印象一般。结果一面拒。

    以上面试的地方或者拒了我或者被我拒,然后机缘巧合加入了现在的团队。

    2021, 2024

    想再补充一点心路历程。

    五月,母校学院组织了一场校友返校见面会。回当时上过课的楼里看了看,也和几个五年没见过的同学吃了饭,算回忆初心,也算了解现状。我开始思考,是不是我的职业发展陷入了某种瓶颈?当时正好每天既要被印度人拷打,又要从事项目里诸多意义不大的脏活。所以,我开始捡起三年未更新的「潜在下一站」列表,重新看看有什么可考虑的机会。

    为什么是三年呢?因为2021是我在此之前最接近决定离开的时间。当时待了两年,也是感觉做的事到了瓶颈,25岁又正是既有力量又敢做梦的年纪。那年leader刚去互联网,走之前还悄悄告诉我说,年轻人应该出去看看。行情也特别好,要不了多久就有猎头加上微信。七八月那段时间,我陷入了严重的厌工情绪,尤其是周日晚上,经常要三四点才能睡着。有时候,我会强行早起,上午去上班,然后中午突然请半天假回家睡觉。无独有偶,24年的夏天我也不时陷入这种状态。甚至有天晚上失眠,凌晨四点在备忘录里写下「不要忘记这个夜晚」。这一次倒是真跳出来了,尽管是被迫的。

    还有点感慨的是,入职新公司后,看到好些优秀的同事,都是在21年左右入职的,赶上了发展最快的时间。自己有时也想,如果21年就下定决心选择离开,后来会不会成长得更快?但转念一想,21年我去了很多地方旅游,薅随心飞隔几周就回次家;22年能窝在张江每周五下午和同事玩线上游戏,干Rust AIX这种now or never的项目,在家里办公2个月每天能吃家人做的饭;23年在家上班同时还能练习烹饪,到处飞看演唱会。确实是什么也换不来的几年。当我觉得别人的生活如何光鲜时,也会有人觉得我怎么过得如此多彩。现在理解了,为什么「一切都是最好的安排」。

    健康

    虽然有种身体一年不如一年的感觉,但好像也没什么大问题,就是健身计划又反复了,尤其是开始八点下班的生活之后。也许这种「坏但是没那么坏」的状态正是中年人身体突然垮掉的心理原因——抵抗平庸的重力!

    多做做体检有必要,哪怕体检再水,抽个血也能看出很多东西。年轻人就该多薅医保羊毛,因为年轻人的医疗需要花不了什么钱,反倒能预防大病,属实是利国利民。没有像2023年一样年底突然来场大发烧,即使这年的流感更严重了。奉劝有条件的朋友,流感或者新冠初愈后,第一件事就该是买一张去广东或者海南的机票,下飞机就能感觉身体恢复了一半。

    腰确实不如以前了,说不清是被椅子还是软床垫害的。脖子也……拍出来不够直。以前特别担心自己的牙,在私营诊所洗牙时医生告知我有牙周炎,询问价格后果断拥抱公立医院。打了两回麻药,被疯狂掏嘴后,现在牙可能是我全身最健康的地方,顺便领略了冲牙器的魅力。

    去哪里

    又去了三次Fear and Dreams演唱会(本来应该是四次),不腻,还想去。场馆服务最好的是西安,居然还有不涨价的酸奶可以买。

    还去了广州、杭州、苏州,在熟悉的城市也去了不熟悉的地方。两个人又去了珠海和澳门。都是好地方。本来预计10月底会去名古屋(其实我投的是越南),因为散伙所以作罢。

    演唱会还看了徐佳莹,陶喆的没买到票。有好几次做梦都把徐佳莹演唱会、去珠海和被裁员三件事闪回到一起,我不知道它们有什么联系,只是因为都发生在八月,也可能是因为欠着八天年假而已。

    年底又参加了RubyConf China,自己放自己出趟差总归是收获颇丰,除了自助餐还有广州本地大佬做东。听听别人的故事,也算「停不低地计划,停不低地建立」之外有点想头。

    Expect<2025>

    没有什么特别的期待,无非就是多锻炼身体、写点东西、把长长的待办列表多完成一些。虽然不希望世界再发生什么乱七八糟的事,但现实主义者的第一要旨就是承认它们完全会发生。

    九月的时候,自己给自己写过一句话:生活中也许90%,也许99%的日子都是无趣的,它们的存在就是为了剩下10%或者1%的精彩时刻做准备。我迷信奇数年我会更开心,而偶数年会更有成长。那,就安心准备,等着那1%的日子到来吧。

  • 快速了解SIMD

    SIMD即Single Instruction Multiple Data缩写,单指令多数据,表示CPU设计中一种提高程序并行度的技术。和它对应的SISD、MISD和MIMD三个概念已很少有人提及,倒是GPU又引入了个新概念叫SIMT (Single Instruction Multiple Threads)。

    换种理解方式或许更实在些:SIMD和SIMT表示逻辑上有多个执行的实体,但只有一个执行的状态。至于其他三种:

    • 一个执行实体,一个执行状态,属于单线程
    • 一个执行实体,多个执行状态,属于协程
    • 多个执行实体,多个执行状态,属于多线程

    更实用地说,编程用途上的SIMD就是一组指令集扩展,能用一条指令同时完成若干个数据的相同操作。由于大量计算程序最耗费时间的代码都是核心的若干循环,如果能有SIMD指令的帮助,虽然复杂度还是没有改变,但相当于耗时除以一个不小的系数,不算免费午餐也是廉价午餐了。

    快速上手SIMD指令

    这里用C语言举例,看下面的代码。

    #include <stdio.h>
    
    typedef unsigned vecu32 __attribute__((vector_size(16)));
    
    extern unsigned data0[];
    extern unsigned data1[];
    extern unsigned datalen;
    
    int main(void) {
      for (int i = 0; i < 100000; ++i) {
        int j = 0;
        for (; j + 4 <= datalen; j += 4) {
          vecu32 a = *(vecu32*)(&data0[j]);
          vecu32 b = *(vecu32*)(&data1[j]);
          *(vecu32*)(&data0[j]) = a + b;
        }
        for (; j < datalen; ++j)
          data0[j] += data1[j];
      }
    }

    为阻止编译器做常量优化,这里把两个数组的定义放置在另一个文件里,长度都为65536个unsigned。在Apple M1上,使用Apple Clang 16搭配-O选项,运行时间是0.87秒。而如果不使用vecu32,即注释掉中间用到vecu32的for循环,运行时间会来到2.40秒。这里一个vecu32是16字节,即能容纳4个unsigned。虽然没有完全达到4倍性能差距,但2.8倍也是非常明显的性能提升,奥秘就在这里声明的vecu32类型。

    如果你稍微熟悉GCC或Clang,就能发现这里的__attribute__是GCC风格属性扩展,而vector_size属性就是为向量类型准备的。在编译器语境,SIMD容器就被称作向量 (vector),实际上确实比C++的std::vector更接近数学上向量的原始定义。这里一个vecu32能容纳4个unsigned变量,那我能否定义一个32字节的vecu32来进一步加速呢?

    先看当前版本的汇编。在ARM上用clang -S可以发现输出中有一条add.4s v0, v0, v1指令,查询文档可得知这条指令的意思就是向量整数加。再把源码中的16改成32,for循环头上的4改成8编译一遍,会发现执行时间几乎没有变化,而汇编里有了两条 add.4s而没有想象中的add.8s存在。失落之余先别着急,在输出汇编的编译命令中加入-arch x86_64 -mavx,能惊喜地发现X86平台是有32字节SIMD指令的,叫vpaddd。甚至,甚至可以更进一步,把上面代码的向量尺寸改为64字节,循环步长改为8,然后加上-mavx512f选项,会发现X86版本还能有对应的单条指令 (尽管名字还叫vpaddd,但寄存器类型变了,实质上是和前面的vpaddd算不同的指令)。

    这告诉我们一个道理,SIMD指令的支持范围和CPU架构有关,和编译器选项也有关。

    SIMD和CPU架构

    不同CPU架构支持的SIMD长度和类型各有不同,但主流指令集都有面向SIMD的扩展。

    x86

    最早的x86 SIMD扩展指令集叫MMX,来自1996年的Intel,以加速多媒体程序。但它仅支持64位长度,并且只能加速8位到32位的整数操作。因为早期x86 CPU的古怪设计,这个MMX指令不光短,还会占用浮点数的寄存器,用今天的目光看实在鸡肋。AMD也看不下去,推出了名叫3DNow!的扩展,支持浮点SIMD。为了应对,Intel很快推出了新的SIMD扩展,也就是今天有名的SSE (Streaming SIMD Extensions)。

    SSE支持128位长度的向量,且可容纳如4xfloat、2xdouble、4xint、8xshort或16xchar等不同类型。更重要的是,因为过去X86对浮点的支持过于奇葩 (x87指令集),SSE对标量(即单个)浮点数的操作也做了延伸,float和double的操作终于可以对应到和整数相似的指令了。SSE经历了多次扩展,涵盖了整数和浮点数从算术到重排和加密等各种操作。对今天的x86 CPU来说,SSE可以视作默认支持。

    十年后,Intel又发布了新的SIMD扩展,称作AVX (Advanced Vector eXtensions),总体和SSE相似,不过长度又扩展了一倍,支持256位向量。AVX还有更变态的延伸版本叫做AVX-512,顾名思义就是512位向量,8个double或者64个char同时操作。

    ARM

    ARMv7引入了高级SIMD扩展,通常也被称作NEON。和x86的MMX/SSE类似,NEON支持64位和128位两种向量。虽然ARM处理器家族比x86更复杂多样,但今天也基本可以假定,主流ARM芯片都支持NEON指令集。

    一部分面向服务器的ARM处理器,为了支持更长的向量,走了和x86不同的道路,推出了称作SVE (Scalable Vector Extension) 的动态向量扩展。和AVX固定256位、AVX512固定512位不同,SVE没有固定向量长度,而是在向量操作之外,又引入了一组谓词指令和寄存器,这就可以在汇编层面体现上层的循环逻辑,从而在运行时确定向量长度(也就代表着循环次数)。这样做的好处是:一个为SVE编译的二进制程序,在不同向量长度的CPU上都可不经改动执行,自动获得硬件向量变长带来的性能提升。

    SVE支持128到2048位的向量,ARMv9引入的SVE2又加入了若干新指令。本文不计划深入讨论SVE的使用。普通桌面级的ARM CPU (如Apple M1),并不支持SVE。

    RISC-V

    RISC-V把除最基本整数指令外的所有指令都归类为扩展,并以单独的字母标记,如扩展F和D分别表示单精度和双精度浮点数。RISC-V曾经有个叫做P (Packed SIMD) 的扩展,但今天更主流的是扩展V。RISC-V的创造者之一David Patterson (《计算机体系结构:量化研究方法》的作者之一),曾经写过一篇文章批评传统的SIMD指令设计不够灵活,增加了复杂度。

    也因此,RISC-V的扩展V (经常称作RVV) 指令设计更类似ARM SVE。而细节上更加灵活。比如说,向量长度存储在一个特殊寄存器中,计算指令也并不包含元素长度,具体元素多长会由特别指令设置。一条vfadd.vv可能是f32也可能是f64。

    在RISC-V语境中,SIMD向量两个词有明显区分:SIMD指x86风格的定长定类指令,向量指可动态扩展的多数据指令。但在本文其他部分,不作严格区分。

    POWER

    在Mac电脑还在使用PowerPC CPU的年代,苹果、IBM和摩托罗拉组建过所谓的AIM联盟。90年代末还没有今天的GPU概念,多媒体相关的加速都由CPU完成。为了和x86 SSE竞争,AIM在PowerPC指令集上推出了Vector Media eXtension (VMX) 扩展。该扩展引入了一种128位向量类型,支持整数和部分单精度浮点指令。

    PowerPC Mac的绝唱,PowerMac G5,支持该指令集。而后续IBM服务器上的POWER指令集依然包括这个扩展。VMX更出名的名称叫AltiVec,但2004年摩托罗拉半导体部门分拆为飞思卡尔公司,AltiVec商标由飞思卡尔持有。为避免商标纠纷,IBM用到的场合继续称之为VMX。在GCC和Clang等编译器眼中,这个指令集依然叫AltiVec。

    从POWER指令集2.06版开始(即POWER 7),POWER在VMX基础上引入了新的VSX (Vector Scalar eXtension) 指令集。在传统的POWER浮点指令外,VSX加入了一组新的和IEEE-754完全兼容的标量和向量浮点指令,类似SSE。浮点和向量寄存器也统一起来,VSX共64个128位寄存器,传统的32个浮点寄存器成为了前32个VSX寄存器前64位的别名,32个VMX寄存器则对应到后32个VSX寄存器。

    WebAssembly

    虽然WebAssembly不是真实的CPU指令集,但因为设计上考虑性能,加入SIMD指令也有助于模拟和JIT执行。Wasm的SIMD类型相对简单,固定128位,配合不同类型的计算指令,和SSE、NEON、VMX/VSX都能对上。

    编译器与SIMD

    虽然SIMD能给计算密集的程序带来提升,但我们并不是每次都要手写这堆奇怪的语法才能用上SIMD。在开优化的情景下,编译器会努力地将代码中的标量操作组合成向量指令,这部分功能在编译器中称作Vectorizer。LLVM中有循环Vectorizer和SLP Vectorizer两类,前者针对循环而后者针对非循环。其实,开头那个程序如果用O3编译,即使不手工使用向量扩展,Clang也可以生成出向量指令。

    像开头例子中这样简单的循环,循环体内只出现了一次加法,为了「凑」出四个加法构成SIMD,编译器需要像我们手写的代码一样把循环体扩充为原来的4倍,然后让循环次数除以4,并且还要处理剩下几个余数的情况,所以汇编容易变得非常大。这种优化叫做循环展开 (Loop Unrolling)。有关其他自动向量化中可能涉及到的优化,可以查阅LLVM向量化的官方文档

    如果还是有要手写向量化代码的情况,除开头提到的__attribute__((vector_size()))外,每个平台都提供了自己的C语言扩展:

  • UTF、Emoji和神奇的连字

    Unicode是人所共知的标准,一般意义上的Unicode做了两件事:

    • 定义了一个集合,力图包括人类语言的大部分文字(学名叫Abstract Character Repertoire,ACR)
    • 为其中每个文字符号赋予一个惟一编号(学名叫Coded Character Set,CCS)

    概念上,这已经足够解决「某些语言的文字不能被正确表示」的问题了。但我们无法轻易地就在计算机程序里用编号表示字符,因为:

    • 整数编号需要确定的长度,如果编号是2字节整数,那么它就无法表示第65537个字符
    • 这个系统最好要与已有编码(尤其是ASCII)兼容
    • 表示方法要考虑到空间,如果编号是4字节整数,那么一个ASCII文件换成Unicode体积就会膨胀到原来的4倍

    UTF

    为了落地解决方案,UTF(Unicode Transformation Format)应运而生,UTF做的事情也有个学名,叫Character Encoding Scheme(CES)。因为单元长度不同,UTF也有几个变种。

    UTF-32

    这是最直接的方案,每个字符都用32位(4字节)表示,由于32位无符号整数上限(4294967296)在可见的未来都足以包括所有Unicode字符,所以这个方案不需要考虑任何额外情况。实际上,截至2023年,Unicode包括的所有字符也没有超过150000个。

    由于UTF-32是多字节编码,依据字节序的不同,UTF-32有little-endian和big-endian两个变种。如果你对两种字节序不了解,可以参看之前的文章

    UTF-32编码有个非常显而易见的好处:字符串(String)的长度就是字符数组(Character Array)的长度,这和ASCII时代的经验一致。为什么强调这点,因为其他的Unicode编码方式未必满足这个要求。

    至于为什么没有UTF-64、UTF-128,只能说可以,但没必要,仓颉从棺材里爬出来他也造不出那么多字。

    UTF-16

    使用16位(2字节)表示字符单元的方案自然就叫做UTF-16,2字节可表示的整数上限是65536。因为Unicode包括的字符总数比这个多,这就遇到问题了。解决方法是什么呢?如果一个双字节单元不够用,那用一对就行了。

    注意,这种情况和UTF-32的区别是:UTF-32是对所有字符都用4字节表示,而UTF-16仅对超过某个范围的字符用4字节表示,其他情况依然使用2字节。为什么说「某个范围」而不是65536?因为我们需要一点额外信息,来区分普通的UTF-16单元,和一对UTF-16单元的其中一部分,这样普通的UTF-16单元就表示不了65536那么多个字符了。

    这种「用一对双字节单元表示单个字符」的情况,叫做UTF-16代理对(surrogate pair)。它有一个具体的编码方案:

    • 如果一个字符的编码在[0, 0xD7FF][0xE000, 0xFFFF]中,则以单个双字节单元表示
    • 如果一个字符的编码在[0x10000, 0x10FFFF]
      • 编码减去0x10000,得到一个20位整数(最大值0xFFFFF
      • 把20位分成两半,前一半加上0xD800作为高部分,后一半加上0xDC00作为低部分
    • [0xD800, 0xDFFF]中单个元素不构成合法字符,仅作为代理对一部分存在

    UTF-16的使用场景比想象的多。Apple平台的NSString内部用UTF-16表示;JavaScript的字符串也是UTF-16;很多要处理字符串的Windows API都分A和W结尾两种变体,A结尾的版本表示接受ASCII字符串,W结尾表示接受UTF-16字符串。

    为什么没有UTF-24?因为3字节不方便。

    UTF-8

    UTF-8较前两者出现得更晚一些。因为用固定单字节表示字符的编码,已经被ASCII以及后续欧洲语言的扩展所占据,所以UTF-8必然是一个变长编码。UTF-32固定使用4字节,UTF-16在有代理对的情况下使用4字节,而它们都没有充分利用编码空间,类似地,UTF-8最多也可能占用4字节。

    UTF-8具体的编码方式更麻烦一些,同样考虑一个在[0, 0x10FFFF]之间的字符:

    • 如果在[0, 0x7F]之间,则用1字节表示,这点保证了和ASCII的兼容
    • 如果在[0x80, 0x7FF]之间,用2字节表示,第一个字节以110开头,后续字节以10开头
      • 110abcde 10fghijk,表示abc_defg_hijk(每4个二进制位构成1十六进制位)
    • 如果在[0x800, 0xFFFF]之间,用3字节表示,第一个字节以1110开头,后续字节以10开头
      • 1110abcd 10efghij 10klmnop表示abcd_efgh_ijkl_mnop
    • 如果在[0x10000, 0x10FFFF]之间,用4字节表示,第一个字节以11110开头,后续字节以10开头
      • 11110abc 10defghi 10jklmno 10pqrstu表示a_bcde_fghi_jklm_nopq_rstu

    UTF-8最早由Ken Thompson和Rob Pike提出,二位也同样是Go语言的发明者。

    UCS

    在某些地方可能还会出现UCS这个名词,它是Universal Coded Character Set的缩写。Unicode相关的各种组织、标准、概念的历史都相当复杂,UCS和Unicode起初也不是从属关系。但今天,大可以粗略地将UCS-4理解为UTF-32的同义词,而UCS-2代表「没有代理对的UTF-16」(也就是只能表示65536个字符)。

    BOM

    每个单元由2个或以上字节组成的编码方式,必然都要考虑字节序的问题。在UTF实践中,文件开头会有称作BOM (Byte-order mark)的标记以明确告知字节序。另一篇专门谈论字节序的文章也提到了BOM,简要说就是:文件开头的0xFE 0xFF表示big-endian,0xFF 0xFE表示little-endian。UTF-8因为最小单元为1字节,因此不需要BOM。

    不是你想的那个字符

    本文到此为止,明确区分了字节与字符的概念。但仍统一将整个文字体系中的多个概念称为字符,这依然是不精确的。所以再对几个都可以被称作字符的不同概念作出区分:

    • 编码单元 (code unit),是一种编码方式中的最小单元,比如UTF-8的编码单元就是1字节,UTF-16的编码单元是2字节
    • 编码点 (code point),是一个或多个编码单元组成的文本单位,也就是前文中介于[0, 0x10FFFF]之间,需要被编码的那个东西
    • 字位 (grapheme),是有意义的最小书写单位,一个字位可能由一个或多个编码点组成。字位和编码点的区别是:一个字位能对应一个可见的文本单元,而编码点则不一定
    • 字形 (glyph),是在文本中能独立显示的图形符号,一个字形可以由一个或多个字位构成。此概念已经超出字符编码的讨论范围,而更多和字体渲染技术相关

    了解完UTF-16和UTF-8这样的编码以后,我们很容易意识到将编码单元理解为想象中的字符并不合适。那编码点和字位又有什么区别呢?

    emoji

    你一定很熟悉emoji了。emoji涵盖了基本表情、旗帜、生活用品还有家庭标记,而对出现人的部分还具备不同肤色的变体。而这些emoji的具体编码并不是逐个对应到一个独立编码点那么简单。

    • 在JavaScript中,😁这个字符串的长度为2,因为它的编码是0x1F601,在UTF-16里需要代理对表示
    • 但国旗🇨🇳的长度就不是2了,而是4
    • 🤵‍♂️的长度是5,带肤色的变体🤵🏻‍♂️则是7
    • 表示情侣的emoji 👩‍❤️‍👨长度为8,如果带上肤色👩🏻‍❤️‍👨🏻则变成了12
    • 也不是所有旗帜的长度都是4,比如彩虹旗🏳️‍🌈长度就是6

    这到底是怎么回事!?


    答案不难理解:JavaScript使用UTF-16,字符串长度代表UTF-16编码单元的数量,因此长度大于2的emoji都不是单个编码点,而是靠多个编码点组合形成的,这也就是字位和编码点的不同。来一一解释前面提到的emoji:

    首先是国家和地区的旗帜,用两个特殊编码点表示,它们实际上是由两个特殊区域的字母拼出来的。在对emoji支持不佳的文本编辑器内,输入🇨🇳再按退格会留下字符🇨,被删掉的那个其实是🇳。这组字母位于[0x1F1E6, 0x1F1FF]的范围。其中所有的旗帜都按照ISO 3166-1中的二字符格式表示,比如中国为CN (🇨 🇳),美国为US (🇺 🇸),法国为FR (🇫 🇷),可以试着复制再删掉这几对特殊字符中的空格。Windows没有为国旗emoji设计内容,所以在Windows上这些字位会被实诚地显示为两个字母。

    然后是穿礼服的男性🤵‍♂️,长度为5,试着把它拆成编码点:0x1F935 0x200D 0x2642 0xFE0F

    • 0x1F935自身代表一个无性别的形象🤵
    • 0x200D在Unicode里代表「零宽度连字符」(zero-width joiner, ZWJ),表示将前后字符连接在一起,在其他语言中也有用到
    • 0x2642单独看是字符♂,表示男性,在这里和零宽度连字符一起修饰emoji的性别
    • 0xFE0F是Unicode中的变体修饰符,[0xFE00, 0xFE0F]中16个编码点都是变体修饰符,该修饰符表示按emoji图像显示(有些字符如0️⃣既可以表示为emoji图像也可以表示为传统字符如0️︎,它们以末尾的修饰符作区分),但因为礼服男性的emoji只有图像风格,所以删去也不影响显示

    带肤色的礼服男性🤵🏻‍♂️长度为7,在第一个码点后多了一个码点0x1F3FB,单独显示为🏻,在特定emoji后作为肤色修饰符。肤色修饰符位于[0x1F3FB, 0x1F3FF],共五种,不带肤色修饰符则以默认黄色显示。

    彩虹旗🏳️‍🌈分解出来也不复杂,它在JavaScript里长度为7,由4个编码点构成,分别是表示白旗🏳️的0x1F3F3,修饰符0xFE0F,零宽度连字符0x200D,以及彩虹🌈的0x1F308

    多人组合恐怕是emoji中最可怕的部分,因为要考虑多个性别、肤色的组合。拆开情侣emoji 👩‍❤️‍👨得到6个编码点:

    • 0x1f469表示女人的形象👩
    • 连字符0x200d
    • 桃心❤的0x2764
    • 修饰桃心为emoji的修饰符0xFE0F,两者组合构成❤️
    • 连字符0x200d
    • 0x1f468表示男人的形象👨

    带肤色的情侣emoji会比默认版本多两个编码点,分别是在左侧形象和右侧形象后面跟上肤色修饰符。因为多出来的两个编码点都需要代理对,所以总长度为12(个UTF-16编码单元),也就是24字节。

    这里的人-心-人的拼接并不是任意而为,而是Unicode为这个emoji特别设计的组合。但emoji中还有其他地方用到了类似的组合,比如男性技术人员👨‍💻实际上就是👨加上连字符再加上💻的组合。

    emoji到今天已经数量繁多,这里没有囊括它的所有规则,但以上几个例子足以说明emoji如何被「拼接」而成,以及字位和编码点在实际环境的不同。

    JavaScript的Unicode字符串常量"\u1234"仅支持小于0xFFFF的情况,编码点更高的字符需要手动拆成代理对,这里提供一段代码以方便实验emoji编码:

    function concatUnicodeScalar(values) {
      return values.map(scalar => {
        if ((scalar >= 0 && scalar <= 0xd7ff) || (scalar >= 0xe000 && scalar <= 0xffff))
          return String.fromCharCode(scalar)
        else if (scalar >= 0x10000 && scalar <= 0x10ffff)
          return String.fromCharCode(((scalar - 0x10000) >> 10) + 0xd800) + String.fromCharCode(((scalar - 0x10000) & 0x3ff) + 0xdc00)
        else
          throw 'Bad unicode scalar'
      }).join('')
    }
    // Run as concatUnicodeScalar([0x1F935, 0x200D, 0x2642, 0xFE0F])

    连接符

    但并不是只有emoji当中才体现了字位和编码点的区别。天城文、马来文等文字也使用了零宽度连接符。

    对于汉字,Unicode还有一组「表意文字描述字符」,可以通过描述结构和每个部分来造字。如0x2FFA表示左下的半包围结构,后跟「礼」与「分」即可组成「𥘶」这个汉字。从0x2FF00x2FFF,Unicode定义了16种描述字符,涵盖了不同的汉字结构。尽管编码层面支持了这套功能,但由于各种技术限制,目前的字体渲染并不支持显示出一个不存在的汉字。

    连字

    前面提到了,在字位之上还有个层次更高的概念叫做字形。这个概念通常和字体渲染相关。这两者的区别,就主要在连字 (ligature) 之上。

    最典型的连字就是小写拉丁字母的fi和fl,i头上的点会被隐藏,然后和f连在一起。如今许多编程字体也利用了这一特性,将多字符操作符渲染为具有数学公司色彩的单个符号。

    Unicode还专门定义了一个叫做零宽度非连字符 (zero-width non-joiner, ZWNJ) 的编码点,插入连字字符之中即可让它们不作为连字显示。

    总结

    • Unicode定义了一组可显示的符号作为字位,每个字位由一个或多个编码点构成,Unicode为每个编码点赋予一个惟一编号
    • 不同的UTF使用不同方法将每个编码点对应到一组字节,其中UTF-16和UTF-8是变长编码,UTF-16的变长情况称作代理对
    • emoji有套复杂的规则将若干编码点组合成一个emoji字位,这套规则使emoji有了创造不同肤色、性别变体的能力
    • 在实际的字体渲染中,因为连字的存在,多个字位也可能被当作一个整体进行渲染

    参考资料

  • 无忧无虑地编程

    偶然发现一个把我看怔住的答案。

    2009年,母亲给单位的某个年轻人介绍朋友,一块吃饭,把我也叫上。那天下午我在玩一张叫《白菜农场》的War3地图,知道了一个叫音悦台,看的第一个视频是《威廉古堡》。那天晚上我第一次去KTV,唱了一首《回到过去》,还被说小孩子懂什么。

    2010年,也是跟着母亲的同事们去一家火锅店,大厅里有张显眼照片,是老板一家和某位过世长者的合影。我提前回家,在灰暗灯光里上着贴吧,看《操作系统革命》,立志自己也要做伟大的程序员。

    2015年,那是个周五,下午的课是《大学生安全教育》,放学后骑车冲向西北食堂,正要在踏板上站起来时,链条突然脱落,整个人摔在地上,好在只有擦伤。那天我也在写正则引擎,运行起来特别高兴,一点也不痛了。

    再晚些的11月,为了应付课程作业,我打算做一个解释器,那一个半月我几乎都在一种强烈的亢奋状态。这件事不会有任何收入,也不是为了被人夸赞,单纯觉得解释器(编译器)这种东西太吸引人了。可以说,到现在我也没有过多少正向反馈如此强烈的日子。

    11月底去无锡玩,为了省钱兼体验,四个人在大学外面的KTV过了一夜,虽然唱到3点纷纷躺下,但那沙发到底是不适合睡觉。天亮以后又去同学宿舍补觉,半睡半醒的状态下,脑子重复的还是——解释器语法树要怎么设计?

    2018年找实习,去某家做数据库的公司面试,第二轮硬是和面试官从B树扯到了LLVM,全程聊了快两个小时,我都怀疑他们拒绝我是嫌我太烦了。好在后来终于如愿以偿,去了开发编译器的团队,然后就是五年。虽然五年来太懒太P太好高骛远导致个人项目一个没完成,但至今听到解释器和新语言这些名词,星星之火似乎又要燃起来。2024了,我好像还没离开九年前那个自己。

    某一年过年去南岸给祖辈扫墓,车上刷知乎看到讨论编译器的回答,评论区一人也说道「不要做CRUD程序员」,我便问CRUD是什么含义,自此后每年到了那座山下,脑海中的cronjob也会按时触发,仿佛某种神秘祖训——不要做CRUD程序员哦。

    28岁,琐事越发的多,好在尚未丢掉热情,好在还有时间,不用再经历六年前无处实习的迷茫焦虑,不用一边和小吴在车库吃炸鸡一边阴阳怪气保研的同学。

    编程就该是无忧无虑的,拿钱是为了能更无忧无虑地编程。

  • 理解Big-endian和Little-endian

    Big-endianLittle-endian,你也许听说过这两个概念,可能也大概知道是什么意思,但未必清楚更深入的细节。这不奇怪,因为不直接和内存打交道的程序员不会对Endian有多少实践层面的理解,而即使写过相关的程序,因为抽象泄露的魔咒,也说不清底层原理。本文试图解释big-endian和little-endian的由来,在执行层面的原理,以及主流高级语言中如何处理Endian相关问题。

    在继续之前,先解释前文提及的概念「抽象泄露」。计算机科学本身可以理解为有关抽象的学问,C语言尽管今天看来相当底层,但也是建立在CPU指令上的抽象,人们用C写的程序,实际上是被编译器转写到机器指令执行。这也意味着,机器指令层面的一些概念,并不能在C语言的模型里体现,即C语言程序的某些问题无法用C语言本身的模型解释,必须依赖更底层的原理。此种情况下,C语言的抽象显得不完备了,这就是抽象泄露。

    典型的抽象泄露例子就是a=a+1语句中,如果变量a不是一个原子(atomic)变量,那么多线程环境中这个语句可能会造成数据不一致。这个问题很难用C语言本身的逻辑理解,我们只知道机器会给a的值加上1(C语言甚至不考虑有符号整数的溢出情况!),但具体CPU是如何计算的,我们不知道。只有当我们知道这个表达式在汇编层面是通过读取内存、执行加法,再写回内存之后能理解数据竞争的起因。这也是我认为编程初学者可以从汇编入门的原因——尽管汇编也有抽象泄露(如预测执行),但总归是比C的模型完备多了,对人也不难理解,C系语言的执行模型实在有点两头不占(另一头是函数式模型)的意思。

    回到正题。来解释big-endian和little-endian的区别。

    什么是Big-endian和Little-endian

    考虑如下一段C程序。

    #include <stdio.h>
    #include <stdint.h>
    uint8_t u8s[4] = { 0x12, 0x34, 0x56, 0x78 };
    uint32_t conv(void) { return *((uint32_t*)u8s); }
    int main(void) { printf("%x\n", conv()); }

    程序把一个4字节数组当作4字节整数读取,输出的内容会是多少呢?按照通常思路,uint32_t占内存中4字节的空间,4个uint8_t也是占4字节空间,那么这个指针强转的结果自然也是16进制的12345678。但实际上,在你的电脑上通常结果是78563412。是的,不是12345678,不是87654321,也不是1e6a2c48。而且这不是什么随机行为,而是基于某种逻辑的固定倒转。

    简单来说,对该程序输出78563412的CPU,就是Little-endian的;而输出12345678,则是Big-endian的。

    这两块内存不都是4字节吗,为什么会有这个区别?一个不精确但通俗的解释是:CPU对聚合类型(数组、结构体)和基本类型的变量有不同的处理方式。

    u8s[0]u8s[1]u8s[2]u8s[3]
    0x120x340x560x78

    以上是u8s这个数组的内存布局,从低地址到高地址排列。对于Big-endian的CPU,从内存读取一个int类型也按照这个顺序。但对Little-endian来说,就要反过来了。

    int[3]int[2]int[1]int[0]
    0x120x340x560x78

    顺序是倒过来的。把以上程序的4字节调整为8字节或2字节,也会得到类似的效果。但注意,这只针对各种内置类型有效,struct {int l; int r;}int[2]有一样的顺序。

    Endian名称的由来

    中文技术资料基本把Endian统一译作「字节序」。在英文语境里,Endian倒是个有典故的词,它来自《格列佛游记》第一卷第四章:

    我下面就要告诉你的是,这两大强国过去三十六个月以来一直在苦战。战争开始是由于以下的原因:我们大家都认为,吃鸡蛋前,原始的方法是打破鸡蛋较大的一端。可是当今皇帝的祖父小时候吃鸡蛋,一次按古法打鸡蛋时碰巧将一个手指弄破了,因此他的父亲,当时的皇帝,就下了一道敕令,命全体臣民吃鸡蛋时打破鸡蛋较小的一端,违者重罚。人民对此法极为反感。历史告诉我们,由此曾发生过六次叛乱,其中一个皇帝送了命,另一个丢了王位。这些内乱常常是由不来夫斯库国的君王们煽动起来的。骚乱平息后,流亡的人总是逃到那个帝国去寻求避难。据估计,先后几次有一万一千人情愿受死也不肯去打破鸡蛋较小的一端。

    所以你吃鸡蛋时先打哪边?

    代码中的字节序

    C/C++

    由于C和C++支持对变量取地址和任意指针类型的互转,我们很容易在C或C++中遇到字节序问题。结合上面使用C语言的例子,要在C语言里检测当前CPU是little-endian还是big-endian也可以用类似的做法:

    bool is_little_endian(void) {
      uint32_t test = 0x12345678U;
      return ((uint8_t*)&test)[0] == 0x78;
    }

    在Little-endian平台上,编译器会聪明地把这个函数优化成return 1。但这对我们来说并不够,因为我们可能需要在不同字节序的机器上编译不同的代码,运行时判断显然不够。好在,主流编译器都有宏用来判断:

    #ifdef __GNUC__
    #if __BYTE_ORDER__ == __ORDER_LITTLE_ENDIAN__
    #define LITTLE_ENDIAN 1
    #else
    #define LITTLE_ENDIAN 0
    #elif _MSC_VER || __INTEL_COMPILER
    #define LITTLE_ENDIAN 1
    #end

    Clang会伪装自己是GCC,二者都支持通过__BYTE_ORDER__宏判断字节序。实际上,除了__ORDER_LITTLE_ENDIAN____ORDER_BIG_ENDIAN__外,实际上GCC中还有一种可能取值是__ORDER_PDP_ENDIAN__。PDP是Unix诞生时的CPU架构,其对16位整数采用Little-endian,而对32位整数使用一种奇怪的字节序,即把0x12345678表示为0x34, 0x12, 0x78, 0x56。技术上,现在已不需要考虑这种字节序。

    对于Intel编译器(ICC)和Visual Studio (MSVC),它们只存在于little-endian平台上,所以发现编译器是它们就可以放心地确认字节序。早期版本的Windows NT支持过PowerPC和MIPS这类以big-endian为主的架构,但即使是这些版本的Windows,也是运行little-endian模式的。

    如果要在所有平台上以big-endian字节序读取整数,请使用位运算:

    uint32_t read32be(uint8_t input[]) {
      return ((uint32_t)input[0] << 24) |
             ((uint32_t)input[1] << 16) |
             ((uint32_t)input[2] << 8) |
             (uint32_t)input[3];
    }

    以Little-endian方式读取,请将数组下标倒过来。

    Rust

    Rust中,可使用target_endian指令在编译期判断字节序:

    #[cfg(target_endian = "little")]
    // Code for little endian
    
    #[cfg(target_endian = "big")]
    // Code for big endian

    要以某种字节序读取数据,可使用byteorder crate。

    Java

    尽管主流CPU都是little-endian,但Java大概因为靠近网络字节序(见下文)的原因,统一使用big-endian。通过java.nio.ByteOrder类的nativeOrder方法,Java程序可以得知当前系统硬件上使用哪种字节序:BIG_ENDIAN或者LITTLE_ENDIAN

    DataInputStream采用Big-endian,如果要以Little-endian方式读取数据,可采用同前面C语言相似的位拼接做法。

    JavaScript

    JavaScript这样的脚本语言通常不太会考虑字节序问题,因为我们无法对一个对象取地址,或者查看它的内存布局。但现代JavaScript依然提供了和字节流打交道的API,这里涉及到三个概念:TypedArrayArrayBufferDataView

    简单来说,TypedArray表示某种固定类型作为元素的数组(可以是8种整数和2种浮点其中之一),ArrayBuffer表示无类型的一串字节,DataView是在前者之上读写的适配器:

    let array = new Uint32Array(1)
    array[0] = 0x12345678
    let view = new DataView(array.buffer)
    view.getUint8(0) // => 120 (0x78)
    view.getUint8(3) // => 18  (0x12)
    view.setUint32(0, 0x12345678, true)  // set data as little-endian
    view.setUint32(0, 0x12345678, false) // set data as big-endian
    view.getUint8(0) // => 18  (0x12)

    在JavaScript中,对TypedArray元素的赋值,字节序由CPU的字节序决定,所以在little-endian机器上自然得到little-endian布局的ArrayBuffer。但DataView的set方法有第三个参数表示是否使用little-endian顺序写入,要注意的是,这个参数的默认值是false,也就是big-endian,这意味着在大多数平台上setUint32及其他类似函数的行为,同一般赋值是相反的。

    Ruby

    Ruby也同理,大部分情况下用不着和二进制表示直接打交道。但Ruby中数组类型有将对象转换为二进制字节的pack方法,字符串类型有把字节串解析回对象的unpack方法。这两个方法利用字符串参数指定要读取的类型,除了类型和长度外,不同记号还代表着不同字节序:

    [100].pack 'L' # => 32-bit int in native endian
    [100].pack 'V' # => 32-bit int in little-endian
    [100].pack 'N' # => 32-bit int in big-endian
    
    # double (1.1) in native endian
    "\x9A\x99\x99\x99\x99\x99\xF1?".unpack 'D'
    # double (1.1) in little endian
    "\x9A\x99\x99\x99\x99\x99\xF1?".unpack 'E'
    # double (1.1) in big endian
    "\x9A\x99\x99\x99\x99\x99\xF1?".reverse.unpack 'G'

    自然可以用同样的方法测试当前系统字节序:

    def is_little_endian?
      [1].pack('L') == [1].pack('V')
    end

    不同硬件架构的字节序

    x86

    主流的32位和64位x86都是little-endian字节序。64位x86指令集有时又被称作amd64,因为它最早是AMD发明的。曾经的Intel提出过支持两种字节序切换的IA-64架构作为x86的64位继承者,后来因为不向前兼容而陨落在历史之中。

    x86一直是little-endian,这意味着从最早最简单的ADD指令,到今天复杂的AVX512或者AMX,只要指令的某个操作数是内存地址,那指令就一定会按照低位在前高位在后的逻辑读取数据。

    ARM

    ARM在指令集层面对没有对字节序作强制要求,也就是说,一块ARM CPU可能是little-endian,可能是big-endian,也可能二者皆支持。可以从目标Triple区分:以aarch64_bearm64_bearmeb开头的,即为big-endian,其他不带be或者eb缩写的,则是little-endian。

    为了和x86兼容,主流ARM基本都是little-endian模式。

    RISC-V

    RISC-V是little-endian,尽管有说法曰作者更喜欢big-endian,但因为主流的ARM和x86都是little-endian,因此也选择了little-endian。

    POWER

    POWER诞生在90年代,那时候RISC工作站还不是个历史概念,各大厂商都在制造自家的RISC CPU和UNIX分支,x86还是穷小子,所以大家依然倾向于big-endian。不过为了便于移植x86 PC程序,Windows NT支持的PowerPC 610还的确支持过little-endian模式,然而PowerPC和Windows的合作并没有持续几代,这个开关也被移除,直到POWER 7。

    但POWER 7的little-endian支持并不完整,例如硬件虚拟化就没法支持little-endian模式。从POWER 8开始,POWER CPU开始完整支持little-endian字节序。也就是说,POWER 8及以上的CPU支持两种字节序,由一个特权寄存器控制。甚至理论上,POWER CPU可以在运行时切换字节序。(但显然没人愿意这么做)

    在POWER支持little-endian前,Linux已经支持32位和64位的big-endian POWER CPU。而在little-endian加入以后,因为两种字节序的程序几乎没法相互兼容,所以它们在Triple上被看作两种不同的架构:ppc64指big-endian,而little-endian常用ppc64le或ppc64el表示。

    长远来看,同样是出于兼容目的,Linux将逐渐去除对64位POWER big-endian模式的支持,little-endian才是未来。不过这个故事仅限于Linux和BSD等开源社区,传统的AIX依然只会支持big-endian模式,且在可见的未来还没有改变的迹象。

    一些应用场景的字节序

    Unicode

    UTF-16和UTF-32这两种格式都会采用多个字节表示单个码点(通俗点说就是字符,尽管并不准确),这就引出little-endian和big-endian两种格式。在多数编辑器或处理字符编码的程序里,UTF-16 LE和UTF-16 BE被看作两种编码。但根据Unicode标准,使用UTF-16和UTF-32的文件都需要在开头用2字节标记文件是big-endian还是little-endian:0xFE 0xFF表示big-endian,0xFF 0xFE表示little-endian。

    对于一个内容为「Test」的文件,以下是三种编码的字节内容:

    feff 0054 0065 0073 0074 (UTF-16 BE)
    fffe 5400 6500 7300 7400 (UTF-16 LE)
    6554 7473 (UTF-8)

    开头的这两个字节被称作BOM(Byte-order mark),显然UTF-8因为基本单位是字节,所以并不需要BOM。但一些Windows程序依然会给UTF-8文件加上0xEF 0xBB 0xBF作为BOM以标记这个文件使用UTF-8。

    网络字节序

    根据RFC 1700,socket通信中的数据都使用big-endian格式传输,因此使用socket的C API时,需要用htonl/htons/ntohl/ntohs系列函数将数据在big-endian和原生字节序之间作转换,比如要绑定的端口号。一些上层库可能已经封装了这个行为,但这一点仍需牢记在心。

    WebAssembly

    WebAssembly采用little-endian,但这指的是内存读写指令采用的字节序,WebAssembly字节码中的整数都用LEB128格式表示。

    字节序不是什么

    • 数组在内存中永远是从前向后排的,和字节序无关
    • C/C++中的结构体也永远是从前向后排的,其他高级语言可能会重排对象的内存布局,但这同样和字节序无关
    • 整数或浮点数在CPU寄存器中使用何种字节顺序或位顺序,和字节序无关,实际上这对程序员也不可见,概念上我们都完全可以认为寄存器都是从前向后,类似big-endian般排列
    • 字节序只和数据从内存被加载到寄存器这一过程有关,且单位是字节而不是位

    意义何在

    字节序是一个习惯远比实际好处重要的领域。如果一定要为两种字节序列出一些好处的话:

    • Big-endian更加直观,读取一个4字节整数和4字节数组采用同样的顺序,由于网络通信使用big-endian,因此big-endian的程序在处理网络数据时还能省去字节反转的开销
    • Little-endian环境下,长度不同的数据能够保证低位重合,也就是说32位的0x1234用16位指令去读取依然是0x1234,这节省了类型强制转换时的开销

    但站在今天的角度,这些优缺点都无伤大雅。最重要的原因,不过是x86活下来成了主流,因此little-endian碰巧也成了主流,一如Windows和C语言。计算机领域各个层次都充满了抽象,如果有什么东西能穿越潮起潮落流传至今,原因几乎就是兼容性而已。

  • 浮点数的NaN和无穷大

    上一篇讨论浮点数的文里提到,浮点数和数学上的实数并不完全等同。但因为四则运算的需要,浮点数的取值依然定义了若干特殊情况。

    浮点数有零吗

    除以0怎么办

    数学上,以0为除数的除法自然没有意义,但计算机设计者无法要求程序在遇到这种除法时就退出甚至关机,所以必然要纳入考虑。

    对于被除数非零的情况,如 3.0/0.0,把0当作一个无穷小算是自然的考虑,这样就可以得出结论,结果是 +∞ 正无穷大。虽然数学上的无穷大并不算做一个数,但浮点数标准真的定义了这个值。

    如果被除数是0,即 0.0/0.0,情况就变棘手了。这个结果该是多少呢?无穷大?不对劲。等于1?也不合理,因为这个结果显然该和正常的1有所区分。为此,IEEE-754额外又定义了一类特殊值,叫做「非数」(NaN, Not a Number)。请注意,这里说的是「一类」而非「一个」,因为NaN真的也不是一个固定表示的数。

    但对于除0,浮点数也不只是生成无穷大或NaN就完了,大多数CPU还会生成一个叫做「浮点异常」的东西。浮点异常和编程语言里的异常概念并不等同,它要底层得多,规定在CPU的指令集文档里。在后续讨论浮点环境的章节,我们会深入讨论浮点异常。

    Python是个例外,Python解释器对浮点数除0也会生成ZeroDivisionError异常。想在Python里获得无穷大,可以通过math.inf常量。

    浮点数0的表示是惟一的吗

    和你想的……不一样,浮点数的0并不惟一!虽然0的指数和尾数全部为0,但还有一个符号位,既可以为0也可以为1. 如果符号位为1,实际上这个值会变成「负零」!

    > dumpFloat64(-0.0)
    Sign 1
    Exponent 00000000000
    Mantissa 0000000000000000000000000000000000000000000000000000

    可能你的第一反应是,0的符号位没有任何意义。但这是独立符号位的必然结果,定点数采用补码就避免了两个0的情况。我们也的确可能得到不同符号的0:

    > -0 - 0
    -0
    > -0 + 0
    0

    不同符号的0自然也会导出不同符号的无穷大:

    > 1.0 / 0.0
    Infinity
    > -1.0 / 0.0
    -Infinity

    好在根据标准,这两个0永远相等,不用再专门调用某个名似iszero的函数了(尽管某些语言还真有)。

    无穷大

    什么是无穷大

    用一个非0的浮点数除以0,会产生一个叫做无穷大的值。逻辑上,这个无穷大依然还要按照浮点数的结构,表示为一串二进制位。根据浮点数标准,指数全1而尾数全0的数被规定为无穷大。因为还有一个符号位,所以无穷大也分为正无穷大和副无穷大。这倒很合理,1.0/0.0是正无穷大,-1.0/0.0是负无穷大嘛。

    用第一章的dumpFloat64()函数打印一个正无穷大的值:

    > dumpFloat64(1.0/0.0)
    Sign 0
    Exponent 11111111111
    Mantissa 0000000000000000000000000000000000000000000000000000

    如何判断一个数是否是无穷大

    因为正负无穷大各自都有惟一表示,因此可以直接用相等去判断一个数是否是无穷大。但出于可读性考虑,以及避免遗漏正负情况,大多数语言都提供了数学函数做这个判断:

    • C/C++: 标准库math.h的isinf(x)或Clang/GCC扩展的__builtin_isinf(x)
    • Java: Double.isInfinite(x)Float.isInfinite(x)
    • Python: math.isinf(x)
    • Ruby: x.infinite?
    • JavaScript: !Number.isNaN(x) && !Number.isFinite(x)

    无穷大和其他浮点数的关系是怎样的

    和你想的一样:

    • 对于既不是无穷大也不是NaN的浮点数,正无穷大比它们都大,负无穷大比它们都小,正无穷大自然也比负无穷大大
    • 正无穷大或负无穷大都和自己相等

    NaN

    为什么会存在NaN这种东西

    浮点数是对数学运算的模拟,因此需要有个概念来表达「运算错误」,否则浮点数作为类型的定义就是不完整的。实用地说,除了两个0相除这种情况,sqrt(-1)或者log(-1)同样会返回NaN,因为在没有复数的情况下,并没有一个正常数值能够表示这种错误结果。

    除此之外,NaN参与运算的结果也会是NaN:

    > NaN + 5
    NaN
    > Math.sqrt(NaN)
    NaN
    > NaN * 0
    NaN

    例外是比较操作,单独的NaN和任何数的任何比较都是false,包括它自己:

    > NaN > -Infinity
    false
    > NaN === NaN
    false
    > NaN > 0
    false

    在JavaScript里,parseFloatparseInt解析到非法内容,也会返回NaN:

    > parseFloat('x')
    NaN
    > parseInt('?')
    NaN

    NaN是如何表示的

    指数全1而尾数全0的数用以表示无穷大,而指数全1尾数非全0就被用来表示NaN了。

    > dumpFloat64(NaN)
    Sign 0
    Exponent 11111111111
    Mantissa 1000000000000000000000000000000000000000000000000000

    只要尾数部分不全为0,不管具体是多少都是NaN. 这也意味着NaN的表示并不惟一,我们不能再用等号去判断一个数是否是NaN,更何况,本来NaN和自己比较相等的结果就是false.

    然而……尾数不同的NaN可能亦有区别。

    如何判断一个数是否是NaN

    最简单的方法,当然是和判断无穷类似:调库。

    • C/C++: isnan(x)或Clang/GCC扩展的__builtin_isnan(x)
    • Java: Double.isNaN(x)Float.isNaN(x)
    • Python: math.isnan(x)
    • Ruby: x.nan?
    • JavaScript: Number.isNaN(x)

    不过既然我们已经知道了原理,不妨实现一个土法isNaN:

    function poorIsNaN(value) {
      let buffer = new ArrayBuffer(8)
      let float64 = new Float64Array(buffer)
      float64[0] = value
      let uint8 = new Uint8Array(buffer)
      // Do not reverse on Big Endian
      let binary = Array.from(uint8).reverse()
      return (binary[0] & 127) === 127 &&
        (binary[1] & 240) == 240 &&
        ((binary[1] & 15) | binary[2] | binary[3] |
          binary[4] | binary[5] | binary[6] | binary[7]) != 0
    }

    要注意,这段代码对大端平台并不管用。

    NaN的尾数部分有何影响

    根据IEEE-754标准,尾数部分第一位为0的NaN被称作Signaling NaN,以1开头的则被称作Quiet NaN. 在大多数硬件架构上,对Signaling NaN的操作会触发浮点异常,而Quiet NaN则不会。

  • 浮点数的格式与存储

    这是计划中一系列讨论浮点数的文章,会涵盖浮点数的标准、误差、异常等细节,以及不同软硬件平台对浮点数的支持等问题。为了阅读和表达的方便,我选择用问答的形式表现。

    如果这系列问答对你带来了帮助,或者有什么遗漏、错误之处,请不吝在评论区回复,谢谢。


    You should not be permitted to write production code if you do not have a journeyman license in regular expressions or floating point math. – Rob Pike

    这是几年前Rob Pike在Twitter上的一句抱怨。很多人感叹真有道理,但他最后还是把这条Tweet给删了——大概是不懂浮点数的人真的很多!当然不懂正则表达式的程序员可能也不少,但相比而言,除开某些情况下的性能问题,使用正则表达式的人更多属于「知其不知」;而浮点数则由于常常被和数学上的小数划等号,导致对其不了解也可以(看起来)正常使用,「不知其不知」,这就更危险了。

    什么是浮点数

    有时候我们需要处理的数据可能(绝对值)非常大或者非常小,这时数据末尾的零或小数点后的零可以被忽略掉,比如对9000000000和0.000000000009,我们要关心的其实只有9这个数字。如果一组数据的范围差不多,那么可以在代码里做个约定,比如数的实际值是存储值的100000000倍。但如果每个数的范围都不一样,那就得把前面的倍数信息也存在这个数内部,这就是浮点数「浮」的含义。

    粗浅理解,前面的9000000000可以把有意义数字和10的指数存储为(9, 9),0.000000000009则是(9, -12).

    浮点数和整数有什么区别

    得益于软件工程对人类生活的不断渗透,普通程序员能接触到的主要复杂度都来自于和社会直接挂钩的那部分——业务,除此之外就是人类社会对业务中共通部分的抽象,比如各国货币、历法和文字。浮点数有些特殊,其本质上是一组数学上的定义,复杂度更多来自这组定义的繁冗细节,以及和真实世界的「错位」。

    一些语言为了方便初学者,会把浮点数类型命名为REAL (实数),但这种做法实际上更加误导人。首先实数有无穷多个,而一个N位浮点数最多也只能表示2N个不同的数。并且浮点数并不是所有情况下都无法精确表示整数。实际上整数和实数都是数学概念。在计算机领域,和浮点数 (floating-point) 相对的是定点数 (fixed-point),也就是不把指数信息保存在内部的数据类型。一般编程语言中的INT类型即是定点数。

    通常来说,因为定点数的定义更为简单,程序中会用它表示整数。在含义确定的情况下,也可以用它表示小数点位数固定的数。

    什么是IEEE-754标准

    IEEE-754是目前最广为接受的浮点数标准,由IEEE于1985年制定,经过2008年和2019年两次修订。目前,几乎所有平台的32位和64位浮点数都兼容这个标准。强调32位和64位对IEEE-754的兼容,是因为16位和128位的情况有些不同。

    除此之外,早期IBM System/360系列大型主机还支持一种称为Hexadecimal Floating Point的格式,现代的IBM Z作为后继者同时支持这两种格式。但无论哪种浮点数格式,数据都如前文所述由三部分组成,区别主要在于后两部分的长度比例,和一些运算细节的处理。

    所有的标准都是冗长的,IEEE-754这种涉及大量数学定义的自然更不例外。所以这里整理出一些关键的地方:

    • 一个浮点数可以表示五种值之一:qNan、sNan、正无穷、负无穷,以及普通的由符号、指数、尾数构成的三元组浮点数
    • 浮点数尾数除了二进制,还可以是十进制的
    • 浮点数不遵循结合律

    后续内容如无特别注明,浮点数均以IEEE-754作为表示格式。

    单精度和双精度浮点数有什么区别

    单精度浮点数指长度为32位的浮点数,双精度浮点数指长度为64位的浮点数。在IEEE-754标准中,它们也被称作binary32和binary64,以和decimal浮点数相区分。

    参考前面介绍的浮点数概念,两者的主要区别在有效值 (尾数,fraction) 和指数 (exponent) 的取值范围上。

    总长度尾数长度指数长度
    单精度32位23位8位
    双精度64位52位11位

    在多数编程语言中,单精度浮点数的数据类型为single或float,双精度浮点数的类型则叫做double.

    浮点数的尾数和指数是什么

    先前介绍了浮点数「概念上」的表示格式:非零部分+进位指数,如300000就是3和5. 现实中的浮点数也分成这两部分存储,另外还带上1位记录符号。这里的非零部分就是尾数,而指数就对应浮点数里的指数。

    注意,我们讨论的都是二进制浮点数,所以实际上尾数和指数都是二进制。你可能会说,二进制和十进制不是本来可以相互转换吗,那为什么要注意这个区别?对于指数部分,其本就是定点数,数值只存在溢出不存在精度问题,所以什么进制都没区别。但尾数因为有舍入操作,不同进制下舍入造成的误差相差甚大。后面会继续讨论浮点数误差的问题。

    浮点数在内存中具体如何存储

    根据IEEE-754标准,浮点数三部分在内存中存储的顺序为符号位、指数和尾数。使用以下JavaScript代码能够打印出一个双精度浮点数的三部分二进制值:

    function dumpFloat64(value) {
      let buffer = new ArrayBuffer(8)
      let float64 = new Float64Array(buffer)
      float64[0] = value
      let uint8 = new Uint8Array(buffer)
      let binary = Array.from(uint8).reverse().map(b => b.toString(2).padStart(8, '0')).join('')
      console.log('Sign', binary[0])
      console.log('Exponent', binary.substr(1, 11))
      console.log('Mantissa', binary.substr(12, 52))
    }

    注意,这段代码为了简便,假定运行机器的字节序 (endianness) 为小端序 (little-endian),好在绝大多数消费级CPU架构都是小端序。

    为了避开误差问题,这里以一个二进制能够精确表示的浮点数0.25作例子解读。

    Sign 0
    Exponent 01111111101
    Mantissa 0000000000000000000000000000000000000000000000000000

    符号位是0没有问题,因为0.25是正数。有意思的是,指数位转换到十进制是1021,而尾数部分竟然全零!

    指数开头是0,因此不像补码。看看输出1.0的结果,指数变成了1023,而1.0是0.25的4倍(因为这里是二进制浮点数,后面的底数是2而不是10),所以真实的指数应该减去1023,也就是-2,实际存储的是加上1023后的偏移量。这倒是符合我们的思路,0.25=1×2-2,但为何尾数部分都是0呢,不应该有个1吗?

    其实这里已经假定,1在尾数部分的开头,也就是说实际存储的尾数是省去了开头1的结果。这是IEEE-754给浮点数的额外规定,一是保证同一个数的表示方式惟一,二则为了节省额外一位空间。比如0.25既可以表示作1×2-2也可以写作0.5×2-1,而这种约束保证了实际采用的一定是指数最小的那种表示法。在一个符合IEEE-754标准的浮点计算环境中,每次运算完后的结果都会按照这个约束进行调整,这个操作称作规格化 (normalization)。

    思考一下又会发现,强制要求所有浮点数都是规格化的,会导致一些「特别特别小」的浮点数无法表示。比如,最小的规格化双精度浮点数是1×2-1023,但如果放弃这个约束,因为尾数部分的小数点还能够往左移。在这种极端情况下,的确有浮点数可以不遵守规格化约束,我们称呼为非规格化 (denormal) 浮点数。后面会详细讨论非规格化浮点数的使用。

  • 2023年终总结

    去过的城市:上海、重庆、成都、厦门、南京、景德镇、杭州、济州岛、合肥、绍兴、珠海、澳门、深圳、香港。看过的演出:李荣浩、汪峰、梁静茹、陈奕迅、五月天、伍佰。和小吴去了厦门和景德镇。

    去年新冠中及康复后食欲大涨,狂吃了不少东西,某天穿着破鞋走了段路,第二天开始脚疼。开始以为只是普通外伤,不小心对比才发觉两只脚已经不一样大,左脚穿不进去鞋了。第一次感受到彻夜长痛。

    过完年后去医院,真没想到看病也能踢皮球,实在受不了医生态度,开始持续有氧。从年初100kg-到年底80kg+,尿酸指标已经合格,Apple Watch之神也对我很满意。年底有些懈怠,2024继续加油。

    工作上继续和Rust搏斗,前一年的坑差不多慢慢填上。给LLVM的提交多了,也没有多很多。自制力和工作量规划依然是不会的。运气好有了Promotion,但愈进步心态就愈内卷。业余项目继续天马行空,最后一个都没弄完,来年二月继续给苹果上供保护费。虽然清楚有27岁过度焦虑的成分,但这部分确实不如运动来得满意。

    也没有读很多书。虽然Kindle很好,但他跑路了。一个蜂窝版iPad确实适合在地铁上读书,可惜我上班不坐地铁。印象最深的书是《被讨厌的勇气》,值得看很多遍,入脑入心入魂。影视剧也不多,就记得年初的《黑暗荣耀》和《狂飚》。看完了《EVA》旧版动画、《真心为你》和新剧场版的序破Q终。哦还有《奥本海默》。把柯南缺掉的主线和几部剧场版补上了。还在待办列表里的有《信号》《重启人生》,有空可能会补《犬夜叉》。

    可能因为坚持做有氧,这一年唱歌气息明显更好了,但还处在感情大于技巧且没多少感情的状态,或许报个声乐课好一点。吉他拖延了。业余无线电证书也拖延了。软考上半年没复习,考前一夜没睡好所以果断放弃了论述题。结果下半年考试时间和去济州岛冲突,只得2024再加油。

    2023年给库克送的钱少了些。集美们有条件iPad一定要上蜂窝版,且如果不是流量真的用不完,副卡真的不值得。连查找都不支持的AirPods落在了济州岛的小火车上,要不是这一出强制报废,估计能用到被系统淘汰。新耳机的主动降噪很好,但用久了会有种孤独感。又玩了几把新键盘。

    幽影计划成功的就500块,靠卖iPhone 15 Pro Max 1TB钛金属原色给黄牛赚的。从2020到2023,每年都抢了新手机,只有这次真卖出去了。说的要跑外卖也没跑,连电动车都没有,说起来我还没骑过电动车(驾驶位)。

    做饭多少有点些经验积累,但技能提升只能说非常有限。参加了暌违四年的RubyConf China,只能说Ruby不愧是我的初心,每次去都有收获(还有两顿自助餐)。

    游戏方面,有在模拟器的《战国无双》前两代。Steam又多了一堆游戏,但印象深的还是《刺客信条:奥德赛》和《Factorio》,前者确实是希腊旅游宣传片,就是太费时间;后者更费时间,但相当推荐,尽管看到虫子真的恶心。

    新的一年,对自己最大的期望就是继续抛弃完美主义,把做任何事的阻力都降到最低。比如憋不出正经的年终总结,但发条微博还是可以的,你看我这不就是写完了吗?

  • 用代码生成的思路做Sqlite的C++ ORM

    有一个未发布的小项目,涉及到本地数据库读写和加密,本来在Apple平台上用Swift实现,为了移植到其他平台的考虑,正在换成C++实现非GUI部分。在此前的Swift版本,我把Sqlite的C API封装为一个「穷人版」的ORM类。但这个实现使用起来遇到过度设计的问题,想调整一下做些非常规查询很麻烦。

    因为要换成C++,考虑到C++模板的功能比Swift强大,也许可以做得更多。一开始的想法非常宏大(个人项目的常见毛病),想的是用C++模板表达和SQL关系代数同等的约束(举个例子,比如用了HAVING子句,SELECT的范围就会受限制)。但这个坑太大,经过缩减变为:给定一组要查询的字段名,和一组字段类型与字段名关联的表定义,查询时通过字段名即可自动推导出结果类型。但这个坑依然很大,用到了不少可变参数模板,并不容易调试,实际使用起来的效果也不尽如人意。

    整理一下思路,有了新想法:前面的设计,是在试图用静态类型约束描述一组动态概念(SQL)。比如我在SQL里查询3个字段,而在获取结果时试图读4个字段,正常封装的做法是抛异常或者返回错误,我想要在编译时就发现这是个错误。但既然要做的查询就那些,为什么不可以把动态的部分和静态部分隔离开,类似Rust里的unsafe?这样我只要保证动态部分逻辑上不会出错就行了。如何保证呢?用脚本生成访问的代码就好了。

    所以我拿起最熟悉的Ruby,整出了如下的DSL:

    model = ModelGenerator.new
    
    model.migrate do |step|
      step.define_table 'users', if_not_exists: true do |table|
        table.primary_key :uuid, 'id'
        table.integer 'role', nullable: false
        table.text 'username', nullable: false
        table.text 'password_digest', nullable: false
        table.real 'updated', nullable: false
        table.real 'created', nullable: false
      end
    
      step.define_table 'posts', if_not_exists: true do |table|
        table.primary_key :uuid, 'id'
        table.text 'title', nullable: false
        table.text 'content', nullable: false
        table.integer 'type', nullable: false
        table.real 'created', nullable: false
        table.foreign_key 'post_user_id', 'users', 'id'
      end
    end
    
    model.alias_model 'user', 'users'
    model.alias_model 'post', 'posts'
    
    model.generate!

    这个DSL可以生成一个Repository类,其中包含常见的对users和posts的增删改查方法。如果想加入新的方法,DSL也有特殊方法指定要查询的字段。得益于Sqlite的数据类型只有四类(BLOB、TEXT、INTEGER和REAL),它们都可以轻松映射到C++的std::vector<std::byte>std::stringint64_tdouble类型。多个查询字段的结果则会被返回为一个std::tuple类型。

    DSL还定义了迁移的概念。后面如果对数据库表有任何修改,往下添加新的migrate块调用就可以。生成器会生成一个临时的Sqlite数据库,把定义的所有修改都在临时数据库里运行一遍,最后通过Sqlite API得到最终的表定义。通过这种方式,我们就不用模拟任何表的迁移过程了——再怎么模拟还能有真的在Sqlite里跑一遍方便吗?

    当然这个DSL目前还欠缺很多东西。因为是项目自用,有什么欠缺的后面需要了再加上。但我对这个思路很满意,整个生成器核心只有一百多行Ruby代码,剩下几百行是针对C++的ERB模板。理论上,后面还可以生成Java、C#、Swift等语言的代码。

    其实我比较认可Svelte这种工具的思路。良好的转译器能够省下很多麻烦。理想情况里,通过一个核心语言(可以是Vala这样专门设计的语言,也可以是已有语言如JavaScript的子集),经过编译能够在不同平台生成核心的业务和GUI代码,比如在Windows生成C#,Android生成Kotlin,iOS生成Swift代码等。剩下的部分只需要在每个平台补充少量支持代码。这个思路可能因为不够通用,也还是需要一些原生技能,所以还没看到有什么例子。(有Svelte Native,但和我预想的似乎不同)