Skip to content

Commit 514f219

Browse files
committed
perf(stream): heap transport buffers for the TLS GraphStage
Motivation: The JDK SSLEngine works on byte[] internally and makes an extra direct<->heap copy on every wrap/unwrap when handed a direct buffer (see Netty's SslHandler, SslEngineType.JDK). TlsGraphStage took its three transport buffers from the TCP direct BufferPool, so every record paid that copy and each connection pinned ~384 KiB of direct memory for buffers that normally only hold a single ~16 KiB TLS record. Modification: Allocate the transport buffers on the heap and size them on demand like Netty: start at one packet and let growTransportOutBuffer enlarge the wrap buffer (capped at MaxApplicationRecordsPerEngineCall packets) only when a larger batch is actually produced. Remove the now-unused BufferPool/Tcp wiring and the pool-derived applicationBufferSize helper. Result: Per-record heap allocation drops ~5-8% for 4 KiB and 64 KiB payloads and stays flat for 256 B (on-demand sizing avoids a fixed-buffer penalty); direct memory per connection drops from ~384 KiB to zero. Behaviour is unchanged. Tests: - stream-tests/testOnly *TlsGraphStageIsolatedSpec *TlsGraphStageEdgeCasesSpec *TlsGraphStageSpec *TlsSpec *TlsEngineSelectionSpec -> 246 succeeded, 0 failed - bench-jmh TlsBenchmark.warmRoundTrip -prof gc (graphstage), gc.alloc.rate.norm: 256B 1657->1672 B/op, 4096B 20559->18980 B/op, 65536B 301864->286199 B/op References: Refs #2878
1 parent ac88902 commit 514f219

2 files changed

Lines changed: 38 additions & 46 deletions

File tree

stream-tests/src/test/scala/org/apache/pekko/stream/io/TlsGraphStageIsolatedSpec.scala

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,5 +234,16 @@ class TlsGraphStageIsolatedSpec extends StreamSpec(TlsSpec.configOverrides) with
234234

235235
outputs.foldLeft(ByteString.empty)(_ ++ _) shouldEqual expected
236236
}
237+
238+
"grow the on-demand transport buffers for multi-record payloads without losing bytes" in {
239+
// Transport buffers start at a single packet and grow on demand (heap, to avoid the JDK
240+
// SSLEngine's direct<->heap copy). A payload spanning many records forces transportOut to grow
241+
// past its initial size and then be reused across records; the round trip must still deliver
242+
// every byte in order.
243+
val payloadSize = 200 * 1024
244+
val payload = ByteString(Array.tabulate[Byte](payloadSize)(i => (i % 251).toByte))
245+
246+
roundTrip(initSslContext("TLSv1.2"), List(payload), timeout = 30.seconds) shouldEqual payload
247+
}
237248
}
238249
}

stream/src/main/scala/org/apache/pekko/stream/impl/io/TlsGraphStage.scala

Lines changed: 27 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,6 @@ import scala.util.control.NonFatal
3030

3131
import org.apache.pekko
3232
import pekko.annotation.InternalApi
33-
import pekko.io.{ BufferPool, Tcp }
3433
import pekko.stream._
3534
import pekko.stream.TLSProtocol._
3635
import pekko.stream.impl.Stages.DefaultAttributes
@@ -78,12 +77,11 @@ import pekko.util.ByteString
7877
private var transportInBuffer: ByteBuffer = _
7978
private var transportUnreadBuffer: ByteBuffer = _
8079
private var userInBuffer: ByteBuffer = _
81-
private var bufferPool: BufferPool = _
82-
private var pooledBufferSize = 0
8380
private var transportPacketSize = 0
8481
private var applicationRecordSize = 0
8582
private var maxUserInBufferSize = 0
8683
private var maxUserOutBufferSize = 0
84+
private var maxTransportOutBufferSize = 0
8785

8886
private val userInput = new UserInputSlot(plainIn)
8987
private val transportInput = new TransportInputSlot(cipherIn)
@@ -104,10 +102,6 @@ import pekko.util.ByteString
104102

105103
override def preStart(): Unit =
106104
try {
107-
val tcp = Tcp(materializer.system)
108-
bufferPool = tcp.bufferPool
109-
pooledBufferSize = tcp.Settings.DirectBufferSize
110-
111105
engine = createSSLEngine()
112106
currentSession = engine.getSession
113107
allocateBuffers(currentSession)
@@ -135,15 +129,22 @@ import pekko.util.ByteString
135129
private def allocateBuffers(session: SSLSession): Unit = {
136130
val packetSize = math.max(1, session.getPacketBufferSize)
137131
val applicationSize = math.max(1, session.getApplicationBufferSize)
138-
val applicationBatchSize = applicationBufferSize(applicationSize, packetSize)
139132
transportPacketSize = packetSize
140133
applicationRecordSize = applicationSize
141-
maxUserInBufferSize = applicationBatchSize
142-
maxUserOutBufferSize = applicationBatchSize
143-
144-
transportOutBuffer = acquireTransportBuffer(packetSize)
145-
transportInBuffer = acquireTransportBuffer(packetSize)
146-
transportUnreadBuffer = acquireTransportBuffer(packetSize)
134+
// multiplyExact so an out-of-range SSLSession buffer size fails explicitly here instead of
135+
// silently overflowing to a negative cap and breaking ByteBuffer.allocate later on.
136+
maxUserInBufferSize = Math.multiplyExact(applicationSize, MaxApplicationRecordsPerEngineCall)
137+
maxUserOutBufferSize = maxUserInBufferSize
138+
maxTransportOutBufferSize = Math.multiplyExact(packetSize, MaxApplicationRecordsPerEngineCall)
139+
140+
// The JDK SSLEngine operates on byte[] internally and makes an extra direct<->heap copy per
141+
// wrap/unwrap when handed a direct buffer (see Netty SslHandler's SslEngineType.JDK). Use heap
142+
// buffers and, like Netty, size them on demand: start at a single record so small-payload or
143+
// short-lived connections allocate little, and let growTransportOutBuffer enlarge the wrap
144+
// buffer when a larger batch is actually produced.
145+
transportOutBuffer = ByteBuffer.allocate(packetSize)
146+
transportInBuffer = ByteBuffer.allocate(packetSize)
147+
transportUnreadBuffer = ByteBuffer.allocate(packetSize)
147148
userInBuffer = ByteBuffer.allocate(applicationSize)
148149
userOutBuffer = ByteBuffer.allocate(applicationSize)
149150

@@ -152,12 +153,6 @@ import pekko.util.ByteString
152153
TlsEngineHelpers.emptyReadBuffer(transportUnreadBuffer)
153154
}
154155

155-
private def applicationBufferSize(applicationSize: Int, packetSize: Int): Int = {
156-
val recordsThatFitTransportBuffer = math.max(1, pooledBufferSize / packetSize)
157-
val recordsPerEngineCall = math.min(MaxApplicationRecordsPerEngineCall, recordsThatFitTransportBuffer)
158-
applicationSize * recordsPerEngineCall
159-
}
160-
161156
private def runPump(): Unit =
162157
if ((stateBits & (StageClosedFlag | StageCompletionPendingFlag)) == 0 &&
163158
(stateBits & InitialPumpEnabledFlag) != 0) {
@@ -403,6 +398,8 @@ import pekko.util.ByteString
403398
var result = engine.wrap(userInBuffer, transportOutBuffer)
404399

405400
while (canContinueWrapping(result)) {
401+
if (transportOutBuffer.remaining < transportPacketSize)
402+
growTransportOutBuffer()
406403
val userPosition = userInBuffer.position()
407404
val transportPosition = transportOutBuffer.position()
408405
result = engine.wrap(userInBuffer, transportOutBuffer)
@@ -417,7 +414,8 @@ import pekko.util.ByteString
417414
result.getStatus == OK &&
418415
result.getHandshakeStatus == NOT_HANDSHAKING &&
419416
userInBuffer.hasRemaining &&
420-
transportOutBuffer.remaining >= transportPacketSize
417+
(transportOutBuffer.remaining >= transportPacketSize ||
418+
transportOutBuffer.capacity < maxTransportOutBufferSize)
421419

422420
@tailrec
423421
private def doUnwrap(ignoreOutput: Boolean): Unit = {
@@ -567,15 +565,17 @@ import pekko.util.ByteString
567565
}
568566

569567
private def growTransportOutBuffer(): Unit = {
570-
val oldBuffer = transportOutBuffer
571-
val oldCapacity = oldBuffer.capacity()
572-
if (oldCapacity > Int.MaxValue / 2)
573-
throw new IllegalStateException(s"Cannot grow TLS transport output buffer beyond $oldCapacity bytes")
568+
val oldCapacity = transportOutBuffer.capacity()
569+
if (oldCapacity >= maxTransportOutBufferSize)
570+
throw new IllegalStateException(
571+
s"Cannot grow TLS transport output buffer beyond $maxTransportOutBufferSize bytes")
574572

575-
val bigger = acquireTransportBuffer(oldCapacity * 2)
573+
// Double, but clamp to the cap so growth never overshoots maxTransportOutBufferSize regardless
574+
// of whether the cap is a power-of-two multiple of the packet size. Double in Long so the
575+
// multiplication cannot overflow before the clamp.
576+
val bigger = ByteBuffer.allocate(math.min(maxTransportOutBufferSize.toLong, oldCapacity.toLong * 2).toInt)
576577
transportOutBuffer.flip()
577578
bigger.put(transportOutBuffer)
578-
releaseTransportBuffer(oldBuffer)
579579
transportOutBuffer = bigger
580580
}
581581

@@ -660,32 +660,13 @@ import pekko.util.ByteString
660660
}
661661

662662
private def releaseBuffers(): Unit = {
663-
releaseTransportBuffer(transportOutBuffer)
664663
transportOutBuffer = null
665664
userOutBuffer = null
666-
releaseTransportBuffer(transportInBuffer)
667665
transportInBuffer = null
668-
releaseTransportBuffer(transportUnreadBuffer)
669666
transportUnreadBuffer = null
670667
userInBuffer = null
671668
}
672669

673-
private def acquireTransportBuffer(requiredCapacity: Int): ByteBuffer = {
674-
val buffer = bufferPool.acquire()
675-
if (buffer.capacity >= requiredCapacity) buffer
676-
else {
677-
releaseTransportBuffer(buffer)
678-
throw new IllegalStateException(
679-
s"TLS packet buffer requires $requiredCapacity bytes but pekko.io.tcp.direct-buffer-size is $pooledBufferSize")
680-
}
681-
}
682-
683-
private def releaseTransportBuffer(buffer: ByteBuffer): Unit =
684-
if (buffer ne null) {
685-
buffer.clear()
686-
bufferPool.release(buffer)
687-
}
688-
689670
private final class UserInputSlot(inlet: Inlet[SslTlsOutbound]) extends InHandler {
690671
private val pendingQueue = new Array[AnyRef](UserInputQueueSize)
691672
private var pendingHead = 0

0 commit comments

Comments
 (0)