問題回顧#

第八章結尾:每次 method dispatch / 每次讀 ivar 都是 hash lookup。對動態語言這是最大效能殺手。

V8、JavaScriptCore、TruffleRuby、PyPy 解這題的核心兩個技術:

  1. Shape(hidden class):給每個物件一個結構描述,把 hash lookup 變 array index
  2. Inline cache:在 call site 紀錄上次 lookup 結果,下次直接用

兩者組合是動態語言「第二代加速」的基石。

Shape / Hidden Class#

想法:「結構相同的物件共享同一個 layout 描述」。

let p = {};
p.x = 1; // shape S1: { x }
p.y = 2; // shape S2: { x, y }

let q = {};
q.x = 10; // shape S1: { x }
q.y = 20; // shape S2: { x, y }   ← 與 p 同 shape

V8 維護一棵 shape 樹:

empty shape
   │ +x
   ▼
{x}
   │ +y
   ▼
{x, y}
   │ +z
   ▼
{x, y, z}

每個物件記住自己的 shape,shape 知道每個 property 在物件裡的 offset:

struct Shape {
    int     property_count;
    HashMap properties;        // name → slot index
    Shape  *parent;            // 加了哪個 property 來的
    HashMap transitions;       // 加 property 後變哪個 shape
};

struct Object {
    Object  obj;
    Shape  *shape;
    Value   slots[];           // 對齊 shape 的 layout
};

p.x

1. 看 p 的 shape
2. shape.properties["x"] → slot index = 0
3. p.slots[0]

關鍵:新增 property 時 shape transition ── 走 transition table,找到「empty + x」對應的 shape,把物件 shape 換掉。

兩個 shape 相同的物件 ➡️ 完全相同 layout ➡️ 任何「假設這個 shape」的 code 都對它有效。這就為 inline cache 鋪了路。

Monomorphic Inline Cache(基本款)#

callsite:

function getX(p) {
  return p.x;
}

第一次執行時 IC 是空的:

[ unfilled ]

執行一次後紀錄:

[ shape = S1, slot = 0 ]

下次再執行 getX(q) 時:

if (q->shape == cache.shape) {
    return q->slots[cache.slot];   // 一條 indexed load!
} else {
    // 慢路徑:full lookup,更新 cache
}

預測 monomorphic(單一 shape)成功率高時 ── 效能直逼靜態語言。錯誤的 shape 觸發更新或 polymorphic。

Polymorphic Inline Cache(PIC)#

如果 callsite 看到 2~4 種不同 shape:

[ S1 → slot 0 ]
[ S2 → slot 1 ]
[ S3 → slot 0 ]
[ S4 → slot 2 ]

每次走線性檢查(最多 4 次比較)。仍比 hash lookup 快得多。

超過 4 種 ➡️ 標記為 megamorphic,退回 full lookup(hash table)。megamorphic call site 對效能很糟,是調優時要避免的。

為什麼 4 而不是更多#

V8 / JSC 都選 4 是 trade-off:

  • 太少(1):稍微多型就退化
  • 太多(10+):線性比較開銷接近 hash lookup
  • 4 涵蓋常見場景:subtype 多型、常見的「2~3 種變體」

寫操作的 IC:transition cache#

p.x = 1 時:

  • p.shape 已有 x ➡️ 寫對應 slot
  • 若沒有 ➡️ shape transition

第二種情況的 IC:紀錄「shape S1 + 寫 x ➡️ 變 shape S2」,下次直接套用。

Method dispatch 的 IC#

物件 method 也一樣 ── method 屬於 class,class 屬於 shape:

arr.map(...)

callsite IC 紀錄:

[ class = Array, method addr = &Array.prototype.map ]

下次:

if (receiver->class == cache.class) {
    call(cache.method);   // 直接 call,不查 method table
}

monomorphic dispatch(這個 callsite 永遠收到 Array)效果極好。

V8 的具體例子#

function Point(x, y) {
  this.x = x;
  this.y = y;
}

const p = new Point(1, 2);
// p 的 shape:[ Map_Point_xy ]
//   x → slot 0
//   y → slot 1

function dist(a, b) {
  const dx = a.x - b.x;
  const dy = a.y - b.y;
  return Math.sqrt(dx * dx + dy * dy);
}

dist(p, p2); // a.x、b.x 等存取都 monomorphic IC

如果你建構物件時不一致:

const p1 = new Point(1, 2); // shape A: [x, y]
const p2 = { y: 1, x: 2 }; // shape B: [y, x]   ← 不同!

p1.x 在 slot 0、p2.x 在 slot 1 ── 同一個 callsite 看到兩種 shape,變 polymorphic。

效能調優 tip:永遠以同樣順序初始化欄位、用 constructor 而非 literal 加欄位、避免後續 delete property(會觸發 shape 退化)。

反向工程:為什麼 V8 對某些寫法特別快#

// 慢:建好物件後又動態加欄位
const obj = {};
for (...) {
    obj.field1 = ...;   // shape transition
    obj.field2 = ...;   // 又一次
}

// 快:constructor 一次建全
function MyObj(a, b) {
    this.field1 = a;
    this.field2 = b;
}
const obj = new MyObj(1, 2);

第二個版本:所有 instance 同 shape,很多優化生效。

TruffleRuby 與 Self 的歷史#

shape / hidden class 概念來自 1989 的 Self 語言(Smalltalk 的後代)。Self 是第一個證明動態語言可以接近 C 效能的研究。

V8 直接吸收 Self 的設計(Lars Bak 同時是 Self、HotSpot、V8 的設計者)。 TruffleRuby 把同概念再帶到 Ruby。CRuby 從 3.2 開始也加了 “Object Shape” 機制。

對 VM 設計的意義#

如果你正在寫一個動態語言 VM:

  1. value 表示 + shape 機制 + inline cache 是三個基石
  2. shape 不需要 JIT 也能用 ── 在直譯器層就有顯著加速
  3. IC 是後加的(先有 shape)── 最簡單形式幾百行 C 就能寫
  4. 這三件做完,效能可從「Python 級」直奔「LuaJIT 級」

實務上小語言(mruby、Wren)為了簡潔常不做 shape,只做最簡 hash-based ivar。教學目的足夠,效能放棄。

global / module 變數的 IC#

不只 instance ivar、不只 method dispatch ── global 與 module 常數也適用 IC:

import math
math.sqrt(2)

math.sqrt 每次呼叫都查 module dict?太慢。Python 3.11 引入 module attribute 的 inline cache:第一次 lookup 後 cache (module_version, sqrt_function)。如果 module 沒被改,後續直接走 cache。

模組版本 ➡️ 任何修改都增加 module.version ➡️ cache 失效。

Cache 失效的成本#

shape transition、class 改 method、module 修改 ── 都會讓 IC 失效。

頻繁失效的 callsite 等同沒有 IC。所以:

  • 動態加 method 雖然語言支援,但效能上是「核子選項」
  • 在熱路徑上 monkey-patch class ➡️ 大量 IC 失效,可能反而比一開始就走通用路徑慢

小結#

  • shape:把「dynamic property bag」轉成「結構描述 + 固定 slot 陣列」
  • IC:把「上次 lookup 結果」cached 在 callsite,monomorphic 時飛快
  • 兩者組合是動態語言不再慢的關鍵
  • monomorphic > polymorphic (4) > megamorphic
  • 程式風格(一致初始化、避免 monkey-patch)對效能影響顯著

下章看記憶體管理 ── GC 是這整套體系的最後一塊。