← All reports

[JSC] Extract fields of descriptor in Object.defineProperty in DFG / FTL

Component: JSC | 93e2909

JSC의 optimizing pipeline은 두 개의 상위 tier로 구성됩니다. Mid-tier dataflow-graph JIT인 DFG와, B3 backend 위에 구축된 top tier FTL입니다. Object.defineProperty는 지금까지 두 tier 모두에게 불투명한 대상이었습니다. Descriptor가 일반 object이다 보니, compiler가 그 필드를 꿰뚫어 볼 수 없었기 때문입니다. Watchpoint는 JSC가 특정 가정을 전제로 컴파일하고, 그 가정이 깨지는 순간 컴파일된 코드를 무효화하는 메커니즘입니다.

JSTests/microbenchmarks/object-define-property-put-by-id-direct.js

+function bench() {
+ var sum = 0;
+ for (var i = 0; i < 1000000; i++) {
+ var obj = {};
+ Object.defineProperty(obj, "prop",
+ { value: i, writable: true, enumerable: true, configurable: true });
+ sum += obj.prop;
+ }
+ return sum;
+}

JSTests/stress/object-define-property-fields-refinement.js

+// testExistingPropertyDoesNotLower — property already exists: must NOT lower
+// testIndexedPropertyDoesNotLower — indexed key: must NOT lower
+// testCrossRealm — foreign-realm descriptor: must NOT lower
+// testAccessorOnValue — accessor field on data desc: must NOT lower

이 commit은 두 개의 watchpoint를 사용합니다. Object.prototype이 lookup에 영향을 주는 방식으로 변경되지 않았음을 보장하는 "sane chain" watchpoint와, descriptor field watchpoint입니다. 이 두 가지를 통해 컴파일 시점에 descriptor가 알려진 structure를 갖는다는 사실을 증명합니다. 이 증명이 성립하면, DFG는 각 descriptor 필드(enumerable, configurable, value, writable, get, set)를 직접 읽어오는 GetByOffset 노드를 삽입합니다. 그러면 새로 도입된 ObjectDefinePropertyFromFields IR 노드를 통해 해당 값들이 후속 최적화 단계에 노출됩니다. 이어서 FTL의 allocation-sinking pass가 descriptor object의 allocation 자체를 완전히 제거할 수 있게 됩니다. 모든 attribute가 default 값이고 base에 해당 key의 기존 property가 없는 경우에는, 이 노드가 한 단계 더 축약되어 PutByIdDirect로 귀결됩니다. 이 경우 defineProperty 처리 로직 전체를 건너뛰게 됩니다.

ObjectDefineProperty(obj, key, desc)   [DFG, given known descriptor struct + watchpoints]
         │
         ▼
ObjectDefinePropertyFromFields(obj, key, e, c, v, w, get, set)
  each field = GetByOffset(desc, field-offset)  OR  empty (absent)
         │
         ├─[e=true, c=true, w=true, no existing prop, non-indexed key]
         │         └──► PutByIdDirect(obj, key, value)   ← IC fast path, ~12x faster
         │
         ├─[data descriptor, any attr differs]
         │         └──► DefineDataProperty
         │
         └─[accessor descriptor]
                   └──► DefineAccessorProperty

Default attribute인 경우 PutByIdDirect로 축약되어 inline-cache fast path를 타게 되며, 일반적인 descriptor 호출 대비 최대 11.9배 빠른 속도를 냅니다. 이 결과에 도달하기 위해서는 Object.defineProperty가 여러 IR lowering 단계에 걸쳐 컴파일되는 방식 자체를 다시 써야 했습니다. Watchpoint 로직, structure assumption, property-storage semantics, FTL allocation sinking까지 모두 손이 닿은 영역입니다.

좁게 보면, tryFoldDefineDataPropertyToPutByIdDirectPutByIdDirect 전제 조건들 — 기존 property가 없을 것, non-indexed key일 것, 모든 attribute가 true일 것 — 은 컴파일 시점에 PropertyStatus를 통해 structural하게 검사됩니다. IC stub이 생성된 이후에도 어떤 runtime 경로도 이 조건들을 위반할 수 없는지 확인할 필요가 있습니다. 후보로는 Proxy로 감싸진 target, non-indexed 검사를 통과하는 numeric string key를 가진 object, prototype-chain lookup을 들 수 있습니다. 이런 문제를 알아보는 단서는, 컴파일 시점의 status.isFound() guard가 emit된 stub 안에서 이에 대응하는 runtime 재검증 없이 사용되는 패턴입니다.

넓게 보면, watchpoint의 범위 자체를 살펴볼 필요가 있습니다. 이 최적화는 Object.prototype에 대한 sane-chain watchpoint와 descriptor-field watchpoint를 전제로 컴파일됩니다. Prototype 변경이 invalidation을 트리거하지 못하는 경우 — non-enumerable property, Symbol property, chain에 끼어든 Proxy 등 — 가 문제가 됩니다. 이 경우 JIT는 descriptor에 대한 GetByOffset을 통해 낡은 structure offset을 계속 읽게 되고, 결과적으로 잘못된 slot을 읽거나 범위를 벗어난 읽기가 발생할 수 있습니다. Cross-realm descriptor는 서로 다른 Object.prototype을 갖는 명시적인 stress-test case입니다. Realm이 동일하다고 가정하지 말고, watchpoint의 범위가 이 경우까지 실제로 커버하는지 검증해야 합니다. 이와 인접한 지점으로 "empty"와 "present-but-undefined"의 구분 문제가 있습니다. 각 필드는 GetByOffset(desc, offset) 또는 empty로 추출되는데, structure 분석에서 실제로는 present-but-undefined인 필드를 absent로 잘못 판단하면 defineProperty에 잘못된 attribute bit가 전달됩니다. 이 경우 non-writable이나 non-configurable property가 조용히 만들어질 수 있습니다. Accessor 필드와 data 필드가 겹치는 지점 — 같은 descriptor slot 안의 get/set offset과 value/writable offset — 이 바로 이런 혼동이 발생할 만한 위치입니다.

가장 넓게 보면, FTL의 allocation sinking을 점검할 필요가 있습니다. FTL은 모든 사용처가 compiler가 인식 가능한 GetByOffset일 때 descriptor allocation을 제거합니다. Sink된 allocation이 추적되지 않는 경로로 escape하는 경우 — defineProperty 내부에서 던져지는 exception, OSR exit, object를 다시 materialize하는 bailout 등 — escape-analysis 실패로 인해 use-after-free나 잘못된 타입의 materialization이 발생할 수 있습니다. ObjectDefinePropertyFromFields가 slow-path transition에서 escape point로 표시되어 있는지 확인해야 합니다. 그리고 sink 가능한 allocation을 사용하면서 runtime call로 전환될 수 있는 다른 모든 노드에도 같은 질문을 적용해 볼 필요가 있습니다.