前端效能病理標本館 · 第二篇 全部數字:宣告 4x CPU throttle、CDP 機器驅動、每個 mode 三輪。 原始資料
docs/measurements/2026-07-26-reproducibility-4x-5.json(66 筆),可自行覆算。 本文所有比值都取自這一份 JSON 內部 —— 跨檔案的絕對值不可比,理由寫在第七節。
第一篇《十二段治療處方,站得住的有四段》的結論是:12 段治療裡,效果既乾淨又可歸因的只有 4 段, 其餘 8 段各有各的毛病 —— 有的同時翻了兩個變因,有的從未進入作用區間,有的那段程式碼根本沒執行過。
那篇文章的最後一句是:「上述缺陷全部尚未修正。先發表判決,再修 —— 反過來做的話,就沒有人能檢查我是不是把結論改成了跟修完的程式一致。」
現在修完了,也重量了。這篇文章講那八格。
| 第一篇的判決 | 修完之後 | |
|---|---|---|
#4 passive 同時翻了兩個變因(43 → 27,0.63×) |
隔離之後 113 → 112,1.0× | 翻 |
| #4 rAF 節流從未進入作用區間 | 閘門真的觸發了,113 → 42,2.7× | 翻 |
| #6 背壓那段程式從未執行 | rendersSkipped 0 → 137/144/142,277 → 136 |
翻 |
| #5 治的是不存在的病 | 三個位移源都真了,CLS 可乾淨拆解 | 翻 |
#1 scheduler.yield 只做了十分之一的工作 |
工作量對齊之後,17× 變成 32× | 翻 |
| #2 虛擬滾動「只有這一臂被儀器加了負載」 | 2.0× 變成 23.3×,而加負載的不是那一臂 | 翻 |
| #6 批次化從未進入作用區間(1.04×) | 進去了,277 → 234,1.2× | 沒翻 |
| #1 Web Worker「搬運比排序貴 20 倍」 | 剩 2.3 倍,但該臂本輪判不可重現 | 部分 |
六格翻案。但真正值得寫的不是「翻了幾格」,是它們往四個不同的方向翻。
直覺會說:修掉實驗設計的漏洞,治療的效果應該往下修 —— 因為那些漏洞本來就是在幫治療臂作弊。
實測是四個方向都有:
歸零 #4 passive 0.63× → 1.0× 隔離之後療效完全消失
從無到有 #4 rAF 節流 沒發生 → 2.7× 閘門先前一次都沒觸發
變大 #1 scheduler.yield 17× → 32× 混淆變因移除,效果反而更大
沒動 #6 批次化 1.04× → 1.2× 進了作用區間,還是零效果
四種都能用護欄計數器交叉證實。以下逐段。
passive 什麼也沒買到第一篇指出標本 #4 的治療一宣稱「只把 wheel 改成 passive: true」,
但程式在換旗標的同時把 handler 也換掉了,順手拿掉一整輪 8000 次 getBoundingClientRect()。
rectReads 從 168,000 砍到 88,000,剛好對半。
修法是讓兩臂共用同一個 handler 識別字,只翻旗標。現在:
passes rectReads wheelCancelable 掉幀峰值
broken 41 / 40 / 38 328k / 320k / 304k 21 / 20 / 20 119 / 113 / 99
fixed-passive 40 / 41 / 41 320k / 328k / 328k 0 / 0 / 0 116 / 112 / 105
passes 與 rectReads 兩欄現在對齊了 —— 兩臂做的是同一份工作。
wheelCancelableCount 從 21 掉到 0,證明旗標確實生效。
而掉幀峰值 113 → 112,1.0×。
第一篇量到 0.63× 的那個「改善」,整份都是「少做一次全掃」,不是 passive 買來的。
把工作量對齊之後,殘差是零。
⚠️ 但這句話有一個必須寫出來的邊界:這把尺量不到 passive 真正買到的東西。
droppedFrames 來自主執行緒的 rAF 迴圈,量的是主執行緒的出幀節奏;
而 passive 買到的是 compositor 側「不必等你的 handler 就能先捲」。
所以「量到一個零」是這把尺的解析度邊界的結論,不是「passive 沒用」的一般結論。
要量它得換一把尺,這一輪沒做。
同一支標本的治療二是 rAF 節流。第一篇的證據是 fixed-raf 與 fixed-passive 的
passes 與 rectReads 逐輪一模一樣 —— 合併閘門一次都沒觸發,因為量測驅動器
每 500ms 才派一次滾輪事件,一幀之內永遠不會有第二個 scroll 可以合併。
修法是一拍連派三格滾輪(不等中間任何一次的回應,否則三格會落在三個不同的幀)。現在:
rafSkipped passes rectReads
fixed-passive 0 / 0 / 0 40 / 41 / 41 320k / 328k / 328k
fixed-raf 20 / 20 / 22 14 / 14 / 14 112k / 112k / 112k
rafSkipped 從恆為 0 變成 20~22 —— 閘門真的在合併了。
passes 從 40 掉到 14、rectReads 從 320k 掉到 112k。
掉幀峰值 113 → 42,2.7×。
這一段從「未進入作用區間」變成一個真實的、可歸因的效果。 而它之所以先前不成立,責任在量測驅動器,不在治療。
標本 #6 的背壓臂是同一種翻案:判斷式建立在一個低估 7.5 倍的自報值上,
於是 Math.max(0, 負數) 恆等於 0,閘門永遠不觸發。改用真實幀成本之後:
rendersSkipped batchesRendered 掉幀峰值
fixed-backpressure 137 / 144 / 142 26 / 26 / 26 137 / 135 / 136
rendersSkipped 從三輪恆為 0 變成 137~144。那段程式碼活了,277 → 136,2.0×。
scheduler.yield 從 17× 變成 32×第一篇最重的一節是「17 倍裡有多少是少做了九成的事」:病變版做完十次排序, 兩段治療各只做完一次、取消九次。那不是作弊 —— 非同步實作本來就該取消被後續操作蓋掉的工作 —— 但它意味著 17× 不是同一份工作量的對照。
修法是讓治療臂改成排隊而不是取消,並把點擊節拍從「盡快連續」改成 17ms 絕對排程。現在:
completedSorts cancelledSorts
broken 10 / 10 / 10 0 / 0 / 0
fixed-yield 10 / 10 / 10 0 / 0 / 0
fixed-worker 10 / 10 / 10 0 / 0 / 0
三臂都做完十次,一次都沒取消。 同一份工作量的對照終於成立。
而 INP median 896 → 28ms,32.0×。比第一篇那個「含混淆變因」的 17× 還大。
這件事值得停一下:混淆變因往哪個方向偏,不能用直覺猜。 第一篇已經抓到標本自己的註解把方向記反了一次(宣稱偏向對治療不利,實測是相反); 這一輪則是移除混淆變因之後效果變大 —— 也就是那個混淆變因其實在壓低療效。
這一格單獨一節,因為它的原因跟其他七格都不同。見第五節。
第一篇給標本 #2 的虛擬滾動臂一個保留意見:「只有這一臂被儀器加了負載」,LCP 1360 → 680ms,2.0×。
保留意見是對的,但指錯了方向。加負載的不是那一臂,是量它的方法。
標本 #2 是 B 類:切換 mode 要重載整份 document,因為 LCP 在第一次互動後定案、 而且是 per-document 的。實作方式是按面板上的按鈕,讓外殼把 iframe 重新導覽。
問題在於 iframe 與外殼共用同一條 renderer 主執行緒。
前一份 document 的拆除與殘留工作落在新 document 的時鐘之內,
而 LCP 取的是 entry.startTime,以新 document 的 timeOrigin 起算。
於是 LCP 帶著一個由「前導 mode 有多重」決定的加項。
同一個 mode,同一組凍結條件,只改前面那一個 mode:
fixed-virtual,前導是 broken LCP 172 ~ 180ms
fixed-virtual,前導是 fixed-content-visibility LCP 888 ~ 892ms
零重疊,差 5 倍。差距全部落在新 document 的 responseEnd → DCL 之間
(responseEnd 一律 13~262ms,不是抓取問題),切換前插 15 秒靜置排不掉。
最刺眼的一組對照是這個:
fixed-content-visibility 40,021 個節點 DCL 819 / 1,081 / 1,392 / 1,626 / 1,692 / 1,907 ms
fixed-virtual 141 個節點 DCL 8,527 / 8,866 / 9,553 ms
(同一支探針、同一輪、cv 與 virtual 交替各跑六次與三次;上面兩列都是切換前不插靜置的那一組, 沒有挑樣本。virtual 另有三筆插了 15 秒靜置的,1,651 / 7,586 / 12,082ms —— 靜置排不掉, 只是多了一個離群值。)
141 個節點的 document 載入耗時是 40,021 個節點那份的五倍。 這個數字結構上不可能在描述標本。
修法是給外殼加 deep-link(?specimen=&mode=&cpu=),
讓每一筆 B 類樣本都從一次全新的導覽開始,目標 mode 就是首載的那一份 document,前導成本歸零。
修完:
fixed-virtual deep-link 全新導覽 LCP 100 / 92 / 108ms 全距 16(雜訊底線 50)
正式三輪之後,標本 #2 的三臂:
broken 1864 / 1788 / 1896 ms 離散度 5.8% ✅
fixed-content-visibility 472 / 548 / 520 ms 離散度 14.6% ✅
fixed-virtual 84 / 76 / 80 ms 離散度 10.0% ✅
病變 → content-visibility 3.6×
病變 → 虛擬滾動 23.3×
第一篇寫的是 2.0×。真實答案是 23.3×。
污染項有多大,要在同一輪之內回答才算數(跨 session 的絕對值不可比,見第七節)。 上面那支探針同一輪裡量到:前導是 cv 時 888~892ms,deep-link 全新導覽時 100/92/108ms —— 同一個 mode、同一組條件,污染項約 790ms,是訊號的八倍。
它沒有症狀,而且它讓數字看起來更漂亮。
舊的量測順序是 for 每一輪 { for 每一個 mode } —— 每個 mode 每輪都佔同一個順位,
於是它的前導永遠是同一個 mode。污染因此是一個常數。
常數不會製造離散度:第一篇那一輪標本 #2 的三臂離散度是 2.9% / 6.1% / 8.2%,
是全站最漂亮的數字之一。
污染安安靜靜地烙進臂間比值裡,而可重現性判準對它一無所知。
後來把量測順序改成每輪輪轉一格,#2 立刻判不可重現(離散度 34.0% / 110.7%)。 當時的直覺是「這一支標本壞了」—— 而它一個字都沒改。 掀開的不是病,是掩蓋。
順帶一提,那個輪轉本身也有缺陷:守衛條件 if (order[0].id === current) 在第一輪必定成立
(current 的初值就是 modes[0].id),把第一輪推成 rot(1),而第二輪算出來也是 rot(1)。
三輪只有兩種順序。註解宣稱「每一輪輪轉一格」,程式做的是「兩輪相同 + 一輪輪轉」。
現在有一條回歸測試(tools/b-class-isolation.mjs)守著這件事:
同一個 mode 量三次,全距必須落在雜訊底線內;舊的量法留在測試裡當對照組,不參與判定。
標本 #6 的治療一是「批次化 + rAF」。第一篇量到 1.04×,並判定它「從未進入作用區間」:
推送源是 setInterval(50ms),一幀之內平均只有 0.33 筆推送,沒有東西可以合併。
修法是把推送間隔改成 25ms。現在:
batchesReceived batchesRendered renderRatio 掉幀峰值
broken 201 / 202 / 201 201 / 202 / 201 1.00 281 / 277 / 275
fixed-batch 206 / 201 / 203 42 / 41 / 42 0.21 234 / 238 / 232
renderRatio 從 1.0 掉到 0.21 —— 批次化確實在合併了,五筆推送才渲染一次。
掉幀峰值 277 → 234,1.2×。
機制生效,收益接近零。 這比第一篇那個「未進入作用區間」更值得寫, 因為它排除了「只是負載不夠」這個解釋:合併真的發生了,渲染次數少了五倍,而幀還是掉。
原因在補充指標上:
有渲染的幀距 median
broken 200.1 / 208.3 / 200.0 ms
fixed-batch 100.1 / 116.6 / 116.6 ms
fixed-granular 16.7 / 16.7 / 16.7 ms
批次化把幀距從 200ms 砍到 100ms,但它仍然在整表重建 —— 每一次渲染還是那麼貴, 只是次數少了。而 5 秒窗的掉幀數對「一幀 100ms」和「一幀 200ms」都一樣是災難。 真正把幀距壓回 16.7ms 的是細粒度更新(只改變動的節點),277 → 18,15.4×。
批次化的收益不在「少渲染幾次」,而在「每次渲染有沒有變便宜」—— 它一點都沒變便宜。
第一篇登記過「跨 session 的絕對值不可比」。這一輪發現那條還不夠強。
同一天、同一台機器、相隔約六小時的兩次完整掃描。標本 #6 的細粒度臂:
上午 本輪
batchesReceived 200 / 200 / 200 200 / 200 / 200
updatesApplied ~18.6k ~18.8k
有渲染的幀距 median 16.7 ×3 16.7 ×3
掉幀峰值 85 / 59 / 84 14 / 18 / 18 ← 差 4~5 倍
工作量一格沒動、幀距一模一樣,掉幀差 4~5 倍。同一輪裡病變臂則幾乎不動 (281/284/277 → 281/277/275)。
原因是機器:本輪的機器比上午快且穩得多(標本 #1 的排序耗時從 132/143/180ms 變成 110.7/111.6/113.9ms,輪內抖動從 36% 降到 3%)。
病變臂飽和了,所以對機器速度不敏感;治療臂沒飽和,所以敏感。 於是比值本身是機器速度的函數。3.3× 與 15.4× 都是真的,差別在機器。
跨 session 比較治療臂的比值,是無效的。 這比「絕對值不可比」更強一級。
同一件事還讓標本 #1 的兇手翻面。上午三輪一致是 presentation;本輪是
presentation / inputDelay / inputDelay:
上午 本輪
inputDelay 3.9 / 0.6 / 0.4 ms 0.8 / 798.5 / 700.9 ms
presentation 1373 / 1422 / 1585 ms 951 / 120.2 / 238.6 ms
機制是第一篇就登記過的那條:INP 看不看得見排隊,不取決於有沒有排隊,
取決於排隊期間瀏覽器有沒有機會畫。 機器變快之後,十次排隊的點擊中間開始塞得進一次 paint,
排隊就從 presentation 浮出來、變成 inputDelay。這支標本正好卡在那條界線上。
這代表標本 #1 的兇手歸因是機器速度的函數。 而它現在登記的 presentation
是上午(較慢的機器)量出來之後改的。依本站規矩,不在這裡改登記 —— 留待下一輪裁決。
第一篇之後留下一個漏洞:「三輪兇手一致」這條判準只檢查三輪彼此一致, 不檢查是否等於當初登記的兇手。也就是「登記的預期整個錯了」這件事,判準看不見。
現在補上了,六種狀態分開:三輪一致且等於登記值 / 三輪一致但不等於登記值(新警示)/ 三輪不一致 / 沒有登記值(治療臂)/ 登記值不在 INP 三段值域 / 該輪沒有樣本。 只比對病變臂 —— 治療臂換兇手正是治療本身,比對會誤報。
但它的覆蓋範圍只有六個標本裡的兩個。
登記兇手是 lcp(#2)、loaf(#4 #6)、cls(#5)的那四支,
一律判「不在 INP 三段值域」,不報警。
這四支標本登記的兇手,到今天為止沒有被任何判準驗證過。 照實記在待裁決清單裡。
八格翻了六格,而它們往四個方向翻。如果要從這一輪取一句話:
修掉實驗設計的漏洞,療效不會一律往下修。
passive 歸零、rAF 從無到有、scheduler.yield 從 17 倍變 32 倍、虛擬滾動從 2 倍變 23 倍。
往下、往上、從零開始、原地不動 —— 四個方向都出現了。
「混淆變因一定在幫治療臂作弊」是一個直覺,而這一輪有兩格是反過來的。
第二件事,比第一件更重要:
最大的一格誤差不是標本產生的,是量測方法產生的,而且它讓數字更漂亮。
虛擬滾動的真實效果是 23.3×,發表出去的是 2.0×。中間那 590ms 是前一個 mode 的殘留, 而它之所以沒被抓到,正是因為它是個常數 —— 三輪離散度 2.9%,全站最漂亮的數字之一。
可重現性判準只看「三輪之間一不一致」。一個穩定的錯誤,它抓不到。
第一篇的結論是「主指標告訴你快了多少,護欄計數器告訴你這個數字能不能拿來說話」。 這一篇要加一句:護欄計數器只保護標本,不保護量測順序。 要抓住量測順序的偏差,得有一條會直接問「同一個 mode 量兩次,數字一樣嗎」的測試。 那條測試現在存在了,而它本來應該從第一天就存在。
fixed-worker 本輪判不可重現(離散度 34.5%),該臂的 1.9× 不列為結論。
它的內部欄位(序列化 52fixed-observer 本輪判不可重現(離散度 54.5%,全距 6 幀、底線 5 幀)。
它是擦線,而擦線就是不過。10.3× 不列為結論。passive 那一格量到的零是儀器解析度的邊界,不是一般結論。要量 compositor 側
買到的東西得換一把尺,這一輪沒做。docs/open-questions.md 裡有 29 條登記檔與程式、與彼此之間的不一致還沒裁決。
其中兩條會影響本文引用的標本:標本 #6 的量測窗登記 10 秒而程式是 5 秒;
標本 #2 登記了一個現行 protocol 結構上量不到的指標。兩條都不改變本文的結論方向。docs/phase2-expected-results.md 的作廢清單裡。