打开网易新闻 查看精彩图片

97个时钟周期。

这是1997年一颗233MHz的奔腾MMX上,你从浮点代码切进MMX代码、算完再切回浮点,凭空扔掉的时间。折成绝对时间是416纳秒。那颗芯片一个周期最多退休两条指令,97个周期约等于194条指令的位置全部空着。

这个数字不在Intel的任何一页宣传材料上。它是我把Agner Fog优化手册里的三个数加起来得到的:浮点指令之后的第一条MMX指令,多花约38个周期;EMMS指令本身,1个周期;EMMS之后的第一条浮点指令,多花约58个周期。我在他那份《How to optimize for the Pentium family》第139页翻到的原话是“The EMMS instruction takes only one clock cycle, but the first floating-point instruction after an EMMS takes approximately 58 clocks extra”。

今天你写SIMD,写NEON也好写AVX也好,不会撞上这种东西,因为向量寄存器是独立的一套。而“独立”这件事之所以值钱,理由就压在1997年这97个周期底下。想明白这笔账,你会对现代CPU里那些看起来纯属浪费的冗余硬件宽容很多。

打开网易新闻 查看精彩图片

借来的八个寄存器

MMX给x86添了8个64位寄存器,MM0到MM7。这8个,一个新的都没有。

x87浮点栈里本来就有8个80位寄存器,扩展精度格式拆开是1位符号、15位指数、64位尾数。Intel把MMX寄存器直接压在尾数那64位上,写MMX数据时顺手把上面16位全填成1。英文维基的MMX条目对这步的描述是“all set to ones”,效果是从浮点单元的角度看,这些寄存器一律成了NaN或无穷大,谁也别想拿去做加减乘除。

这么干省下来的东西非常具体。当年的操作系统做线程切换时都会存一份x87状态,FSAVE和FRSTOR这对指令是现成的。MMX寄存器物理上就是x87寄存器,于是内核代码一个字都不用改,MMX状态跟着被保存和恢复。要是Intel真加一套全新的64位寄存器堆,进程控制块的布局就得改,那是整个生态一起动的活儿。

HN那条讨论里有人一句话说穿了这个动机,userbinator写的是这样做“makes it easier to save and restore state when it's basically the same registers”。省的不是晶体管,省的是别人的工程时间。

九十七个周期的账单

代价就是开头那个数。

MMX和浮点在同一段代码里互斥。用完MMX必须发一条EMMS,把x87的标签寄存器全标回有效,浮点单元才能正常工作。忘了会怎么样,HN上那位ack_complete描述得挺吓人:接下来几条x87运算可能直接吐NaN,因为浮点栈溢出,“entire calculation domain would get poisoned and slow down by ~20x”。这类bug在1997年查起来大概率是噩梦,因为它不崩,只是结果慢慢变成垃圾。

EMMS本身只要1个周期,看着人畜无害。钱花在它两边。我把97个周期换算成这颗芯片能干的活:按一个写得不错的MMX图像循环、每8字节吃2个周期算,97个周期够处理388字节。来回切一次浮点和MMX,代价相当于白扔了将近400个像素的处理量。

这就是为什么当年MMX代码的写法长成那个样子:把所有MMX活儿攒成一大坨,一次做完,最后收一条EMMS。不是风格问题,是被硬件逼的。

打开网易新闻 查看精彩图片

八路并行的水分

现在说好的那一面。pikuma那篇文章里Gustavo Pezzi给的例子很典型,把一张8位灰度图整体提亮:

movq mm0, [esi] ; 一次读进8个像素movq mm1, [intensity] ; 8份同样的增量paddusb mm0, mm1 ; 8个字节同时加,溢出自动钳在255movq [edi], mm0 ; 一次写回8个

PADDB和PADDUSB一条顶8次字节加法,PADDW处理16位就是4路。饱和是白送的:标量写法里要判断有没有超过255再钳位,在MMX里那是指令自带的语义。

我照P55C的双流水线规则算了一遍上面这个循环。Agner的手册里写明,MMX指令基本都是1个周期,除了乘法要3个;除EMMS之外都能在U管或V管里配对,但访问内存或整数寄存器的MMX指令只能走U管,而且不跟非MMX指令配对。于是这段循环的瓶颈是两条movq——一读一写,两个U管槽位,中间那条paddusb可以塞进V管。8个字节2个周期,每字节0.25周期。标量版本要处理钳位,一个字节大概5条指令,双发射摊下来2.5周期。两边一比,10倍。

然后我算了第二件事,这一步把前面那个漂亮数字打回原形。

233MHz、每周期吃4个字节吐4个字节,意味着每秒需要往芯片里灌932MB、再往外倒932MB,合计1864MB/s。而那个年代的Socket 7平台,外频66MHz、64位数据总线,理论峰值是533MB/s。需求比总线能给的多出3.5倍。

一张640×480的8位图是300KB,而P55C的L1数据缓存只有16KB。这种从头扫到尾、扫完就不回头的循环,缓存帮不上忙。所以真正跑起来,MMX这个循环是被总线卡住的:533MB/s摊成一读一写,等于每秒267M像素。而标量版本每秒只处理93M像素,流量186MB/s,安安稳稳待在总线预算里——它压根不是带宽受限,它是算力受限。

两个数一除,2.86倍。再按1997年EDO内存实得峰值六成多算,落到1.86倍。

Intel当年给的口径是多少?非MMX软件快10%到20%,跑MMX优化过的程序最多快60%。1.6倍。跟我算出来的1.86倍,居然在同一个量级上。

这里必须说清口径:那个60%我没能拿到Intel的原始白皮书。1997年1月8日那份发布新闻稿的官网存档地址我试了两次,服务器直接甩403回来,只能按PC Tech Guide这类二手技术资料的转述写。这个数到底基于哪套负载、哪种内存,我没核到。

被算在MMX头上的那16KB

还有一笔账更值得掰。

P55C相对上一代P54C,改的不只是指令集。最要紧的一处是L1缓存:指令和数据各8KB,翻成了各16KB,总量从16KB变32KB。顺带还把流水线加到6级、换了更好的分支预测器,晶体管数从330万涨到450万。

Intel自己那句“非MMX软件快10%到20%”,说的就是这部分。换句话说,1997年你拿一台奔腾MMX跑一个完全不认识MMX指令的老程序,它照样比同频的老奔腾快一到两成,这一到两成里没有MMX指令的任何功劳。

把这层剥掉再看那个60%:1.60除以1.15,剩1.39。MMX这57条新指令的净贡献大概是39%,不是60%。取10%那档也不过45%。当年媒体和用户感受到的“MMX真快”,有相当可观的一块其实是缓存翻倍。

我还用阿姆达尔定律倒推了一次。假定MMX段能拿到8倍加速(按PADDB的纯宽度算,比我上面那个10倍保守),要让整机跑出1.6倍,MMX化的代码必须占掉原运行时间的42.9%。1997年能把将近一半运行时间塞进手写汇编MMX内核的多媒体程序,实在不多。HN那条讨论里nojokepoke的说法是MMX普及“slooooow”,SIMD真正被广泛使用花了五到十年。这两件事对得上。

那20个周期的差额

写到这儿有个东西我没查明白。

Agner给的两个数是不对称的:浮点切MMX是38个周期,MMX切回浮点是58个周期,差了20个。从功能上看这两个方向做的事像是一体两面:去的时候把x87的标签位全部作废,回来的时候再把它们标回有效。我原以为这该是对称的开销。

他自己用的词是“approximately”,说明这是他在真机上测出来的,不是从Intel文档里抄的,而Intel那边我确实没找到公开的官方数字。这20个周期究竟是微码路径长度的差异,还是恢复方向多了一步浮点栈指针重建,我翻了手册和当年的优化文档都没找到解释。