AyCode.Core/AyCode.Core/docs/BINARY/BINARY_WHYUSE.md

9.8 KiB
Raw Blame History

Why AcBinary?

Architectural framing: where AcBinary fits in the serializer landscape, what value it adds over wire-only serializers, and how to read its benchmark numbers in that context.

Companion docs: features detail in BINARY_FEATURES.md · wire format in BINARY_FORMAT.md · options/presets in BINARY_OPTIONS.md · streaming I/O in BINARY_ASYNCPIPE_ISSUES.md + BINARY_ASYNCPIPE_TODO.md.

Category

AcBinary serves a different category than wire-only serializers (Protobuf, MessagePack, MemoryPack). It is a graph-aware serializer with referential integrity preserved on the wire via IdTracking + StringInterning, and on the receive side via a populate-merge deserialization path that reuses existing reference identity. The primary value is correctness and developer-ergonomics in stateful live-data scenarios (Blazor / MAUI / WPF binding, SignalR streaming, server-push reconciliation) — not raw single-shot throughput on flat DTO RPC.

Three-pillar value proposition

1. Graph integrity → populate-merge correctness in bound UIs

On a live-data UI, an incoming server update must merge into the in-memory graph without breaking binding references, child-list identity, or duplicating shared sub-objects (Product, Customer, Partner, etc. — entities referenced from many parent rows). Wire-only serializers always reconstruct a fresh tree per call → the consumer hand-codes the merge / rebinding logic, which is fragile and verbose. AcBinary's IId-keyed reference tracking preserves identity end-to-end: shared sub-objects deduplicate on the wire AND on the client, existing references survive the round-trip, bindings stay valid, change-tracking continues uninterrupted. This is the central value for Blazor components, IList<T>-bound MAUI grids, and SignalR datasource subscribers.

2. Bandwidth → reference + string deduplication on the wire

On real production graphs (many Order → OrderItem → Product → Customer → Partner → GenericAttribute references sharing the same back-end entities), IdTracking and StringInterning emit each unique object/string once as the full body, then 1-2 bytes per subsequent reference. Wire-only serializers re-emit the full object body every time. Bench numbers (at 20% IId-ref-rate in the test fixture):

  • Latin1Long charset: -6.9% arith / -7.4% geo wire size vs MemoryPack
  • Latin1FixAscii charset: -18.2% arith / -21.3% geo wire size vs MemoryPack

Production graphs with higher ref-rate (the typical case — many rows pointing at the same Product/Customer) see significantly larger savings; see How to read AcBinary benchmarks below.

3. Streaming → memory pressure mitigation on memory-constrained hosts

AsyncPipeReaderInput + AsyncPipeWriterOutput deliver chunked I/O with peak memory ≈ chunk-size, not full payload size. On WASM (Mono / NativeAOT-LLVM), a 10 MB monolithic byte[] allocation triggers GC pressure or OOM under concurrent task load; chunked delivery keeps the working set bounded. Combined with the wire-size win, decode CPU drops proportionally — fewer bytes on the wire → fewer varint + UTF-8 decode operations during deserialization. SignalR's AcBinaryHubProtocol consumes via AsyncPipeReaderInput directly, no monolithic buffer materialization.

Real-world reference: WASM SignalR receive

Measured production payload: ~10 MB / ~7900 orders (full graph Order → OrderItem → Product → Customer → Partner → GenericAttribute), 4 SignalR messages, Blazor WASM client.

  • Before AcBinary (MemoryPack baseline): ~8 seconds Deser for ~4K records / ~20 MB payload — the MemoryPack output was nearly 2× larger because the same Product/Customer entities re-serialized per Order. JSON baseline was ~30 seconds.
  • After AcBinary: ~470 ms Deser for ~8K records / ~10 MB payload — wire compaction from graph deduplication + chunked-pipe streaming + decode-CPU reduction stack multiplicatively.

The speed-up is not primarily "AcBinary is faster than MemoryPack on the same bytes." It is "AcBinary emits ~50% fewer bytes for the same graph, then decodes them with bounded memory pressure". The feature stack is the win — single-cell bench Ser/Deser ratios alone do not capture it.

Alternative usage: shallow serialization for delta updates

The three pillars above describe AcBinary's deep, graph-aware mode (the default — entire object graph round-trips with identity preserved). The same serializer also covers the opposite scenario via explicit opt-in: deliberately shallow serialization where only the root's flat scalar columns ship and nested sub-graphs become Null markers on the wire.

Use case

Client-side grid / form edits → server endpoint accepts a flat-shape entity to UPDATE a single DB row. The full nested graph (Items, Owner, Tags, ...) is wasted bandwidth — the server already has those, and the contract is "update only the columns I touched". Traditional wire-only serializers force consumers to either maintain a separate Edit-DTO type per editable entity (boilerplate explosion) or null nested fields manually before serialize (fragile, error-prone, easy to miss a new property added later).

How it works

Use the AcBinarySerializerOptions.FlatCopy preset:

var options = AcBinarySerializerOptions.FlatCopy;
// MaxDepth = 0                              → only the root, no nested levels
// MaxDepthBehavior = Truncate               → nested complex props become Null markers
// UseStringInterning = None                 → no scan-phase overhead
// ReferenceHandling = None                  → no ref tracking (single-shot flat write)

Or hand-tune for the "root + one level" case:

var options = new AcBinarySerializerOptions
{
    MaxDepth = 1,                                      // root + first level
    MaxDepthBehavior = MaxDepthBehavior.Truncate,
};

The deserializer reconstructs the root's scalar columns; nested complex properties remain at their CLR default. The Null at the truncation boundary is the developer's contract — the receiving endpoint defines what "nested null" means in its protocol (typically "no change at this level, keep server state untouched"). Works with any persistence layer (Dapper, ADO.NET, Cosmos DB, MongoDB, Redis, EF Core, etc.) — no requirement for the full graph to perform a single-row UPDATE.

Why it matters

For data-grid / master-detail / inline-edit UI flows this is a frequent and performance-critical pattern: dozens of edits per minute × many concurrent clients × small per-update wire-size compounds at scale. One entity model serves both the deep-read AND shallow-write paths — no separate DTO hierarchy, no manual null-out boilerplate, no risk of drift between read-DTO and write-DTO schemas. The default MaxDepthBehavior = Throw prevents unintentional truncation from sneaking through; Truncate is explicit opt-in per call (or per endpoint via a configured options instance).

When AcBinary fits

  • Live-data UIs requiring graph-merge into a bound client-side model (Blazor / MAUI / WPF / WinForms with two-way binding, SignalR datasource subscribers)
  • SignalR / WebSocket / IPC transports with repeated entity references across messages
  • Server-push reconciliation flows where ID-keyed identity must survive the wire round-trip
  • Memory-bounded clients (WASM, mobile, embedded) receiving large object graphs
  • Delta-update / partial-write flows (client grid → server single-row UPDATE) — see Shallow serialization above
  • Workloads where wire-size + decode-CPU + memory-peak matter together, not just single-message raw Ser throughput

When AcBinary is NOT the right fit

  • Single-shot DTO RPC with no entity reuse (e.g., (int, int) → int calculator endpoints) — wire-only serializers have less per-message overhead on flat payloads
  • Schema-evolving public contracts where wire-format stability across language ecosystems is the primary requirement → Protobuf is the established choice
  • Cross-platform clients on big-endian hosts → currently unsupported on the wire (see BINARY_ISSUES.md#accore-bin-i-e4n9)
  • Append-only log formats where each record is independent — IdTracking's scan-phase overhead is wasted when there are no shared references to deduplicate

How to read AcBinary benchmarks

Console.FullBenchmark test cells use 20% IId-ref-rate test fixtures — flat enough to compare against wire-only serializers like MemoryPack on the same data shape. The cells deliberately undermeasure the feature wins:

  • Production graphs typically have 50-90% IId-ref-rate (many rows → same Product / Customer); IdTracking's wire-size win on those graphs is multiples larger than the bench numbers show.
  • Repeated Strings is the only bench cell stress-testing StringInterning. Other cells have low string-repetition, so the interning win there is bounded by the fixture.
  • The bench does NOT cover the AsyncPipe path (only Byte[] and BufferWriter I/O modes). The streaming-memory advantage doesn't surface in the bench numbers.

Bench wins on the current fixture (-6 to -10% RT, -7 to -18% size) should be read as a lower bound for AcBinary's production advantage on graph-shaped, ref-heavy workloads. The matching position against MemoryPack on flat-payload Ser/Deser is intentional — AcBinary should not pay a tax for its features on workloads that don't benefit from them. The cases where the features pay back are workloads the bench does not measure directly; the WASM reference numbers above are the production-scale signal.