Fix unbounded memory growth and hot path overhead - #19
Draft
bobmulder wants to merge 2 commits into
Draft
Conversation
Every derived instance held a strong reference to the instance it was derived from, so a chain kept all of its intermediate results alive. An accumulating loop grew without bound: folding 50k additions retained 7.6 MB. The parent is referenced weakly now, which brings that down to 92 KB while parent() keeps working for every number that is still referenced. Arithmetic and comparison methods no longer allocate an instance per scalar operand, and no longer normalize operands that already fit the internal scale. Comparisons run a single bccomp instead of building up to two instances and comparing twice. - round(), ceil() and floor() are calculated with bcmath instead of casting to float, which was lossy beyond 15 significant digits and produced exponential notation that made every following bcmath call throw a ValueError. - Exponential notation is expanded on input, so floats like 0.00001 and strings like '1e3' can be used in calculations. - min(), max(), clamp() and the divide() fallback no longer truncate their value to 4 decimals. - Formatters are reused per locale and options instead of being rebuilt on every call, which makes formatting 10x (decimals) to 18x (money) faster. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015oVaCdzr9zJS75QQcHKNzj
Member
Author
CI statusGreen on PHP 8.0, 8.1 and 8.2. Two jobs are red, both for the same reason, and both are red on Tests / P7.4 — 77 of the 78 tests pass, one errors: Psalm — one error, the same line: PHP 7.4 does not know Fixing it is a support decision rather than a bug fix, which is why it is not in this PR:
Happy to push either one — say which and I will add it here. Generated by Claude Code |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Analysis of the package for memory leaks and efficiency losses, plus the fixes for everything found.
The memory leak
AbstractNumberstored a strong reference to the instance it was derived from. Since every operation returns a new instance, a chain keeps its whole history alive: an accumulating loop grows without bound.Measured with 50.000 additions: 7.596 KB retained, growing linearly (≈155 MB for a million rows). Discarding such a chain also destroyed it recursively, one stack frame per link.
The parent is now referenced through
WeakReference, so an intermediate result is released as soon as nothing else refers to it. Same loop: 92 KB retained.parent()keeps working for every number that is still referenced, and returnsnullonce the parent has been collected — documented in the README.Efficiency losses
add('1.5')isEqual()isGreaterThanOrEqual()isZero()min()->max()absolute()Formatter::format()Formatter::formatMoney()round(2)add('1.5')built a fullNumber(with its own parent link) just to read the string back out. Scalar operands are handled as strings now;getNumberFromInput()stays for implementations that use it.bcadd('0.0000', $value, 12). Padding with zeroes does not change a value, so that call is only made when an operand actually has more than 12 decimals.isGreaterThanOrEqual()andisLessThanOrEqual()ran two full comparisons (two operand conversions, twobccompcalls) where onebccompanswers both.isZero()/isThirteen()went throughisEqual(), which built an instance to compare against a constant.absolute()/opposite()ran abcmulby-1to flip a sign character.clamp()built three instances and converted its result to a string in between.Formatterbuilt a newNumberFormatteron every single call, while constructing one costs roughly twenty times as much as formatting a value with it. Formatters are cached per locale and options now (bounded to 32,Formatter::flush()empties it).Formatter::get()still hands out a fresh instance, so an instance you modify yourself is never shared.Correctness fixed along the way
round(),ceil()andfloor()cast to float, which defeats the purpose of a bcmath backed number. Beyond 15 significant digits the result was wrong (ceil('282913.00000000000002322266')returned282913), and for large values(string) round(...)produced exponential notation which made every following bcmath call throwValueError—Number::create('12345678901234567890.5')->round(0)was fatal. All three are calculated with bcmath now, including the four rounding modes (which had no test coverage). This is the one change that costs performance: exact rounding is ~1µs slower per call than a native float round.Number::create(0.00001)stored1.0E-5, which no bcmath function accepts, so the value was unusable in any calculation.min(),max(),clamp()and thedivide()fallback truncated to 4 decimals, because they converted their value through__toString().(new Number('1.123456789'))->min('2.987654321')returned2.9876.divide()fallback accepted any type; it is validated like every other input now.Verification
php-cs-fixerclean,psalmreports the same single pre-existing error asmaster.Found but not changed
jsonSerialize(): mixeduses PHP 8.0 syntax whilecomposer.jsonrequires^7.4, so the package does not parse on PHP 7.4 and psalm has been failing onmastersince that change. Fixing it means either dropping 7.4 or dropping the return type — your call, so I left it alone.Generated by Claude Code