Skip to content

feat: QoL: Byte Packing of integers [MTT-4924] #2276

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 20 commits into from
Nov 8, 2022
Merged

Conversation

ShadauxCat
Copy link
Collaborator

@ShadauxCat ShadauxCat commented Oct 25, 2022

Adds byte packing for most integer fields in the SDK (a few, such as hashes, are left un-packed because they're likely to be large integers and not benefit - and may possibly actually be harmed - by variable-length encoding). Integers in RPCs and NetworkVariables are also packed properly. No functionality has been added to allow a value to be sent unpacked in RPCs or NetworkVariables in cases where packing may be detrimental, but this can be achieved as follows:

public struct UnpackedInt32 : INetworkSerializeByMemcpy
{
    int Value;
}

public NetworkVariable<UnpackedInt32> MyVariable;

In addition to byte packing most integer fields, this PR updates WriteValueBitPacked() to be able to handle the full range of its type (now using an extra byte - and thus ending up making the representation larger instead of smaller - for values at the extreme ends of the size), at the cost of using one extra bit to encode the size of the type. WriteValueBitPacked shrinks values as follows:

  • 16-bit integers:
    • 0-6 bits used: 1 byte
    • 7-14 bits used: 2 bytes
    • 15-16 bits used: 3 bytes
  • 32-bit integers:
    • 0-5 bits used: 1 byte
    • 6-13 bits used: 2 bytes
    • 14-21 bits used: 3 bytes
    • 22-29 bits used: 4 bytes
    • 30-32 bits used: 5 bytes
  • 64-bit integers:
    • 0-4 bits used: 1 byte
    • 5-12 bits used: 2 bytes
    • 13-20 bits used: 3 bytes
    • 21-28 bits used: 4 bytes
    • 29-36 bits used: 5 bytes
    • 37-44 bits used: 6 bytes
    • 45-52 bits used: 7 bytes
    • 53-60 bits used: 8 bytes
    • 61-64 bits used: 9 bytes

Because this provides better compression than WriteValuePacked in all cases and no longer has any size limits, the old implementation for WriteValuePacked has been removed and all calls to WriteValuePacked now simply forward to WriteValueBitPacked.

This scheme was chosen over the standard Variable-Length Quantity representation for two reasons:

  1. It's less computationally expensive than VLQ (as VLQ has to mask out a bit from every single byte and combine multiple 7-bit values into a single 16-, 32-, or 64-bit value), and
  2. While VLQ can achieve better compression with small values, it scales poorly by compression - a full-size 64-bit integer ends up using 10 bytes with VLQ, rather than 9 bytes with this algorithm (or its native 8 bytes)

Here are some graphs that show the comparisons (the lower the value the better the compression; the further to the right the value changed, the better the compression scales). WriteValuePacked here represents the old method, which has been removed in this PR.

Compression Comparison (16-bit)
Compression Comparison (32-bit)
Compression Comparison (64-bit)

Changelog

  • Changed: Optimized bandwidth usage by encoding most integer fields using variable-length encoding.

Testing and Documentation

  • No tests have been added (as no functionality was added or changed, only optimized).
  • No documentation changes or additions were necessary.

@ShadauxCat ShadauxCat requested a review from a team as a code owner October 25, 2022 17:51
@ShadauxCat ShadauxCat requested a review from a team as a code owner October 25, 2022 20:51
@@ -356,11 +342,17 @@ public static unsafe void ReadValueBitPacked(FastBufferReader reader, out ushort
*ptr = *data;
*(ptr + 1) = *(data + 1);
break;
case 3:
// First byte contains no data, it's just a marker. The data is in the remaining two bytes.
*ptr = *(data + 1);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same as above *(ptr++) = *(data++); twice, with extra ++data at beginning ? maybe it's just style.

@@ -500,58 +500,23 @@ public static unsafe void ReadValueBitPacked(FastBufferReader reader, out ulong
*(ptr + 6) = *(data + 6);
*(ptr + 7) = *(data + 7);
break;
case 9:
// First byte contains no data, it's just a marker. The data is in the remaining two bytes.
*ptr = *(data + 1);
Copy link
Contributor

@jeffreyrainy jeffreyrainy Oct 28, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like a manually unrolled loop. :-) Is there any higher-level construct that will instruct to compiler to copy those 8 bytes that is easier to read and/or doesn't go so squarely against the DRY principle?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IIRC when I was writing this, I did performance comparisons between:

  • UnsafeUtility.MemCpy
  • A for() loop
  • And a manually unrolled loop

And the manually unrolled loop was measurably faster. Normally I'd say "don't worry about that, clarity's more important", but given that this is something we do potentially dozens to hundreds to even thousands of times per frame depending how many NetworkVariables are out there and how much they're changing, this is one area of code where I'm personally inclined to err on the side of optimization over clarity.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

manually unrolled loop was measurably faster

even in standalone release builds too?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I was testing in both mono and IL2CPP in standalone builds.

Copy link
Contributor

@jeffreyrainy jeffreyrainy left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

approved with some minor stylistic nits

// Note: This value can't be packed because we don't know how large it will be in advance
// we reserve space for it, then write the data, then come back and fill in the space
// to pack here, we'd have to write data to a temporary buffer and copy it in - which
// isn't worth possibly saving one byte IFF the data is less than 63 bytes long...
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit-pick or curious what it means:
IFF? abbreviation or misspelled?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Haha, sorry, using formal logical language... IFF is shorthand for "If And Only If"

https://en.wikipedia.org/wiki/If_and_only_if

I'll change it.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

@@ -692,6 +699,18 @@ protected void DestroySceneNetworkObjects()
}
}

/// <summary>
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

😻
Awesome! Nice addition!

@@ -842,7 +841,7 @@ private bool RotationMatchesValue(Vector3 rotationEulerToMatch)
}
if (!nonauthorityIsEqual)
{
VerboseDebug($"NonAuthority position {nonAuthorityRotationEuler} != rotation to match: {rotationEulerToMatch}!");
VerboseDebug($"NonAuthority rotation {nonAuthorityRotationEuler} != rotation to match: {rotationEulerToMatch}!");
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice copy paste debug output catch!

Copy link
Collaborator

@NoelStephensUnity NoelStephensUnity left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Other than the following:

  • The 8 to 5 bytes for the SceneObject structure
  • This could possibly need to be updated as a minor version feat! due to the deprecated public const values.

The rest looks good to me, will review again once you come to a conclusion about the above two items...I will do a second pass over it all once more.

private const int k_MessageHeaderSize = 2;
private static readonly int k_ServerLogSentMessageOverhead = 2 + k_MessageHeaderSize;
private static readonly int k_ServerLogReceivedMessageOverhead = 2;
// Header is dynamically sized due to packing, will be 3 bytes for all test messages.
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note: This increased from 2 bytes to 3 bytes here because BytePacker.WriteValueBitPacked was changed to reserve 3 bits instead of 2 for a uint. It used to be that message length <= 63 would take 1 byte, now message length <= 31 does. The message length for a guid is consistently in between those sizes, so the message header for this test is now 3 bytes.

Copy link
Contributor

@Rosme Rosme left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this change. Looks good!

# Conflicts:
#	com.unity.netcode.gameobjects/CHANGELOG.md
#	com.unity.netcode.gameobjects/Runtime/Core/NetworkObject.cs
@ShadauxCat ShadauxCat enabled auto-merge (squash) November 7, 2022 17:05
@ShadauxCat ShadauxCat merged commit bca25b9 into develop Nov 8, 2022
@ShadauxCat ShadauxCat deleted the feat/bytepacking branch November 8, 2022 16:57
jakobbbb pushed a commit to GooseGirlGames/com.unity.netcode.gameobjects that referenced this pull request Feb 22, 2023
Adds byte packing for most integer fields in the SDK (a few, such as hashes, are left un-packed because they're likely to be large integers and not benefit - and may possibly actually be harmed - by variable-length encoding). Integers in `RPC`s and `NetworkVariable`s are also packed properly. No functionality has been added to allow a value to be sent unpacked in `RPC`s or `NetworkVariable`s in cases where packing may be detrimental, but this can be achieved as follows:

```csharp
public struct UnpackedInt32 : INetworkSerializeByMemcpy
{
    int Value;
}

public NetworkVariable<UnpackedInt32> MyVariable;
````

In addition to byte packing most integer fields, this commit updates `WriteValueBitPacked()` to be able to handle the full range of its type (now using an extra byte - and thus ending up making the representation larger instead of smaller - for values at the extreme ends of the size), at the cost of using one extra bit to encode the size of the type.
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.

6 participants