Skip to content

Commit ca0ca58

Browse files
lavenzgfacebook-github-bot
authored andcommitted
SIMD-accelerate JSON string scanning in JSONLexer
Summary: Add SIMD-optimized character scanning functions (scanSpecialCharU8, scanSpecialCharU16) that find the first byte/char16_t that is '"', '\\', or <= 0x1F. Use these in JSONLexer::scanString() to skip over runs of normal characters in bulk during the escape-free hot loop. The SIMD scan applies only to ASCII and UTF16 encodings (where UsesRawPtr is true and we have a contiguous buffer with known bounds). UTF8 uses UTF16Stream which is not a raw pointer and is unaffected. Jenkins hash is handled separately: after SIMD skips a range of normal characters, the hash is computed over that range in a scalar loop, keeping the SIMD scan free of data dependencies. Implementation tiers: - NEON (aarch64): 16 bytes / 8 char16_t per iteration - SSE2 (x86-64): 16 bytes / 8 char16_t per iteration - Scalar fallback for all other platforms Differential Revision: D93549305
1 parent 5477e1c commit ca0ca58

6 files changed

Lines changed: 344 additions & 0 deletions

File tree

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
/**
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*
7+
* @format
8+
*/
9+
10+
// Benchmark for JSON.stringify and JSON.parse with string values.
11+
// Exercises the string quoting/scanning hot paths where SIMD can
12+
// skip over long runs of non-escape characters in bulk.
13+
14+
var start = Date.now();
15+
(function () {
16+
var numIter = 200000;
17+
18+
// Build strings with long runs of normal characters and occasional
19+
// escape characters to exercise both the SIMD bulk skip and the
20+
// scalar escape handling.
21+
var shortStr = 'hello world';
22+
var base = 'abcdefghijklmnopqrstuvwxyz0123456789';
23+
var longStr = '';
24+
for (var i = 0; i < 8; i++) {
25+
longStr += base;
26+
}
27+
// 296 chars, no escapes.
28+
29+
var longStrWithEscapes = '';
30+
for (var i = 0; i < 8; i++) {
31+
// 35 normal chars then a quote and a backslash.
32+
longStrWithEscapes += 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789' + '"\\';
33+
}
34+
longStrWithEscapes = longStrWithEscapes.substring(0, 256);
35+
36+
// An object with multiple string fields, typical of real payloads.
37+
var obj = {
38+
name: 'John Doe',
39+
address: '123 Main St, Anytown, USA 12345',
40+
bio: longStr.substring(0, 64),
41+
notes: longStrWithEscapes.substring(0, 64),
42+
};
43+
44+
var sum = 0;
45+
for (var i = 0; i < numIter; i++) {
46+
// Stringify exercises quoteStringForJSON.
47+
var s1 = JSON.stringify(shortStr);
48+
var s2 = JSON.stringify(longStr);
49+
var s3 = JSON.stringify(longStrWithEscapes);
50+
var s4 = JSON.stringify(obj);
51+
sum += s1.length + s2.length + s3.length + s4.length;
52+
53+
// Parse exercises JSONLexer::scanString.
54+
var p1 = JSON.parse(s1);
55+
var p2 = JSON.parse(s2);
56+
var p3 = JSON.parse(s3);
57+
var p4 = JSON.parse(s4);
58+
sum += p1.length + p2.length + p3.length + p4.notes.length;
59+
}
60+
61+
print('done, sum =', sum);
62+
})();
63+
var end = Date.now();
64+
print('Time: ' + (end - start));

include/hermes/Support/FastArraySearch.h

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,4 +83,16 @@ int64_t searchReverseU64(
8383
size_t end,
8484
uint64_t target);
8585

86+
/// Scan [start, end) for the first byte that requires escaping in a JSON
87+
/// string per ECMA-404: '"' (0x22), '\\' (0x5C), or any control character
88+
/// (<= 0x1F).
89+
/// \return pointer to the first such character, or \p end if none found.
90+
const char *scanJsonEscapeU8(const char *start, const char *end);
91+
92+
/// Scan [start, end) for the first char16_t that requires escaping in a JSON
93+
/// string per ECMA-404: '"' (0x22), '\\' (0x5C), or any control character
94+
/// (<= 0x1F).
95+
/// \return pointer to the first such character, or \p end if none found.
96+
const char16_t *scanJsonEscapeU16(const char16_t *start, const char16_t *end);
97+
8698
} // namespace hermes

lib/Support/FastArraySearch.cpp

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -470,4 +470,122 @@ int64_t searchReverseU64(
470470
return searchU64Impl<true>(arr, start, end, target);
471471
}
472472

473+
//===----------------------------------------------------------------------===//
474+
// Special character scanning for JSON strings
475+
//===----------------------------------------------------------------------===//
476+
477+
/// Scalar helper: scan for first byte that is '"', '\\', or <= 0x1F.
478+
static const char *scalarScanU8(const char *p, const char *end) {
479+
while (p < end) {
480+
unsigned char ch = static_cast<unsigned char>(*p);
481+
if (ch <= 0x1F || ch == '"' || ch == '\\')
482+
return p;
483+
++p;
484+
}
485+
return end;
486+
}
487+
488+
/// Scalar helper: scan for first char16_t that is '"', '\\', or <= 0x1F.
489+
static const char16_t *scalarScanU16(const char16_t *p, const char16_t *end) {
490+
while (p < end) {
491+
char16_t ch = *p;
492+
if (ch <= 0x1F || ch == u'"' || ch == u'\\')
493+
return p;
494+
++p;
495+
}
496+
return end;
497+
}
498+
499+
const char *scanJsonEscapeU8(const char *start, const char *end) {
500+
assert(start <= end && "start must be <= end");
501+
const char *p = start;
502+
503+
#ifdef HERMES_SIMD_NEON
504+
// Broadcast each special character/threshold into 128-bit vectors.
505+
uint8x16_t vQuote = vdupq_n_u8('"');
506+
uint8x16_t vBackslash = vdupq_n_u8('\\');
507+
uint8x16_t vCtrlLimit = vdupq_n_u8(0x20);
508+
while (p + 16 <= end) {
509+
uint8x16_t data = vld1q_u8(reinterpret_cast<const uint8_t *>(p));
510+
// Check three conditions in parallel:
511+
// cmp1: byte == '"', cmp2: byte == '\\', cmp3: byte < 0x20 (control).
512+
uint8x16_t cmp1 = vceqq_u8(data, vQuote);
513+
uint8x16_t cmp2 = vceqq_u8(data, vBackslash);
514+
uint8x16_t cmp3 = vcltq_u8(data, vCtrlLimit);
515+
// OR all three results; any non-zero lane means a special char exists.
516+
uint8x16_t match = vorrq_u8(vorrq_u8(cmp1, cmp2), cmp3);
517+
if (vmaxvq_u8(match))
518+
// Fall back to scalar to find the exact byte within this chunk.
519+
return scalarScanU8(p, p + 16);
520+
p += 16;
521+
}
522+
#elif defined(HERMES_SIMD_SSE2)
523+
__m128i vQuote = _mm_set1_epi8('"');
524+
__m128i vBackslash = _mm_set1_epi8('\\');
525+
__m128i vCtrlLimit = _mm_set1_epi8(0x20);
526+
while (p + 16 <= end) {
527+
__m128i data = _mm_loadu_si128(reinterpret_cast<const __m128i *>(p));
528+
__m128i cmp1 = _mm_cmpeq_epi8(data, vQuote);
529+
__m128i cmp2 = _mm_cmpeq_epi8(data, vBackslash);
530+
// _mm_cmplt_epi8 is signed comparison; this works for JSON because
531+
// all valid JSON string bytes are < 0x80 (high bit clear), so
532+
// signed < 0x20 is equivalent to unsigned < 0x20.
533+
__m128i cmp3 = _mm_cmplt_epi8(data, vCtrlLimit);
534+
__m128i match = _mm_or_si128(_mm_or_si128(cmp1, cmp2), cmp3);
535+
// 1 mask bit per byte; countTrailingZeros gives exact byte offset.
536+
unsigned mask = static_cast<unsigned>(_mm_movemask_epi8(match));
537+
if (mask)
538+
return p + llvh::countTrailingZeros(mask);
539+
p += 16;
540+
}
541+
#endif
542+
543+
return scalarScanU8(p, end);
544+
}
545+
546+
const char16_t *scanJsonEscapeU16(const char16_t *start, const char16_t *end) {
547+
assert(start <= end && "start must be <= end");
548+
const char16_t *p = start;
549+
550+
#ifdef HERMES_SIMD_NEON
551+
// Same approach as scanJsonEscapeU8 but with 16-bit lanes (8 per chunk).
552+
uint16x8_t vQuote = vdupq_n_u16(u'"');
553+
uint16x8_t vBackslash = vdupq_n_u16(u'\\');
554+
uint16x8_t vCtrlLimit = vdupq_n_u16(0x20);
555+
while (p + 8 <= end) {
556+
uint16x8_t data = vld1q_u16(reinterpret_cast<const uint16_t *>(p));
557+
uint16x8_t cmp1 = vceqq_u16(data, vQuote);
558+
uint16x8_t cmp2 = vceqq_u16(data, vBackslash);
559+
uint16x8_t cmp3 = vcltq_u16(data, vCtrlLimit);
560+
uint16x8_t match = vorrq_u16(vorrq_u16(cmp1, cmp2), cmp3);
561+
if (vmaxvq_u16(match))
562+
return scalarScanU16(p, p + 8);
563+
p += 8;
564+
}
565+
#elif defined(HERMES_SIMD_SSE2)
566+
__m128i vQuote = _mm_set1_epi16(u'"');
567+
__m128i vBackslash = _mm_set1_epi16(u'\\');
568+
// Bias for unsigned comparison: XOR with 0x8000 converts signed
569+
// _mm_cmplt_epi16 into an unsigned less-than, avoiding false positives
570+
// for char16_t values >= 0x8000 (e.g. CJK, surrogates).
571+
__m128i bias = _mm_set1_epi16(static_cast<short>(0x8000));
572+
__m128i vCtrlLimitBiased = _mm_xor_si128(_mm_set1_epi16(0x20), bias);
573+
while (p + 8 <= end) {
574+
__m128i data = _mm_loadu_si128(reinterpret_cast<const __m128i *>(p));
575+
__m128i cmp1 = _mm_cmpeq_epi16(data, vQuote);
576+
__m128i cmp2 = _mm_cmpeq_epi16(data, vBackslash);
577+
__m128i cmp3 = _mm_cmplt_epi16(_mm_xor_si128(data, bias), vCtrlLimitBiased);
578+
__m128i match = _mm_or_si128(_mm_or_si128(cmp1, cmp2), cmp3);
579+
// Each 16-bit lane produces 2 mask bits; divide by 2 for the
580+
// element index.
581+
unsigned mask = static_cast<unsigned>(_mm_movemask_epi8(match));
582+
if (mask)
583+
return p + llvh::countTrailingZeros(mask) / 2;
584+
p += 8;
585+
}
586+
#endif
587+
588+
return scalarScanU16(p, end);
589+
}
590+
473591
} // namespace hermes

lib/VM/JSLib/JSONLexer.cpp

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
#include "JSONLexer.h"
99

10+
#include "hermes/Support/FastArraySearch.h"
1011
#include "hermes/VM/StringPrimitive.h"
1112

1213
#include "hermes/Support/BuildTable256.h"
@@ -264,6 +265,26 @@ ExecutionStatus JSONLexer<Kind>::scanString() {
264265
if (LLVM_UNLIKELY(!hasChar())) {
265266
return error("Unexpected end of input");
266267
}
268+
269+
// SIMD fast scan: skip over runs of normal characters in bulk.
270+
if constexpr (Traits::UsesRawPtr) {
271+
const CharT *scanEnd;
272+
if constexpr (sizeof(CharT) == 1)
273+
scanEnd = scanJsonEscapeU8(iter_.cur, iter_.end);
274+
else
275+
scanEnd = scanJsonEscapeU16(iter_.cur, iter_.end);
276+
277+
if (scanEnd > iter_.cur) {
278+
if constexpr (ForKey::value) {
279+
for (const CharT *p = iter_.cur; p < scanEnd; ++p)
280+
hash = hermes::updateJenkinsHash(hash, *p);
281+
}
282+
iter_.cur = scanEnd;
283+
if (!hasChar())
284+
return error("Unexpected end of input");
285+
}
286+
}
287+
267288
CharT curVal = *iter_.cur;
268289
if (curVal == '"') {
269290
// Reached the end of string.

lib/VM/JSLib/RuntimeJSONStringify.cpp

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
#include "Object.h"
1111

1212
#include "hermes/Support/BuildTable256.h"
13+
#include "hermes/Support/FastArraySearch.h"
1314
#include "hermes/VM/ArrayLike.h"
1415
#include "hermes/VM/ArrayStorage.h"
1516
#include "hermes/VM/Callable.h"
@@ -619,6 +620,15 @@ void quoteStringForJSON(Output &output, llvh::ArrayRef<CharT> view) {
619620
const CharT *beginUnescPtr = begin;
620621
// Quote.2.
621622
while (cursor < end) {
623+
// SIMD fast skip: advance cursor past normal characters in bulk.
624+
// Only for ASCII — UTF-16 needs surrogate handling that scanJsonEscapeU16
625+
// does not cover.
626+
if constexpr (sizeof(CharT) == 1) {
627+
const CharT *scanEnd = scanJsonEscapeU8(cursor, end);
628+
cursor = scanEnd;
629+
if (cursor >= end)
630+
break;
631+
}
622632
CharT ch = *cursor;
623633
if constexpr (sizeof(CharT) > 1) {
624634
if (ch >= UNICODE_SURROGATE_FIRST && ch <= UNICODE_SURROGATE_LAST) {

unittests/Support/FastArraySearchTest.cpp

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -679,4 +679,123 @@ TEST(SIMD, ReverseU64VariousOffsets) {
679679
EXPECT_EQ(searchReverseU64(arr, 0, 5, 42), -1);
680680
}
681681

682+
//===----------------------------------------------------------------------===//
683+
// scanJsonEscapeU8
684+
//===----------------------------------------------------------------------===//
685+
686+
TEST(SIMD, ScanU8Empty) {
687+
const char *p = "";
688+
EXPECT_EQ(scanJsonEscapeU8(p, p), p);
689+
}
690+
691+
TEST(SIMD, ScanU8NoSpecial) {
692+
const char *s = "hello world 123";
693+
EXPECT_EQ(scanJsonEscapeU8(s, s + 15), s + 15);
694+
}
695+
696+
TEST(SIMD, ScanU8Quote) {
697+
const char *s = "hello\"world";
698+
EXPECT_EQ(scanJsonEscapeU8(s, s + 11), s + 5);
699+
}
700+
701+
TEST(SIMD, ScanU8Backslash) {
702+
const char *s = "hello\\world";
703+
EXPECT_EQ(scanJsonEscapeU8(s, s + 11), s + 5);
704+
}
705+
706+
TEST(SIMD, ScanU8ControlChar) {
707+
std::string s = "hello";
708+
s += '\x0A'; // newline
709+
s += "world";
710+
EXPECT_EQ(scanJsonEscapeU8(s.data(), s.data() + s.size()), s.data() + 5);
711+
}
712+
713+
TEST(SIMD, ScanU8AtStart) {
714+
const char *s = "\"hello";
715+
EXPECT_EQ(scanJsonEscapeU8(s, s + 6), s);
716+
}
717+
718+
TEST(SIMD, ScanU8AtEnd) {
719+
std::string s(30, 'a');
720+
s += '"';
721+
EXPECT_EQ(scanJsonEscapeU8(s.data(), s.data() + s.size()), s.data() + 30);
722+
}
723+
724+
TEST(SIMD, ScanU8Large) {
725+
// Test across SIMD boundaries (>16 bytes).
726+
std::string s(200, 'x');
727+
s[150] = '"';
728+
EXPECT_EQ(scanJsonEscapeU8(s.data(), s.data() + s.size()), s.data() + 150);
729+
}
730+
731+
TEST(SIMD, ScanU8LargeNoSpecial) {
732+
std::string s(200, 'x');
733+
EXPECT_EQ(
734+
scanJsonEscapeU8(s.data(), s.data() + s.size()), s.data() + s.size());
735+
}
736+
737+
TEST(SIMD, ScanU8ControlZero) {
738+
std::string s = "abc";
739+
s += '\0';
740+
s += "def";
741+
EXPECT_EQ(scanJsonEscapeU8(s.data(), s.data() + s.size()), s.data() + 3);
742+
}
743+
744+
//===----------------------------------------------------------------------===//
745+
// scanJsonEscapeU16
746+
//===----------------------------------------------------------------------===//
747+
748+
TEST(SIMD, ScanU16Empty) {
749+
const char16_t *p = u"";
750+
EXPECT_EQ(scanJsonEscapeU16(p, p), p);
751+
}
752+
753+
TEST(SIMD, ScanU16NoSpecial) {
754+
const char16_t *s = u"hello world 123";
755+
EXPECT_EQ(scanJsonEscapeU16(s, s + 15), s + 15);
756+
}
757+
758+
TEST(SIMD, ScanU16Quote) {
759+
const char16_t *s = u"hello\"world";
760+
EXPECT_EQ(scanJsonEscapeU16(s, s + 11), s + 5);
761+
}
762+
763+
TEST(SIMD, ScanU16Backslash) {
764+
const char16_t *s = u"hello\\world";
765+
EXPECT_EQ(scanJsonEscapeU16(s, s + 11), s + 5);
766+
}
767+
768+
TEST(SIMD, ScanU16ControlChar) {
769+
std::u16string s = u"hello";
770+
s += u'\x0A';
771+
s += u"world";
772+
EXPECT_EQ(scanJsonEscapeU16(s.data(), s.data() + s.size()), s.data() + 5);
773+
}
774+
775+
TEST(SIMD, ScanU16Large) {
776+
std::u16string s(200, u'x');
777+
s[150] = u'"';
778+
EXPECT_EQ(scanJsonEscapeU16(s.data(), s.data() + s.size()), s.data() + 150);
779+
}
780+
781+
TEST(SIMD, ScanU16LargeNoSpecial) {
782+
std::u16string s(200, u'x');
783+
EXPECT_EQ(
784+
scanJsonEscapeU16(s.data(), s.data() + s.size()), s.data() + s.size());
785+
}
786+
787+
TEST(SIMD, ScanU16HighUnicode) {
788+
// Characters above 0x1F but not quote/backslash should be skipped.
789+
std::u16string s(20, u'\u4e2d'); // Chinese character
790+
s += u'"';
791+
EXPECT_EQ(scanJsonEscapeU16(s.data(), s.data() + s.size()), s.data() + 20);
792+
}
793+
794+
TEST(SIMD, ScanU16HighBit) {
795+
// Values >= 0x8000 must not false-positive as control chars.
796+
std::u16string s(20, u'\uFFFF');
797+
s += u'"';
798+
EXPECT_EQ(scanJsonEscapeU16(s.data(), s.data() + s.size()), s.data() + 20);
799+
}
800+
682801
} // namespace

0 commit comments

Comments
 (0)