Skip to content

Remote: PrimitiveSerializers as a native SerializerV2 (same id 17, same bytes) - #8719

Merged
Aaronontheweb merged 2 commits into
akkadotnet:devfrom
Aaronontheweb:feature/primitive-serializer-v2
Oct 2, 2026
Merged

Aaronontheweb merged 2 commits into
akkadotnet:devfrom
Aaronontheweb:feature/primitive-serializer-v2

Conversation

@Aaronontheweb

Copy link
Copy Markdown
Member

Part of #8675.

Summary

PrimitiveSerializers (id 17) now derives from SerializerV2 instead of SerializerWithStringManifest. It writes the same bytes under the same id.

  • Serialize writes into the caller's IBufferWriter<byte>. Strings encode UTF-8 straight into writer.GetSpan(byteCount). Ints and longs use BinaryPrimitives little-endian.
  • SizeHint is exact: Encoding.UTF8.GetByteCount(s), 4, 8.
  • Deserialize reads from a ReadOnlySequence<byte>. Single-segment input takes a fast path. Multi-segment ints and longs copy into a stack buffer. Multi-segment strings decode with Encoding.UTF8.GetString(in ReadOnlySequence<byte>), so no joined copy.
  • ToBinary is overridden. The SerializerV2 base allocates an ArrayBufferWriter and copies, which would slow persistence journals and classic remoting. The override stays one exact-size allocation, as before.
  • FromBinary(byte[], string) uses the base. It wraps the array in a ReadOnlySequence struct and shows no change in the benchmark.

Why

The format is already minimal. The cost was the API. Artery reached this serializer through the V1 adapter, which called ToBinary, allocated a byte[] and copied it into the frame. Artery now gets the serializer itself.

What stays the same

  • Id 17 and the primitive alias. The module table row in RemoteSerializers.cs is untouched.
  • Wire bytes for string, int and long. BitConverter used machine byte order. All supported .NET platforms are little-endian, so little-endian is the same format.
  • All nine manifest spellings are read: S/I/L, the .NET Core names and the .NET Framework names. An unknown manifest throws the same ArgumentException.
  • use-legacy-behavior: on returns the type-qualified name, off returns S/I/L and rejects other types. Manifest is unchanged.
  • The constructor, its ConfigurationException on null config, and the error text for unsupported types.
  • Int and long reads read the first 4 or 8 bytes and ignore trailing bytes, like BitConverter.ToInt32(bytes, 0). Input that is too short throws ArgumentException (a BitConverter call threw the same type or a subclass).

Tests

PrimitiveSerializersSpec:

  • Golden bytes. I captured the output of the old implementation for empty, ASCII, 2-byte, 3-byte, surrogate-pair and lone-surrogate strings, int and long edges (0, 1, -1, min, max), and hard-coded the hex. A 156,000-byte string is checked by length and SHA-256 (also computed independently in Python). V2 Serialize and ToBinary both match.
  • Reads legacy bytes for all nine manifest spellings through FromBinary(byte[]) and Deserialize(ReadOnlySequence).
  • Multi-segment reads. Strings (including splits inside a multi-byte character), ints and longs are split at every offset, including empty first and last segments and one byte per segment. A 156 KB string is split into 7-byte segments. Malformed UTF-8 decodes the same split or whole.
  • Writers. A writer that hands out fresh garbage-filled spans of exactly the requested size, or larger, with content already in front. SizeHint equals the bytes written.
  • FindSerializerV2For returns the PrimitiveSerializers instance itself (no adapter) for string, int and long.
  • Short int and long input throws, whole and segmented.
  • use-legacy-behavior on and off manifests, unsupported types, null config.

Also run: Akka.Remote.Tests in full (814 passed, 5 skipped, 0 failed), Akka.Tests --filter Serializ (150 passed, 1 skipped), Akka.API.Tests (24 passed), dotnet build Akka.slnx -c Release -warnaserror (0 warnings).

Benchmark

PrimitiveSerializerBenchmarks is new. Short job (--job short: 3 warmup, 3 iterations, 1 launch), .NET 10.0.11, i9-9900K, Linux. A ShortRun has wide error bars, so read these as direction, not exact ratios. "Before" is dev; its SerializeToWriter and DeserializeFromSequence go through the V1 adapter, the way Artery reached it.

Method Kind Before (mean, alloc) After (mean, alloc)
SerializeToWriter Int32 10.67 ns, 0 B 3.40 ns, 0 B
ToBinary Int32 14.14 ns, 32 B 8.50 ns, 32 B
DeserializeFromSequence Int32 53.09 ns, 56 B 20.35 ns, 24 B
FromBinary Int32 21.10 ns, 24 B 17.24 ns, 24 B
SerializeToWriter Int64 12.66 ns, 0 B 4.01 ns, 0 B
ToBinary Int64 12.39 ns, 32 B 7.88 ns, 32 B
DeserializeFromSequence Int64 40.78 ns, 56 B 19.50 ns, 24 B
FromBinary Int64 16.62 ns, 24 B 16.53 ns, 24 B
SerializeToWriter String (16 chars) 32.17 ns, 40 B 17.24 ns, 0 B
ToBinary String (16 chars) 30.20 ns, 40 B 20.78 ns, 40 B
DeserializeFromSequence String (16 chars) 57.81 ns, 96 B 38.77 ns, 56 B
FromBinary String (16 chars) 34.24 ns, 56 B 34.71 ns, 56 B
SerializeToWriter String (1 KB) 249.94 ns, 1048 B 72.42 ns, 0 B
ToBinary String (1 KB) 209.86 ns, 1048 B 157.52 ns, 1048 B
DeserializeFromSequence String (1 KB) 394.36 ns, 3120 B 234.68 ns, 2072 B
FromBinary String (1 KB) 301.62 ns, 2072 B 259.34 ns, 2072 B

The writer path no longer allocates. The byte[] paths (ToBinary, FromBinary) allocate the same as before, as intended.

Breaking changes

  • PrimitiveSerializers changes its base class from SerializerWithStringManifest to SerializerV2. Code that casts it to SerializerWithStringManifest, or that relies on members only that base declares, breaks at compile time and at run time. SerializerV2 still derives from Serializer, so code that uses it as a Serializer is unaffected.
  • The wire format does not change. There is no rolling-upgrade concern.
  • The API approval files (CoreAPISpec.ApproveRemote.*.verified.txt) record the new base class and the new Deserialize, Serialize and SizeHint members.

Closes #8688

…me bytes)

Derive PrimitiveSerializers from SerializerV2. Strings encode straight into the
writer's span, ints and longs use BinaryPrimitives little-endian, and SizeHint is
exact. Deserialize reads all nine manifest spellings from a ReadOnlySequence,
including multi-segment input. ToBinary stays a single exact-size allocation.

Wire format, id, manifests and use-legacy-behavior are unchanged. Golden-byte
specs captured from the previous implementation lock that in. The API approval
files record the new base class.
Measure serialize-into-writer, ToBinary, Deserialize and FromBinary for short and
1 KB strings, int and long.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Serialization V2: PrimitiveSerializers as a native SerializerV2 (same id 17, same bytes)

1 participant