What type of issue is this?
Prerequisites
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
-
Whole-payload DOM. DeSerializer.cs:90 — JsonDocument.Parse(stream) materialises all
57.9 MB plus metadata and holds it live while the DTO list grows. MessagePack streams.
-
@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.
-
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.
-
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.
-
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.
What type of issue is this?
Prerequisites
Description
Deserializing the same model costs ~7x more through
SysML2.NET.Serializer.Jsonthan throughSysML2.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.
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
Whole-payload DOM.
DeSerializer.cs:90—JsonDocument.Parse(stream)materialises all57.9 MB plus metadata and holds it live while the DTO list grows. MessagePack streams.
@typeread twice, allocating both times.DeSerializer.cs:184-186doesTryGetProperty("@type", …)+GetString()to select the deserializer; the selecteddeserializer then re-reads and re-allocates it to validate (
PartUsageDeSerializer.cs:65-73) —a check that cannot fail.
DeSerializer.cs:184also uses the UTF-16 literal"@type"where thegenerated code correctly uses
"@type"u8, so the hottest lookup in the library transcodes onevery comparison.
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.
TryGetGuidreads straight from UTF-8. Scales with edges,not elements. The MessagePack equivalent is
ReadGuidBin16— 16 raw bytes.TryGetPropertyper declared property — a linear scan each time, so O(declared × present).~240 name comparisons per
PartUsagewhere a streaming reader does ~22. The MessagePackformatter never searches; a positional
switchdetermines identity.ILoggercreated per element (PartUsageDeSerializer.cs:63), andLogDebugallocates itsobject[]and boxes theGuidat the call site even underNullLogger. Minor in a densepayload; proportional to absent properties, so worse on real sparse models.
Proposed fix. Generated code changes belong in the templates, not the
AutoGenfiles.Stage 1 — allocations.
"@type"u8inDeSerializer.cs; drop the redundant@typere-validation;
TryGetGuidinstead ofGuid.Parse(GetString()); hoist the logger out of theper-element method; guard or remove the "property not found" logs.
Stage 2 — per-object parsing. Replace
JsonDocument.Parse(stream)with aUtf8JsonReaderloopcalling
JsonDocument.ParseValue(ref reader)per element and disposing each immediately (buffersreturn 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 Utf8JsonReaderand switch onthe properties actually present via
ValueTextEquals("…"u8), withSkip()for unknowns. TurnsO(declared × present) into O(present), removes the DOM, and structurally eliminates the
absent-property logging. Breaking:
Func<JsonElement, …>cannot becomeUtf8JsonReader-based (aref structcannot be a generic argument), so the provider surface needs a custom delegate and all175 deserializers regenerating. For dispatch,
@typeis written first unconditionally(
PartUsageSerializer.cs:62-65), so read property 1 and dispatch; fall back to a rewind (thereader is a struct, so a copy is a free snapshot) when a third-party producer puts it elsewhere.
System Configuration
5588929e)headless, Jiterpreter (default), no AOT
net10.0on the server,dotnet.wasmin the browser(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.