Skip to content

[Bug]: JSON deserialization is ~7x slower than MessagePack, and far worse on WebAssembly #352

Description

@antoineatstariongroup

What type of issue is this?

  • Bug report
  • Feature request

Prerequisites

  • I have written a descriptive issue title
  • I have verified that I am running the latest version of the SysML2.NET
  • I have searched open and closed issues to ensure it has not already been reported

Description

Deserializing the same model costs ~7x more through SysML2.NET.Serializer.Json than through
SysML2.NET.Serializer.MessagePack. Same DTOs, same generator, only the wire format differs. With
#347 fixed, this is now the largest cost in the SysML v2 load path.

Elements Server JSON Server MsgPack WASM JSON WASM MsgPack
5 000 78 ms 16 ms 2.23 s 242 ms
50 000 169 ms 130 ms 17.54 s 2.65 s
100 000 280 ms 76 ms 40.07 s 5.12 s

WASM throughput is ~2 500 elements/sec for JSON against ~19 000 for MessagePack, flat across sizes.
Under Blazor WebAssembly that is 40 s of fully blocked UI thread at 100 000 elements.

Causes

  1. Whole-payload DOM. DeSerializer.cs:90JsonDocument.Parse(stream) materialises all
    57.9 MB plus metadata and holds it live while the DTO list grows. MessagePack streams.

  2. @type read twice, allocating both times. DeSerializer.cs:184-186 does
    TryGetProperty("@type", …) + GetString() to select the deserializer; the selected
    deserializer then re-reads and re-allocates it to validate (PartUsageDeSerializer.cs:65-73) —
    a check that cannot fail. DeSerializer.cs:184 also uses the UTF-16 literal "@type" where the
    generated code correctly uses "@type"u8, so the hottest lookup in the library transcodes on
    every comparison.

  3. Guid.Parse(x.GetString()) for every id and every reference
    (PartUsageDeSerializer.cs:79-87, and inside each reference-array loop). Allocates 36 chars,
    transcodes, parses back to 16 bytes. TryGetGuid reads straight from UTF-8. Scales with edges,
    not elements. The MessagePack equivalent is ReadGuidBin16 — 16 raw bytes.

  4. TryGetProperty per declared property — a linear scan each time, so O(declared × present).
    ~240 name comparisons per PartUsage where a streaming reader does ~22. The MessagePack
    formatter never searches; a positional switch determines identity.

  5. ILogger created per element (PartUsageDeSerializer.cs:63), and LogDebug allocates its
    object[] and boxes the Guid at the call site even under NullLogger. Minor in a dense
    payload; proportional to absent properties, so worse on real sparse models.

Proposed fix. Generated code changes belong in the templates, not the AutoGen files.

Stage 1 — allocations. "@type"u8 in DeSerializer.cs; drop the redundant @type
re-validation; TryGetGuid instead of Guid.Parse(GetString()); hoist the logger out of the
per-element method; guard or remove the "property not found" logs.

Stage 2 — per-object parsing. Replace JsonDocument.Parse(stream) with a Utf8JsonReader loop
calling JsonDocument.ParseValue(ref reader) per element and disposing each immediately (buffers
return to the pool). Peak memory drops from whole-payload to one element. Every generated signature
stays intact — best value per unit of risk.

Stage 3 — streaming reads. Regenerate deserializers to take ref Utf8JsonReader and switch on
the properties actually present via ValueTextEquals("…"u8), with Skip() for unknowns. Turns
O(declared × present) into O(present), removes the DOM, and structurally eliminates the
absent-property logging. Breaking: Func<JsonElement, …> cannot become Utf8JsonReader-based (a
ref struct cannot be a generic argument), so the provider surface needs a custom delegate and all
175 deserializers regenerating. For dispatch, @type is written first unconditionally
(PartUsageSerializer.cs:62-65), so read property 1 and dispatch; fall back to a rewind (the
reader is a struct, so a copy is a free snapshot) when a third-party producer puts it elsewhere.

System Configuration

  • SysML2.NET version: 0.22.0 (5588929e)
  • Environment (Operating system, version and so on): Windows 11 26100 x64; browser runs are Chrome
    headless, Jiterpreter (default), no AOT
  • .NET Framework version: .NET 10.0.11 — net10.0 on the server, dotnet.wasm in the browser
  • Additional information: assembler timings excluded; POCO assembly is format-independent
    (1.14 s JSON vs 1.24 s MessagePack at 50 000 elements), so the entire gap is the deserializer.
    AOT (wasm-tools) was not tested and may change the WASM figures.

Metadata

Metadata

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions