Skip to content

Reduce memory allocations on the write path - #357

Open
irrld wants to merge 3 commits into
CloudburstMC:3.0from
irrld:pr
Open

Reduce memory allocations on the write path#357
irrld wants to merge 3 commits into
CloudburstMC:3.0from
irrld:pr

Conversation

@irrld

@irrld irrld commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

This PR cuts two per-packet allocations on the write path.

Void promises

BedrockPeer.flushPacketQueue called channel.write(packet), which allocates a ChannelPromise
per packet. The future is never used: the batch encoder completes it and drops it. The queue drain
now passes the channel's shared void promise instead.

Write failures still reach the pipeline as exceptions, since channel.voidPromise() returns the
pipeline's instance, which is constructed with fireException = true.

Length prefix written in place

Batching used to allocate a separate 5-byte buffer for each packet's VarInt length and add it as
its own component, so every packet cost one buffer allocation and two CompositeByteBuf.Components.

The packet codec now reserves 5 bytes (the maximum width of an unsigned 32-bit VarInt) ahead of the
packet by starting the encode buffer at offset 5:

index:   0    1    2    3    4    5                        205
       +----+----+----+----+----+------------------------------+
       | ?? | ?? | ?? | ?? | ?? | 41 41 41 ... (200 bytes)     |
       +----+----+----+----+----+------------------------------+
                                ^                              ^
                        readerIndex=5                writerIndex=205

At batch time the actual prefix width is known, so it is written right-aligned against the packet
and the reader index moves back by exactly that many bytes:

index:   0    1    2    3    4    5                        205
       +----+----+----+----+----+------------------------------+
       | ?? | ?? | ?? | c8 | 01 | 41 41 41 ... (200 bytes)     |
       +----+----+----+----+----+------------------------------+
                      ^
              setUnsignedInt(message, 3, 200)

The component then covers [3, 205). The unused reserved bytes fall outside it and never reach the
wire, so the output is byte-identical to the old path and no padding is involved.

This removes one component per packet, plus the header buffer. In the profile below the components
(addComponent, 29.14%) cost about thirty times what the header buffers did (ioBuffer, 0.94%),
because the header buffer is pooled and recycled while every Component is a fresh object. The
component is where the saving is.

Notes

  • The encode buffer request is unchanged at 128 bytes with the reservation inside it, so it stays
    on the same pooled size class.
  • Buffers that carry no reservation (pre-encoded, decoded, or caller-supplied) take the original
    two-component path. reservedPrefixBytes defaults to 0 and setPacketBuffer clears it, so a
    reservation cannot outlive the buffer it describes.
  • encodeHeader implementations are unaffected. _v1, _v2 and _v3 all write relatively
    (writeByte, writeUnsignedInt), so a pre-positioned writer index is transparent to them.
  • Adds a test source set to common and tests pinning sizeOfUnsignedInt and setUnsignedInt to
    writeUnsignedInt, plus batch tests asserting both paths emit identical bytes.

Profile

This is a before profile, the measurement that drove these changes.

image

@irrld irrld changed the title Improve memory allocations Reduce memory allocations on the write path Sep 2, 2026
@irrld
irrld marked this pull request as draft September 2, 2026 22:12
@irrld

irrld commented Sep 2, 2026

Copy link
Copy Markdown
Contributor Author

Included a patch to FlagTransformer as well. I wanna do a bit more testing before it is ready to merged.

@irrld
irrld marked this pull request as ready for review September 3, 2026 12:19
@SupremeMortal

Copy link
Copy Markdown
Member

Is it me or does reservedPrefixBytes always equal 0 or 5? Could that not instead be a boolean?

@irrld

irrld commented Sep 6, 2026

Copy link
Copy Markdown
Contributor Author

Yes, it is set to either 0 or 5 by the library itself. Now that you mentioned, I think we could change it to a byte instead, so we would save memory without making it hardcoded on 5 bytes prefix. I don't think making it a boolean and hardcoding to 5 bytes would buy us anything over using byte.

If we make it byte, then it will allow someone using the library to change how the prefixed bytes behaves depending on how they want it to work, maybe they can guarantee that specific packets are always smaller than some size and reduce the prefixed byte size.

@irrld irrld closed this Sep 6, 2026
@irrld irrld reopened this Sep 6, 2026
@irrld

irrld commented Sep 6, 2026

Copy link
Copy Markdown
Contributor Author

I have no idea how I did that lmao

@irrld

irrld commented Sep 6, 2026

Copy link
Copy Markdown
Contributor Author

Okay, after a quick check, the size of the BedrockPacketWrapper object is 48 bytes without int reservedPrefixBytes, adding any other field means that the object size will be aligned to Java's 8 byte alignment regardless if its a byte or int, so the object size will grow to 56 bytes. What we can do to keep BedrockPacketWrapper at 48 bytes is demoting the senderSubClientId and targetSubClientId types to byte instead, that would keep the BedrockPacketWrapper size at 48 bytes with the byte reservedPrefixBytes

I don't think senderSubClientId can be anything above 127 anyway so would it be okay to change?

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.

2 participants