← All reports

[JSC] Add JSArray::fastSplice

Component: JSC Array builtins / DFG | d29218b

Source/JavaScriptCore/runtime/JSArray.cpp

+bool JSArray::fastSplice(JSGlobalObject* globalObject, uint64_t length, uint64_t start, uint64_t deleteCount, std::span<const EncodedJSValue> items)
+{
+ ...
+ if (holesMustForwardToPrototype()) [[unlikely]]
+ return false;
+ ...
+ uint64_t newLength = length - deleteCount + itemCount;
+ if (newLength > MAX_STORAGE_VECTOR_LENGTH)
+ return false;
+ if (itemCount != deleteCount && length - start - deleteCount >= MIN_SPARSE_ARRAY_INDEX)
+ return false;
+ ...
+ else {
+ gcSafeMemcpy(butterfly->contiguous().data() + start, std::bit_cast<const WriteBarrier<Unknown>*>(items.data()), sizeof(JSValue) * items.size());
+ vm.writeBarrier(this);
+ }
+ return true;
+}

Source/JavaScriptCore/dfg/DFGOperations.cpp

+ std::span<const EncodedJSValue> items { buffer, itemCount };
+ // 여기서부터 fastSplice까지 user code가 실행될 수 없으므로, 복사 없이 scratch buffer에서 item을 읽을 수 있습니다.
+ if (arraySpeciesWatchpointIsValid(vm, base)) {
+ ...
+ bool spliced = base->fastSplice(globalObject, length, actualStart, actualDeleteCount, items);

JSC는 array element를 butterfly에 저장하며, 그 layout은 indexing type(Int32, Double, Contiguous, ArrayStorage)에 따라 달라집니다. 이때 hole은 특수한 값으로 인코딩됩니다. Double array에서는 PNaN이, 나머지 type에서는 empty JSValue가 사용됩니다. 문제는 스펙상 splice가 HasProperty/Get을 사용한다는 점입니다. 그래서 hole을 읽으면 Array.prototype이나 Object.prototype까지 조회가 이어질 수 있고, 이 과정에서 getter나 proxy가 실행될 수 있습니다.

JSC는 이 상황을 holesMustForwardToPrototype로 추적합니다. 이 값이 false이면 hole을 단순히 비어 있는 element로 취급할 수 있습니다. 이 경우 splice는 사용자에게 관찰되는 side effect 없이 element를 제자리에서 이동할 수 있습니다. 한편 shift/unshift count helper는 이전부터 element를 옮기기 전에 hole 여부를 확인하고 있었습니다.

이 commit은 Int32, Double, Contiguous array를 대상으로 제자리에서 splice를 수행하는 JSArray::fastSplice를 추가했습니다. 먼저 indexing type이 이 세 가지가 아니거나, length가 butterfly의 public length와 일치하지 않으면 false를 반환합니다. 그러면 호출자는 generic path로 fallback합니다. 이 검사를 통과한 뒤, 아무것도 바뀌지 않는 splice라면 즉시 true를 반환합니다.

그 외의 경우에도 다음 조건 중 하나에 해당하면 false를 반환합니다. 먼저 holesMustForwardToPrototype가 설정된 경우입니다. 삽입할 item이 type에 맞지 않는 경우도 해당합니다. Int32 array에는 Int32가 아닌 값, Double array에는 숫자가 아니거나 NaN인 값이 이에 속합니다. 새 length가 MAX_STORAGE_VECTOR_LENGTH를 초과하는 경우도 마찬가지입니다. 또한 item 수와 delete 수가 다를 때 tail 길이가 MIN_SPARSE_ARRAY_INDEX에 도달하면 false를 반환합니다.

모든 검사를 통과하면 shiftCountWithAnyIndexingType / unshiftCountWithAnyIndexingType으로 tail을 이동합니다. 이 범위 안에서는 helper 호출이 성공한다고 assert되어 있습니다. 이후 item을 butterfly에 직접 기록합니다. helper 쪽도 수정되어, hole 때문에 실패할 경우 array를 변경하지 않은 상태로 남겨 둡니다. 이 동작은 helper를 사용하는 generic 호출자에게 중요합니다.

호출 측을 보면, arrayProtoFuncSplice는 call frame의 인자를 ArgList로 전달합니다. 반면 DFG의 arraySpliceImpl은 scratch buffer의 item을 span 형태로 그대로 전달합니다. MarkedArgumentBuffer는 fast path가 거절된 뒤에야 생성됩니다. 또한 species watchpoint가 유효하면 제거된 element로 구성할 array를 위해 JSArray::fastSlice도 호출합니다. 다만 결과가 사용되는 경우(!ignoreResult)에만 해당합니다.

Host function (arrayProtoFuncSplice):
  splice(start, del, ...items)
    ├─► result array built (generic species path)
    ├─► isJSArray(this)? ──► JSArray::fastSplice(items from ArgList)
    │                          ├─ true  ─► return result
    │                          └─ false ─► generic shift/unshift + write items

DFG/FTL (arraySpliceImpl):
  splice(start, del, ...items)   items stay in scratch buffer (span, no copy)
    └─► arraySpeciesWatchpointIsValid?
          ├─ no ──────────────────────────────► generic path
          └─ yes
              ├─ !ignoreResult: JSArray::fastSlice(removed)
              │     └─ null ──────────────────► generic path
              └─ ignoreResult || slice ok:
                    JSArray::fastSplice(items span)
                      ├─ true  ─► return (undefined | slice result)
                      └─ false ─► generic path
  generic path (DFG): items copied to MarkedArgumentBuffer now, then species construct,
                      removed-array slice skipped if didFastSlice, shift/unshift + write items

splice는 자주 호출되는 hot operation입니다. 이번 변경으로 host function과 DFG/FTL operation 양쪽 모두, 일반적인 경우에는 element 단위로 스펙 semantics를 따르는 generic 처리를 건너뛰게 되었습니다. DFG fast splice가 성공하면 MarkedArgumentBuffer는 아예 생성되지 않습니다. 이 복사가 fast path 실패 시점까지 미뤄지기 때문입니다. 또한 shift/unshift helper의 실패 시 동작 계약도 바뀌었습니다. 그 결과 호출자는 array가 수정되지 않은 상태로 generic path로 빠져나갈 수 있습니다.

핵심 builtin에 직접 작성된 in-place array 변경 코드가 새로 추가되었습니다. host function과 DFG operation 양쪽에서 도달 가능하며, 여러 전제 조건이 동시에 성립해야 안전하게 동작합니다.