Skip to content

Commit ee8aa40

Browse files
shs96cclaude
andcommitted
[java] Speed up JSON tokenising and decimal parsing
Profile-guided improvements to the JSON read path: - Test for the four JSON whitespace characters before consulting Character.isWhitespace, which was a third of wire-payload parse time - Parse decimals with Clinger's fast path when the significand fits exactly in a double, falling back to Double.parseDouble otherwise; a differential test pins results bit-for-bit to the JDK - Replace the container deque with a byte-array stack and let ObjectCoercer handle nulls itself, trimming per-value overhead Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent a3bfdba commit ee8aa40

6 files changed

Lines changed: 300 additions & 51 deletions

File tree

java/src/org/openqa/selenium/json/Input.java

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,10 @@ public Input(Reader source) {
7474
* input is exhausted
7575
*/
7676
public int peek() {
77+
int next = position + 1;
78+
if (next < filled) {
79+
return buffer[next];
80+
}
7781
return fill() ? buffer[position + 1] : EOF;
7882
}
7983

@@ -84,6 +88,11 @@ public int peek() {
8488
* input is exhausted
8589
*/
8690
public int read() {
91+
int next = position + 1;
92+
if (next < filled) {
93+
position = next;
94+
return buffer[next];
95+
}
8796
return fill() ? buffer[++position] : EOF;
8897
}
8998

@@ -151,7 +160,7 @@ public void skipWhitespace() {
151160
while (fill()) {
152161
int start = position + 1;
153162
for (int i = start; i < filled; i++) {
154-
if (!Character.isWhitespace(buffer[i])) {
163+
if (!isWhitespace(buffer[i])) {
155164
position = i - 1;
156165
return;
157166
}
@@ -160,6 +169,18 @@ public void skipWhitespace() {
160169
}
161170
}
162171

172+
/**
173+
* Test for whitespace, checking the JSON whitespace characters (RFC 8259 §2) directly before
174+
* consulting {@link Character#isWhitespace}, whose table lookups are measurably slower and which
175+
* is retained only to stay lenient about exotic whitespace between tokens.
176+
*/
177+
private static boolean isWhitespace(char c) {
178+
if (c == ' ' || c == '\n' || c == '\t' || c == '\r') {
179+
return true;
180+
}
181+
return Character.isWhitespace(c);
182+
}
183+
163184
/**
164185
* Consume ASCII digits, appending them to the supplied builder in bulk. Stops before the first
165186
* non-digit character, which is left unconsumed.

java/src/org/openqa/selenium/json/JsonInput.java

Lines changed: 129 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,8 @@
2525
import java.io.UncheckedIOException;
2626
import java.lang.reflect.Type;
2727
import java.time.Instant;
28-
import java.util.ArrayDeque;
2928
import java.util.ArrayList;
3029
import java.util.Arrays;
31-
import java.util.Deque;
3230
import java.util.List;
3331
import java.util.Map;
3432
import java.util.function.BiFunction;
@@ -47,13 +45,13 @@ public class JsonInput implements Closeable {
4745
private JsonTypeCoercer coercer;
4846
private PropertySetting setter;
4947
private final Input input;
50-
// Used when reading maps and collections so that we handle de-nesting and
51-
// figuring out whether we're expecting a NAME properly.
52-
private final Deque<Container> stack = new ArrayDeque<>();
48+
// Stack of open containers, used to handle de-nesting and to figure out
49+
// whether we're expecting a NAME. Kept as plain arrays (rather than a deque
50+
// of objects) because the top of the stack is touched for every value read.
51+
private byte[] containerState = new byte[16];
5352
// Parallel stack tracking whether the current container has seen at least
5453
// one element. Used by hasNext() to enforce comma separators between
55-
// elements while remaining lenient about a single trailing comma. Kept as
56-
// a plain array because it is touched for every element read.
54+
// elements while remaining lenient about a single trailing comma.
5755
private boolean[] containerHasElement = new boolean[16];
5856
private int containerDepth;
5957
// Memoized type of the pending token; cleared whenever the token is consumed.
@@ -314,9 +312,7 @@ public Number nextNumber() {
314312
}
315313
return Long.valueOf(builder.toString());
316314
}
317-
// JSON's number grammar is a subset of what parseDouble accepts, and parseDouble is
318-
// considerably cheaper than going through BigDecimal.
319-
double value = Double.parseDouble(builder.toString());
315+
double value = parseDouble(builder);
320316
if (Double.isInfinite(value) || Double.isNaN(value)) {
321317
throw new JsonException("Number is out of range for a double: " + builder + ". " + input);
322318
}
@@ -330,6 +326,96 @@ private static boolean isDigit(int c) {
330326
return c >= '0' && c <= '9';
331327
}
332328

329+
/** Powers of ten that are exactly representable as doubles. */
330+
private static final double[] POW_10 = {
331+
1e0, 1e1, 1e2, 1e3, 1e4, 1e5, 1e6, 1e7, 1e8, 1e9, 1e10, 1e11, 1e12, 1e13, 1e14, 1e15, 1e16,
332+
1e17, 1e18, 1e19, 1e20, 1e21, 1e22
333+
};
334+
335+
/**
336+
* Parse a JSON number that contains a fraction or exponent, as lexed into {@code raw} by {@link
337+
* #nextNumber}.
338+
*
339+
* <p>Uses Clinger's fast path where possible: when the significand has at most 15 digits it is
340+
* exactly representable as a double, as are powers of ten up to 10^22, so a single floating-point
341+
* multiply or divide performs the one correctly-rounded step the conversion needs. Everything
342+
* else (long significands, large exponents) falls back to {@link Double#parseDouble}, so results
343+
* are always bit-for-bit identical to the JDK.
344+
*/
345+
private static double parseDouble(StringBuilder raw) {
346+
int length = raw.length();
347+
int index = 0;
348+
boolean negative = false;
349+
if (raw.charAt(0) == '-') {
350+
negative = true;
351+
index = 1;
352+
}
353+
354+
long significand = 0;
355+
int digits = 0;
356+
int fractionDigits = 0;
357+
358+
while (index < length) {
359+
char c = raw.charAt(index);
360+
if (c < '0' || c > '9') {
361+
break;
362+
}
363+
significand = significand * 10 + (c - '0');
364+
digits++;
365+
index++;
366+
}
367+
368+
if (index < length && raw.charAt(index) == '.') {
369+
index++;
370+
while (index < length) {
371+
char c = raw.charAt(index);
372+
if (c < '0' || c > '9') {
373+
break;
374+
}
375+
significand = significand * 10 + (c - '0');
376+
digits++;
377+
fractionDigits++;
378+
index++;
379+
}
380+
}
381+
382+
int exponent = 0;
383+
if (index < length) {
384+
// By construction the remainder is ('e' | 'E') ('+' | '-')? 1*DIGIT.
385+
index++;
386+
boolean exponentNegative = false;
387+
char sign = raw.charAt(index);
388+
if (sign == '+' || sign == '-') {
389+
exponentNegative = sign == '-';
390+
index++;
391+
}
392+
while (index < length) {
393+
// Clamp rather than overflow; anything this large falls back below anyway.
394+
if (exponent < 100_000) {
395+
exponent = exponent * 10 + (raw.charAt(index) - '0');
396+
}
397+
index++;
398+
}
399+
if (exponentNegative) {
400+
exponent = -exponent;
401+
}
402+
}
403+
404+
int netExponent = exponent - fractionDigits;
405+
406+
if (digits <= 15 && netExponent >= -22 && netExponent <= 22) {
407+
double value = (double) significand;
408+
if (netExponent > 0) {
409+
value = value * POW_10[netExponent];
410+
} else if (netExponent < 0) {
411+
value = value / POW_10[-netExponent];
412+
}
413+
return negative ? -value : value;
414+
}
415+
416+
return Double.parseDouble(raw.toString());
417+
}
418+
333419
private static String describeChar(int c) {
334420
return c == Input.EOF ? "<EOF>" : "'" + (char) c + "'";
335421
}
@@ -377,7 +463,7 @@ public void nextEnd() {
377463
* @throws UncheckedIOException if an I/O exception is encountered
378464
*/
379465
public boolean hasNext() {
380-
if (stack.isEmpty()) {
466+
if (containerDepth == 0) {
381467
throw new JsonException(
382468
"Unable to determine if an item has next when not in a container type. " + input);
383469
}
@@ -417,8 +503,7 @@ public boolean hasNext() {
417503
*/
418504
public void beginArray() {
419505
expect(JsonType.START_COLLECTION);
420-
stack.addFirst(Container.COLLECTION);
421-
pushContainer();
506+
pushContainer(COLLECTION);
422507
input.read();
423508
}
424509

@@ -429,12 +514,11 @@ public void beginArray() {
429514
*/
430515
public void endArray() {
431516
expect(JsonType.END_COLLECTION);
432-
if (stack.peekFirst() != Container.COLLECTION) {
517+
if (topContainer() != COLLECTION) {
433518
// The only other thing we could be closing is a map
434519
throw new JsonException(
435520
"Attempt to close a JSON List, but a JSON Object was expected. " + input);
436521
}
437-
stack.removeFirst();
438522
containerDepth--;
439523
input.read();
440524
}
@@ -446,8 +530,7 @@ public void endArray() {
446530
*/
447531
public void beginObject() {
448532
expect(JsonType.START_MAP);
449-
stack.addFirst(Container.MAP_NAME);
450-
pushContainer();
533+
pushContainer(MAP_NAME);
451534
input.read();
452535
}
453536

@@ -458,10 +541,9 @@ public void beginObject() {
458541
*/
459542
public void endObject() {
460543
expect(JsonType.END_MAP);
461-
if (stack.peekFirst() != Container.MAP_NAME) {
544+
if (topContainer() != MAP_NAME) {
462545
throw new JsonException("Attempt to close a JSON Map, but not ready to. " + input);
463546
}
464-
stack.removeFirst();
465547
containerDepth--;
466548
input.read();
467549
}
@@ -587,7 +669,7 @@ public <T> List<T> readArray(Type type) {
587669
* @return {@code true} is awaiting a property name; otherwise {@code false}
588670
*/
589671
private boolean isReadingName() {
590-
return stack.peekFirst() == Container.MAP_NAME;
672+
return topContainer() == MAP_NAME;
591673
}
592674

593675
/**
@@ -607,14 +689,13 @@ private void expect(JsonType type) {
607689
peekedType = null;
608690

609691
// Special map handling. Woo!
610-
Container top = stack.peekFirst();
692+
byte top = topContainer();
611693

612694
if (type == JsonType.NAME) {
613-
if (top == Container.MAP_NAME) {
614-
stack.removeFirst();
615-
stack.addFirst(Container.MAP_VALUE);
695+
if (top == MAP_NAME) {
696+
containerState[containerDepth - 1] = MAP_VALUE;
616697
return;
617-
} else if (top != null) {
698+
} else if (top != NONE) {
618699
throw new JsonException("Unexpected attempt to read name. " + input);
619700
}
620701

@@ -626,20 +707,26 @@ private void expect(JsonType type) {
626707
// Closing the container - don't treat as a new element in it.
627708
return;
628709
}
629-
if (top == Container.MAP_VALUE) {
630-
stack.removeFirst();
631-
stack.addFirst(Container.MAP_NAME);
710+
if (top == MAP_VALUE) {
711+
containerState[containerDepth - 1] = MAP_NAME;
632712
markElementRead();
633-
} else if (top == Container.COLLECTION) {
713+
} else if (top == COLLECTION) {
634714
markElementRead();
635715
}
636716
}
637717

638-
private void pushContainer() {
639-
if (containerDepth == containerHasElement.length) {
718+
private byte topContainer() {
719+
return containerDepth == 0 ? NONE : containerState[containerDepth - 1];
720+
}
721+
722+
private void pushContainer(byte state) {
723+
if (containerDepth == containerState.length) {
724+
containerState = Arrays.copyOf(containerState, containerDepth * 2);
640725
containerHasElement = Arrays.copyOf(containerHasElement, containerDepth * 2);
641726
}
642-
containerHasElement[containerDepth++] = false;
727+
containerState[containerDepth] = state;
728+
containerHasElement[containerDepth] = false;
729+
containerDepth++;
643730
}
644731

645732
private void markElementRead() {
@@ -788,14 +875,15 @@ private void skipWhitespace(Input input) {
788875
input.skipWhitespace();
789876
}
790877

791-
/** Used to track the current container processing state. */
792-
private enum Container {
878+
/** Container processing states: not in a container. */
879+
private static final byte NONE = 0;
793880

794-
/** Processing a JSON array */
795-
COLLECTION,
796-
/** Processing a JSON object property name */
797-
MAP_NAME,
798-
/** Processing a JSON object property value */
799-
MAP_VALUE,
800-
}
881+
/** Container processing states: processing a JSON array. */
882+
private static final byte COLLECTION = 1;
883+
884+
/** Container processing states: processing a JSON object property name. */
885+
private static final byte MAP_NAME = 2;
886+
887+
/** Container processing states: processing a JSON object property value. */
888+
private static final byte MAP_VALUE = 3;
801889
}

java/src/org/openqa/selenium/json/JsonTypeCoercer.java

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -204,20 +204,17 @@ public Object apply(JsonInput json, PropertySetting setter) {
204204
* @return {@link BiFunction} object to deserialize the specified Java type
205205
*/
206206
private BiFunction<JsonInput, PropertySetting, Object> buildCoercer(Type type) {
207-
BiFunction<JsonInput, PropertySetting, Object> inner =
207+
TypeCoercer<?> matched =
208208
coercers.stream()
209209
.filter(coercer -> coercer.test(narrow(type)))
210210
.findFirst()
211-
.map(
212-
coercer -> {
213-
@SuppressWarnings("unchecked")
214-
BiFunction<JsonInput, PropertySetting, Object> funct =
215-
(BiFunction<JsonInput, PropertySetting, Object>) coercer.apply(type);
216-
return funct;
217-
})
218211
.orElseThrow(() -> new JsonException("Unable to find type coercer for " + type));
219212

220-
if (isOptional(type)) {
213+
@SuppressWarnings("unchecked")
214+
BiFunction<JsonInput, PropertySetting, Object> inner =
215+
(BiFunction<JsonInput, PropertySetting, Object>) matched.apply(type);
216+
217+
if (matched.handlesNull() || isOptional(type)) {
221218
return inner;
222219
}
223220

java/src/org/openqa/selenium/json/ObjectCoercer.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,11 @@ public boolean test(Class type) {
3535
return Object.class.equals(type);
3636
}
3737

38+
@Override
39+
boolean handlesNull() {
40+
return true;
41+
}
42+
3843
@Override
3944
public BiFunction<JsonInput, PropertySetting, Object> apply(Type type) {
4045
// Resolve the possible target coercers once rather than paying a cache lookup per value.
@@ -49,6 +54,9 @@ public BiFunction<JsonInput, PropertySetting, Object> apply(Type type) {
4954

5055
return (jsonInput, setting) -> {
5156
switch (jsonInput.peek()) {
57+
case NULL:
58+
return jsonInput.nextNull();
59+
5260
case BOOLEAN:
5361
return booleanCoercer.apply(jsonInput, setting);
5462

java/src/org/openqa/selenium/json/TypeCoercer.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,4 +30,12 @@ public abstract class TypeCoercer<T>
3030

3131
@Override
3232
public abstract BiFunction<JsonInput, PropertySetting, T> apply(Type type);
33+
34+
/**
35+
* Whether the functions produced by {@link #apply} handle a pending JSON null themselves. When
36+
* {@code false}, {@link JsonTypeCoercer} consumes the null before the function is invoked.
37+
*/
38+
boolean handlesNull() {
39+
return false;
40+
}
3341
}

0 commit comments

Comments
 (0)