원문: How we made JSON.stringify more than twice as fast — V8 Blog원문은 CC-BY-3.0 라이선스로 제공되며, 이 글은 한국어로 번역한 2차 저작물입니다.
JSON.stringify는 데이터를 직렬화하는 핵심 JavaScript 함수입니다. 이 함수의 성능은 네트워크 요청을 위한 데이터 직렬화부터 localStorage에 데이터를 저장하는 등 웹 전반의 일반적인 작업에 직접적인 영향을 미칩니다. 더 빠른 JSON.stringify는 더 빠른 페이지 상호작용과 더 반응적인 애플리케이션으로 이어집니다. V8의 JSON.stringify가 최근 엔지니어링 노력을 통해 2배 이상 빨라졌음을 기쁜 마음으로 공유합니다. 이 글에서는 이러한 개선을 가능하게 한 기술적 최적화 과정을 상세히 설명합니다.부작용 없는 빠른 경로
이 최적화의 기반은 간단한 전제에 구축된 새로운 빠른 경로입니다. 객체 직렬화가 부작용을 유발하지 않는다고 보장할 수 있다면, 훨씬 빠르고 특화된 구현을 사용할 수 있습니다. 이 맥락에서 "부작용"이란 객체의 단순하고 간결한 순회를 방해하는 모든 것을 의미합니다. 여기에는 직렬화 중에 사용자 정의 코드를 실행하는 명백한 경우뿐만 아니라, 가비지 컬렉션 주기를 트리거할 수 있는 더 미묘한 내부 작업도 포함됩니다. 부작용을 유발할 수 있는 것과 이를 방지하는 방법에 대한 자세한 내용은 [제한 사항]을 참조하세요.
V8이 직렬화가 이러한 효과에서 벗어날 수 있다고 판단하는 한, 이 고도로 최적화된 경로를 유지할 수 있습니다. 이를 통해 범용 직렬화기가 요구하는 많은 비용이 많이 드는 검사와 방어 로직을 우회할 수 있으므로, 일반적인 순수 데이터 JavaScript 객체 유형에 대해 상당한 속도 향상을 얻을 수 있습니다.
또한, 새로운 빠른 경로는 재귀적인 범용 직렬화기와 달리 반복적입니다. 이 아키텍처 선택은 스택 오버플로 검사의 필요성을 제거하고 [다른 문자열 표현 처리] 후 신속하게 재개할 수 있도록 할 뿐만 아니라, 개발자가 이전에 가능했던 것보다 훨씬 더 깊이 중첩된 객체 그래프를 직렬화할 수 있도록 합니다.
다른 문자열 표현 처리
V8의 문자열은 1바이트 또는 2바이트 문자로 표현될 수 있습니다. 문자열에 ASCII 문자만 포함된 경우 V8에서 1바이트당 1바이트를 사용하는 1바이트 문자열로 저장됩니다. 그러나 문자열에 ASCII 범위를 벗어나는 단일 문자라도 포함되면 문자열의 모든 문자가 2바이트 표현을 사용하게 되어 메모리 사용량이 거의 두 배가 됩니다.
통합 구현의 지속적인 분기 및 유형 검사를 피하기 위해 전체 문자열 처리기가 이제 문자 유형에 대해 템플릿화되었습니다. 즉, 직렬화기의 두 가지 고유하고 특화된 버전을 컴파일합니다. 하나는 1바이트 문자열에 대해 완전히 최적화되었고, 다른 하나는 2바이트 문자열에 대해 최적화되었습니다. 이는 바이너리 크기에 영향을 미치지만, 성능 향상이 확실히 가치가 있다고 생각합니다.
구현은 혼합 인코딩을 효율적으로 처리합니다. 직렬화 중에 빠른 경로에서 처리할 수 없는 표현(예:
ConsString, 평탄화 중에 GC를 트리거할 수 있음)을 감지하기 위해 각 문자열의 인스턴스 유형을 검사해야 하며, 이는 느린 경로로 대체되어야 합니다. 이 필요한 검사 또한 문자열이 1바이트 또는 2바이트 인코딩을 사용하는지 여부를 드러냅니다.이로 인해 낙관적인 1바이트 문자열 처리기에서 2바이트 버전으로 전환하는 결정은 거의 무료입니다. 이 기존 검사에서 2바이트 문자열이 발견되면 새로운 2바이트 문자열 처리기가 생성되고 현재 상태를 상속합니다. 마지막으로, 초기 1바이트 문자열 처리기의 출력과 2바이트 문자열 처리기의 출력을 단순히 연결하여 최종 결과가 구성됩니다. 이 전략은 일반적인 경우에 고도로 최적화된 경로를 유지하도록 보장하는 동시에 2바이트 문자를 처리하기 위한 전환은 가볍고 효율적입니다.
SIMD를 사용한 문자열 직렬화 최적화
JSON으로 직렬화할 때 이스케이프가 필요한 문자를 JavaScript 문자열은 포함할 수 있습니다 (예:
" 또는 \). 이를 찾는 전통적인 문자 단위 루프는 느립니다.이를 가속화하기 위해 문자열 길이에 기반한 2단계 전략을 사용합니다.
- 긴 문자열의 경우, 전용 하드웨어 SIMD 명령어(예: ARM64 Neon)로 전환합니다. 이를 통해 훨씬 더 큰 문자열 청크를 넓은 SIMD 레지스터로 로드하고 몇 가지 명령만으로 여러 바이트를 한 번에 검사하여 이스케이프 가능한 문자를 찾을 수 있습니다. (source)
- 하드웨어 명령어의 설정 비용이 너무 높은 짧은 문자열의 경우, **SWAR(SIMD Within A Register)**이라는 기술을 사용합니다. 이 접근 방식은 일반적인 범용 레지스터에서 정교한 비트 단위 논리를 사용하여 매우 낮은 오버헤드로 여러 문자를 한 번에 처리합니다. (source)
어떤 방법을 사용하든 이 프로세스는 매우 효율적입니다. 청크 단위로 문자열을 빠르게 스캔합니다. 청크에 특수 문자가 포함되어 있지 않으면(일반적인 경우), 전체 문자열을 복사하기만 하면 됩니다.
빠른 경로의 고속 차선
주요 빠른 경로 내에서도 더 빠르고 '고속 차선'이 될 수 있는 기회를 발견했습니다. 기본적으로 빠른 경로는 여전히 객체의 속성을 반복해야 하며, 각 키에 대해 일련의 검사를 수행해야 합니다. 키가
Symbol이 아닌지 확인하고, 열거 가능한지 확인하고, 마지막으로 이스케이프가 필요한 문자(예: " 또는 \)에 대해 문자열을 검색합니다.이를 제거하기 위해 객체의 숨겨진 클래스에 플래그를 도입했습니다. 객체의 모든 속성을 직렬화한 후, 속성 키가
Symbol이 아니고, 모든 속성이 열거 가능하며, 속성 키에 이스케이프가 필요한 문자가 포함되어 있지 않은 경우 해당 숨겨진 클래스를 fast-json-iterable로 표시합니다.이전에 직렬화한 객체와 동일한 숨겨진 클래스를 가진 객체를 직렬화할 때 (예: 동일한 모양을 가진 객체 배열) 그리고 fast-json-iterable인 경우, 추가 검사 없이 모든 키를 문자열 버퍼에 복사할 수 있습니다.
또한
JSON.parse에도 이 최적화를 추가했으며, 배열을 파싱하는 동안 빠른 키 비교에 사용할 수 있습니다. 이는 배열 내의 객체가 종종 동일한 숨겨진 클래스를 가진다고 가정하기 때문입니다.더 빠른 double-to-string 알고리즘
숫자를 문자열 표현으로 변환하는 것은 놀라울 정도로 복잡하고 성능에 중요한 작업입니다.
JSON.stringify 작업의 일환으로 핵심 DoubleToString 알고리즘을 업그레이드하여 이 프로세스를 크게 가속화할 기회를 식별했습니다. 이제 오랫동안 사용된 Grisu3 알고리즘을 Dragonbox로 교체하여 가장 짧은 길이의 숫자-문자열 변환을 수행합니다.이 최적화는
JSON.stringify 프로파일링에 의해 주도되었지만, 새로운 Dragonbox 구현은 V8 전반의 모든 Number.prototype.toString() 호출에 이점을 제공합니다. 이는 JSON 직렬화뿐만 아니라 숫자를 문자열로 변환하는 모든 코드에서 성능 향상을 무료로 볼 수 있다는 것을 의미합니다.기본 임시 버퍼 최적화
모든 문자열 구축 작업에서 상당한 오버헤드 소스는 메모리 관리 방식입니다. 이전에는 문자열 처리기가 C++ 힙의 단일 연속 버퍼에 출력을 구축했습니다. 간단하지만 이 접근 방식에는 상당한 단점이 있습니다. 버퍼 공간이 부족할 때마다 더 큰 버퍼를 할당하고 전체 기존 콘텐츠를 복사해야 했습니다. 대규모 JSON 객체의 경우, 재할당 및 복사 주기가 상당한 성능 오버헤드를 발생시켰습니다.
중요한 통찰은 임시 버퍼를 연속으로 강제하는 것이 실제 이점을 제공하지 않는다는 것이었습니다. 최종 결과는 매우 나중에 단일 문자열로 조립되기 때문입니다.
이를 염두에 두고 기존 시스템을 분할 버퍼로 교체했습니다. 하나의 크고 확장되는 메모리 블록 대신, 이제 V8의 Zone 메모리에 할당된 작은 버퍼(또는 "세그먼트") 목록을 사용합니다. 세그먼트가 가득 차면 새 세그먼트를 할당하고 계속해서 쓰기만 하면 되므로 비용이 많이 드는 복사 작업이 완전히 제거됩니다.
제한 사항
새로운 빠른 경로는 일반적이고 간단한 경우를 전문화하여 속도를 달성합니다. 직렬화되는 데이터가 이러한 기준을 충족하지 않으면, V8은 정확성을 보장하기 위해 범용 직렬화기로 대체됩니다. 전체 성능 이점을 얻으려면
JSON.stringify 호출이 다음 조건을 준수해야 합니다.
또는replacer
인수 없음:space
함수 또는 보기 좋게 인쇄하기 위한replacer
/space
인수를 제공하는 것은 범용 경로에서만 처리되는 기능입니다. 빠른 경로는 압축된 비변환 직렬화를 위해 설계되었습니다.gap- 순수 데이터 객체 및 배열: 직렬화되는 객체는 간단한 데이터 컨테이너여야 합니다. 즉, 객체와 해당 프로토타입에는 사용자 정의
메서드가 없어야 합니다. 빠른 경로는 사용자 정의 직렬화 로직이 없는 표준 프로토타입(예:.toJSON()
또는Object.prototype
)을 가정합니다.Array.prototype - 객체에 인덱스 속성 없음: 빠른 경로는 정규 문자열 기반 키를 가진 객체에 최적화되어 있습니다. 객체가 배열과 유사한 인덱스 속성(예:
)을 포함하는 경우, 느리고 더 일반적인 직렬화기에서 처리됩니다.'0', '1', ... - 간단한 문자열 유형: 일부 내부 V8 문자열 표현(예:
)은 직렬화하기 전에 평탄화하기 위해 메모리 할당이 필요할 수 있습니다. 빠른 경로는 이러한 할당을 트리거할 수 있는 모든 작업을 피하고 간단하고 순차적인 문자열에 가장 적합합니다. 이는 웹 개발자가 영향을 미치기 어려운 부분입니다. 하지만 대부분의 경우 문제가 없을 것입니다.ConsString
API 응답을 위한 데이터 직렬화 또는 구성 객체 캐싱과 같은 대부분의 사용 사례에서는 이러한 조건이 자연스럽게 충족되므로 개발자는 성능 개선의 이점을 자동으로 누릴 수 있습니다.
결론
JSON.stringify를 최상위 논리부터 핵심 메모리 및 문자 처리 작업까지 처음부터 재고함으로써, JetStream2 json-stringify-inspector 벤치마크에서 측정된 2배 이상의 성능 개선을 제공했습니다. 다른 플랫폼에서의 결과는 아래 그림을 참조하세요. 이러한 최적화는 V8 버전 13.8(Chrome 138)부터 사용할 수 있습니다.JetStream2 결과
