Skip to content

Speed up the text reader: bulk ASCII runs, fewer unreads, base64 groups, one-pass field names - #1172

Open
rp872 wants to merge 6 commits into
amazon-ion:masterfrom
rp872:text-reader-fast-paths
Open

rp872 wants to merge 6 commits into
amazon-ion:masterfrom
rp872:text-reader-fast-paths

Conversation

@rp872

@rp872 rp872 commented Oct 27, 2026

Copy link
Copy Markdown

Issue #, if available: none

Description of changes:

Text Ion reads in ion-java-benchmark-cli get 10–31% faster, with no API change and one minor diagnostic change (see below). The PR adds five independent fast paths to the text reader (IonReaderTextRawTokensX, IonReaderTextRawX, IonReaderTextSystemX, UnifiedInputStreamX), one commit each, plus a test commit. It's 264 added and 6 removed lines in src/main (201 lines of code without comments and blank lines). Each fast path handles only the common case and falls back to the existing character-at-a-time code for everything else, so escapes, newlines and line counting, comments, non-ASCII, page ends, padding and errors keep their current behavior.

Commit Change
Skip whitespace without unreading the character that ends it skip_over_whitespace() called skip_whitespace(), which unreads the first non-whitespace character, then read_char() to read it again, before every token. It now returns that character directly.
Unread within the current page without a page lookup UnifiedInputStreamX.unread() looked up the current page and its starting offset on every call. That offset is now cached in make_page_current(), the one place a page becomes current. While a page is current its starting offset can only decrease, and a page is only reloaded through refill(), which goes through make_page_current() again, so the cached value stays an upper bound of the starting offset: when _pos is above it, stepping back stays inside the page. Other cases take the existing path.
Copy and skip plain-ASCII runs in bulk New UnifiedInputStreamX helpers (appendAsciiRun, skipRun, skipAsciiRun) consume a run of characters straight from the current page, without refilling. Each run uses a table holding exactly the characters the existing loop passes over without acting on them; the existing loop then continues with the character that stopped the run. Used for double-quoted strings and symbol identifiers (load and skip), and for skipping strings, containers and blobs. Line ends always stop a run, so line counting is unchanged.
Decode whole base64 groups straight from the page Groups of four base64 alphabet characters are decoded directly from the page into the lob buffer, for byte and character sources. Whitespace, =, the closing brace, invalid characters and page ends are left to read_base64_byte(), after which readBytes() goes back to bulk decoding whenever no decoded bytes are pending.
Read unquoted field names in one pass A field name was skipped to a save point and then read back from it, so it was scanned twice with a save-point push and pop. An unquoted identifier is now read once with token_contents_load(), after the same state change finish_value() made; other names keep the existing path.
Test the text reader fast paths IonReaderTextFastPathsTest, described under Testing.

Performance

These numbers come from ion-java-benchmark-cli with the settings of ion-java-performance-regression-detector.yml and its six generated datasets (50 KB each), plus --format ion_text. The workflow as written benchmarks ion_binary, the CLI's default, so on this PR it won't exercise the text reader.

Settings: --format ion_text --mode AverageTime --forks 5 --warmups 2 --iterations 3 --io-type buffer, JDK 17. Benchmarks ran one at a time, before and after, on an otherwise idle 16-core x86_64 Linux r5.4xl host. Values are ms/op, lower is better, ± the 99.9% confidence interval; every change is outside both intervals.

Dataset Streaming read before after change DOM read before after change
nestedList 1.920 ± 0.017 1.380 ± 0.011 −28.1% 3.095 ± 0.066 2.519 ± 0.051 −18.6%
nestedStruct 3.229 ± 0.048 2.410 ± 0.013 −25.3% 5.321 ± 0.046 4.465 ± 0.098 −16.1%
sexp 0.718 ± 0.021 0.605 ± 0.008 −15.8% 1.261 ± 0.013 1.127 ± 0.037 −10.6%
realWorldDataSchema01 4.636 ± 0.064 3.573 ± 0.064 −22.9% 7.342 ± 0.117 6.135 ± 0.058 −16.4%
realWorldDataSchema02 4.815 ± 0.068 3.529 ± 0.056 −26.7% 7.988 ± 0.135 6.764 ± 0.157 −15.3%
realWorldDataSchema03 4.397 ± 0.039 3.045 ± 0.054 −30.7% 7.080 ± 0.166 5.910 ± 0.202 −16.5%

These datasets have few large blobs. For a single 8 MiB blob read with newBytes() (rough timing, best of 5 after 2 warm-ups, not JMH), the time changes as follows:

Source Before After Change
byte[] 55.2 ms 33.0 ms −40%
InputStream 103.9 ms 40.7 ms −61%
InputStream, base64 wrapped at 76 characters 95.0 ms 41.4 ms −56%
String 115.5 ms 54.0 ms −53%
Reader 121.3 ms 63.6 ms −48%

To reproduce, build the CLI against each side and run, for each dataset:

java -jar ion-java-benchmark-cli-0.0.1-SNAPSHOT-jar-with-dependencies.jar read --api streaming --format ion_text \
    --mode AverageTime --forks 5 --warmups 2 --iterations 3 --io-type buffer <dataset>.10n

Testing

  • ./gradlew build passes: SpotBugs, Spotless, ktlint and the third-party license check. test and minifyTest8, minifyTest11, minifyTest17 and minifyTest21 each give 197,949 passing and 586 skipped, the same skipped set as without the change.

  • IonReaderTextFastPathsTest (61 cases) has two parts.

  • Its reader-level tests read their inputs from byte arrays, streams that return a few bytes per read (so pages end anywhere), strings and readers. They cover:

    • strings, symbols and blobs longer than a page
    • blobs with whitespace inside groups, wrapped lines and padding
    • skipped containers whose contents look like their closing bracket (quoted strings and symbols, comments, escapes, lobs)
    • line numbers after skipped multi-line lists, structs, blobs and strings, for \n, \r\n and \r
    • raw line ends in skipped strings, which must still be errors
    • non-ASCII characters inside blobs, which must still give the same error
    • after byteSize(), one getBytes() of the first 1 to 8 bytes, which come from the cached blob, then the next value
    • identifiers next to operator characters
    • field-name errors, with their exception type, message and position pinned
    • value spans after field names
    • random documents round-tripped through the text writer

    They check results, not which path produced them, and pass before and after the change.

  • Its unit tests call the new UnifiedInputStreamX helpers and the bulk base64 read directly, and check that each consumes a whole run or whole groups and stops at the right character. This includes a bulk read shorter than the blob, which leaves the following decoded byte to read_base64_byte().

  • Together they fail when any character table (line ends included), the unread floor, a non-ASCII check, the base64 group decoding, the bulk base64 read or the character-source copy is broken.

  • Outside this PR, the old and new reader were compared on 20,000 random documents and 8 MB of real data, each read four ways. That covered comments, newline forms, escapes, surrogate pairs, page-sized values, wrapped base64, clobs, truncation and corrupted bytes. Values, field names, annotations, spans, and exceptions with their messages were identical.

Notes for reviewers

  • The one diagnostic change: a keyword used as a field name (true, false, null, nan) is now reported at the end of the name instead of after the spaces and comments that follow it. For example, {true :1} reports offset 6 instead of 7, and {x:{nan /*c*/ : 1}} offset 8 instead of 15. The exception type and message are unchanged, and when the name is directly followed by : or a line end the offset is unchanged too. Keeping the old offset would take extra code on the error path for no benefit to callers, so this PR doesn't; the test pins the new offsets.

By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.

rp872 added 6 commits October 26, 2026 22:52
skip_over_whitespace() called skip_whitespace(), which unreads the first character after the
whitespace, then read_char() to read it again. The text reader does this before every token. Return
that character directly instead. The cases match the old code: a '/' that does not start a comment
is returned as '/', EOF is returned as EOF, and read_char() still counts newlines.
UnifiedInputStreamX.unread() looked up the current page and its starting offset on every call to
check whether the character being stepped back over is still in the current page. Cache that starting
offset (_unread_floor) in make_page_current(), the one place a page becomes current. While a page is
current its starting offset can only decrease (unreading before its first character lowers it), and a
page is only reloaded by refill(), which makes it current again through make_page_current(), so the
cached value stays an upper bound of the starting offset: a position above it is inside the page.
When _pos is above it, unread() only decrements _pos; every other case takes the existing path.
Strings, symbol identifiers and skipped values were scanned one character at a time through
UnifiedInputStreamX.read(). Add helpers that consume a run of characters directly from the current
page: appendAsciiRun() copies accepted ASCII characters into a StringBuilder, skipRun() and
skipAsciiRun() skip them. None of them refills; the existing character-at-a-time loop continues with
the character that stopped the run, or with read() at the end of the page.

Each table accepts exactly the characters the existing loop passes over without acting on them, so
everything else (escapes, quotes, line ends and line counting, comments, brackets, non-ASCII) still
goes through the existing code. Spaces and tabs are skipped with the rest when skipping a container,
since the container scan ignores them. Used when loading double-quoted strings and symbol
identifiers, and when skipping symbol identifiers, double-quoted strings, containers and blobs.
Blobs were decoded a byte at a time through read_base64_byte(), which reads each base64
character through UnifiedInputStreamX.read(). Decode groups of four alphabet characters directly
from the current page into the lob buffer instead, for byte and character sources. Decoding stops at
whitespace, '=' (which the alphabet table maps to -1), the closing brace, a non-ASCII or invalid
character, or the end of the page, and leaves all of those to read_base64_byte(), so padding,
wrapped base64 and errors behave as before. readBytes() goes back to bulk decoding after each such
character whenever no decoded bytes are pending, never asks for more than the requested length, and
still marks the token finished only when it reached the end of the lob.
For a field name, parse_to_next_value() finished the name token to a save point, which skips
over it, and then read the name back by reactivating that save point. Each field name was scanned
twice and paid for a save-point push and pop. When the name is an unquoted identifier and nothing is
loaded yet, read it directly with token_contents_load(), which records the save point and marks the
token finished, after making the same state change finish_value() made; every other case keeps the
existing path.

After the name the stream is now before the whitespace that follows it, not after it. The next
nextToken() skips that whitespace either way, so spans and line numbers are unchanged. One
diagnostic moves: a keyword used as a field name (true, false, null, nan) is now reported at the end
of the name, where the stream is, not after the spaces and comments that follow it.
IonReaderTextFastPathsTest has two parts.

Reader-level tests read strings, symbols and blobs longer than a page, skipped containers whose
contents look like their closing bracket, skipped multi-line values, malformed blobs, prefix reads
of cached blobs, field-name spans and errors, and random documents written by the text writer, from
byte arrays, streams that return a few bytes per read (so pages end anywhere), strings and readers.
They check results, not which path produced them, and pass before and after the fast paths;
they would not notice a fast path no longer being called, which is left to benchmarks.

Unit tests call the new UnifiedInputStreamX helpers and the bulk base64 read directly, and check that
each consumes a whole run or whole groups and stops at the right character. Together the tests fail
when any character table, the unread floor, a non-ASCII check, the base64 group decoding or the
bulk base64 read is broken.
@codecov

codecov Bot commented Oct 28, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 91.50327% with 13 lines in your changes missing coverage. Please review.
✅ Project coverage is 68.28%. Comparing base (3c1b6b1) to head (007bc04).
⚠️ Report is 157 commits behind head on master.

Files with missing lines Patch % Lines
.../java/com/amazon/ion/impl/UnifiedInputStreamX.java 91.74% 0 Missing and 9 partials ⚠️
...in/java/com/amazon/ion/impl/IonReaderTextRawX.java 71.42% 0 Missing and 2 partials ⚠️
...a/com/amazon/ion/impl/IonReaderTextRawTokensX.java 96.55% 1 Missing ⚠️
...java/com/amazon/ion/impl/IonReaderTextSystemX.java 87.50% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##             master    #1172      +/-   ##
============================================
+ Coverage     67.23%   68.28%   +1.04%     
- Complexity     5484     5737     +253     
============================================
  Files           159      160       +1     
  Lines         23025    23519     +494     
  Branches       4126     4241     +115     
============================================
+ Hits          15481    16059     +578     
+ Misses         6262     6152     -110     
- Partials       1282     1308      +26     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant