Skip to content
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

Improve float serialization, add .NET 5 Half support. #77

Open
wants to merge 6 commits into
base: master
Choose a base branch
from

Conversation

PJB3005
Copy link
Contributor

@PJB3005 PJB3005 commented Nov 21, 2020

Apologies for doing this all in one PR. Doing it separately would've produced three conflicting PRs which is even less ideal.

Optimized float serialization to be faster and write less bytes in almost all cases. Add .NET 5 Half support. See commits for details.

Benchmarks for writing/reading code mentioned
BenchmarkDotNet=v0.12.1, OS=arch 
Intel Core i7-8750H CPU 2.20GHz (Coffee Lake), 1 CPU, 12 logical and 6 physical cores
.NET Core SDK=5.0.100
  [Host]     : .NET Core 5.0.0 (CoreCLR 5.0.20.51904, CoreFX 5.0.20.51904), X64 RyuJIT
  DefaultJob : .NET Core 5.0.0 (CoreCLR 5.0.20.51904, CoreFX 5.0.20.51904), X64 RyuJIT
Method Mean Error StdDev Median
BenchWrite16Span 13.65 ns 0.305 ns 0.775 ns 13.38 ns
BenchWrite32Span 13.65 ns 0.172 ns 0.152 ns 13.62 ns
BenchWrite64Span 13.09 ns 0.278 ns 0.298 ns 13.02 ns
BenchRead16Span 13.52 ns 0.250 ns 0.267 ns 13.43 ns
BenchRead32Span 13.32 ns 0.287 ns 0.255 ns 13.36 ns
BenchRead64Span 12.57 ns 0.175 ns 0.164 ns 12.49 ns
BenchWrite16Byte 10.28 ns 0.227 ns 0.279 ns 10.24 ns
BenchWrite32Byte 16.89 ns 0.360 ns 0.400 ns 16.98 ns
BenchWrite64Byte 30.58 ns 0.639 ns 1.261 ns 30.42 ns
BenchRead16Byte 11.30 ns 0.238 ns 0.274 ns 11.26 ns
BenchRead32Byte 16.09 ns 0.342 ns 0.267 ns 16.10 ns
BenchRead64Byte 29.17 ns 0.630 ns 1.836 ns 28.89 ns

Span code is used on .NET Core/.NET 5 for 32/64 bit since it's faster.

Benchmark code:

using System;
using System.Buffers.Binary;
using System.IO;
using BenchmarkDotNet.Attributes;

namespace Content.Benchmarks
{
    [SimpleJob]
    public class NetSerializerIntBenchmark
    {
        private MemoryStream _writeStream;
        private MemoryStream _readStream;
        private ushort _x16 = 5;
        private uint _x32 = 5;
        private ulong _x64 = 5;
        private ushort _read16;
        private uint _read32;
        private ulong _read64;

        [GlobalSetup]
        public void Setup()
        {
            _writeStream = new MemoryStream(64);
            _readStream = new MemoryStream();
            _readStream.Write(new byte[]{0x1, 0x2, 0x3, 0x4, 0x5, 0x6, 0x7, 0x8});
        }

        [Benchmark]
        public void BenchWrite16Span()
        {
            _writeStream.Position = 0;
            WriteUInt16Span(_writeStream, _x16);
        }

        [Benchmark]
        public void BenchWrite32Span()
        {
            _writeStream.Position = 0;
            WriteUInt32Span(_writeStream, _x32);
        }

        [Benchmark]
        public void BenchWrite64Span()
        {
            _writeStream.Position = 0;
            WriteUInt64Span(_writeStream, _x64);
        }

        [Benchmark]
        public void BenchRead16Span()
        {
            _readStream.Position = 0;
            _read16 = ReadUInt16Span(_readStream);
        }

        [Benchmark]
        public void BenchRead32Span()
        {
            _readStream.Position = 0;
            _read32 = ReadUInt32Span(_readStream);
        }

        [Benchmark]
        public void BenchRead64Span()
        {
            _readStream.Position = 0;
            _read64 = ReadUInt64Span(_readStream);
        }

        [Benchmark]
        public void BenchWrite16Byte()
        {
            _writeStream.Position = 0;
            WriteUInt16Byte(_writeStream, _x16);
        }

        [Benchmark]
        public void BenchWrite32Byte()
        {
            _writeStream.Position = 0;
            WriteUInt32Byte(_writeStream, _x32);
        }

        [Benchmark]
        public void BenchWrite64Byte()
        {
            _writeStream.Position = 0;
            WriteUInt64Byte(_writeStream, _x64);
        }

        [Benchmark]
        public void BenchRead16Byte()
        {
            _readStream.Position = 0;
            _read16 = ReadUInt16Byte(_readStream);
        }
        [Benchmark]
        public void BenchRead32Byte()
        {
            _readStream.Position = 0;
            _read32 = ReadUInt32Byte(_readStream);
        }

        [Benchmark]
        public void BenchRead64Byte()
        {
            _readStream.Position = 0;
            _read64 = ReadUInt64Byte(_readStream);
        }

        private static void WriteUInt16Byte(Stream stream, ushort value)
        {
            stream.WriteByte((byte) value);
            stream.WriteByte((byte) (value >> 8));
        }

        private static void WriteUInt32Byte(Stream stream, uint value)
        {
            stream.WriteByte((byte) value);
            stream.WriteByte((byte) (value >> 8));
            stream.WriteByte((byte) (value >> 16));
            stream.WriteByte((byte) (value >> 24));
        }

        private static void WriteUInt64Byte(Stream stream, ulong value)
        {
            stream.WriteByte((byte) value);
            stream.WriteByte((byte) (value >> 8));
            stream.WriteByte((byte) (value >> 16));
            stream.WriteByte((byte) (value >> 24));
            stream.WriteByte((byte) (value >> 32));
            stream.WriteByte((byte) (value >> 40));
            stream.WriteByte((byte) (value >> 48));
            stream.WriteByte((byte) (value >> 56));
        }

        private static ushort ReadUInt16Byte(Stream stream)
        {
            ushort a = 0;

            for (var i = 0; i < 16; i += 8)
            {
                var val = stream.ReadByte();
                if (val == -1)
                    throw new EndOfStreamException();

                a |= (ushort) (val << i);
            }

            return a;
        }

        private static uint ReadUInt32Byte(Stream stream)
        {
            uint a = 0;

            for (var i = 0; i < 32; i += 8)
            {
                var val = stream.ReadByte();
                if (val == -1)
                    throw new EndOfStreamException();

                a |= (uint) (val << i);
            }

            return a;
        }

        private static ulong ReadUInt64Byte(Stream stream)
        {
            ulong a = 0;

            for (var i = 0; i < 64; i += 8)
            {
                var val = stream.ReadByte();
                if (val == -1)
                    throw new EndOfStreamException();

                a |= (ulong) (val << i);
            }

            return a;
        }

        private static void WriteUInt16Span(Stream stream, ushort value)
        {
            Span<byte> buf = stackalloc byte[2];
            BinaryPrimitives.WriteUInt16LittleEndian(buf, value);

            stream.Write(buf);
        }

        private static void WriteUInt32Span(Stream stream, uint value)
        {
            Span<byte> buf = stackalloc byte[4];
            BinaryPrimitives.WriteUInt32LittleEndian(buf, value);

            stream.Write(buf);
        }

        private static void WriteUInt64Span(Stream stream, ulong value)
        {
            Span<byte> buf = stackalloc byte[8];
            BinaryPrimitives.WriteUInt64LittleEndian(buf, value);

            stream.Write(buf);
        }

        private static ushort ReadUInt16Span(Stream stream)
        {
            Span<byte> buf = stackalloc byte[2];
            var wSpan = buf;

            while (true)
            {
                var read = stream.Read(wSpan);
                if (read == 0)
                    throw new EndOfStreamException();
                if (read == wSpan.Length)
                    break;
                wSpan = wSpan[read..];
            }

            return BinaryPrimitives.ReadUInt16LittleEndian(buf);
        }

        private static uint ReadUInt32Span(Stream stream)
        {
            Span<byte> buf = stackalloc byte[4];
            var wSpan = buf;

            while (true)
            {
                var read = stream.Read(wSpan);
                if (read == 0)
                    throw new EndOfStreamException();
                if (read == wSpan.Length)
                    break;
                wSpan = wSpan[read..];
            }

            return BinaryPrimitives.ReadUInt32LittleEndian(buf);
        }

        private static ulong ReadUInt64Span(Stream stream)
        {
            Span<byte> buf = stackalloc byte[8];
            var wSpan = buf;

            while (true)
            {
                var read = stream.Read(wSpan);
                if (read == 0)
                    throw new EndOfStreamException();
                if (read == wSpan.Length)
                    break;
                wSpan = wSpan[read..];
            }

            return BinaryPrimitives.ReadUInt64LittleEndian(buf);
        }
    }
}
Proof that compressed int writing for floats does not help
[Test]
public void TestAllHalf()
{
	ushort i = 0;
	var ms = new MemoryStream();
	do
	{
		var half = Unsafe.As<ushort, Half>(ref i);

		if (Half.IsNormal(half) || Half.IsInfinity(half) || Half.IsNaN(half))
		{
			Primitives.WritePrimitive(ms, half);
			Assert.That(ms.Position != 1, $"Failed: {i}");
			ms.Position = 0;
		}

		i += 1;
	} while (i != ushort.MaxValue);
}

[Test]
public void TestAllFloat()
{
	Assert.Multiple(() =>
	{
		uint i = 0;
		var ms = new MemoryStream();
		do
		{
			var single = Unsafe.As<uint, float>(ref i);

			if (float.IsNormal(single) || float.IsInfinity(single) || float.IsNaN(single))
			{
				Primitives.WritePrimitive(ms, single);
				if (ms.Position < 3)
				{
					Assert.Fail($"Failed: {i}");
				}
				ms.Position = 0;
			}

			i += 1;
		} while (i != uint.MaxValue);
	});
}
```cs

</details>

.NET Core 3.0 is unsupported now and targeting it actively throws a warning on the .NET 5 SDK.
For future commit to add Half support.
Float serialization used the same serialization code as normal integers, which writes less bytes if the integer is of sufficiently small size. The problem however is that a float is basically *never* so small due to how the bits are laid out and this almost always resulted in a wasted extra byte vs just writing the 4/8 bytes of the float directly.

("basically never" is everything except 0/subnormals, which I'd say is not worth adding an extra byte to almost all other floats over. I even tested all possible floats just to make sure.)

The new code is also faster on Core because it writes the 4/8 bytes at once instead of individual WriteByte/ReadByte() calls (see benchmarks in PR).
Copy link
Owner

@tomba tomba left a comment

Choose a reason for hiding this comment

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

Single PR is fine. It's the commits themselves which are important (doing just a single thing in a single commit).

I had some minor comments, but otherwise this looks good.

NetSerializer/Primitives.cs Outdated Show resolved Hide resolved
NetSerializer.UnitTests/PrimitivesTest.cs Outdated Show resolved Hide resolved
}

return a;
}
Copy link
Owner

Choose a reason for hiding this comment

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

Shouldn't these two methods be part of the next commit ("Add support for .NET 5 Half type.")?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yeah I was kind of debating that as well but figured this would make the diffs cleaner since now all these uint read/write methods are in the same commit.

Copy link
Owner

Choose a reason for hiding this comment

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

The reasons why I think it should not be part of the commit:

  • This commit is "improve float serialization", i.e. improve what we already have
  • The new methods are not used (so they cannot even improve the float serialization)
  • The commit message doesn't mention adding new feature
  • If you do add a note to the commit message, it would probably be something like this at the end of the message "Also, add xyz", which is a clear hint that the code is probably really not part of the commit.

@tomba
Copy link
Owner

tomba commented Nov 29, 2020

The "Fix indentation" looks fine, but please don't add "fix an earlier commit in this pull request" commits to a pull request. Just fix the earlier commits, and push the new branch over the old pull request. Or if there are a lot of changes, perhaps create a new pull request.

Github is broken and doesn't support the above model properly, but I don't want such fixes merged. They just make the history more confusing, and in some cases (not here, though) break bisect.

@tjs-shah
Copy link

tjs-shah commented Aug 4, 2021

Any plan to release new version of NetSerializer with .NET 5.0 and higher? Do you have any timeline?

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.

3 participants