本文描述了本人在香港科技大学第一周的蠕动史。因为实在抑制不住上大学的情绪,就来 blog 里写一篇发泄。在这里,我的思绪如潮水般涌动(Here my thoughts flow down like rivers)
蠕动经历
上课和做题
我不太想写日记式的文字。所以就泛泛地讲一下。
都说香港科技大学是,The Hong Kong University of Stress and Tension。但说实话,我的第一周真的没有啥压力。我们有三种课,Lab(实操)Lecture(讲座)和 Tutorial(做题)其中只有 Tutorial 要签到。换句话说,其他课去不去随你。有人告诉我第一周没有 Tutorial 所以……
我看看我第一周都学些什么啊:
- python 编程入门。不得不说,第一节课真的被老师吓到了。又是什么啊你要花至少 5 小时一天在上面啊,又是什么期末均分不到 50 啊。吓死我了。结果第二节课去听发现真的就是入门…… 那还说啥了兄弟。
- 高等数学。第一周就是高中数学。
- 大学物理。第一周就是高中物理。
- 大学英文。
- 水课。
HMAW 之啊 sir 我來同你玩
要说水课,无非就是签到打卡然后找个机会跑路。我们的水课是 HMAW。第一节课内容是反诈骗,然后他们直接找了警署里的条子来给我们讲课。
我的意思是,看了那么多香港的警匪片,这是我第一次见真的香港条子。欸,原来刻板印象是真的!就是全程插兜加上超级拽的粤语和带有非常港式的英文口音的英语。
我一直在想为什么香港的警察能给人一种特有的魅力。也许是类似于特工的感觉吧,非常酷。加上粤语本身就有一种……
好了我的语言形容不出来这样的感觉了。
第一次记旷课!
事情是这样的。我周四睡觉的时候看明天的课表明明是先高等数学的 Lecture,然后大学英语的 Tutorial。然后一睡觉起来发现第一节课变成大学英语了(??)然后想到哦之前的课都是没有 Tutorial 的,那不去了(???)然后就接着睡觉(????)然后睡醒之后就看到朋友给我发的雷霆信息。
“你是不是死了啊!!!!!!!”
我:?
然后我洗漱了准备去上英语课(?????)然后发现欸下一节课怎么是数学。然后哦哦哦上数学,然后走到英语教室去了(??????)然后到地方了发现没带文具(???????)准备回宿舍发现学生卡没带打不开宿舍(????????)然后好了数学课也上不了了,花十块钱招宿管开门。回去一看,我去,大大的缺席。
哪个该死的告诉我第一周没有 Tutorial??????????
竞赛社团
Robot Master(RM)
Okay,我说 CTF 已经没有希望了,我们去打 RM 吧。看到 RM 招新我觉得我虽然没有嵌入式基础但是写了四五年 C 了怎么说进个战队应该没问题吧……
然后到面试的那天,去了,到那之后人家直接问我说看过面试题了吗?
我:?
不是哥们,你们开卷面试啊。
好吧,那我就正常闭卷面试,这个时候我已经紧张地不行了。然后第一个问题:问单核 CPU 怎么同时执行多个任务。
我去!这题我会啊。我最近在 php 内核里就在干这个!然后我就从并行和并发开始讲,从 RTOS 到 Fibers,抢占式和协作式全部讲,还讲了进程线程,条件竞争,DMA。bro 已经完全沉浸在自己的艺术里了。两个面试官开始还跟着我点头,后面直接开始面露难色。哥们你是人类吗?
然后第二个问题问了什么通讯协议(spi 和 cann),然后我又想,这题我会啊。我做过固件逆向!然后我又开始沉浸了……
CTF
Fair,我们还是要去看看 CTF 战队的。但是!在港科你要参与 CTF 必须要选一个课,通过了之后才能去预选。我一开始还天真的以为要写邮件申请就完了。哈哈。
临近开课前一天我才发现有新生赛。然后我就想,炸鱼?然后大概花了半小时打到比第二名多了快一倍的分数就睡觉去了。
海边和城市?
港科靠海,所以看海很容易;港科也靠着城市,所以看城市很容易。
城市
炫目的夜色;汽车的喧嚣;街道的霓虹灯。嘈杂的人群;颓废的都市;明灭的红绿灯。
香气缭绕,城市之子们(Children of Cities)在霓虹灯下漫步。
香港都市的晚上,大厦反而愈来愈有活力。我和我的朋友在 Mall 下一边喝伏特加一边走着。
我如同奔跑在黄昏街道上的僵尸。
海边
香港的海边与深圳的海边没有不同。我会偶尔在背着吉他一个人去那边躺着弹琴。闭眼用手指构想白云的轮廓和天空的高度。
我!要!回!家!
虽然说香港的一切都好吧,但是总觉得还是差点味道。是什么呢?
我们说,思乡病(Homesick)总是在离家刚开始的几天最明显。当然,除了想家之外显然我还有几件事情要回大陆做的:
- 我的耳朵旧病复发了
- 给我的鼠标拿充电线
- 拿机械键盘
- 拿眼镜
- 拿开发板
欸,为什么你第一次去的时候不拿。答曰:忘了。
当然主要还是因为想家了。我很难描述这样的感觉。总之周五上午缺席两节课之后我马上就!要!回!家!下午的课?我去我上午直接出席了 0 节干脆下午的也翘掉算了。总之我要回家。
看着 91M 巴士到站那么多人跟丧尸围城一样抢着上车我就知道:看来我不是孤独的。但是,我的计划是 13:35 到站沙口,然后买 13:45 的大巴直接做到深圳湾口岸。谁知道哪个天杀的想出来,在大巴发车之前 10 分钟不允许购票了。
我真去他的吧。好了,接下来别急,我们是大人了,好好想想怎么解决。
当时我灵机一动啊买了 14:45 的票,因为他的票是二维码嘛,我就拿浏览器扫了一下看看它的二维码是什么
1 | 202609041445XXXXXXX(X代表任意英文字符) |
其中 XXXXXXX 显然是票号,前面的是上车的时间。我当时就打开二维码编码器,把 X 改掉一位然后把 1445 改成 1345。当时那个扫码的来我就给他扫这个码,显然报错了,说票不存在。然后我后面还排着三十多号人。
我:你们会不会做购票系统?我明明买的对的。
对方:(又扫了很多次)
我:你看我都付款了(出示微信支付)要不你把你经理叫来,或者我给他打电话?
然后对方就让我上车了!哦天哪。对不起!!!
然后就回家了。
技术上做了什么?
有人说,你到底是去上大学的还是干嘛……
好,以下是 nerd 时间。请 nerd 们做好准备我要开始讲我这周干的技术活了。
Lament 立项
你看,很多艺术作品里搞技术的都有机械外骨骼。比如凯尔希大哥和 M3,以及伊冯大哥和塔塔。
我一直非常想有一个!现在,托 php foundation 的福,我拥有这个世界上最牛逼的 AI 模型,而且基本用不完额度。
所以我这一周在手搓一个 agent 集群的框架,暂时叫他 Lament,目前搭载着 fable 5.1 大哥和 Astra 大哥。让我们看看我能坚持多久。(^~^)
php 内核优化
将 php 语言的所有对单元素数组排序的性能提升 50.4%
有人可能要说了:欸,单元素数组排什么,直接返回不就好了。别急。
在 php 里,排序函数(sort, usort, rsort, rasort, uasort, asort等等)的实现是靠 zend_array_sort,我们来看实现:
1 | static zend_always_inline void zend_array_sort(HashTable *ht, bucket_compare_func_t compare_func, bool renumber) { |
跟进 zend_array_sort_ex
1 | ZEND_API void ZEND_FASTCALL zend_array_sort_ex(HashTable *ht, sort_func_t sort, bucket_compare_func_t compar, bool renumber) |
这里的 ht 就是要排序的数组。我们很容易看到,这里为了防止 RCn 断言失败,我们实际上把 packed 的 array 做了一次 unpack,在使它的 ref count 加一。这是一种比较 hack 的办法,但是确实可以防止 bug。跟进 zend_hash_sort_internal
1 | static void zend_hash_sort_internal(HashTable *ht, sort_func_t sort, bucket_compare_func_t compar, bool renumber) |
这里的 sort 参数是一个函数指针,指向一个排序函数。这里指向 zend_sort。我们看到其中逻辑有:
1 | switch (nmemb) { |
其中 nmemb 是数组的长度。也就是说在实际排序的时候我们已经对 0 长度和 1 长度数组做了特殊优化(fast-path)就是直接返回。
看到 zend_hash_sort_internal 这里的 renumber 是布尔标志,表示排序后是否重新生成从 0 开始的数字索引。
- renumber = true: 对应
sort,rsort,usort等重置键名的函数。 - renumber = false: 对应
asort,arsort,uasort等保持原键名(关联数组关系)的函数。
可以看到,有 if (!(ht->nNumOfElements>1) && !(renumber && ht->nNumOfElements>0))。这是排序的快速退出判定(Guard Clause),避免不必要的计算:
!(ht->nNumOfElements > 1): 如果元素数量为 0 或 1,元素之间无需重新比对排序。!(renumber && ht->nNumOfElements > 0): 如果只有一个元素(nNumOfElements == 1),虽然不需要“排序”,但如果 renumber 为 true,依然需要进入后续逻辑去把该元素的键名重写为 0;只有当 renumber 为 false 且元素量不超过 1 时,才可以直接 return。
有人说,啥意思,啥键名重写为 0,你在说什么。
这就不得不提到 php 里臭名昭著的“特性”了:
1 |
|
假如这里的 array 是 packed 的,就是没有键只有值(类似于 [1, 2, 3, 4])那自然无所谓。但是有时候数组是 unpacked 或者 mixed,就是存在自定义键(['name' => 'PHP',10 => 'value',])这时候,一部分排序函数会把键覆盖成自然数列(0, 1,2,3,4…)对值做排序。一部分不会。会的那部分 renumber 就是 true。
排序函数除了排序,其实还做了其他的事情。所以如果 renumber 是 true 我们还要对单元素数组做排序的流程,只是我们实际上不在乎排序本身罢了。
到这里,聪明的你看出来哪里可以优化了吗?
是的!既然单元素数组这一种情况不需要实际上做排序,我们就完全不需要考虑在 zend_array_sort_ex 里把 packed array 展开!实际上单元素数组的排序流程非常快,大部分的性能都消耗在 (un)packing 上。
比如说,我们现在有一个数组 [42]
1 |
|
调用链是
1 | sort() |
所以真正的排序器从来不会被调用。问题在于,在到达 zend_sort 之前,zend_array_sort_ex 已经做了大量工作。
我们的 [42] 最初是 packed array,只需要连续存储 zval:
1 | arPacked: |
为了安全执行可能调用用户代码的比较器,我们的 zend_array_sort_ex 先调用:zend_hash_packed_to_hash(ht);
它会:
- 分配 mixed HashTable 内存;
- 把每个 zval 扩展为 Bucket;
- 补充数字键 h 和 key;
- 释放旧 packed 存储;
- 重建哈希索引。
在典型 64 位平台上:
1 | sizeof(zval) ≈ 16 bytes |
而 HashTable 最小容量通常是 8,所以即使只有一个元素,也会产生明显的固定分配与初始化成本。
接下来:GC_ADDREF(ht);
这是为了防止排序器执行用户代码时把正在排序的数组释放掉。
例如,多元素 usort 的 callback 理论上可以执行任意 PHP 代码。排序期间必须保证 HashTable 仍然存活。
但单元素时 zend_sort 根本不会调用排序器(被 fast-path 直接返回了),因此这层保护没有必要。
还没完。sort, rsort, usort 排序完要重新编号键。旧路径排序结束后还需要:
- 设置每个 Bucket 的数字键;
- 释放字符串键;
- 设置 nNextFreeElement;
- 再分配 packed 存储;
- 把 Bucket 中的 zval 拷回 packed array;
- 释放 mixed HashTable。
结果就是:
1 | packed → mixed → 什么都没比较 → packed |
这里有思路了之后就很好改了,我们看diff。

如果元素个数小于 1,直接跳过从 zend_array_sort_ex 到 zend_hash_sort_internal 的过程直接执行 zend_hash_sort_internal。这里使用zend_hash_sort_ex函数。它的实现是:
1 | ZEND_API void ZEND_FASTCALL zend_hash_sort_ex(HashTable *ht, sort_func_t sort, bucket_compare_func_t compar, bool renumber) |
所以约等于直接调用 zend_hash_sort_internal (只有一个断言语句) 这样就没那么多事情了。

首先我们保留原来的行为,如果数组的长度是 0,那不管怎么样直接返回。
然后我们在 zend_hash_sort_internal 里面再做一个优化。还记得我们说的 renumber 行为吗?如果有 renumber 我们再排序,因为除了排序还有事情要做。没有我们干脆返回得了……吗?
不行,不够快。我们希望对 renumber=true 的单元素数组也优化!我们来想:renumber=true 的时候,只有当数组是 unpacked 的情况下才要重写吧,如果这个数组本来就没有键,那还做什么重写?这跟 renumber 根本就无关。假如数组是 unpacked 且只有一个元素,就应该直接返回。
所以我们创建这样一个 fast-path:
1 | if (sort == zend_sort && HT_IS_PACKED(ht) && HT_IS_WITHOUT_HOLES(ht)) { |
如果排序函数是 zend_sort(一般都是),且被排序的数组是 packed 的,也就是没有自定义键(HT_IS_PACKED(ht))理论上就可以跳过覆盖键的逻辑直接返回!
有人可能会问:HT_IS_WITHOUT_HOLES(ht) 是啥意思。
packed 并不一定意味着当前键已经是连续的。如:
1 |
|
这个时候,其实在内核里 array 的第一个元素 10 虽然被删了,但是它变成了一个洞(HOLE)。这时虽然只剩一个元素,但内核里 20 仍然位于索引 1(索引 0 是个洞)所以仅检查数组只有一个元素是不够的。我们很容易就知道,我们的 fast-path 只能适用于没有洞的数组。所以用 HT_IS_WITHOUT_HOLES 宏检测这个数组有没有洞。
最终我们终于得出了我们 fast-path 的条件:ht->nNumOfElements == 1 && sort == zend_sort && HT_IS_PACKED(ht) && HT_IS_WITHOUT_HOLES(ht)
OKAY! 接下来呢?直接返回吗?肯定是不行的。我们还需要设置两个量。
首先:
1 | nInternalPointer = 0 |
这是因为排序操作会重置数组内部指针。而走不走 fast-path 我们都希望重置这个指针。所以必须在返回前重置它。我们来看:
1 |
|
如果 fast path 只是直接 return,这里会错误地继续处于无效位置。
其次
1 | nNextFreeElement = 1 |
通俗地讲,这个字段决定:
1 |
|
的时候使用哪个数字键。
即使数组 packed、无洞且只有一个元素,nNextFreeElement 仍然可能大于 1:
1 |
|
此时:
1 | nNumUsed = 1 |
数组已经重新成为 packed 且无洞,但 next-free index 仍可能保留为 11。
sort() 具有重新编号语义,所以排序后:
1 |
|
必须得到:
1 | [ |
而不是
1 | [ |
因此,我们必须手动设置 nNextFreeElement = 1。
以上,大功告成!我们可以观测到 sort([42]) 这一行代码有 50% 的性能提升!PR 已经 merge。
其他
以上是一个大工程,花了我很多时间。在这之外我还做了一些改动。比如去除了 OpenSSL 测试里的锁文件(CONFLICTS)允许它们并行,然后毫不意外地触发了一大堆条件竞争问题。你以为我又捅娄子了?No,我把条件竞争全部修完了(笑)。这下好了,反而是大大提升了 OpenSSL 插件的测试性能。
我还在 php 基金会官网发表了一篇 blog。见:https://github.com/ThePHPF/thephp.foundation/pull/319
好了肯定还有别的但是我写累了就这样吧。
结语
写不动了。但是还是家里睡得舒服。哈哈。