[JSC] Add JSArray::fastSplice
Component: JSC Array builtins / DFG | d29218b
Source/JavaScriptCore/runtime/JSArray.cpp
Source/JavaScriptCore/dfg/DFGOperations.cpp
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
Significance
splice는 자주 호출되는 hot operation입니다. 이번 변경으로 host function과 DFG/FTL operation 양쪽 모두, 일반적인 경우에는 element 단위로 스펙 semantics를 따르는 generic 처리를 건너뛰게 되었습니다. DFG fast splice가 성공하면 MarkedArgumentBuffer는 아예 생성되지 않습니다. 이 복사가 fast path 실패 시점까지 미뤄지기 때문입니다. 또한 shift/unshift helper의 실패 시 동작 계약도 바뀌었습니다. 그 결과 호출자는 array가 수정되지 않은 상태로 generic path로 빠져나갈 수 있습니다.
Audit directions
핵심 builtin에 직접 작성된 in-place array 변경 코드가 새로 추가되었습니다. host function과 DFG operation 양쪽에서 도달 가능하며, 여러 전제 조건이 동시에 성립해야 안전하게 동작합니다.
- 보호되지 않는 item의 GC 안전성. DFG path는 더 이상 item을
MarkedArgumentBuffer로 복사하지 않습니다. 따라서 삽입되는 cell을 참조하는 것은 scratch buffer의 raw 값뿐입니다. 주석에는 user code가 실행될 수 없다고 적혀 있습니다. 하지만unshiftCountWithAnyIndexingType은 butterfly를 확장할 수 있고, 실제로 코드도 호출 직후 exception을 확인합니다. 확장에는 allocation이 수반되므로 GC나 OOM이 발생할 가능성이 있습니다. 호출 전 구간에서 scratch buffer가 scan되는지 확인할 필요가 있습니다. 또한gcSafeMemcpy와writeBarrier이전에 어떤 collection도 item을 관찰하거나 해제할 수 없는지 점검해야 합니다. - Write barrier. Int32 path는 barrier 없이 일반
memcpy를 사용합니다. 모든 item이 먼저 int32로 검증되기 때문에 성립하는 방식입니다. 반면 Contiguous path는gcSafeMemcpy로 raw cell을 기록한 뒤vm.writeBarrier(this)를 호출합니다. 어떤 early return 경로에서도 barrier가 누락되지 않는지 확인이 필요합니다. 한편 Double path는 NaN을 거부한 뒤asNumber로 item을 읽습니다. 이 경로에서 hole로 다시 읽히는 PNaN이 기록될 여지가 없는지도 살펴봐야 합니다. - Bounds 계산.
newLength는 항상MAX_STORAGE_VECTOR_LENGTH와 비교됩니다. 다만 tail 길이를MIN_SPARSE_ARRAY_INDEX와 비교하는 검사는itemCount != deleteCount일 때만 적용됩니다. 이후 값은shiftCount/unshiftCount에 전달되기 위해 unsigned로 truncate됩니다. 두 count가 같은 경우를 점검할 필요가 있는데, 이때는 COW를 처리하는ensureWritable만 실행됩니다. 또한 이러한 검사에도 불구하고start + itemCount/start + deleteCount가 uint32 범위를 넘을 수 있는지 확인해야 합니다. - Shift/unshift 실패 계약. helper는 이제
startIndex를 값으로 받습니다. 호출자는header + resultCount/header + currentCount를 전달하며,header32write-back은 제거되었습니다. generic builtin과 DFG path를 포함해 shift/unshift/splice의 모든 호출 지점을 점검해야 합니다. 특히 시도가 실패한 뒤 일부만 변경된 상태나 header 재사용을 전제로 하는 코드가 있는지 살펴볼 필요가 있습니다. release build에서는ASSERT_UNUSED(shifted/unshifted)호출이 false 반환을 잡아내지 못합니다. - DFG의 species 및 removed array 흐름.
fastSlice는fastSplice보다 먼저 실행되며,arraySpeciesWatchpointIsValid와!ignoreResult조건이 모두 충족될 때만 호출됩니다.didFastSlice는 exception 없이fastSlice를 시도했다면, null을 반환했더라도 설정됩니다. 이 경우 generic path는 FastPath slice 생성 블록을 건너뜁니다. 먼저fastSlice가 null을 반환했을 때 generic path가 무엇을 생성하는지 확인할 필요가 있습니다. non-null slice 이후fastSplice가 false를 반환하는 경우도 마찬가지입니다. species construction이 FastPath를 반환한다는 ASSERT가 성립하는지도 봐야 합니다. 마지막으로 slice와 splice 사이에 receiver가 바뀔 수 없는지 점검해야 합니다. setLengthearly return. non-ArrayStorage array에서 length가 바뀌지 않으면 이제 early return합니다. read-only length를 가질 수 있는 것은 ArrayStorage뿐이라는 주장에 근거한 변경입니다. 이 주장이 frozen, sealed, non-extensible array에서도 성립하는지 확인해야 합니다. 또한 기존setLength호출이 처리하던 다른 length 관련 상태도 함께 점검할 필요가 있습니다.