This is an automated email from the ASF dual-hosted git repository.
pjfanning pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/pekko-http.git
The following commit(s) were added to refs/heads/main by this push:
new f98599752 perf: find SSE line terminators with ByteString.indexOf
(#1226)
f98599752 is described below
commit f9859975230740340d3f23f4a7e7b0b6f0209375
Author: PJ Fanning <[email protected]>
AuthorDate: Tue Sep 1 11:21:56 2026 +0100
perf: find SSE line terminators with ByteString.indexOf (#1226)
Motivation:
LineParser scanned its buffer one byte at a time with `bs(at)`. The buffer
is
built as `buffer ++ grab(in)`, so it is a multi-fragment ByteString
whenever a
line spans several chunks, and `ByteStrings.apply` walks the fragment list
from
the first fragment on every access. Parsing an SSE line that arrives in many
chunks therefore costs O(bytes * fragments).
Modification:
Locate the next CR or LF with ByteString.indexOf, which is fragment aware
and
scans several bytes at a time, instead of testing every byte. The three
identical line-emitting blocks are factored into a local helper. Line
termination semantics (CR, LF, CRLF, and a CRLF split across chunks) are
unchanged.
Result:
Large SSE lines delivered in small chunks parse dramatically faster; small
lines are unaffected.
lineSize chunkSize before after
1 KB 512 0.265 ms 0.189 ms
1 KB 8192 0.295 ms 0.183 ms
128 KB 512 71.210 ms 1.013 ms
128 KB 8192 3.269 ms 0.342 ms
1 MB 512 9821.281 ms 54.361 ms
Tests:
- sbt "http-tests / Test / testOnly
org.apache.pekko.http.scaladsl.unmarshalling.sse.*" - 48 passed (46 existing
plus 2 new)
- sbt "http-bench-jmh/Jmh/run -f 1 -wi 3 -i 3 -p lineSize=... -p
chunkSize=... .*LineParserBenchmark.*" - numbers above, JDK 21
- scalafmt --mode diff-ref=upstream/main - clean
References:
None - found while auditing ByteString usage across the code base
---
.../unmarshalling/sse/LineParserSpec.scala | 18 +++++
.../scaladsl/unmarshalling/sse/LineParser.scala | 78 ++++++++++------------
2 files changed, 54 insertions(+), 42 deletions(-)
diff --git
a/http-tests/src/test/scala/org/apache/pekko/http/scaladsl/unmarshalling/sse/LineParserSpec.scala
b/http-tests/src/test/scala/org/apache/pekko/http/scaladsl/unmarshalling/sse/LineParserSpec.scala
index 9c83956a9..dd7373232 100644
---
a/http-tests/src/test/scala/org/apache/pekko/http/scaladsl/unmarshalling/sse/LineParserSpec.scala
+++
b/http-tests/src/test/scala/org/apache/pekko/http/scaladsl/unmarshalling/sse/LineParserSpec.scala
@@ -63,5 +63,23 @@ final class LineParserSpec extends AsyncWordSpec with
Matchers with BaseUnmarsha
.runWith(Sink.seq)
.map(_ shouldBe Vector("after", "", "before", "middle"))
}
+
+ "parse a CRLF that is split across two chunks" in {
+ Source(("line1\r" :: "\nline2\r\n" :: Nil).map(ByteString(_)))
+ .via(new LineParser(1048576))
+ .runWith(Sink.seq)
+ .map(_ shouldBe Vector("line1", "line2"))
+ }
+
+ "parse lines from a multi-fragment ByteString" in {
+ // a single element that is a concatenation of several chunks, as
produced by the stage's own buffering
+ val input = ByteString("line1\nli") ++ ByteString("ne2\r") ++
ByteString("\nline3\r") ++ ByteString("line4\n")
+ input.isCompact shouldBe false
+ Source
+ .single(input)
+ .via(new LineParser(1048576))
+ .runWith(Sink.seq)
+ .map(_ shouldBe Vector("line1", "line2", "line3", "line4"))
+ }
}
}
diff --git
a/http/src/main/scala/org/apache/pekko/http/scaladsl/unmarshalling/sse/LineParser.scala
b/http/src/main/scala/org/apache/pekko/http/scaladsl/unmarshalling/sse/LineParser.scala
index e339c0ebc..c29c67250 100644
---
a/http/src/main/scala/org/apache/pekko/http/scaladsl/unmarshalling/sse/LineParser.scala
+++
b/http/src/main/scala/org/apache/pekko/http/scaladsl/unmarshalling/sse/LineParser.scala
@@ -73,6 +73,13 @@ private final class LineParser(maxLineSize: Int,
}
}
+ def lineAt(bs: ByteString, from: Int, until: Int): Option[String] = {
+ val lineByteSize = until - from
+ val line = bs.slice(from, until).utf8String
+ if (maxLineSize > 0 && lineByteSize > maxLineSize)
handleLineOversized(lineByteSize, line)
+ else Some(line)
+ }
+
@tailrec
def parseLines(
bs: ByteString,
@@ -82,49 +89,36 @@ private final class LineParser(maxLineSize: Int,
lastCharWasCr: Boolean): (ByteString, Vector[String], Boolean) =
if (at >= bs.length)
(bs.drop(from), parsedLines, lastCharWasCr)
- else
- bs(at) match {
- case CR_BYTE if at < bs.length - 1 && bs(at + 1) == LF_BYTE =>
- // Lookahead for LF after CR
- val lineByteSize = at - from
- val line = bs.slice(from, at).utf8String
- val processedLine = if (maxLineSize > 0 && lineByteSize >
maxLineSize) {
- handleLineOversized(lineByteSize, line)
- } else {
- Some(line)
- }
- val newParsedLines =
processedLine.fold(parsedLines)(parsedLines :+ _)
- parseLines(bs, at + 2, at + 2, newParsedLines, lastCharWasCr =
false)
- case CR_BYTE =>
- // if is a CR but we don't know the next character, slice it
but flag that the last character was a CR so if the next happens to be a LF we
just ignore
- val lineByteSize = at - from
- val line = bs.slice(from, at).utf8String
- val processedLine = if (maxLineSize > 0 && lineByteSize >
maxLineSize) {
- handleLineOversized(lineByteSize, line)
- } else {
- Some(line)
- }
- val newParsedLines =
processedLine.fold(parsedLines)(parsedLines :+ _)
- parseLines(bs, at + 1, at + 1, newParsedLines, lastCharWasCr =
true)
- case LF_BYTE if lastCharWasCr =>
- // if is a LF and we just sliced a CR then we simply advance
- parseLines(bs, at + 1, at + 1, parsedLines, lastCharWasCr =
false)
- case LF_BYTE =>
- // a LF that wasn't preceded by a CR means we found a new slice
- val lineByteSize = at - from
- val line = bs.slice(from, at).utf8String
- val processedLine = if (maxLineSize > 0 && lineByteSize >
maxLineSize) {
- handleLineOversized(lineByteSize, line)
- } else {
- Some(line)
- }
- val newParsedLines =
processedLine.fold(parsedLines)(parsedLines :+ _)
- parseLines(bs, at + 1, at + 1, newParsedLines, lastCharWasCr =
false)
- case _ =>
- // for other input, simply advance
- // Reset lastCharWasCr if we encounter any non-LF character
after CR
- parseLines(bs, from, at + 1, parsedLines, lastCharWasCr =
false)
+ else if (lastCharWasCr && bs(at) == LF_BYTE)
+ // the LF of a CRLF whose CR already ended a line in a previous
chunk, simply advance
+ parseLines(bs, at + 1, at + 1, parsedLines, lastCharWasCr = false)
+ else {
+ // jump straight to the next line terminator instead of testing
every single byte:
+ // ByteString.indexOf scans several bytes at a time and, unlike
indexed access, does not
+ // walk the fragment list of a multi-chunk ByteString on every byte
+ val crIx = bs.indexOf(CR_BYTE, at)
+ val lfIx = bs.indexOf(LF_BYTE, at)
+ val terminator =
+ if (crIx == -1) lfIx
+ else if (lfIx == -1) crIx
+ else math.min(crIx, lfIx)
+
+ if (terminator == -1)
+ // no line terminator in the rest of the buffer
+ (bs.drop(from), parsedLines, false)
+ else {
+ val newParsedLines = lineAt(bs, from,
terminator).fold(parsedLines)(parsedLines :+ _)
+ if (terminator == lfIx)
+ parseLines(bs, terminator + 1, terminator + 1, newParsedLines,
lastCharWasCr = false)
+ else if (terminator < bs.length - 1 && bs(terminator + 1) ==
LF_BYTE)
+ // lookahead for LF after CR
+ parseLines(bs, terminator + 2, terminator + 2, newParsedLines,
lastCharWasCr = false)
+ else
+ // a CR but we don't know the next character yet, flag it so
that a LF starting the
+ // next chunk is ignored
+ parseLines(bs, terminator + 1, terminator + 1, newParsedLines,
lastCharWasCr = true)
}
+ }
// start the search where it ended, prevent iterating over all the
buffer again
val currentBufferStart = math.max(0, buffer.length - 1)
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]