Skip to content

Read ISO 8211 subfields without allocating per value - #32

Merged
philliphoff merged 2 commits into
mainfrom
claude/field-reader-performance
Oct 11, 2026
Merged

philliphoff merged 2 commits into
mainfrom
claude/field-reader-performance

Conversation

@philliphoff

Copy link
Copy Markdown
Owner

Summary

Items 2 and 3 of the performance plan: precompute each field definition's layout once, and decode subfield values without allocating. No existing public signature changes; the new API is additive.

Why

Iso8211FieldReader allocated about 430 bytes per coordinate:

Per point (2 subfields) ≈ bytes
ParsedSubfield table built in a List, then copied by ToArray 64
An Iso8211SubfieldGroup per repetition 40
GetSubfield("YCOO"): LINQ FirstOrDefault closure, delegate and enumerator, twice ~220
ConvertValue<T> boxing each value as object 48

S100's S101DocumentReader uses the same pattern for S-101 data (133 uses), so it gains from these changes once it updates the package.

Changes

  • Iso8211FieldLayout (internal) works out a definition's subfield names, formats and repeating-group shape once.

    • It is cached per definition instance in a ConditionalWeakTable, not stored on the record. A cached member on the Iso8211FieldDefinition record would take part in its generated equality, and a with expression would copy a stale layout.
    • When every subfield is fixed width (SG2D, SG3D, FSPT, VRPT), a subfield instance's byte offset follows from its position, so the reader keeps no offset table. Other fields get an exactly sized table.
  • Conversions return T directly. Each (T)(object)value sits behind a typeof(T) == typeof(…) check, which optimized code compiles to no box. UTF-8 character data is trimmed before decoding, so it decodes once and no longer allocates TrimEnd's parameter array.

  • New, additive API, in the style of ADO.NET's GetOrdinal/GetInt32(i):

    • GetOrdinal(name)
    • GetSubfield<T>(groupIndex, ordinal) and TryGetSubfield<T>(groupIndex, ordinal, out value)
    • GetSubfieldBytes(groupIndex, ordinal)

    The name-based members are unchanged and are now non-allocating too.

  • LINQ replaced with loops in GetSubfieldDefinition, GetFieldDefinition, GetFieldByTag and GetFieldsByTag.

  • S57DocumentReader uses the ordinal accessors in its six group loops. It keeps its "log a warning and stop" handling for malformed fields, including a field cut off partway through a repetition.

  • Docs: docs/reading-iso8211.md gains a short example of reading a long repeating group by ordinal. The example was compiled and run against a real cell.

Results

Memory budgets (synthetic harbour cell; exact figures):

Budget Before After
S57DocumentReader.Read 29.0 MB 8.0 MB (−72%)
SG2D via GetSubfieldGroups + names 6.19 MB 0.85 MB (−86%)
SG2D via ordinals (new budget) — 0.14 MB: one reader per field, nothing per point
DDR reader 50 KB 33 KB

On the older 3.5 MB US4MD81M edition, S57DocumentReader went from 203 MB to 62 MB allocated, and roughly from 290 ms to 185 ms. The timings come from a shared, noisy machine, so treat them as indicative; this PR's Benchmarks job gives the base-vs-head comparison. Most of what remains is the intermediate Iso8211Document and its GC cost, which the next PR targets.

How behaviour is pinned

  • Iso8211FieldReaderEquivalenceTests compares the new reader with LegacyIso8211FieldReader, a frozen copy of the previous implementation kept under tests/…/Reference/, on:

    • every field of the synthetic harbour cell and an update;
    • every truncated prefix of 150 fields, with and without a trailing field terminator;
    • 11 hand-built layouts covering every format type (A, I, R, b1/b2/b4 of several widths, B(n)), fixed and variable groups, a fixed prefix before a group (S-101's VCID*YCOO!XCOO!ZCOO), and lexical level 2.

    It also checks the new ordinal accessors against the group objects. Locally, it also passed on all 19 real cell and update files with ENCDOTNET_ENC_ROOT set.

  • S57DocumentReaderFingerprintTests pins SHA-256 fingerprints of the S-57 documents the reader produced before this change, for the three synthetic profiles and two update chains. Locally, fingerprints of the real cell and update files are also identical before and after.

One subtle difference

Name lookups now match subfields by position in SubfieldDefinitions. Previously they matched on the definition's Index property. The DDR reader always sets Index equal to the position, so parsed data behaves identically. Only a hand-built definition whose Index values disagree with their positions would read differently, and it previously returned the wrong subfield.

Standards alignment

  • ISO/IEC 8211 (iso8211)
  • S-57 records, topology, updates or exchange sets (s57-enc)
  • Legacy chart viewer, maintenance only (chart-viewer)
  • N/A: build, CI, docs or tooling only

Standard section references cited in code or docs: none new; subfield parsing rules are unchanged.

Tests

  • Added or updated xUnit tests under tests/
  • Test input is built in memory; no real ENC cells are committed
  • dotnet test --configuration Release passes locally: 517 unit tests and 13 performance tests. Unit test time rose from about 1 s to 9 s, mostly from the comparison and fingerprint tests.

dotnet format (CI diagnostics) and dotnet pack with package validation against 0.7.0 both pass.

Documentation

  • Updated the affected package's src/<project>/README.md
  • Updated guides under docs/ if user-facing behavior changed
  • New public APIs have XML doc comments
  • docfx docfx.json --warningsAsErrors passes, if docs changed. docfx isn't installed in the dev sandbox; the docs workflow will check it.

Dependencies

  • No new NuGet dependencies, OR versions added to
    Directory.Packages.props (not in the .csproj)
  • gh-advisory-database security check run for any new dependency

Breaking changes

None. Package validation against 0.7.0 passes: the new members are additions. EncDotNet.S100 needs no changes, and its S-101 reader benefits on its next package update.

🤖 Generated with Claude Code

https://claude.ai/code/session_01YagvELyJKBANk61DyFGUNJ


Generated by Claude Code

Iso8211FieldReader allocated about 430 bytes per coordinate: a LINQ
closure, delegate and enumerator for every name lookup, a boxed value
for every read, a group object per repetition, and a subfield offset
table built in a List and copied by ToArray.

- Work out each field definition's layout once (Iso8211FieldLayout,
  cached per definition in a ConditionalWeakTable, not on the record):
  subfield names, formats and, when every subfield is fixed width, the
  byte offsets of a repetition. Fixed-width fields such as SG2D, SG3D,
  FSPT and VRPT then need no per-instance offset table.
- Convert values to T without boxing, and decode character data once.
- Add GetOrdinal plus GetSubfield<T>/TryGetSubfield<T>/GetSubfieldBytes
  by group index and ordinal, for reading long repeating groups without
  a name lookup or group object per repetition. Additive only.
- Replace LINQ lookups in GetSubfieldDefinition, GetFieldDefinition,
  GetFieldByTag and GetFieldsByTag with loops.
- Use the ordinal accessors in S57DocumentReader's group loops, keeping
  its warn-and-skip handling of malformed fields.

S57DocumentReader on the synthetic harbour cell allocates 8.0 MB, down
from 29.0 MB. Behavior is pinned by a frozen copy of the previous field
reader that a differential test compares against (synthetic, truncated,
hand-built and, locally, real cells) and by fingerprints of the S-57
documents the reader produced before the change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YagvELyJKBANk61DyFGUNJ
The fingerprint tests failed on Windows: StringBuilder.AppendLine writes
Environment.NewLine, so the hashed text had CRLF line endings there.
End lines with '\n' and format numbers with the invariant culture, which
also keeps a culture with a different minus sign (sv-SE uses U+2212)
from changing the hash. The hashes are unchanged on Linux.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YagvELyJKBANk61DyFGUNJ
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants