Skip to content

Commit 74207c6

Browse files
authored
Merge pull request #10938 from NelsonVides/perf/json_swar
Use SWAR to optimize JSON string scanning (~5% to ~50% improvements) OTP-20072
2 parents 5735c70 + 61c3df6 commit 74207c6

2 files changed

Lines changed: 69 additions & 19 deletions

File tree

lib/stdlib/src/json.erl

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -361,15 +361,15 @@ escape_binary(Bin) -> escape_binary_ascii(Bin, [$"], Bin, 0, 0).
361361

362362
escape_binary_ascii(Binary, Acc, Orig, Skip, Len) ->
363363
case Binary of
364-
<<B1, B2, B3, B4, B5, B6, B7, B8, Rest/binary>> when ?are_all_ascii_plain(B1, B2, B3, B4, B5, B6, B7, B8) ->
365-
escape_binary_ascii(Rest, Acc, Orig, Skip, Len + 8);
364+
<<W:56, Rest/binary>> when ?are_all_ascii_plain_swar(W) ->
365+
escape_binary_ascii(Rest, Acc, Orig, Skip, Len + 7);
366366
Other ->
367367
escape_binary(Other, Acc, Orig, Skip, Len)
368368
end.
369369

370370
escape_binary(<<Byte, Rest/binary>>, Acc, Orig, Skip, Len) when ?is_ascii_plain(Byte) ->
371-
%% we got here because there were either less than 8 bytes left
372-
%% or we have an escape in the next 8 bytes,
371+
%% we got here because there were either less than 7 bytes left
372+
%% or we have an escape in the next 7 bytes,
373373
%% escape_binary_ascii would fail and dispatch here anyway
374374
escape_binary(Rest, Acc, Orig, Skip, Len + 1);
375375
escape_binary(<<Byte, Rest/binary>>, Acc, Orig, Skip0, Len) when ?is_ascii_escape(Byte) ->
@@ -410,8 +410,8 @@ escape_all(Bin) -> escape_all_ascii(Bin, [$"], Bin, 0, 0).
410410

411411
escape_all_ascii(Binary, Acc, Orig, Skip, Len) ->
412412
case Binary of
413-
<<B1, B2, B3, B4, B5, B6, B7, B8, Rest/binary>> when ?are_all_ascii_plain(B1, B2, B3, B4, B5, B6, B7, B8) ->
414-
escape_all_ascii(Rest, Acc, Orig, Skip, Len + 8);
413+
<<W:56, Rest/binary>> when ?are_all_ascii_plain_swar(W) ->
414+
escape_all_ascii(Rest, Acc, Orig, Skip, Len + 7);
415415
Other ->
416416
escape_all(Other, Acc, Orig, Skip, Len)
417417
end.
@@ -1175,8 +1175,8 @@ string(Binary, Original, Skip, Acc, Stack, Decode) ->
11751175

11761176
string_ascii(Binary, Original, Skip, Acc, Stack, Decode, Len) ->
11771177
case Binary of
1178-
<<B1, B2, B3, B4, B5, B6, B7, B8, Rest/binary>> when ?are_all_ascii_plain(B1, B2, B3, B4, B5, B6, B7, B8) ->
1179-
string_ascii(Rest, Original, Skip, Acc, Stack, Decode, Len + 8);
1178+
<<W:56, Rest/binary>> when ?are_all_ascii_plain_swar(W) ->
1179+
string_ascii(Rest, Original, Skip, Acc, Stack, Decode, Len + 7);
11801180
Other ->
11811181
string(Other, Original, Skip, Acc, Stack, Decode, Len)
11821182
end.
@@ -1218,8 +1218,8 @@ string_utf8(_, Orig, Skip, Acc, Stack, Decode, Len, _State0) ->
12181218

12191219
string_ascii(Binary, Original, Skip, Acc, Stack, Decode, Start, Len, SAcc) ->
12201220
case Binary of
1221-
<<B1, B2, B3, B4, B5, B6, B7, B8, Rest/binary>> when ?are_all_ascii_plain(B1, B2, B3, B4, B5, B6, B7, B8) ->
1222-
string_ascii(Rest, Original, Skip, Acc, Stack, Decode, Start, Len + 8, SAcc);
1221+
<<W:56, Rest/binary>> when ?are_all_ascii_plain_swar(W) ->
1222+
string_ascii(Rest, Original, Skip, Acc, Stack, Decode, Start, Len + 7, SAcc);
12231223
Other ->
12241224
string(Other, Original, Skip, Acc, Stack, Decode, Start, Len, SAcc)
12251225
end.

lib/stdlib/src/json.hrl

Lines changed: 59 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -179,13 +179,63 @@
179179
Byte =:= 127
180180
).
181181

182-
-define(are_all_ascii_plain(B1, B2, B3, B4, B5, B6, B7, B8),
183-
(?is_ascii_plain(B1)) andalso
184-
(?is_ascii_plain(B2)) andalso
185-
(?is_ascii_plain(B3)) andalso
186-
(?is_ascii_plain(B4)) andalso
187-
(?is_ascii_plain(B5)) andalso
188-
(?is_ascii_plain(B6)) andalso
189-
(?is_ascii_plain(B7)) andalso
190-
(?is_ascii_plain(B8))
182+
%% SWAR (SIMD Within A Register) check for 7 bytes of plain ASCII at once.
183+
%%
184+
%% Instead of matching 8 individual bytes and checking each against
185+
%% is_ascii_plain/1 (which the JIT compiles to 8 jump table lookups
186+
%% with indirect branches), we match a single 56-bit integer and use
187+
%% bitwise arithmetic to validate all 7 bytes in parallel.
188+
%%
189+
%% We use 56 bits (7 bytes) because it is the largest value that fits
190+
%% in a BEAM small integer (59-bit on 64-bit). This ensures all
191+
%% bitwise and arithmetic guard operations (band, bor, bxor, +, -)
192+
%% compile to single native instructions with no type checks or
193+
%% bignum fallback calls. Benchmarks showed 4/6/7-byte variants all
194+
%% outperform the original, with 7 bytes winning on string-heavy
195+
%% inputs (up to 55% faster) due to its larger stride.
196+
%%
197+
%% The byte-by-byte is_ascii_plain fallback path handles any remaining
198+
%% bytes (< 7) and is always entered when the SWAR check fails, so
199+
%% correctness does not depend on the SWAR path.
200+
201+
-define(SWAR_MASK80, 16#80808080808080).
202+
-define(SWAR_MASK01, 16#01010101010101).
203+
204+
%% Detect if any byte in a 56-bit word is zero (Mycroft's trick).
205+
%%
206+
%% This is a simplified variant that omits the standard (bnot V) term.
207+
%% The full formula is: ((V - 0x01..01) band (bnot V) band 0x80..80).
208+
%% The (bnot V) term filters out false positives from bytes >= 0x80,
209+
%% where subtracting 0x01 does not clear the high bit. This term is
210+
%% unnecessary here: check 1 in are_all_ascii_plain_swar/1 proves all
211+
%% bytes of W are < 128 before no_zero_byte is reached (thanks to
212+
%% andalso short-circuit evaluation), and XOR of two 7-bit values is
213+
%% still 7-bit, so no byte in V can have bit 7 set.
214+
%%
215+
%% We also avoid bnot because the JIT lacks an always_small fast path
216+
%% for it, emitting runtime type checks and bignum fallback calls even
217+
%% when the result provably fits in a small.
218+
%%
219+
%% Borrow propagation between bytes may cause rare false positives
220+
%% (a non-zero byte adjacent to a zero byte detected as zero), but
221+
%% these are harmless: we simply fall through to the byte-by-byte
222+
%% path which is always correct.
223+
-define(no_zero_byte(V),
224+
((V) - ?SWAR_MASK01) band ?SWAR_MASK80 =:= 0
225+
).
226+
227+
%% SWAR check: all 7 bytes (in one 56-bit word) are "plain ASCII"
228+
%% i.e., in [32, 127] and not $" (0x22) or $\\ (0x5C).
229+
%%
230+
%% Four checks, each operating on all 7 bytes simultaneously:
231+
%% 1. band with 0x80..80 detects bytes >= 128
232+
%% 2. add 0x60..60 then band 0x80..80 detects bytes < 32
233+
%% (byte + 0x60 sets the high bit iff byte >= 0x20, given byte < 0x80)
234+
%% 3. XOR with 0x22..22 maps $" to 0, then no_zero_byte detects it
235+
%% 4. XOR with 0x5C..5C maps $\\ to 0, then no_zero_byte detects it
236+
-define(are_all_ascii_plain_swar(W),
237+
(W) band ?SWAR_MASK80 =:= 0 andalso
238+
((W) + 16#60606060606060) band ?SWAR_MASK80 =:= ?SWAR_MASK80 andalso
239+
?no_zero_byte((W) bxor 16#22222222222222) andalso
240+
?no_zero_byte((W) bxor 16#5C5C5C5C5C5C5C)
191241
).

0 commit comments

Comments
 (0)