From f7b835d7e9c0782392e54706c2c214874cb9551f Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Mon, 13 Jul 2026 23:10:41 -0700 Subject: [PATCH] perf(saves): 2.2x faster BufferWriter write path, single-pass short strings Tier-1 disasm of a generated-style Serialize showed PGO's guarded devirtualization already inlines every IGenericWriter.Write body (interface vs concrete-typed callsites measured identical), so no API or generator changes are needed. What remained as a real call per primitive write was the Index property setter - too large to inline due to its range-check throw path, plus per-write high-water tracking, an AsSpan bounds check, and a BinaryPrimitives span check. The write path now reserves capacity once (the existing grow-on-Flush check), then does an unaligned store through a ref with a raw index increment - the capacity check proves the store in-bounds, and the index only moves forward between Seeks. The _bytesWritten high-water mark (used only by SeekOrigin.End) folds at Seek/Resize instead of per write, which is equivalent because writes are monotonic between seeks. The Index property keeps its validating semantics for Seek and subclasses. BufferWriter also gains class-level implementations of the hottest IGenericWriter default interface methods (WriteEncodedInt, DateTime, TimeSpan, Point2D/3D, Rectangle2D/3D, Map, Race): a DIM dispatches again on `this` for every nested Write even at a devirtualized callsite, and the class overloads keep the whole write inlined. Strings (arbitrary UTF-16) previously walked every string twice (GetByteCount then GetBytes) because the variable-width length prefix precedes the bytes. Strings of 85 chars or fewer (any content, incl. surrogate pairs - 85 * 3 = 255 bytes max) now encode once into a 256-byte stack scratch, then write the prefix and copy. Byte output is identical. Measured: 34.4 -> 15.7 ns/entity on a generated-style write mix (~20 writes, 58 bytes), 22.5 -> 11.8 ns per short-string write, and the end-to-end freeze benchmark (10M entities / 1.7GB / 24 cores, dense profile) drops from ~99ms to ~74-82ms. Combined with the earlier pipeline commits: ~740ms -> ~78ms. New tests pin byte-level output and position semantics: primitive little-endian layouts, Seek(End) high-water behavior, growth preservation, span writes across growth, encoded-int formats, and string equivalence across the scratch/two-pass boundary with mixed-width UTF-16 content. Co-Authored-By: Claude Fable 5 --- .../Tests/Serialization/BufferWriterTests.cs | 254 ++++++++++++++++++ Projects/Server/Serialization/BufferWriter.cs | 204 ++++++++++---- 2 files changed, 412 insertions(+), 46 deletions(-) create mode 100644 Projects/Server.Tests/Tests/Serialization/BufferWriterTests.cs diff --git a/Projects/Server.Tests/Tests/Serialization/BufferWriterTests.cs b/Projects/Server.Tests/Tests/Serialization/BufferWriterTests.cs new file mode 100644 index 000000000..105ae4249 --- /dev/null +++ b/Projects/Server.Tests/Tests/Serialization/BufferWriterTests.cs @@ -0,0 +1,254 @@ +using System; +using System.Buffers.Binary; +using System.IO; +using System.Text; +using Xunit; + +namespace Server.Tests; + +/// +/// Pins BufferWriter's byte-level output and position semantics so the write path can be +/// optimized without behavioral drift. +/// +public class BufferWriterTests +{ + [Fact] + public void PrimitivesAreLittleEndianAtExpectedOffsets() + { + var writer = new BufferWriter(new byte[256], true); + + writer.Write((byte)0xAB); + writer.Write((sbyte)-5); + writer.Write(true); + writer.Write(false); + writer.Write((short)-12345); + writer.Write((ushort)54321); + writer.Write(-123456789); + writer.Write(3123456789u); + writer.Write(-1234567890123456789L); + writer.Write(12345678901234567890UL); + writer.Write(1234.5678d); + writer.Write(56.75f); + writer.Write((Serial)0x40000001u); + + Assert.Equal(1 + 1 + 1 + 1 + 2 + 2 + 4 + 4 + 8 + 8 + 8 + 4 + 4, writer.Position); + + var b = writer.Buffer; + Assert.Equal(0xAB, b[0]); + Assert.Equal(unchecked((byte)-5), b[1]); + Assert.Equal(1, b[2]); + Assert.Equal(0, b[3]); + Assert.Equal(-12345, BinaryPrimitives.ReadInt16LittleEndian(b.AsSpan(4))); + Assert.Equal(54321, BinaryPrimitives.ReadUInt16LittleEndian(b.AsSpan(6))); + Assert.Equal(-123456789, BinaryPrimitives.ReadInt32LittleEndian(b.AsSpan(8))); + Assert.Equal(3123456789u, BinaryPrimitives.ReadUInt32LittleEndian(b.AsSpan(12))); + Assert.Equal(-1234567890123456789L, BinaryPrimitives.ReadInt64LittleEndian(b.AsSpan(16))); + Assert.Equal(12345678901234567890UL, BinaryPrimitives.ReadUInt64LittleEndian(b.AsSpan(24))); + Assert.Equal(1234.5678d, BinaryPrimitives.ReadDoubleLittleEndian(b.AsSpan(32))); + Assert.Equal(56.75f, BinaryPrimitives.ReadSingleLittleEndian(b.AsSpan(40))); + Assert.Equal(0x40000001u, BinaryPrimitives.ReadUInt32LittleEndian(b.AsSpan(44))); + } + + [Fact] + public void SeekEndUsesHighWaterMarkNotCurrentPosition() + { + var writer = new BufferWriter(new byte[256], true); + + writer.Write(1L); + writer.Write(2L); + writer.Write(3L); // high water = 24 + + writer.Seek(4, SeekOrigin.Begin); + writer.Write(99); // position now 8, high water still 24 + + Assert.Equal(24, writer.Seek(0, SeekOrigin.End)); + Assert.Equal(24, writer.Position); + } + + [Fact] + public void SeekCurrentAndBeginBehave() + { + var writer = new BufferWriter(new byte[64], true); + + writer.Write(0xDEADBEEF); + Assert.Equal(2, writer.Seek(2, SeekOrigin.Begin)); + Assert.Equal(3, writer.Seek(1, SeekOrigin.Current)); + + writer.Write((byte)0x77); + Assert.Equal(0x77, writer.Buffer[3]); + } + + [Fact] + public void GrowthPreservesContentAndPosition() + { + var writer = new BufferWriter(new byte[16], true); + + for (var i = 0; i < 100; i++) + { + writer.Write((long)i); + } + + Assert.Equal(800, writer.Position); + Assert.True(writer.Buffer.Length >= 800); + + for (var i = 0; i < 100; i++) + { + Assert.Equal(i, BinaryPrimitives.ReadInt64LittleEndian(writer.Buffer.AsSpan(i * 8))); + } + } + + [Fact] + public void SpanWriteCrossesGrowthBoundary() + { + var writer = new BufferWriter(new byte[8], true); + + Span payload = stackalloc byte[64]; + for (var i = 0; i < payload.Length; i++) + { + payload[i] = (byte)(i + 1); + } + + writer.Write((ushort)7); + writer.Write(payload); + + Assert.Equal(66, writer.Position); + Assert.Equal(payload.ToArray(), writer.Buffer.AsSpan(2, 64).ToArray()); + } + + [Theory] + [InlineData(0, new byte[] { 0x00 })] + [InlineData(127, new byte[] { 0x7F })] + [InlineData(128, new byte[] { 0x80, 0x01 })] + [InlineData(0x3FFF, new byte[] { 0xFF, 0x7F })] + [InlineData(int.MaxValue, new byte[] { 0xFF, 0xFF, 0xFF, 0xFF, 0x07 })] + [InlineData(-1, new byte[] { 0xFF, 0xFF, 0xFF, 0xFF, 0x0F })] + public void EncodedIntMatchesFormat(int value, byte[] expected) + { + var writer = new BufferWriter(new byte[16], true); + + ((IGenericWriter)writer).WriteEncodedInt(value); + + Assert.Equal(expected.Length, writer.Position); + Assert.Equal(expected, writer.Buffer.AsSpan(0, expected.Length).ToArray()); + } + + [Fact] + public void PrefixedStringsWriteFlagLengthAndUtf8() + { + var writer = new BufferWriter(new byte[256], true); + + writer.Write("héllo Ωorld"); + var utf8 = Encoding.UTF8.GetBytes("héllo Ωorld"); + + var b = writer.Buffer; + Assert.Equal(1, b[0]); // not-null flag + Assert.Equal(utf8.Length, b[1]); // encoded length (small string = 1 byte) + Assert.Equal(utf8, b.AsSpan(2, utf8.Length).ToArray()); + Assert.Equal(2 + utf8.Length, writer.Position); + + writer.Write((string)null); + Assert.Equal(0, b[2 + utf8.Length]); // null flag + } + + [Theory] + [InlineData(84)] // scratch path + [InlineData(85)] // scratch path boundary + [InlineData(86)] // two-pass path + [InlineData(300)] // two-pass, length prefix > 1 byte + public void StringPathsAgreeAcrossTheScratchBoundary(int chars) + { + // Mixed ASCII, 2-byte, 3-byte, and surrogate-pair (4-byte) content + var builder = new StringBuilder(chars); + for (var i = 0; builder.Length < chars; i++) + { + switch (i % 4) + { + case 0: + builder.Append('a'); + break; + case 1: + builder.Append('é'); + break; + case 2: + builder.Append('Ω'); + break; + default: + if (builder.Length + 2 <= chars) + { + builder.Append("𝔘"); // surrogate pair + } + else + { + builder.Append('z'); + } + break; + } + } + + var value = builder.ToString(); + Assert.Equal(chars, value.Length); + + var writer = new BufferWriter(new byte[16], true); // forces growth through both paths + writer.Write(value); + + var utf8 = Encoding.UTF8.GetBytes(value); + var b = writer.Buffer; + Assert.Equal(1, b[0]); + + // decode the 7-bit encoded length prefix + var offset = 1; + var length = 0; + var shift = 0; + byte current; + do + { + current = b[offset++]; + length |= (current & 0x7F) << shift; + shift += 7; + } while ((current & 0x80) != 0); + + Assert.Equal(utf8.Length, length); + Assert.Equal(utf8, b.AsSpan(offset, length).ToArray()); + Assert.Equal(offset + length, writer.Position); + } + + [Fact] + public void DateTimeWritesUtcTicksViaInterface() + { + var writer = new BufferWriter(new byte[64], true); + IGenericWriter iface = writer; + + var utc = new DateTime(2026, 7, 13, 1, 2, 3, DateTimeKind.Utc); + var local = utc.ToLocalTime(); + + iface.Write(utc); + iface.Write(local); // must convert to UTC + + Assert.Equal(utc.Ticks, BinaryPrimitives.ReadInt64LittleEndian(writer.Buffer.AsSpan(0))); + Assert.Equal(utc.Ticks, BinaryPrimitives.ReadInt64LittleEndian(writer.Buffer.AsSpan(8))); + } + + [Fact] + public void Point3DWritesThreeInts() + { + var writer = new BufferWriter(new byte[64], true); + IGenericWriter iface = writer; + + iface.Write(new Point3D(100, -200, 30)); + + Assert.Equal(12, writer.Position); + Assert.Equal(100, BinaryPrimitives.ReadInt32LittleEndian(writer.Buffer.AsSpan(0))); + Assert.Equal(-200, BinaryPrimitives.ReadInt32LittleEndian(writer.Buffer.AsSpan(4))); + Assert.Equal(30, BinaryPrimitives.ReadInt32LittleEndian(writer.Buffer.AsSpan(8))); + } + + [Fact] + public void DecimalRoundTripsThroughReader() + { + var writer = new BufferWriter(new byte[64], true); + writer.Write(1234567.89012m); + + IGenericReader reader = new BufferReader(writer.Buffer); + Assert.Equal(1234567.89012m, reader.ReadDecimal()); + } +} diff --git a/Projects/Server/Serialization/BufferWriter.cs b/Projects/Server/Serialization/BufferWriter.cs index 45f9ed379..f03bb88f7 100644 --- a/Projects/Server/Serialization/BufferWriter.cs +++ b/Projects/Server/Serialization/BufferWriter.cs @@ -30,6 +30,10 @@ public class BufferWriter : IGenericWriter private readonly ConcurrentQueue _types; private readonly Encoding _encoding; private readonly bool _prefixStrings; + + // High-water mark for SeekOrigin.End. Writes advance _index directly and this is folded + // in Seek/Resize (the only places the index can move backward), so the hot write path + // carries no per-write bookkeeping. private long _bytesWritten; private long _index; @@ -76,7 +80,7 @@ public class BufferWriter : IGenericWriter _types = types; } - public virtual long Position => Index; + public virtual long Position => _index; protected virtual int BufferSize => 256; @@ -89,6 +93,8 @@ public class BufferWriter : IGenericWriter [MethodImpl(MethodImplOptions.AggressiveInlining)] public void Resize(int size) { + _bytesWritten = Math.Max(_bytesWritten, _index); + // We shouldn't ever resize to a 0 length buffer. That is dangerous if (size <= 0) { @@ -107,13 +113,23 @@ public class BufferWriter : IGenericWriter public virtual void Flush() => Resize(Math.Clamp(_buffer.Length * 2, BufferSize, _buffer.Length + 1024 * 1024 * 64)); + /// + /// Ensures capacity, returns a ref at the current position, and advances the index. + /// The capacity check proves the caller's unaligned store is in-bounds, and the index + /// only moves forward between Seek calls, so no per-write validation is needed. Growth + /// (Flush -> Resize) always adds at least BufferSize, covering any primitive width. + /// [MethodImpl(MethodImplOptions.AggressiveInlining)] - private void FlushIfNeeded(int amount) + private ref byte Reserve(int bytes) { - if (Index + amount > _buffer.Length) + if (_index + bytes > _buffer.Length) { Flush(); } + + ref var result = ref Unsafe.Add(ref MemoryMarshal.GetArrayDataReference(_buffer), (nint)_index); + _index += bytes; + return ref result; } public virtual void Write(byte[] bytes) => Write(bytes.AsSpan()); @@ -130,7 +146,7 @@ public class BufferWriter : IGenericWriter } bytes.CopyTo(_buffer.AsSpan((int)_index)); - Index += length; + _index += length; } [MethodImpl(MethodImplOptions.AggressiveInlining)] @@ -149,9 +165,11 @@ public class BufferWriter : IGenericWriter "Attempting to seek to an invalid position using SeekOrigin.Current" ); + _bytesWritten = Math.Max(_bytesWritten, _index); + return Index = Math.Max(0, origin switch { - SeekOrigin.Current => Index + offset, + SeekOrigin.Current => _index + offset, SeekOrigin.End => _bytesWritten + offset, _ => offset // Begin }); @@ -181,95 +199,99 @@ public class BufferWriter : IGenericWriter [MethodImpl(MethodImplOptions.AggressiveInlining)] public void Write(long value) { - FlushIfNeeded(8); + if (!BitConverter.IsLittleEndian) + { + value = BinaryPrimitives.ReverseEndianness(value); + } - BinaryPrimitives.WriteInt64LittleEndian(_buffer.AsSpan((int)_index), value); - Index += 8; + Unsafe.WriteUnaligned(ref Reserve(8), value); } [MethodImpl(MethodImplOptions.AggressiveInlining)] public void Write(ulong value) { - FlushIfNeeded(8); + if (!BitConverter.IsLittleEndian) + { + value = BinaryPrimitives.ReverseEndianness(value); + } - BinaryPrimitives.WriteUInt64LittleEndian(_buffer.AsSpan((int)_index), value); - Index += 8; + Unsafe.WriteUnaligned(ref Reserve(8), value); } [MethodImpl(MethodImplOptions.AggressiveInlining)] public void Write(int value) { - FlushIfNeeded(4); + if (!BitConverter.IsLittleEndian) + { + value = BinaryPrimitives.ReverseEndianness(value); + } - BinaryPrimitives.WriteInt32LittleEndian(_buffer.AsSpan((int)_index), value); - Index += 4; + Unsafe.WriteUnaligned(ref Reserve(4), value); } [MethodImpl(MethodImplOptions.AggressiveInlining)] public void Write(uint value) { - FlushIfNeeded(4); + if (!BitConverter.IsLittleEndian) + { + value = BinaryPrimitives.ReverseEndianness(value); + } - BinaryPrimitives.WriteUInt32LittleEndian(_buffer.AsSpan((int)_index), value); - Index += 4; + Unsafe.WriteUnaligned(ref Reserve(4), value); } [MethodImpl(MethodImplOptions.AggressiveInlining)] public void Write(short value) { - FlushIfNeeded(2); + if (!BitConverter.IsLittleEndian) + { + value = BinaryPrimitives.ReverseEndianness(value); + } - BinaryPrimitives.WriteInt16LittleEndian(_buffer.AsSpan((int)_index), value); - Index += 2; + Unsafe.WriteUnaligned(ref Reserve(2), value); } [MethodImpl(MethodImplOptions.AggressiveInlining)] public void Write(ushort value) { - FlushIfNeeded(2); + if (!BitConverter.IsLittleEndian) + { + value = BinaryPrimitives.ReverseEndianness(value); + } - BinaryPrimitives.WriteUInt16LittleEndian(_buffer.AsSpan((int)_index), value); - Index += 2; + Unsafe.WriteUnaligned(ref Reserve(2), value); } [MethodImpl(MethodImplOptions.AggressiveInlining)] public void Write(double value) { - FlushIfNeeded(8); + if (!BitConverter.IsLittleEndian) + { + value = BitConverter.Int64BitsToDouble(BinaryPrimitives.ReverseEndianness(BitConverter.DoubleToInt64Bits(value))); + } - BinaryPrimitives.WriteDoubleLittleEndian(_buffer.AsSpan((int)_index), value); - Index += 8; + Unsafe.WriteUnaligned(ref Reserve(8), value); } [MethodImpl(MethodImplOptions.AggressiveInlining)] public void Write(float value) { - FlushIfNeeded(4); + if (!BitConverter.IsLittleEndian) + { + value = BitConverter.Int32BitsToSingle(BinaryPrimitives.ReverseEndianness(BitConverter.SingleToInt32Bits(value))); + } - BinaryPrimitives.WriteSingleLittleEndian(_buffer.AsSpan((int)_index), value); - Index += 4; + Unsafe.WriteUnaligned(ref Reserve(4), value); } [MethodImpl(MethodImplOptions.AggressiveInlining)] - public void Write(byte value) - { - FlushIfNeeded(1); - _buffer[Index++] = value; - } + public void Write(byte value) => Reserve(1) = value; [MethodImpl(MethodImplOptions.AggressiveInlining)] - public void Write(sbyte value) - { - FlushIfNeeded(1); - _buffer[Index++] = (byte)value; - } + public void Write(sbyte value) => Reserve(1) = (byte)value; [MethodImpl(MethodImplOptions.AggressiveInlining)] - public unsafe void Write(bool value) - { - FlushIfNeeded(1); - _buffer[Index++] = *(byte*)&value; // up to 30% faster to dereference the raw value on the stack - } + public void Write(bool value) => Reserve(1) = Unsafe.As(ref value); [MethodImpl(MethodImplOptions.AggressiveInlining)] public void Write(Serial serial) => Write(serial.Value); @@ -298,12 +320,102 @@ public class BufferWriter : IGenericWriter Write(MemoryMarshal.Cast(buffer)); } + // Class-level implementations of the hottest IGenericWriter default interface methods. + // The JIT devirtualizes and inlines interface calls to the concrete type, but a default + // interface method dispatches again internally on `this` for every nested Write — these + // keep the whole write inlined instead. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void WriteEncodedInt(int value) + { + var v = (uint)value; + + while (v >= 0x80) + { + Write((byte)(v | 0x80)); + v >>= 7; + } + + Write((byte)v); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void Write(DateTime value) + { + // If DateTimeKind is Unspecified, we can't assume it needs to be converted. + if (value.Kind == DateTimeKind.Local) + { + value = value.ToUniversalTime(); + } + + Write(value.Ticks); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void Write(TimeSpan value) => Write(value.Ticks); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void Write(Point3D value) + { + Write(value.m_X); + Write(value.m_Y); + Write(value.m_Z); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void Write(Point2D value) + { + Write(value.m_X); + Write(value.m_Y); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void Write(Rectangle2D value) + { + Write(value.Start); + Write(value.End); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void Write(Rectangle3D value) + { + Write(value.Start); + Write(value.End); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void Write(Map value) => Write((byte)(value?.MapIndex ?? 0xFF)); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void Write(Race value) => Write((byte)(value?.RaceIndex ?? 0xFF)); + internal void InternalWriteString(string value) { + // Single pass for typical (short) strings: encode into a stack scratch, then write the + // length prefix and copy. The two-pass path below walks the string twice + // (GetByteCount + GetBytes) because the variable-width prefix must precede the bytes. + // UTF8 needs at most 3 bytes per non-surrogate char (surrogate pairs encode 2 chars + // into 4 bytes), so 85 chars always fit 255 bytes. + if (value.Length <= 85) + { + Span scratch = stackalloc byte[256]; + var written = _encoding.GetBytes(value, scratch); + + WriteEncodedInt(written); + + while (_buffer.Length - _index < written) + { + Flush(); + } + + scratch[..written].CopyTo(_buffer.AsSpan((int)_index)); + _index += written; + return; + } + var length = _encoding.GetByteCount(value); - ((IGenericWriter)this).WriteEncodedInt(length); + WriteEncodedInt(length); while (_buffer.Length - _index < length) { @@ -311,6 +423,6 @@ public class BufferWriter : IGenericWriter } // We don't use spans here since that incurs extra allocations for safety. - Index += _encoding.GetBytes(value, 0, value.Length, _buffer, (int)_index); + _index += _encoding.GetBytes(value, 0, value.Length, _buffer, (int)_index); } }