Skip to content

Gas: format and parse without the per-digit loops - #337

Merged
thedavidmeister merged 9 commits into
mainfrom
2026-10-07-issue-310-addsub-format
Oct 11, 2026
Merged

thedavidmeister merged 9 commits into
mainfrom
2026-10-07-issue-310-addsub-format

Conversation

@thedavidmeister

@thedavidmeister thedavidmeister commented Oct 7, 2026 •

Copy link
Copy Markdown
Contributor

The last items of #310: #11 (format and parse) is kept, #6 (add/sub maximizing one operand) is rejected.

Kept: #11, format and parse

  • toDecimalString writes its digits straight into one buffer in assembly. There are no more string.concat loops or OpenZeppelin Strings calls, and the import is gone. Trailing decimal zeros come out with a 64/32/16/8/4/2/1 divide cascade, not one digit at a time.
  • parseDecimalFloat's trailing-zero scan over the fraction is an assembly loop. It stops at the decimal point at the latest, so it needs no bounds check.

test/lib/LibFormatDecimalFloatPr321.sol and test/lib/LibParseDecimalFloatPr321.sol are verbatim copies of both libraries on main: format as of ce55522 (#321's head), whose code main has not changed since, and parse at ff6d70c, main's parse with the #355/#356 exponent-wrap guard and the past-int256 integer path. The equivalence fuzz checks the new code against them byte for byte: output strings, parse error selectors and packed floats. The gas bench measures only the library call between two gasleft() reads. Every row asserts identical bytes and no more gas than the copy (forge test --mc LibDecimalFloatFormatParseGasTest -vv). Re-measured at 3e3e59a (after merging main 1047972, which includes #398; main changed only a format doc comment, which this branch carries):

op case before after Δ
format 1 2207 973 -1234
format -1 2291 983 -1308
format 1.5 2912 1214 -1698
format 100 2891 1170 -1721
format amt18 10584 2268 -8316
format 1/3 27717 5295 -22422
format -1/3 27801 5305 -22496
format 3.75 as add result 29204 1472 -27732
format 1e-50 12077 1182 -10895
format 1e50 17159 1231 -15928
format int224.max 17903 5267 -12636
format int224.min 17987 5277 -12710
format int224.max e-1000 205538 7187 -198351
format 1e-1000 192947 3015 -189932
formatSci 1 3104 1827 -1277
formatSci -1 3188 1837 -1351
formatSci 1.5 18927 2007 -16920
formatSci 100 4174 1969 -2205
formatSci amt18 16526 2824 -13702
formatSci 1/3 11685 6302 -5383
formatSci -1/3 11841 6312 -5529
formatSci 3.75 as add result 18787 2030 -16757
formatSci 1e-50 4253 2063 -2190
formatSci 1e50 4241 2031 -2210
formatSci int224.max 11602 6368 -5234
formatSci int224.min 11758 6378 -5380
formatSci int224.max e-1000 11693 6462 -5231
formatSci 1e-1000 4399 2187 -2212
formatSci 1e2147483647 4762 2527 -2235
formatSci int224.min e(int32.min) 12291 6906 -5385
parse 1 1808 1808 0
parse -1 1797 1797 0
parse 1.5 3261 3211 -50
parse 1.50000 4069 3819 -250
parse 1.234567890123456789 8752 8702 -50
parse 0.000000000000000001 8533 8483 -50
parse 1234567890123456789012345 9560 9560 0
parse 123.456000000000000000 7583 6783 -800
parse 1.5e10 4783 4733 -50
parse -1.5e-10 4774 4724 -50
parse 1.000000000000000000000000000000 8153 6603 -1550
parse 0.0 2228 2128 -100
parse 1. 1631 1631 0

Format costs 1.2k to 198k less per call and parse 0 to 1.55k less, with identical output on every row.

Assembly against the same algorithm in plain Solidity

test/lib/LibFormatDecimalFloatSolidity.sol is the same digit-buffer algorithm with every assembly block written as plain Solidity: digits backwards into a buffer, copied into an output of exact length, and the same 64/32/16/8/4/2/1 trailing-zero cascade. testFormatSolidityCopyEquivalence fuzzes it against the library for identical bytes and identical revert data. The formatSolVsAsm / formatSciSolVsAsm rows of the same bench assert identical bytes and that the assembly costs no more:

op case plain Solidity assembly Δ
format 1 1450 973 -477
format -1 1510 983 -527
format 1.5 1906 1214 -692
format 100 1853 1170 -683
format amt18 7414 2268 -5146
format 1/3 23067 5295 -17772
format -1/3 23127 5305 -17822
format 3.75 as add result 2461 1472 -989
format 1e-50 8145 1182 -6963
format 1e50 8195 1231 -6964
format int224.max 23170 5267 -17903
format int224.min 23230 5277 -17953
format int224.max e-1000 146592 7187 -139405
format 1e-1000 133728 3015 -130713
formatSci 1 2540 1827 -713
formatSci -1 2615 1837 -778
formatSci 1.5 2972 2007 -965
formatSci 100 3117 1969 -1148
formatSci amt18 8243 2824 -5419
formatSci 1/3 24637 6302 -18335
formatSci -1/3 24712 6312 -18400
formatSci 3.75 as add result 3259 2030 -1229
formatSci 1e-50 3523 2063 -1460
formatSci 1e50 3448 2031 -1417
formatSci int224.max 25190 6368 -18822
formatSci int224.min 25265 6378 -18887
formatSci int224.max e-1000 25596 6462 -19134
formatSci 1e-1000 4185 2187 -1998
formatSci 1e2147483647 6096 2527 -3569
formatSci int224.min e(int32.min) 27988 6906 -21082

The assembly is cheaper on every row, by 477 to 139405, so src stays assembly. Plain Solidity pays a bounds check and a masked single-byte write per digit where the assembly copies 32 bytes per word. The parse scan has no plain-Solidity counterpart: parseDecimalFloatInline works on memory cursors, which Solidity cannot read without assembly, so its rows above compare against the isMask loop only.

The main suites, forge test --mc 'LibDecimalFloat(Ops)?GasTest|LibParseDecimalFloatGasTest' -vv, run on main 51a3451 and on 68cb426 (format and parse src unchanged since): every op row is identical except the parse rows, which only drop.

LibParseDecimalFloatGasTest main PR Δ
0 1739 1739 0
1 1806 1806 0
100 2452 2452 0
-42 2118 2118 0
1.5 3259 3209 -50
-123.456 4553 4503 -50
0.000001 4655 4605 -50
1e18 3328 3328 0
1.5e-7 4447 4397 -50
1000000000000000000 7620 7620 0
123456789012345678901234567890.123456789 15210 15160 -50
LibDecimalFloatGasTest.testGasParse main PR Δ
1 1798 1798 0
1.5 3251 3201 -50
1.234567890123456789 8745 8695 -50
-1.5e-10 4767 4717 -50
1e18 3320 3320 0

Rejected: #6, add/sub maximizing one operand

Every variant costs more on the packed path wherever B's order of magnitude wins (B is swapped in), and that path is the common one. Measured against #321's add with a bench whose rows all assert identical results. Gas is the add call alone:

case #321 v2: maximize A, align B, bool swap v3: as v2, reusing the scale on swap v4: skip B's maximize only when B is not above A
packed 1.5 + 2.25 1669 1163 1166 1719
packed amt18 + amt18b 1219 925 928 1269
packed 1/3 + 1/3 1746 1230 1233 1796
packed 1000 + 1 1719 1101 1104 1769
packed 1 + 1000 (B wins) 1729 1906 (+177) 1848 (+119) 1779 (+50)
packed 1 + amt18 (B wins) 1493 1199 1202 1543 (+50)
packed 1/3 + amt18 (B wins) 1501 1678 (+177) 1620 (+119) 1551 (+50)
packed 1e-60 + 1e30 (B wins, A dropped) 1622 1810 (+188) 1859 (+237) 1671 (+49)
packed 1e30 + 1e-60 (B dropped) 1612 1149 1149 1131
packed int224.max + int224.max 1611 1121 1124 1661 (+50)
unpacked 1 + int256.max (B wins) 1433 1610 1552 1483
unpacked floor 1 + floor 1 1939 1964 1964 1989

v2 and v3 save about 300 to 600 when A wins, but add 119 to 237 whenever B wins. v4 adds about 50 on almost every row. None of them is free on the packed path, so add is unchanged.

QA

  • Discriminating tests: LibFormatDecimalFloatMemoryTest (testFormatMemoryScientific, testFormatMemoryNonScientificFraction, testFormatMemoryNonScientificInteger) is new. It checks that format leaves the allocation before it untouched, does not let a second call write over the first string, and leaves the string's padding zero. Each test fails under the mutants below that it kills, and passes on the code. Byte identity with Gas: maximize, mul and packLossy without touching the packed path #321 is checked by LibDecimalFloatFormatParsePr321EquivalenceTest and by every row of LibDecimalFloatFormatParseGasTest.

  • Mutations applied: 89 mutants, run with mutation-probe over every forge contract that reaches format or parse. 82 were killed by the suite at 1c2258b. Examples: the scientific 76/75 digit threshold, the exponent sign and digits, each stripTrailingZeros step, each copy loop's bound and cursor advance, the ZEROS/ZERO_POINT constants, and all five parse-scan mutants (byte, offset, step, char, disabled). They were killed by testFormatPr321EquivalenceDigitCounts, testFormatDecimalExamples, testFormatGas, testParseLiteralDecimalFloatDecimals and others. Four more are killed only by the new memory test:

    • S05: the scientific buffer offset 0x80 → 0x20 writes digits below the free pointer. Killed by testFormatMemoryScientific.
    • N07: the non-scientific buffer offset 0x60 → 0x20 does the same. Killed by testFormatMemoryNonScientific*.
    • S26 and N32: padding set dirty. Killed by the memory tests.

    Three are equivalent. N02 and N03 move the positive exponent cap 68 → 69 or 79. int224.max / 10^e is 0 for every e in [68, 78], including the wrapped 10^78, so the call still reverts with the same error. N34 rounds the free pointer with 0x1f in place of 0x3f, which still covers the padding. The record is in audit/mutation-test-scans.json.

  • Oracle: byte identity with verbatim copies of main's format (unchanged since ce55522) and main's parse at ff6d70c, as Gas: core arithmetic, packing and comparison hot spots #310 requires, plus the existing hand-written expected strings in the format and parse tests. The memory test's expected strings are written out by hand: -1/3 as -3.33…e-1 and -0.33…, and int224.min. testParsePr321EquivalenceExponentBounds adds literals at the int256 exponent bounds (from a fraction, from trailing zeros past int256, and exponent digits past int256), so the wrap guard's ParseDecimalPrecisionLoss / ExponentOverflow split is checked byte for byte through the new scan.

  • Category check: Gas: maximize, mul and packLossy without touching the packed path #321 says it leaves only test add #6 and 2024 07 10 mul #11 of Gas: core arithmetic, packing and comparison hot spots #310 open ("items test add #6 and 2024 07 10 mul #11 stay open there"). This PR does 2024 07 10 mul #11 (format/parse) and rejects test add #6 with the numbers above, so it closes Gas: core arithmetic, packing and comparison hot spots #310.

  • Runs at 3e3e59a: forge build --deny warnings --force and forge lint clean, forge fmt --check clean, forge test: 867 passed, 0 failed. cargo test --release in crates/tests: 159 passed, 0 failed.

  • Merge with main: this PR's src lines are unchanged by it. The only src edit is keeping CMASK_ZERO imported for main's parseIntegerPastInt256, so the mutation record above still covers them.

  • Merge of main 1047972 (e1d4a3e): one conflict, the _toScientific NatSpec, resolved by keeping this branch's function and adding main's e0 omission sentence (this formatter already omits e0; main's test: check formatting against an independent rendering #398 oracle tests pass unchanged). audit/mutation-test-scans.json merged as the union of both sides (40 entries).

Closes #310

🤖 Generated with Claude Code

baku-ccron and others added 3 commits October 7, 2026 16:40
Format writes digits directly in assembly, dropping the OpenZeppelin
Strings import; parse's trailing-zero scan is an assembly loop. Verbatim
copies of both libraries at ce55522 back an equivalence fuzz and a gas
bench asserting identical bytes and lower-or-equal gas.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Oct 7, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Next included review available in 26 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

Learn how review limits work.

Review configuration:

⚙️ Run configuration
  • Configuration used: Organization UI
  • Review profile: ASSERTIVE
  • Plan: Advanced
  • Run ID: ba8f5e20-d35c-4ace-833f-2c53b5fb5a49

📥 Commits

Reviewing files that changed from the base of the PR and between 1047972 and 3e3e59a.


📒 Files selected for processing (10)
  • audit/mutation-test-scans.json
  • src/lib/format/LibFormatDecimalFloat.sol
  • src/lib/parse/LibParseDecimalFloat.sol
  • test/concrete/FormatParseGas.sol
  • test/lib/LibFormatDecimalFloatPr321.sol
  • test/lib/LibFormatDecimalFloatSolidity.sol
  • test/lib/LibParseDecimalFloatPr321.sol
  • test/src/lib/LibDecimalFloat.formatParseGas.t.sol
  • test/src/lib/LibDecimalFloat.formatParsePr321Equivalence.t.sol
  • test/src/lib/format/LibFormatDecimalFloat.memory.t.sol

  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

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.

baku-ccron and others added 4 commits October 7, 2026 17:57
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Parse keeps main's int256 exponent-wrap guard and past-int256 integer
path beside the assembly fraction scan; CMASK_ZERO stays imported for
main's parseIntegerPastInt256. The parse equivalence copy is refreshed
to main's parse at ff6d70c (the format copy already equals main's), and
the equivalence suite gains the int256 exponent-bound literals. The scan
log is the jq union of both sides.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@thedavidmeister
thedavidmeister changed the base branch from 2026-10-06-issue-310-core-gas to main October 10, 2026 12:34
baku-ccron and others added 2 commits October 11, 2026 08:19
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…idity

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@thedavidmeister
thedavidmeister merged commit 6dde241 into main Oct 11, 2026
7 of 8 checks passed
@github-actions

Copy link
Copy Markdown

@coderabbitai assess this PR size classification for the totality of the PR with the following criterias and report it in your comment:

S/M/L PR Classification Guidelines:

This guide helps classify merged pull requests by effort and complexity rather than just line count. The goal is to assess the difficulty and scope of changes after they have been completed.

Small (S)

Characteristics:

  • Simple bug fixes, typos, or minor refactoring
  • Single-purpose changes affecting 1-2 files
  • Documentation updates
  • Configuration tweaks
  • Changes that require minimal context to review

Review Effort: Would have taken 5-10 minutes

Examples:

  • Fix typo in variable name
  • Update README with new instructions
  • Adjust configuration values
  • Simple one-line bug fixes
  • Import statement cleanup

Medium (M)

Characteristics:

  • Feature additions or enhancements
  • Refactoring that touches multiple files but maintains existing behavior
  • Breaking changes with backward compatibility
  • Changes requiring some domain knowledge to review

Review Effort: Would have taken 15-30 minutes

Examples:

  • Add new feature or component
  • Refactor common utility functions
  • Update dependencies with minor breaking changes
  • Add new component with tests
  • Performance optimizations
  • More complex bug fixes

Large (L)

Characteristics:

  • Major feature implementations
  • Breaking changes or API redesigns
  • Complex refactoring across multiple modules
  • New architectural patterns or significant design changes
  • Changes requiring deep context and multiple review rounds

Review Effort: Would have taken 45+ minutes

Examples:

  • Complete new feature with frontend/backend changes
  • Protocol upgrades or breaking changes
  • Major architectural refactoring
  • Framework or technology upgrades

Additional Factors to Consider

When deciding between sizes, also consider:

  • Test coverage impact: More comprehensive test changes lean toward larger classification
  • Risk level: Changes to critical systems bump up a size category
  • Team familiarity: Novel patterns or technologies increase complexity

Notes:

  • the assessment must be for the totality of the PR, that means comparing the base branch to the last commit of the PR
  • the assessment output must be exactly one of: S, M or L (single-line comment) in format of: SIZE={S/M/L}
  • do not include any additional text, only the size classification
  • your assessment comment must not include tips or additional sections
  • do NOT tag me or anyone else on your comment

@linear

linear Bot commented Oct 11, 2026

Copy link
Copy Markdown

RAI-3197

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.

Gas: core arithmetic, packing and comparison hot spots

1 participant