structuredClone in native

structuredClone은 객체를 deep copy 하는 API 이다. 만약 structuredClone 가 Node.js의 JavaScript 영역에 구현되어있다면 Native 영역으로 구현부를 이동시킬때 유의미한 성능향상이 있을 것이다. src: implement structuredClone 은 그런면에서 흥미로운 내용일 것이라 생각하고 리뷰해보았다.

기존 구현

Web Platform API인 structuredClone은 원래 global 영역에 노출되어있지 않은 API였다. Deep Copy를 JavaScript 에서 제공하지 않지만, Web Spec. 인 MessageChannel 이나 IndexedDB 같은 기능을 위해선 Object의 직렬화/역직렬화 기능은 필요할 수 밖에 없었다. 예를들어 Web Worker에서 한 Thread에서 다른 Thread로 전달하는 것은 서로 다른 JavaScript Context로 데이터를 넘기는 일이기 때문에 Deep Copy 기능이 필요하다.

Node.js 에는 structuredClone API 앞서 MessageChannel이 구현되어 있었는데 이를 활용한 hack 을 이용하면 structuredClone의 결과와 같은 Deep Copy를 구현할 수 있었다. 그래서 기존에는 아래와 같은 방식으로 구현되었었다.

structured_clone.jslink
13
14
15
16
17
18
19
20
21
22
23
24
25
function structuredClone(value, options = undefined) {
if (arguments.length === 0) {
throw new ERR_MISSING_ARGS('value');
}

// TODO: Improve this with a more efficient solution that avoids
// instantiating a MessageChannel
channel ??= new MessageChannel();
channel.port1.unref();
channel.port2.unref();
channel.port1.postMessage(value, options?.transfer);
return receiveMessageOnPort(channel.port2).message;
}

아이디어는 MessageChannel의 인스턴스를 생성하고 해당 channel로 데이터를 전달함으로서 그 과정에서 수행되는 structuredCloned 을 이용하는 것이다.

MessageChannel 내부

MessageChannel::postMessage(value[,{ transfer }]) 내부 구현에서 structuredClone의 구현은 아래와 같은 두개의 Step을 통해 구현된다.

  • Step 1. clone 하거나 transfer 해야할 객체를 리스트업 한다.
  • Step 2. 직렬화/역직렬화 과정을 수행한다.

기존 구현의 TODO로 기술되어있듯이 원 저자 역시 structuredClone을 위해 MessageChannel을 사용하는 것이 그렇게 효율적인 방법은 아니라는 것을 기술해 두었다. PR Author는 MessageChannel::postMessage()에서 상기 Step 1 동작을 수행하는 부분을 직접 JavaScript 영역에서 호출하면 MessageChannel을 이용하지 않을 수 있을 것이라 생각했다. 현재 코드 상에서 큰 리팩토링을 우려했지만 생각보다는 적은 수고로 가능한 내용이었다.

  • As-Is: structuredClone (JS)
    • ... (1) new MessageChannel
    • MessageChannel::postMessage (C++)
      • ... (2) MessageChannel 구현
      • Step 1
      • Step 2
      • ... (3)

위는 기존 구현부를 단순화한 내용이다. PR의 아이디어는 MessageChannel 와 관련된 위의 (1), (2), (3) 해당하는 부분을 제거한다. Step 1에 해당하는 부분을 MessageChannel 와 공유하고 Step 2 구현을 구현하여 structuredClone의 구현체를 단순화 시키는 내용이다.

Step 1

transfer를 해야할 객체의 리스트업을 다음 함수 GetTransferList로 분리한 뒤 새로 native side에서 구현한 structuredClone과 기존 MessageChannel::postMessage()에서 공동으로 사용하도록 한다.

1
2
3
4
5
6
bool GetTransferList(Environment* env,
Local<Context> context,
Local<Value> transfer_list_v,
TransferList* transfer_list_out) {
...
}

Step 2

이후 직렬화/역직렬화를 수행해 clone을 수행한다.

  • To-Be: structuredClone (JS)
    • internalBinding('messaging').structuredClone (C++)
      • Step 1
      • Step 2
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Local<Value> value = args[0];
TransferList transfer_list;
...

// step 1
GetTransferList(env, context, transfer_list_v, &transfer_list)
...

// step 2
Local<Value> result;
if (msg->Serialize(env, context, value, transfer_list, Local<Object>())
.IsNothing() ||
!msg->Deserialize(env, context, nullptr).ToLocal(&result)) {
return;
}

Step 3

직렬화/역질화 의 내부 알고리즘은 V8 내부에서 수행하며, src/node_messaging.cc 에는 Node.js에서 정의한 객체에 대한 clonning을 어떻게 할지가 정의되어 있다.

성능의 향상?

이 PR의 제목만 보면 JavaScript side에서 수행하는 Cloning을 native side로 이동시키면서 상당한 성능의 향상이 있을 것이란 생각이 들었다. 그러나 실제로는 MessageChannel의 instance 를 생성하는 부하만 줄이는 것이며 실제로 Cloning되는 메카니즘은 그대로이다. 그래서 structuredClone 자체의 성능은 더 개선할 내용이 있는 것이다.

GetTransferList 안은 ReadIterable이 구현되어있으며 transfer 목록을 순회하기 위해 iterator를 호출한다. 이 과정에서 JavaScript Side와 Native Side간 Context Switching 이 일어나 속도가 저하된다. 이 부분은 여전히 TODO로 남겨졌다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
static Maybe<bool> ReadIterable(Environment* env,
Local<Context> context,
// NOLINTNEXTLINE(runtime/references)
TransferList& transfer_list,
Local<Value> object) {
if (!object->IsObject()) return Just(false);

....

std::vector<Local<Value>> entries;
while (env->can_call_into_js()) {
Local<Value> result;

// 이곳의 while 문에서 C++ -> JavaScript 호출이 되며 성능 저하가 일어난다.
if (!next.As<Function>()->Call(context, iterator, 0, nullptr)
.ToLocal(&result)) return Nothing<bool>();

transfer_list.AllocateSufficientStorage(entries.size());
std::copy(entries.begin(), entries.end(), &transfer_list[0]);
return Just(true);
}

결과

structuredClone을 위해 MessageChannel instance가 생성되는 시간을 줄이고 기존에 JavaScript side에서 구현되어 snapshot에 포함되지 못했던 structuredClone 기능을 snapshot에 포함할 수 있었던 것이 해당 PR의 의미라고 할 수 있다.

1
2
// snapshot 생성시 포함되도록 등록
registry->Register(StructuredClone);

한가지 의문은 Web Spec.에 의하면 trasfer 는 An array of transferable objects that will be moved rather than cloned to the returned object. 이라고 정의 되어있는데 왜 iterable로서 순회해야하는지는 의문이다. Array 객체로서 순회하는 것은 V8 API로 존재하고 있기 때문에 iteratable interface에 따라 next() 호출을 해주지 않아도 구현이 가능할 것 같은데 말이다. 아무튼, iterable로 처리되야한다면 상기 언급했던 Step 1를 JavaScript 쪽에서 목록을 만들고 C++ 로 넘길 수 있으면 TODO로 남겨진 성능 저하를 개선 할 수 있을 것이라 생각된다.

관련 컨텐츠

참고

Share