← All reports

[JSC] Do not allocate promise reaction when it is initial `then` calls and one handler attachment

Component: JSC | 3f9955f

JSPromise는 이전까지 JSInternalFieldObjectImpl을 상속받는 구조였습니다. 이 base 클래스는 객체 상태를 고정 크기의 internal-fields 배열에 JSValue 형태로 저장하는데, WeakRefFinalizationRegistry와 동일한 모델입니다. 이 모델 하에서는 .then()을 호출할 때마다 callback과 child-promise pointer를 담은 별도의 JSPromiseReaction heap cell이 할당되었는데, 실제로 handler가 하나만 실행되는 경우에도 마찬가지였습니다. CompactPointerTuple은 cell pointer와 몇 개의 bit flag를 하나의 machine word에 packing하는 JSC의 관용적 기법입니다.

JSTests/stress/promise-inline-child-reaction.js

+// PATH 1 — inline-child: single .then(f), no allocation
+{
+ let d = defer();
+ let q = d.promise.then(v => { counter++; return v + 1; });
+ d.resolve(10);
+ shouldBe(await q, 11);
+}
+
+// PATH 2 — spill: second .then() converts inline storage to reaction list
+{
+ let d = defer();
+ let a = d.promise.then(v => v * 2);
+ let b = d.promise.then(v => v * 3); // triggers spillInlineReaction
+ let c = d.promise.then(v => v * 4);
+ d.resolve(5);
+ shouldBe(await a, 10);
+ shouldBe(await b, 15);
+ shouldBe(await c, 20);
+}
+
+// PATH 3 — two-arg .then(f,g): falls through to JSFullPromiseReaction, no inline
+{
+ let d = defer();
+ let q = d.promise.then(x => x + '!', e => 'err-' + e);
+ d.resolve('r');
+ shouldBe(await q, 'r!');
+}

이번 commit은 JSPromiseJSInternalFieldObjectImpl에서 일반 JSObject로 재구조화하고, 첫 번째 reaction을 promise 자체의 CompactPointerTuple 필드에 inline으로 저장하도록 변경합니다. Heap 할당은 두 번째 .then()이 들어올 때까지 지연되며, 이 시점에 spillInlineReaction이 inline cell을 JSSlimPromiseReaction linked list로 승격시킵니다. 새로 추가된 두 개의 DFG 노드 NewPromisePhantomNewPromiseJSFunction이 allocation sinking을 처리하는 방식을 그대로 미러링합니다. 이로써 컴파일러는 hot path에서 promise allocation을 제거할 수 있게 되며, PhantomNewPromise가 OSR-exit materialization에도 관여합니다.

Before (every .then()):
  JSPromise [JSInternalFieldObjectImpl]
    internalFields[0]: status/value  (JSValue)
    internalFields[1]: reactions ──► JSPromiseReaction (heap cell)
                                       fulfillHandler
                                       rejectHandler
                                       nextReaction → null

After — first .then(f)  [no heap allocation]:
  JSPromise [JSObject]
    flags:  status | InlineHandler
    packed: CompactPointerTuple
              cell ──► fulfillHandler (JSFunction*)
              bits:    InlineHandler flag

After — second .then() triggers spillInlineReaction:
  JSPromise [JSObject]
    flags:  status | Spilled
    packed: CompactPointerTuple
              cell ──► JSSlimPromiseReaction ──► JSSlimPromiseReaction ──► null

대부분을 차지하는 단일 handler 케이스에서, 이제 .then(f) 호출이 heap 할당을 전혀 거치지 않게 되면서 Promise가 많이 쓰이는 workload 전반에서 GC 부담이 줄어듭니다. 다만 그 대가로 코드 표면적이 넓어집니다. 새로운 inline reaction 상태 머신, spill 로직, 객체 레이아웃 변경, DFG 노드 확장이 한꺼번에 도입되었고, 이 모두가 JS 코드가 직접 다룰 수 있는 클래스 안에 놓이게 되었습니다.

가장 좁은 지점은 packed field 자체입니다. CompactPointerTuple은 raw JSCell*을 bit flag와 함께 하나의 word에 저장하는데, inlineReactionKind()를 먼저 확인하지 않고 cell을 읽는 코드가 있다면 단순 JSFunction handler를 JSSlimPromiseReaction의 head로 잘못 해석하거나 그 반대의 오류가 발생할 수 있습니다. payloadCell()setPackedCell()의 flag-masking 로직이 이 지점의 gate 역할을 하며, kind check 없이 packed word를 읽는 모든 지점이 의심 대상입니다. settleInlineInternalMicrotasksettleInlineHandler도 같은 문제를 안고 있습니다. 두 settler가 서로 다른 semantics로 하나의 storage word를 공유하기 때문에, masking이 잘못되면 엉뚱한 callback이 실행되거나 이중으로 settle되는 상황이 발생할 수 있습니다.

한 단계 더 넓혀보면 GC 영역이 있습니다. JSInternalFieldObjectImpl에서 벗어난다는 것은 internal-fields 배열이 더 이상 자동으로 visit되지 않는다는 의미입니다. 이제 packed cell pointer는 명시적인 visitChildrenImpl()을 통해 visit되어야 합니다. setPackedCell() 호출 사이에 inline-reaction 상태가 reachable하지만 아직 visit되지 않는 순간이 존재한다면, 그 사이 GC가 발생할 경우 handler나 child promise가 live 상태임에도 수거될 가능성이 있습니다. Spill 경로에도 시간 축에서 동일한 구조의 문제가 존재합니다. spillInlineReaction()은 inline cell을 heap list node로 승격시켜야 하는데, 기존 cell을 읽은 시점과 새 JSSlimPromiseReaction에 rooting하는 시점 사이에 GC가 발생하면 해당 cell이 수거되지 않도록 보장해야 합니다. 추가된 GC stress test는 바로 이 window를 정면으로 겨냥합니다. JSC에서 internal-fields 객체를 수작업 packed field로 대체하는 다른 지점에서도 동일한 패턴을 점검할 필요가 있습니다. Automatic visit에서 manual visit으로 전환되는 지점이 반복적으로 나타나는 위험 요소이며, 같은 diff 안에서 base 목록에서 JSInternalFieldObjectImpl이 제거되면서 raw cell 멤버가 추가되는 형태가 그 tell에 해당합니다.

가장 넓은 범위에서는, PhantomNewPromise가 scalar replacement와 OSR-exit object reconstruction에 관여한다는 점을 봐야 합니다. 따라서 phantom field와 실제 JSPromise 레이아웃(flags word, packed tuple) 사이의 매핑이 정확히 일치해야 합니다. Materialization 도중 field-offset이 어긋나면 flags가 손상되었거나 cell pointer가 dangling된 상태의 live promise가 만들어질 수 있고, promise가 settle되는 순간 JS에서 이 상태에 도달할 수 있습니다. DFG에서 수작업으로 레이아웃을 구성한 객체를 materialize하는 모든 phantom 노드가 이와 동일한 의무를 지닙니다. 해당 concrete type의 레이아웃이 변경될 때마다 PhantomNewFunction, PhantomNewObject를 비롯한 다른 materialization case들의 offset 일치 여부도 함께 점검할 필요가 있습니다.