Skip to content

⚡ Skip doomed numeric parses and the merge pass on common loads#90

Merged
frenck merged 6 commits into
mainfrom
frenck/perf-pass
Jun 23, 2026
Merged

⚡ Skip doomed numeric parses and the merge pass on common loads#90
frenck merged 6 commits into
mainfrom
frenck/perf-pass

Conversation

@frenck

@frenck frenck commented Jun 23, 2026

Copy link
Copy Markdown
Owner

Breaking change

None. Internal performance work; types and values are unchanged.

Proposed change

Two behavior-preserving optimizations found by profiling loads over a representative real-world corpus (311 documents, ~566 KB, across Kubernetes, GitHub Actions, Docker Compose, Home Assistant, Helm, OpenAPI, Prometheus, and ESPHome) under callgrind.

1. Skip doomed numeric parses in the scalar resolver (1.2 and 1.1). classify attempted a full integer parse and a float parse (dec2flt) on every plain scalar, including the common case of a string that is plainly not a number: a name, a path, a word. Every YAML integer and float starts with a digit, a sign, or a dot, so after the null/bool/merge checks, a plain scalar that starts with anything else returns Str immediately. The same gate applies to the 1.1 resolver, where it pays off more because 1.1 has a larger boolean set and octal/binary/sexagesimal/underscore integer forms to skip.

2. Skip the merge-key post-pass when no merge key was seen. apply_merge_keys walks the whole decoded value tree to resolve and strip << markers, and ran for every document. The decoder now records whether any merge key was produced and skips the pass entirely otherwise, which is the common case.

Measured (real-world corpus, median over 50 runs)

operation before after speedup
loads (YAML 1.2) 10.50 ms 9.58 ms ~9%
loads (YAML 1.1) 12.05 ms 9.52 ms ~21%

Re-profiling confirms classify roughly halved and the numeric-parse and merge-pass functions dropped out of the hot list. No hot-path branches were added; both changes only remove work.

Type of change

  • Dependency or tooling upgrade
  • Bugfix (non-breaking change that fixes an issue)
  • New feature (non-breaking change that adds functionality)
  • Deprecation (replaces or removes a feature, with a migration path)
  • Breaking change (a fix or feature that changes existing behavior)
  • Code quality, refactor, or test-only change
  • Documentation only

Additional information

  • This PR fixes or closes issue: fixes #
  • This PR is related to:
  • Link to a separate documentation pull request:

Checklist

  • I have read the AI Policy, and this pull request was not created by an autonomous agent.
  • I fully understand the code in this pull request and can explain every line, including any AI-assisted changes.
  • The change is covered by tests, and uv run pytest passes locally. A pull request cannot be merged unless CI is green.
  • uv run ruff check . and uv run ruff format --check . pass.
  • cargo fmt --check and cargo clippy --all-targets -- -D warnings pass.
  • Round-trip fidelity is preserved: an unmodified document still re-emits byte-for-byte.
  • No commented-out or dead code is left in the pull request.

If the change is user-facing:

  • Documentation under docs/ is added or updated, and docs/verify_examples.py still passes.

frenck added 3 commits June 23, 2026 07:01
classify_plain_12 attempted a full integer parse and a float parse (dec2flt)
on every plain scalar, including the common case of a string that is plainly
not a number (a name, path, or word). Every YAML integer or float starts with
a digit, a sign, or a dot, so after the null/bool/merge checks, gate the parse
attempts on the first byte and return Str directly otherwise. Behavior is
unchanged; it just avoids two doomed parses per string scalar.
… 1.1)

Apply the same first-byte gate as the 1.2 resolver: after the null/merge/bool
checks, a plain scalar that does not start with a digit, sign, or dot cannot be
any 1.1 number (decimal, 0x/0o/0b, sexagesimal, underscored, .inf/.nan), so
return Str without trying the integer, big-integer, and float parses. Behavior
unchanged.
apply_merge_keys walks the entire decoded value tree to resolve and strip <<
markers, and ran unconditionally for every document. Track whether any merge
key was produced during decode and skip the pass entirely otherwise, which is
the common case (most documents have no merge keys). Behavior is unchanged: a
stream with no << has no markers to resolve or strip.
Copilot AI review requested due to automatic review settings June 23, 2026 07:02
@frenck frenck added the performance Improving performance, not introducing new features. label Jun 23, 2026
@coderabbitai

coderabbitai Bot commented Jun 23, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@frenck, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 29 minutes and 8 seconds. Learn how PR review limits work.

Your organization has used up its prepaid credits, and credit purchases are no longer available. Enable the review add-on in the billing tab to keep reviews running — you're only billed for reviews past your plan's rate limits ($0.25/file).

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based credits.

🚦 How do rate limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan refill rate.

For paid Pro and Pro+ PR reviews, CodeRabbit uses rolling per-developer review limits. Reviews become available again as older review attempts age out of the rolling limit window.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 4942fec8-f5a3-4fa8-9f7f-ec5753753472

📥 Commits

Reviewing files that changed from the base of the PR and between b10d8e3 and 60a4b69.

📒 Files selected for processing (3)
  • src/decode/mod.rs
  • src/resolver/yaml11.rs
  • tests/core/test_yaml_1_1.py
📝 Walkthrough

Walkthrough

Two independent performance optimizations are added. In src/resolver/yaml11.rs and src/resolver/yaml12.rs, the plain-scalar classifiers (classify_plain_11 and classify_plain_12) each gain an early guard that inspects the first byte of the input; if it is not a digit, sign character, or dot, the function immediately returns ScalarKind::Str, bypassing all numeric parse attempts. In src/decode/mod.rs, the Decoder struct gains a saw_merge boolean field (initialized to false) that is set to true only when a ScalarKind::Merge scalar is encountered during decoding; decode_collecting now calls apply_merge_keys only when this flag is set.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 60.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the two main performance optimizations: skipping doomed numeric parses and skipping the merge pass on common loads, which directly reflects the core changes in the changeset.
Description check ✅ Passed The description provides a detailed explanation of both optimizations with real-world measurements, the profiling context, and measured speedups, directly relating to all changes in the changeset.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@codspeed-hq

codspeed-hq Bot commented Jun 23, 2026

Copy link
Copy Markdown

Merging this PR will improve performance by 5.42%

⚡ 1 improved benchmark
✅ 15 untouched benchmarks

Performance Changes

Benchmark BASE HEAD Efficiency
test_loads[large] 4.6 ms 4.4 ms +5.42%

Tip

Curious why this is faster? Comment @codspeedbot explain why this is faster on this PR, or directly use the CodSpeed MCP with your agent.


Comparing frenck/perf-pass (60a4b69) with main (1641ae8)

Open in CodSpeed

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 1


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 40b31360-e19c-445b-91d1-bd137dd419f8

📥 Commits

Reviewing files that changed from the base of the PR and between 1641ae8 and b10d8e3.

📒 Files selected for processing (3)
  • src/decode/mod.rs
  • src/resolver/yaml11.rs
  • src/resolver/yaml12.rs

Comment thread src/decode/mod.rs

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 applies two profiling-driven, intended-to-be-behavior-preserving micro-optimizations to the fast loads path of YAMLRocks (a Rust/PyO3 YAML library). It adds an early-exit in the scalar resolvers so plainly-non-numeric scalars skip the integer/float parse attempts, and it skips the whole-tree merge-key post-pass when no merge key was produced during decoding.

Changes:

  • Added a first-byte gate in classify_plain_12/classify_plain_11 that returns Str immediately when a plain scalar doesn't start with a digit, sign, or dot (no number can), avoiding wasted parse attempts.
  • Added a saw_merge flag on the decoder that records whether any << merge marker was produced, and used it to skip apply_merge_keys entirely when no merge was seen.

Reviewed changes

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

File Description
src/resolver/yaml12.rs First-byte gate to skip int/float parsing for non-numeric plain scalars (1.2 schema). Verified correct and behavior-preserving.
src/resolver/yaml11.rs Same first-byte gate for the richer 1.1 number forms. Verified correct and behavior-preserving.
src/decode/mod.rs Records saw_merge when a plain << marker is produced and skips the merge post-pass otherwise; the sibling explicit-!!merge-tag marker site (line 649) is not flagged, which is a correctness gap.

Notes for the author:

  • The numeric-parse gate is sound: is_null consumes the empty string before the gate, so the value.as_bytes()[0] index cannot panic, and every YAML 1.1/1.2 integer, float, and special-float form begins with a digit, +, -, or ., so no number is misclassified as a string.
  • The merge-skip optimization misses one of the two merge-marker producers (the YAML 1.1 explicit !!merge tag on an empty node), which can silently break explicit-tag merges and leak an internal marker to the public API. See the inline comment.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/decode/mod.rs
The merge-skip optimization gated the merge post-pass on a saw_merge flag set
where a plain << produced a merge marker. The YAML 1.1 resolver also produces a
merge marker from the !!merge tag (classify_tagged_11), via the tagged-empty
node path, which did not set the flag, so a marker from that path would skip
the post-pass and leak unresolved. Set the flag there too. Caught in review by
CodeRabbit on #90; add a 1.1 regression test.
@frenck

frenck commented Jun 23, 2026

Copy link
Copy Markdown
Owner Author

Good catch, this was real. I had checked only the 1.2 resolver's classify_tagged, which has no merge case, and missed that the 1.1 resolver maps !!merge (and tag:yaml.org,2002:merge) to ScalarKind::Merge. So a merge marker created from the !!merge tag through the tagged-empty-node path did not set saw_merge, and the post-pass would skip it.

Fixed in f74e07e: the tagged-merge path sets the flag too, so the optimization is now equivalent to always running the post-pass (it runs whenever any marker exists). Added a 1.1 regression test for x: !!merge.

../Frenck

                       

Blogging my personal ramblings at frenck.dev

Copilot AI review requested due to automatic review settings June 23, 2026 07:22

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

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

Comment thread src/resolver/yaml11.rs Outdated
The 1.1 int and float parsers strip every underscore before parsing, so a
leading-underscore scalar (_5 -> 5, _1:30 -> 90) resolves as a number on main.
The first-byte gate added for the perf pass dropped those to strings. Add _ to
the gate's allowed first bytes so they still reach the parsers, keeping the
optimization behavior-preserving. Caught in review by Copilot on #90; add a
regression test. (1.2 has no underscore separators, so its gate is unaffected.)
@frenck

frenck commented Jun 23, 2026

Copy link
Copy Markdown
Owner Author

Also a valid catch. The 1.1 int and float parsers strip every underscore before parsing, so a leading-underscore scalar resolves as a number on main (_5 -> 5, _1:30 -> 90, _1.5 -> 1.5). The first-byte gate dropped those to strings, an observable change, so not behavior-preserving as claimed.

Fixed in 60a4b69 by adding _ to the gate's allowed first bytes, so a leading-underscore scalar still reaches the parsers and resolves exactly as before. Added a 1.1 regression test for _5, _1:30, _1.5, and _hello. Whether _5-as-a-number is itself desirable is a separate question from this performance change, so I kept the existing behavior here. (YAML 1.2 has no underscore separators, so its gate is unaffected.)

../Frenck

                       

Blogging my personal ramblings at frenck.dev

@frenck frenck merged commit 14a0cda into main Jun 23, 2026
61 checks passed
@frenck frenck deleted the frenck/perf-pass branch June 23, 2026 07:55
@github-actions github-actions Bot locked as resolved and limited conversation to collaborators Jun 24, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

performance Improving performance, not introducing new features.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants