Add WireMode for fast/compact binary serialization

Introduces a WireMode enum to select between Compact (VarInt + UTF-8) and Fast (fixed-width + UTF-16) wire formats for binary serialization. Updates AcBinarySerializerOptions to include a WireMode property (default: Fast). Serialization and deserialization logic now conditionally uses fixed-width or variable-length encoding for integers and strings based on the selected mode, enabling a tradeoff between output size and performance.
This commit is contained in:
Loretta 2026-02-17 09:53:15 +01:00
parent b244d9219a
commit 98d7a27245
6 changed files with 85 additions and 8 deletions

View File

@ -114,6 +114,22 @@ public enum ReferenceHandlingMode : byte
All = 2 All = 2
} }
/// <summary>
/// Wire encoding mode for binary serialization.
/// </summary>
public enum WireMode : byte
{
/// <summary>
/// Compact encoding: VarInt for integers, UTF-8 for strings. Smaller output.
/// </summary>
Compact = 0,
/// <summary>
/// Fast encoding: fixed-width integers, UTF-16 for strings. Larger output, faster encode/decode.
/// </summary>
Fast = 1
}
/// <summary> /// <summary>
/// Delegate for custom property mapping during cross-type deserialization/population. /// Delegate for custom property mapping during cross-type deserialization/population.
/// Enables mapping between different class hierarchies or renamed properties. /// Enables mapping between different class hierarchies or renamed properties.

View File

@ -12,7 +12,7 @@ public static partial class AcBinaryDeserializer
{ {
private static readonly Encoding Utf8NoBom = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false); private static readonly Encoding Utf8NoBom = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false);
#region Buffer State owned by context for zero virtual dispatch #region Buffer State <EFBFBD> owned by context for zero virtual dispatch
internal byte[] _buffer = null!; internal byte[] _buffer = null!;
internal int _bufferLength; internal int _bufferLength;
@ -20,7 +20,7 @@ public static partial class AcBinaryDeserializer
#endregion #endregion
// String caching state needed for WASM optimization // String caching state <EFBFBD> needed for WASM optimization
// The cache dictionary is owned by context (pooled), passed in at init time. // The cache dictionary is owned by context (pooled), passed in at init time.
private bool _useStringCaching; private bool _useStringCaching;
private int _maxCachedStringLength; private int _maxCachedStringLength;
@ -172,6 +172,16 @@ public static partial class AcBinaryDeserializer
return value; return value;
} }
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public T ReadRaw<T>() where T : unmanaged
{
var size = Unsafe.SizeOf<T>();
EnsureAvailable(size);
var value = Unsafe.ReadUnaligned<T>(ref _buffer[_position]);
_position += size;
return value;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)] [MethodImpl(MethodImplOptions.AggressiveInlining)]
public int ReadInt32Raw() public int ReadInt32Raw()
{ {
@ -188,6 +198,7 @@ public static partial class AcBinaryDeserializer
[MethodImpl(MethodImplOptions.AggressiveInlining)] [MethodImpl(MethodImplOptions.AggressiveInlining)]
public int ReadVarInt() public int ReadVarInt()
{ {
if (FastWire) { return ReadRaw<int>(); }
var raw = ReadVarUInt(); var raw = ReadVarUInt();
var value = (int)(raw >> 1) ^ -(int)(raw & 1); var value = (int)(raw >> 1) ^ -(int)(raw & 1);
return value; return value;
@ -196,6 +207,7 @@ public static partial class AcBinaryDeserializer
[MethodImpl(MethodImplOptions.AggressiveInlining)] [MethodImpl(MethodImplOptions.AggressiveInlining)]
public uint ReadVarUInt() public uint ReadVarUInt()
{ {
if (FastWire) { return ReadRaw<uint>(); }
// Fast path: single byte (0-127) - ~70% of cases // Fast path: single byte (0-127) - ~70% of cases
var b0 = _buffer[_position]; var b0 = _buffer[_position];
if ((b0 & 0x80) == 0) if ((b0 & 0x80) == 0)
@ -245,6 +257,7 @@ public static partial class AcBinaryDeserializer
[MethodImpl(MethodImplOptions.AggressiveInlining)] [MethodImpl(MethodImplOptions.AggressiveInlining)]
public long ReadVarLong() public long ReadVarLong()
{ {
if (FastWire) { return ReadRaw<long>(); }
var raw = ReadVarULong(); var raw = ReadVarULong();
var value = (long)(raw >> 1) ^ -((long)raw & 1); var value = (long)(raw >> 1) ^ -((long)raw & 1);
return value; return value;
@ -253,6 +266,7 @@ public static partial class AcBinaryDeserializer
[MethodImpl(MethodImplOptions.AggressiveInlining)] [MethodImpl(MethodImplOptions.AggressiveInlining)]
public ulong ReadVarULong() public ulong ReadVarULong()
{ {
if (FastWire) { return ReadRaw<ulong>(); }
ulong value = 0; ulong value = 0;
var shift = 0; var shift = 0;
while (true) while (true)
@ -277,7 +291,7 @@ public static partial class AcBinaryDeserializer
#endregion #endregion
/// <summary> /// <summary>
/// Called on first StringInternFirst marker disables _stringCache because /// Called on first StringInternFirst marker <EFBFBD> disables _stringCache because
/// interned strings are resolved via _internCache and plain strings appear only once. /// interned strings are resolved via _internCache and plain strings appear only once.
/// </summary> /// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)] [MethodImpl(MethodImplOptions.AggressiveInlining)]
@ -311,6 +325,17 @@ public static partial class AcBinaryDeserializer
return string.Empty; return string.Empty;
} }
// FastWire: length is char count, data is UTF-16 (2 bytes per char)
if (FastWire)
{
var byteLen = length * 2;
EnsureAvailable(byteLen);
var chars = MemoryMarshal.Cast<byte, char>(_buffer.AsSpan(_position, byteLen));
var value = new string(chars);
_position += byteLen;
return value;
}
EnsureAvailable(length); EnsureAvailable(length);
// WASM optimization: cache short strings to reduce allocations // WASM optimization: cache short strings to reduce allocations
@ -319,8 +344,8 @@ public static partial class AcBinaryDeserializer
return ReadStringUtf8Cached(length); return ReadStringUtf8Cached(length);
} }
// ASCII fast path: short strings (?128 bytes) with all ASCII bytes // ASCII fast path: short strings (128 bytes) with all ASCII bytes
// use string.Create + direct byte?char widening, avoiding UTF8Encoding overhead. // use string.Create + direct bytechar widening, avoiding UTF8Encoding overhead.
if (length <= 128 && System.Text.Ascii.IsValid(_buffer.AsSpan(_position, length))) if (length <= 128 && System.Text.Ascii.IsValid(_buffer.AsSpan(_position, length)))
{ {
var pos = _position; var pos = _position;
@ -333,9 +358,9 @@ public static partial class AcBinaryDeserializer
}); });
} }
var value = Utf8NoBom.GetString(_buffer, _position, length); var value2 = Utf8NoBom.GetString(_buffer, _position, length);
_position += length; _position += length;
return value; return value2;
} }
private string ReadStringUtf8Cached(int length) private string ReadStringUtf8Cached(int length)
@ -366,7 +391,7 @@ public static partial class AcBinaryDeserializer
/// <summary> /// <summary>
/// Full-content hash for string caching. /// Full-content hash for string caching.
/// CRITICAL: DO NOT SIMPLIFY prevents hash collisions for similar property names. /// CRITICAL: DO NOT SIMPLIFY <EFBFBD> prevents hash collisions for similar property names.
/// See BinaryDeserializationContext for full history. /// See BinaryDeserializationContext for full history.
/// </summary> /// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)] [MethodImpl(MethodImplOptions.AggressiveInlining)]

View File

@ -63,6 +63,7 @@ public static partial class AcBinaryDeserializer
public bool HasMetadata; public bool HasMetadata;
public bool IsMergeMode; public bool IsMergeMode;
public bool RemoveOrphanedItems; public bool RemoveOrphanedItems;
public bool FastWire;
// Options-derived properties // Options-derived properties
public byte MinStringInternLength => Options.MinStringInternLength; public byte MinStringInternLength => Options.MinStringInternLength;
@ -138,6 +139,7 @@ public static partial class AcBinaryDeserializer
HasMetadata = false; HasMetadata = false;
IsMergeMode = false; IsMergeMode = false;
RemoveOrphanedItems = false; RemoveOrphanedItems = false;
FastWire = Options.WireMode == WireMode.Fast;
ChainTracker = null; ChainTracker = null;
} }

View File

@ -227,6 +227,7 @@ public static partial class AcBinarySerializer
/// Reference handling is safe because generated code inlines TryConsumeWritePlanEntry for IId types. /// Reference handling is safe because generated code inlines TryConsumeWritePlanEntry for IId types.
/// </summary> /// </summary>
public bool IsDirectObjectWrite => !UseMetadata && !HasPropertyFilter; public bool IsDirectObjectWrite => !UseMetadata && !HasPropertyFilter;
public bool FastWire { get; private set; }
public byte MinStringInternLength => Options.MinStringInternLength; public byte MinStringInternLength => Options.MinStringInternLength;
public byte MaxStringInternLength => Options.MaxStringInternLength; public byte MaxStringInternLength => Options.MaxStringInternLength;
public BinaryPropertyFilter? PropertyFilter => Options.PropertyFilter; public BinaryPropertyFilter? PropertyFilter => Options.PropertyFilter;
@ -249,6 +250,7 @@ public static partial class AcBinarySerializer
public BinarySerializationContext(AcBinarySerializerOptions options) public BinarySerializationContext(AcBinarySerializerOptions options)
{ {
Reset(options); Reset(options);
FastWire = options.WireMode == WireMode.Fast;
} }
/// <summary> /// <summary>
@ -262,6 +264,7 @@ public static partial class AcBinarySerializer
// IMPORTANT: base.Reset sets Options first, so derived code can use Options-derived properties // IMPORTANT: base.Reset sets Options first, so derived code can use Options-derived properties
base.Reset(options); base.Reset(options);
HasPropertyFilter = Options.PropertyFilter != null; HasPropertyFilter = Options.PropertyFilter != null;
FastWire = Options.WireMode == WireMode.Fast;
} }
public override void Clear() public override void Clear()
@ -386,6 +389,7 @@ public static partial class AcBinarySerializer
[MethodImpl(MethodImplOptions.AggressiveInlining)] [MethodImpl(MethodImplOptions.AggressiveInlining)]
public void WriteVarUInt(uint value) public void WriteVarUInt(uint value)
{ {
if (FastWire) { WriteRaw(value); return; }
if (value < 0x80) if (value < 0x80)
{ {
if (_position >= _bufferEnd) if (_position >= _bufferEnd)
@ -405,6 +409,7 @@ public static partial class AcBinarySerializer
[MethodImpl(MethodImplOptions.AggressiveInlining)] [MethodImpl(MethodImplOptions.AggressiveInlining)]
public void WriteVarInt(int value) public void WriteVarInt(int value)
{ {
if (FastWire) { WriteRaw(value); return; }
var encoded = (uint)((value << 1) ^ (value >> 31)); var encoded = (uint)((value << 1) ^ (value >> 31));
WriteVarUInt(encoded); WriteVarUInt(encoded);
} }
@ -412,6 +417,7 @@ public static partial class AcBinarySerializer
[MethodImpl(MethodImplOptions.AggressiveInlining)] [MethodImpl(MethodImplOptions.AggressiveInlining)]
public void WriteVarULong(ulong value) public void WriteVarULong(ulong value)
{ {
if (FastWire) { WriteRaw(value); return; }
if (value < 0x80) if (value < 0x80)
{ {
if (_position >= _bufferEnd) if (_position >= _bufferEnd)
@ -431,6 +437,7 @@ public static partial class AcBinarySerializer
[MethodImpl(MethodImplOptions.AggressiveInlining)] [MethodImpl(MethodImplOptions.AggressiveInlining)]
public void WriteVarLong(long value) public void WriteVarLong(long value)
{ {
if (FastWire) { WriteRaw(value); return; }
var encoded = (ulong)((value << 1) ^ (value >> 63)); var encoded = (ulong)((value << 1) ^ (value >> 63));
WriteVarULong(encoded); WriteVarULong(encoded);
} }
@ -481,6 +488,18 @@ public static partial class AcBinarySerializer
public void WriteStringUtf8(string value) public void WriteStringUtf8(string value)
{ {
if (FastWire)
{
// UTF-16: char count (fixed uint) + raw char data (zero-encoding memcopy)
var charLen = value.Length;
var byteLen = charLen * 2;
WriteRaw(charLen);
EnsureCapacity(byteLen);
MemoryMarshal.AsBytes(value.AsSpan()).CopyTo(_buffer.AsSpan(_position, byteLen));
_position += byteLen;
return;
}
var charLength = value.Length; var charLength = value.Length;
// Speculative ASCII fast path: assume byteCount == charLength // Speculative ASCII fast path: assume byteCount == charLength

View File

@ -970,6 +970,14 @@ public static partial class AcBinarySerializer
#endif #endif
} }
// FastWire: skip FixStr optimization (UTF-8 specific), write String marker + UTF-16 data
if (context.FastWire)
{
context.WriteByte(BinaryTypeCode.String);
context.WriteStringUtf8(value);
return;
}
// Fast path for short strings: check length first (cheap), then ASCII // Fast path for short strings: check length first (cheap), then ASCII
// FixStr encodes type+length in single byte for strings <= 31 chars // FixStr encodes type+length in single byte for strings <= 31 chars
var length = value.Length; var length = value.Length;

View File

@ -95,6 +95,13 @@ public sealed class AcBinarySerializerOptions : AcSerializerOptions
/// </summary> /// </summary>
public StringInterningMode UseStringInterning { get; set; } = StringInterningMode.Attribute; public StringInterningMode UseStringInterning { get; set; } = StringInterningMode.Attribute;
/// <summary>
/// Wire encoding mode.
/// Compact: VarInt + UTF-8 (default, smaller output).
/// Fast: Fixed-width integers + UTF-16 (larger output, faster encode/decode).
/// </summary>
public WireMode WireMode { get; set; } = WireMode.Fast;
/// <summary> /// <summary>
/// When true, checks for duplicate property name hashes during serialization (UseMetadata mode). /// When true, checks for duplicate property name hashes during serialization (UseMetadata mode).
/// Throws exception if FNV-1a hash collision is detected between property names of the same type. /// Throws exception if FNV-1a hash collision is detected between property names of the same type.