修掉實驗設計再量一次,八格判決翻了六格

前端效能病理標本館 · 第二篇 全部數字:宣告 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

passesrectReads 兩欄現在對齊了 —— 兩臂做的是同一份工作。 wheelCancelableCount 從 21 掉到 0,證明旗標確實生效

而掉幀峰值 113 → 112,1.0×

第一篇量到 0.63× 的那個「改善」,整份都是「少做一次全掃」,不是 passive 買來的。 把工作量對齊之後,殘差是零。

⚠️ 但這句話有一個必須寫出來的邊界:這把尺量不到 passive 真正買到的東西。 droppedFrames 來自主執行緒的 rAF 迴圈,量的是主執行緒的出幀節奏; 而 passive 買到的是 compositor 側「不必等你的 handler 就能先捲」。 所以「量到一個零」是這把尺的解析度邊界的結論,不是「passive 沒用」的一般結論。 要量它得換一把尺,這一輪沒做。


三、從無到有:閘門先前一次都沒觸發過

同一支標本的治療二是 rAF 節流。第一篇的證據是 fixed-raffixed-passivepassesrectReads 逐輪一模一樣 —— 合併閘門一次都沒觸發,因為量測驅動器 每 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×


四、變大的那兩段

4-1 工作量對齊之後,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× 還大。

這件事值得停一下:混淆變因往哪個方向偏,不能用直覺猜。 第一篇已經抓到標本自己的註解把方向記反了一次(宣稱偏向對治療不利,實測是相反); 這一輪則是移除混淆變因之後效果變大 —— 也就是那個混淆變因其實在壓低療效。

4-2 虛擬滾動從 2.0× 變成 23.3×

這一格單獨一節,因為它的原因跟其他七格都不同。見第五節。


五、儀器不是只會高估,它把 23 倍壓成了 2 倍

第一篇給標本 #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 量兩次,數字一樣嗎」的測試。 那條測試現在存在了,而它本來應該從第一天就存在。


附錄:這篇文章沒有做到的事