Skip to content

Close the benchmark dark spots and collect the declaration tax - #185

Merged
HowardvanRooijen merged 9 commits into
perf/extension-adapter-allocationsfrom
perf/benchmark-gapfill
Aug 13, 2026
Merged

Close the benchmark dark spots and collect the declaration tax#185
HowardvanRooijen merged 9 commits into
perf/extension-adapter-allocationsfrom
perf/benchmark-gapfill

Conversation

@HowardvanRooijen

Copy link
Copy Markdown
Contributor

Summary

Stacked on #184. Two deliverables: the benchmark suite now covers every hot-path-relevant area §19's coverage analysis found dark (with realistic, census-calibrated corpora), and the first optimisation driven by the new data — a 72% allocation cut on the feed shape most production RSS actually takes.

Part 1 — the gapfill benchmark suite (6 commits)

Four corpus generator files and nine benchmark classes (category gapfill), 333 cases total (was 261). Every element mix cites its realism source: the 627-feed and 1,934-feed censuses, the corpus manifests, the committed samples and scenario fixtures. Every generator is guard-verified in setup, several encoding arm meaning (the declared-but-unused arm asserts Dublin Core IS attached and geo/GeoRSS are NOT, so a detection-semantics change fails loudly instead of silently inverting the arm).

Covered: legacy formats (RSS 0.90–1.0 incl. the real ISO-8859-1+DOCTYPE wire shape, Atom 0.3, RSD 0.6), Podcasting 2.0 at census element ratios with iTunes at real podcast density, the declared-but-unused production shape, a maximal every-family ceiling, Google sitemap extensions (video/news/image/hreflang) for load AND save, BlogML/APML scaling curves, save breadth for OPML/APML/BlogML/RSD, the per-format async surface (stub-handler transport per the recorded no-sockets decision), and opt-in real-corpus calibration arms.

Coverage proof (in-process instrumented run, §20): benchmark line coverage Data 21→57.5%, Extensions 17→31%, Syndication 29→40%; every class named in the plan left the zero list. Baseline dataset (full BenchmarkDotNet JSON + markdown, quiet-window certified by a control arm) preserved for future regression analysis; both synthetic-vs-real calibration gates passed (podcast 1.27× vs an 18.5 MB real feed, hreflang 1.72× vs GitLab's).

Part 2 — the declaration tax, collected (1 commit)

The baseline's top finding: on feeds that declare extension namespaces without using them — a 96% declared-vs-used gap in the live census, i.e. the WordPress shape — every declaration-matched family still constructed a fresh extension and ran a full context Load per entity to find nothing, costing more than parsing six families' real payloads.

SyndicationExtensionAdapter.FillCore now adds a presence layer: one walk over the entity's child elements ORs per-namespace probe masks (C# [InlineArray] stack buffers, FrozenDictionary maps), and only families with in-namespace content proceed. Prefix rebinding is honoured via tracked document bindings (real feeds bind media to at least three URIs); FeedHistory's RFC 5005 cross-namespace reads are covered by a content-map-only override.

Scenario (100 items) Before After Change
Declared-but-unused (the production shape) 3,105 KB / 2,188 µs 880 KB / 981 µs −72% / −55%
Atom 0.3 load (collateral — its 11.3× was the same tax) 1,577 KB 909 KB −42%
Six families in use 2,190 KB 2,047 KB −6.5%
Extension-free, maximal, 50K sitemap, Atom 1.0 unchanged

Verification

  • Extension-content inventory (115,417 per-entity records over the 76 MiB real corpus): byte-identical across the change — including the two prefix-rebinding documents the instrument caught on the first attempt (recorded in §22 with the other two instrument catches).
  • All five commit gates hold at every commit: 0 warnings both configs, offline suite exactly 3,554, both format gates, examples 204/204, samples 21/21.
  • Engineering log §§19–23 (local) carries full provenance, the ranked next-targets board, and the cleared list.

🤖 Generated with Claude Code

https://claude.ai/code/session_01CBG52K8XTzAPk4Vat1pnhp

HowardvanRooijen and others added 7 commits August 13, 2026 06:51
The six legacy adapters total 1,520 lines with zero benchmark coverage
(build-warnings.md §19): every load benchmark parsed RSS 2.0 or Atom
1.0, so the RDF-shaped parse, the ISO-8859-1 + DOCTYPE wire shape real
0.91 feeds still ship, and namespace-sniffed version dispatch had no
price. LegacyFeedCorpus generates all six formats with element mixes
modelled on the FeedTestData fixtures and the real 1999-2005 corpus
documents; LegacyFormatLoadBenchmarks prices them at 15 items (RSS
0.91's own DTD cap) and 100.

Setup guards are structural because version identity is not observable
(RssFeed.Version is a fixed 2.0): the PICS rating only the 0.91 adapter
reads, cloud+enclosure for 0.92, an accented title a wrong decode
cannot reproduce for the ISO-8859-1 arm, attached Dublin Core for the
RDF item parse, and namespace-differential entry counts for Atom 0.3.
A version="0.94" arm is recorded as rejected: the dispatch throws for
it, so it would measure an exception and its guard could never pass.

Smoke: 28 cases executed, zero guard failures, zero exceptions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CBG52K8XTzAPk4Vat1pnhp
Podcasting 2.0 - declared by 62% of 1,934 censused feeds - had zero
benchmark coverage, and the existing extension corpus emits six
families' simplest elements, leaving nine families and every rich
element mix dark (build-warnings.md §19).

ExtensionFeedCorpus adds three generators with cited realism sources:
- GeneratePodcastFeedUtf8: census-ratio Podcasting 2.0 (§2.50) with
  transcripts cycling the three dominant MIME types including the
  unregistered application/srt, the misused copyright-notice license
  value publishers actually write, and iTunes at The Daily's measured
  per-item density including the nested category and owner that light
  ITunesCategory/ITunesOwner.
- GenerateDeclaredOnlyProductionUtf8: the ~96% production shape - nine
  namespaces declared, five families used (627-feed census: geo 121
  declared/5 used, georss 144/4). Detection answers on the prefix
  alone, so unused declarations force extension loads that find
  nothing; this is the shape most production loads actually take.
- GenerateMaximalExtensionFeedUtf8: the ceiling document - every
  family, every collection-bearing element, with honesty labels for
  the spec-example-only families and LiveJournal's URI mismatch.

PodcastFeedLoadBenchmarks prices load and the aggregator-shaped read
separately; ExtensionElementMixBenchmarks prices the four density
points, with guards that encode arm meaning: declared-only asserts
Dublin Core IS attached and geo/GeoRSS/CreativeCommons are NOT, so a
detection-semantics change fails the guard instead of silently
inverting the declaration-tax arm.

Smoke: both classes executed with zero guard failures and zero
exceptions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CBG52K8XTzAPk4Vat1pnhp
The four Google sitemap extension families total 930 lines with zero
benchmark coverage - SitemapVideo alone is 519 - and no sitemap writer
had a benchmark at all (build-warnings.md §19). The §13.5 defect (video
elements emitted in an order Google's schema rejects, green through
every round-trip test) shipped in exactly this territory.

SitemapExtensionCorpus generates the four families at real publisher
ratios: Yoast's 14-child video shape with intra-document optionality
(content_loc 1-in-3, view_count 2-in-3, non-standard width/height a
parser must skip), BBC/NYT/Guardian news shapes cycling, the bare
image:loc pairs that are all the wild still uses post-2022-deprecation,
and GitLab's attribute-only hreflang links - the opposite parse profile
from every other family.

SitemapExtensionLoadBenchmarks prices each family over the plain-urlset
floor at 100/1,000 URLs; SitemapSaveBenchmarks prices the writers,
including the WriteXsiSchemaLocation arm (SitemapSchemaLocations was at
zero) and the schema-order-pinned video save. Guards verify counts,
per-arm extension attachment, and that the video optionality cycling
survived generation.

Smoke: 18 cases executed, zero guard failures.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CBG52K8XTzAPk4Vat1pnhp
…L/RSD

The committed samples are loaded once by FormatBreadthLoadBenchmarks,
so BlogML and APML had token presence but no cost-vs-size curve, and
the save table had two formats in it (build-warnings.md §19). The §17
RSS save regression was a save-vs-load asymmetry that went unpriced
until its benchmark existed; this puts numbers on the remaining
writers before their regression rather than after.

SpecializedDocumentCorpus scales the committed samples' shapes: the
approval-status mix an importer must not get wrong, category hierarchy
with parentref, comments including unapproved ones, trackbacks, an
embedded ~1 KB base64 attachment on every sixteenth post (where the
decode cost lives), and APML's twin implicit/explicit sub-trees with a
meaningful defaultprofile.

SpecializedDocumentScaleBenchmarks prices both formats at 10/100/1,000;
FormatSaveBreadthBenchmarks prices the four writers at fixed realistic
sizes (299 outlines = the real Power BI Weekly subscription list).

Smoke: 10 cases executed, zero guard failures.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CBG52K8XTzAPk4Vat1pnhp
Every per-format LoadAsync/CreateAsync state machine sat at zero
benchmark coverage except the two the polling class exercises
(build-warnings.md §19). AsyncLoadBenchmarks prices CreateAsync - and
through its delegation, LoadAsync - for AtomFeed, AtomEntry, Opml,
Apml, BlogML, Rsd, Sitemap, SitemapIndex, AtomServiceDocument,
AtomCategoryDocument and AtomEntryResource, against a sync Load anchor
on the same document so the a->b delta is the async-over-sync overhead
with no network in it.

Transport is a stub HttpMessageHandler serving preloaded committed
samples, honouring the recorded PollingSteadyStateBenchmarks decision
against loopback sockets; RssFeed/GenericSyndicationFeed async is
deliberately absent as already priced there.

Smoke: 12 cases executed, zero failures.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CBG52K8XTzAPk4Vat1pnhp
RealWorldExtensionLoadBenchmarks (opt-in, machine-local, per the
RealWorldLoadBenchmarks precedent) loads the documents that calibrate
the gapfill generators: simplecast-thedaily.xml (18.5 MB, 2,939-item
podcast), gitlab-pages.xml (17,854 attribute-only hreflang links), and
every rss09x/rss10/atom03 corpus document. Two documents the dispatch
rejects by design (version="0.94"; the atom draft-04) are skip-listed
with a guard asserting they STILL throw - if the library ever gains
support, the guard fails and the skip list is the thing to delete.

The §20 coverage re-run (recorded in build-warnings.md) surfaced two
generator omissions, fixed here: the APML corpus lacked
Author-in-Source and Applications (215 dark lines), and the maximal
extension feed lacked the RFC 5005 atom:link rels that
FeedHistoryLinkRelation parses (79). Both are now emitted and
guard-asserted.

Result: 333 benchmark cases (was 261); every class §19 named has left
the zero-coverage list; Data 21->57%, Extensions 17->31%, Syndication
29->40% benchmark line coverage. What remains dark is recorded with
reasons.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CBG52K8XTzAPk4Vat1pnhp
The gapfill baseline's top finding (§21): on the shape most production
feeds take - namespaces stamped into the root and never used, a 96%
declared-vs-used gap in the live census - every declaration-matched
family still constructed a fresh extension and ran a full context Load
per entity to find nothing, costing more than parsing six families'
real payloads.

FillCore now adds a presence layer after declaration matching: one walk
over the entity's child elements ORs per-namespace probe masks, and
only families with in-namespace content proceed to construct-and-load.
Skipping is observationally identical to a Load returning false (the
instance was fresh; nothing could observe its Loaded event). Prefix
rebinding is honoured - the declaration walk records each matched
prefix's document binding in C# InlineArray stack storage, so feeds
binding "media" or "lj" to variant URIs keep attaching exactly as
before; the corpus's squarespace and lj-brad documents are the proof.
The sole cross-namespace family (FeedHistory reads RFC 5005 atom:link
relations) is covered by a content-map-only override, deliberately kept
out of the declaration map so Atom documents gain no phantom candidate.

Declared-but-unused @100 items: 3,105 KB -> 880 KB (-72%), 2,188 us ->
981 us (-55%); the declaration tax (over the extension-free floor)
falls 85%. Six-families-in-use -6.5%; extension-free and maximal arms
unchanged; wikihow 50K sitemap unchanged at 63.50 MB. Extension-content
inventory byte-identical across all 115,417 corpus records. Detail and
the three instrument catches: build-warnings.md §22.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CBG52K8XTzAPk4Vat1pnhp
Copilot AI lite review requested due to automatic review settings August 13, 2026 09:22
@github-actions

github-actions Bot commented Aug 13, 2026

Copy link
Copy Markdown

Test Results

3 574 tests  ±0   3 574 ✅ ±0   24s ⏱️ -8s
    1 suites ±0       0 💤 ±0 
    1 files   ±0       0 ❌ ±0 

Results for commit 021f151. ± Comparison against base commit 8168ddd.

♻️ This comment has been updated with latest results.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR extends the benchmark suite to cover previously unmeasured hot paths (especially extension-heavy and legacy formats) and introduces a load-side optimization in SyndicationExtensionAdapter to avoid paying per-entity extension Load costs when namespaces are declared but unused.

Changes:

  • Add a “content presence” filtering layer to framework extension auto-detection to skip extension probes when no in-namespace child elements exist for the current entity.
  • Add new corpus generators for specialized documents (BlogML/APML) and sitemap extensions (video/news/image/hreflang).
  • Add multiple new BenchmarkDotNet benchmark classes for load/save breadth, scaling, async surfaces, real-world calibration, and extension density mixes.

Reviewed changes

Copilot reviewed 14 out of 14 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
Solutions/Argotic.Extensions/SyndicationExtensionAdapter.cs Adds namespace-content presence filtering to reduce extension detection overhead on declared-but-unused feeds.
Solutions/Argotic.Benchmarks/SpecializedDocumentCorpus.cs Generates scalable BlogML/APML benchmark inputs.
Solutions/Argotic.Benchmarks/SitemapExtensionCorpus.cs Generates extension-bearing sitemap inputs (video/news/image/hreflang).
Solutions/Argotic.Benchmarks/Saving/SitemapSaveBenchmarks.cs Benchmarks sitemap save paths including schemaLocation and extensions.
Solutions/Argotic.Benchmarks/Saving/FormatSaveBreadthBenchmarks.cs Benchmarks save breadth for OPML/APML/BlogML/RSD.
Solutions/Argotic.Benchmarks/Loading/SpecializedDocumentScaleBenchmarks.cs Benchmarks BlogML/APML load scaling across sizes.
Solutions/Argotic.Benchmarks/Loading/SitemapExtensionLoadBenchmarks.cs Benchmarks per-sitemap-extension load deltas over plain urlset.
Solutions/Argotic.Benchmarks/Loading/RealWorldExtensionLoadBenchmarks.cs Adds opt-in real-world calibration benchmark loads (podcast/hreflang/legacy sets).
Solutions/Argotic.Benchmarks/Loading/PodcastFeedLoadBenchmarks.cs Benchmarks podcast feed load and a consumer-shaped “load + read” composite.
Solutions/Argotic.Benchmarks/Loading/LegacyFormatLoadBenchmarks.cs Benchmarks legacy RSS variants, Atom 0.3, and RSD 0.6 loading.
Solutions/Argotic.Benchmarks/Loading/ExtensionElementMixBenchmarks.cs Benchmarks extension-density scenarios including declared-but-unused “production shape”.
Solutions/Argotic.Benchmarks/Loading/AsyncLoadBenchmarks.cs Benchmarks async CreateAsync surfaces with stubbed HTTP transport.
Solutions/Argotic.Benchmarks/LegacyFeedCorpus.cs Generates legacy-format corpora (RSS 0.90–0.92, RSS 1.0 RDF, Atom 0.3, RSD 0.6).
Solutions/Argotic.Benchmarks/ExtensionFeedCorpus.cs Generates extension-focused corpora (podcast, declared-only, maximal every-family).

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +64 to +65
this.handler = new SampleServingHandler();
this.client = new HttpClient(this.handler, disposeHandler: false);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in c96d71dSetup() now disposes the field-initializer placeholder instances before reassigning (the HttpClient disposes its inner handler; the standalone stub is disposed explicitly). All gates and the class smoke re-verified.

HowardvanRooijen and others added 2 commits August 13, 2026 10:13
…ransport

Review on #184 found that MatchFrameworkProbes read ProbeMasks.Value
after moving the navigator onto the namespace axis - the lazy
initializer is the one thing on that path that can throw (the >64-probe
guard), and a throw there would strand the navigator on a namespace
node, contradicting the method's restore contract. Rather than the
suggested try/finally, both walks (MatchFrameworkProbes and
FilterByPresentContent, which inherited the shape) now resolve the lazy
BEFORE the first move, making the documented "nothing that can throw
runs between move and restore" invariant true again at zero cost.

Review on #185 found AsyncLoadBenchmarks.Setup replacing the
field-initializer HttpClient/handler instances without disposing them;
they are now disposed before reassignment.

Verified: extension inventory byte-identical (115,417 records), wikihow
50K and plain-RSS allocations unchanged, all five gates green, async
class smoke clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CBG52K8XTzAPk4Vat1pnhp
The four generators built their documents from hundreds of
StringBuilder.Append calls full of escaped quotes and \n suffixes; the
XML was unreadable as XML. They now use C# raw string literals - static
sections as plain blocks, per-item sections as interpolated blocks with
CultureInfo.InvariantCulture passed to the handler overload (CA1305) -
so the emitted document reads directly off the source. One idiom is
documented in each file header: every block ends with a blank line,
because that final empty line is what encodes the trailing newline the
closing delimiter would otherwise swallow.

Byte-identity is proven, not assumed: a hash sweep of all 17 generators
across 11 counts chosen to exercise every shape-cycle branch (%2 %3 %4
%8 %10 %16 %33) - 187 SHA-256 hashes - is identical before and after.
The sweep caught one real slip during the rewrite (a 2-bytes-per-item
indent loss in the podcast episode ternary), which is exactly the
instrument working. Committed baselines and calibration numbers
therefore remain valid unchanged.

All five gates green; the full gapfill suite executes with zero guard
failures.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CBG52K8XTzAPk4Vat1pnhp
@HowardvanRooijen
HowardvanRooijen merged commit 1ea18be into perf/extension-adapter-allocations Aug 13, 2026
3 checks passed
@HowardvanRooijen
HowardvanRooijen deleted the perf/benchmark-gapfill branch August 13, 2026 10:51
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