Skip to content

perf: decode parser byte ranges in bulk instead of char by char - #1229

Open
pjfanning wants to merge 1 commit into
apache:mainfrom
pjfanning:perf/ascii-string-decode
Open

perf: decode parser byte ranges in bulk instead of char by char#1229
pjfanning wants to merge 1 commit into
apache:mainfrom
pjfanning:perf/ascii-string-decode

Conversation

@pjfanning

Copy link
Copy Markdown
Member

Motivation

asciiString (impl/engine/parsing/package.scala) built its String one character at a time:

@tailrec def build(ix: Int = start, sb: JStringBuilder = new JStringBuilder(end - start)): String =
  if (ix == end) sb.toString else build(ix + 1, sb.append(input(ix).toChar))

Two problems. Indexed access into a multi-fragment ByteString walks the fragment list on every byte, and the loop cannot use the JDK's bulk String decode. ByteStringParserInput.sliceString already does the fast thing for exactly the same job - bytes.slice(start, end).decodeString(ISO_8859_1) - so the two were inconsistent.

It also sign extended: input(ix).toChar turns byte 0xC3 into U+FFC3, not U+00C3. Three call sites can see bytes above 0x7F:

  • the response reason phrase (HttpResponseParser:99) - scanNewLineIdx accepts any byte until CR/LF;
  • chunk extensions (HttpMessageParser:299) - parseChunkExtensions accepts any byte until CR/LF;
  • header names (HttpHeaderParser:169) - when illegal-response-header-name-processing-mode is warn or ignore.

The two call sites in scanHeaderValue cannot: sb stays null only while every character so far is legal 7-bit ASCII (' ' <= c <= 0x7F), so the range handed to asciiString there is always ASCII, where old and new agree exactly.

Modification

private[http] def asciiString(input: ByteString, start: Int, end: Int): String =
  if (start == end) "" else input.slice(start, end).decodeString(ISO88591)

Reusing the existing ISO88591 constant from pekko.http.impl.util. The name asciiString is kept - callers expect it and most ranges really are ASCII - with a scaladoc note about the above-0x7F behaviour.

Result

One bulk decode instead of a per-byte loop, and byte ranges above 0x7F decode to the Latin-1 character instead of a sign-extended one.

Measured over 3M extractions of a 4-character header name from a request-sized ByteString: 234 ms -> 167 ms.

Note this is on the uncached header path - HttpHeaderParser reaches asciiString only when a header is not already in its trie, so for traffic with repetitive headers most requests never get here. This is an allocation-pressure and tail-latency improvement rather than something that moves steady-state throughput.

A pekko-side decodeString(charset, from, until) would take this further by removing the intermediate slice as well (the same measurement puts that at ~108 ms), and this becomes a one-line follow-up if and when that lands. The slice form works on every pekko version in the meantime, including the 1.1.5 that the 1.4.x branch is pinned to.

Tests

  • sbt "http-core / Test / testOnly org.apache.pekko.http.impl.engine.parsing.*" - 242 passed.
  • New ResponseParserSpec case: a response with a non-ASCII header name, parsed with IllegalResponseHeaderNameProcessingMode.Ignore. It fails before this change, where the two UTF-8 bytes of the name are parsed as the sign-extended characters U+FFC3 U+FFB6 instead of U+00C3 U+00B6.
  • sbt http-core/mimaReportBinaryIssues - clean.
  • scalafmt --mode diff-ref=upstream/main - clean.

Note on an existing test gap

While writing the test I first tried to assert a non-ASCII chunk extension, which is the other affected path. That turned out to be untestable through these specs: StrictEqualHttpResponse compares entities via toStrict, which collapses a chunked entity to its concatenated data and discards the extensions. So the existing "message chunk with and without extension" case does not actually assert the extension strings either. Worth a separate look, out of scope here.

References

None - found while auditing ByteString usage across the code base

Motivation:
asciiString built its String one character at a time through indexed ByteString
access and a StringBuilder. Indexed access into a multi-fragment ByteString walks
the fragment list on every byte, and the loop cannot use the JDK's bulk decode.
ByteStringParserInput.sliceString already does the fast thing for the same job.

It also sign extended: input(ix).toChar turns byte 0xC3 into U+FFC3 rather than
U+00C3. Three of the call sites can see bytes above 0x7F - the response reason
phrase, chunk extensions, and header names when
illegal-response-header-name-processing-mode is warn or ignore - so those ranges
were decoded into replacement-looking garbage. The two call sites in
scanHeaderValue cannot: they only ever cover a range already checked to be legal
7-bit ASCII, where the old and new behaviour agree.

Modification:
asciiString now slices and calls decodeString(ISO-8859-1), matching
ByteStringParserInput.sliceString. Reuses the existing ISO88591 constant from
pekko.http.impl.util.

Result:
One bulk decode instead of a per-byte loop, and byte ranges above 0x7F decode to
the Latin-1 character rather than a sign extended one. Measured over 3M
extractions of a 4-character header name from a request-sized ByteString: 234 ms
before, 167 ms after.

Tests:
- sbt "http-core / Test / testOnly org.apache.pekko.http.impl.engine.parsing.*" - 242 passed
- New ResponseParserSpec case parses a non-ASCII header name with illegal names ignored; it fails before this change, where the name comes out as f\uFFC3\uFFB6o
- sbt http-core/mimaReportBinaryIssues - clean
- scalafmt --mode diff-ref=upstream/main - clean

References:
None - found while auditing ByteString usage across the code base
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