Skip to content

perf(stdlib): use io_lib.format for string.format float conversion#319

Merged
davydog187 merged 4 commits into
mainfrom
perf/string-format-iolib-float
Jun 1, 2026
Merged

perf(stdlib): use io_lib.format for string.format float conversion#319
davydog187 merged 4 commits into
mainfrom
perf/string-format-iolib-float

Conversation

@davydog187
Copy link
Copy Markdown
Contributor

use io_lib.format for string.format float conversion

Plan: .agents/plans/B14-string-format-iolib-float.md
Closes #311

Goal

Cut the per-call cost of string.format float specifiers by delegating %f (and the %e/%g mantissa) to a single native :io_lib.format/2 call, replacing the :erlang.float_to_binary + expand_float/2 post-processing chain. Correctness against C Lua is the gate.

Success criteria

  • format_spec_float/2 uses :io_lib.format(~c"~.*f", [P, abs(val)]) with sign reapplied separately, plus an explicit precision-0 path. Verified: new fixed_float/2 clauses.
  • expand_float/2 deleted; round_mantissa/2 and normalize_mantissa/2 removed (folded into mantissa_with_carry/3). Verified: mix compile --warnings-as-errors clean, no unused-function warnings.
  • %f rounding matches C Lua (round-half-to-even) at the .5 boundary: %.0f of 2.5→2, 0.5→0, 1.5→2, 3.5→4, -2.5→-2. Verified: new unit test asserts each against /opt/homebrew/bin/lua.
  • %f of 0/0"nan" no longer raises. Verified: new unit test. (1/0 never reaches as infinity in this VM — see Out of scope.)
  • All existing format cases in test/lua/vm/string_test.exs and test/language/stdlib/string_test.exs pass unchanged (no test encoded the old half-away precision-0 behavior). Verified: both files green.
  • New unit tests for precision-0 sign-boundary rounding, %.20f large precision, and nan. Verified: describe "string.format float rounding and non-finite values".
  • mix compile --warnings-as-errors clean.
  • mix test full suite green, no regression (2117 passed).
  • mix test --only lua53 pass count unchanged (17 passed, 12 skipped — same as main baseline).
  • Float-format microbenchmark ~1.7ms for n=1000 vs 7.42ms baseline cited in perf(stdlib): use io_lib.format for string.format float conversion #311.

Changes

 lib/lua/vm/stdlib/string.ex | 128 +++++++++++++++-----------------------------
 test/lua/vm/string_test.exs |  43 +++++++++++++++
 2 files changed, 85 insertions(+), 86 deletions(-)

Verification output (tail)

mix test:            Result: 2117 passed (60 doctests, 51 properties, 2006 tests), 19 skipped, 1 excluded
mix test --only lua53: Result: 17 passed, 12 skipped, 2108 excluded   (main baseline: 17 passed, 12 skipped)
string_test.exs:     Result: 155 passed (41 properties, 114 tests)

Out of scope

Replace the :erlang.float_to_binary + expand_float/2 post-processing
chain in format_spec_float/2 with a single native :io_lib.format/2
call, and route the %e/%g mantissa through the same path. Precision-0
gets a dedicated round-half-to-even clause (io_lib raises on ~.*f with
P=0 on OTP 29), matching C Lua; 0/0 (the :nan atom) now formats as
"nan" instead of raising. Deletes expand_float/2, round_mantissa/2 and
normalize_mantissa/2.

Plan: B14
Closes #311
Copy link
Copy Markdown
Contributor Author

@davydog187 davydog187 left a comment

Choose a reason for hiding this comment

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

Automated review (skeptical senior pass). Verdict: clean-with-nits.

I fetched the PR head (816514d), read the migrated string.ex, and sanity-checked :io_lib.format output against PUC-Lua (/opt/homebrew/bin/lua, 5.5 — identical printf delegation for %f/%e/%g) for the rounding-sensitive cases. The conversion is byte-for-byte correct across everything I tried.

Correctness — verified against C Lua (all pass)

  • %.0f round-half-to-even (round_half_even/1, string.ex:487): 2.5→2, 0.5→0, 1.5→2, 3.5→4, 4.5→4, -2.5→-2, 2.4→2, 2.6→3, 1000000.5→1000000 — all match C Lua. The .5 ties are exactly representable in IEEE754 so frac == 0.5 is exact; no FP-precision trap. Above 2^52 there is no .5 tie so frac is 0.0 — safe.
  • Negative-rounds-to-zero sign: %.0f of -0.4 → "-0", -0.6 → "-1" match C Lua (sign preserved for negative non-zero inputs).
  • Large precision (fixed_float/2, :io_lib.format(~c"~.*f", ...)): %.20f of 1.0 → 1.00000000000000000000, 0.1 → 0.10000000000000000555 — match C Lua exactly.
  • %e/%g mantissa via mantissa_with_carry/3 (string.ex:529): verified %.6e 1.0 → 1.000000e+00, the 9.99→10 carry (%.2e 9.999999 → 1.00e+01, %.6e 9.9999999 → 1.000000e+01), %.0e, and 1.5e-10 → 1.500000e-10 — all match. 2-digit exponent padding (e+00) correctly retained; :io_lib's single-digit exponents are not used. Good call keeping format_scientific_str/2.
  • %g trailing-zero stripping + boundary: 100000.0→100000, 1000000.0→1e+06, 0.0001→0.0001, 0.00001→1e-05, 0.1→0.1, 1.0→1, 99999.99→100000 (carry), 1234567.0→1.23457e+06 — all match C Lua. C's %g trailing-zero strip is preserved via strip_trailing_zeros{,_scientific}/1.
  • nan: 0/0 surfaces as the :nan atom (executor.ex:2559) and the new format_spec_float(:nan, _) clause returns "nan", matching C Lua; no longer raises. Verified the VM produces :nan for 0/0.
  • inf: this VM clamps 1/0 → 1.0e308 / -1/0 → -1.0e308 (executor.ex:2560-2561, math.huge-consistent), so a non-finite value never reaches the formatter. %f of 1.0e308 produces identical bytes (316 chars) to C Lua's finite output. The C-Lua inf/-inf divergence is a pre-existing VM-wide design decision, correctly scoped out.

Findings

  • [minor] -0.0 input loses its sign (fixed_float/2, string.ex:469 & 474). sign = if float_val < 0.0 — but -0.0 < 0.0 is false on the BEAM, so %f of literal -0.0 yields 0.000000 and %.0f yields 0. C Lua emits -0.000000 / -0, and the old float_to_binary path did preserve it (float_to_binary(-0.0) → "-0.0"). This is a genuine regression vs both C Lua and prior behavior, though narrow: it only triggers when the input is exactly -0.0 (e.g. string.format("%f", -0.0) or -1*0.0); negative values that round to zero (-0.4) are handled correctly. No existing or new test covers -0.0, so the suite stays green. Cheap fix: detect negative-zero via something like float_val < 0.0 or (float_val == 0.0 and 1.0/float_val < 0.0) (or match the sign bit). Worth a one-line test pinning string.format("%f", -0.0) == "-0.000000" either way.

  • [nit] Plan id B14 used as commit SCOPE. chore(B14): start plan / chore(B14): mark plan as review violate the repo rule that commit subjects use the affected subsystem as scope, never the plan id. The substantive perf(stdlib): ... commit is correctly scoped and keeps Plan: B14 in the body — these are just the housekeeping commits, but the scope convention still applies.

Convention / scope checks (clean)

  • No dangling callers of expand_float/2, round_mantissa/2, or normalize_mantissa/2 — all three fully removed.
  • No plan-id (B14) leakage in source or test files.
  • No Co-Authored-By AI trailer.
  • Scope confined to the plan file, string.ex, and string_test.exs — no stray edits into sibling-PR (#316/#317) territory.
  • New tests added per the issue: precision-0 sign-boundary set, %.20f large precision, and 0/0 → "nan". (Note: large-precision is covered only for 1.0; an irrational like %.20f of 0.1 would be a stronger pin, and a -0.0 case is the gap called out above.)

Nothing here is a blocker. Recommend addressing the -0.0 sign before merge (or consciously documenting it as an accepted edge), and renaming the chore(B14) commit scopes.

Derive the printed sign from the IEEE-754 sign bit instead of `< 0.0`.
On the BEAM `-0.0 < 0.0` is false, so the old check dropped the sign of
exact negative zero, yielding "0.000000" where C printf / PUC-Lua give
"-0.000000". Applied to the %f, %e/%E, and %g/%G paths.

Plan: B14
Addresses the PR #319 review finding on fixed_float/2 negative-zero sign.
@davydog187
Copy link
Copy Markdown
Contributor Author

Fixed the negative-zero sign loss in fixed_float/2. The sign is now derived from the IEEE-754 sign bit (<<sign_bit::1, _::63>> = <<float_val::float>>) rather than float_val < 0.0, since -0.0 < 0.0 is false on the BEAM. Applied consistently to the %f, %e/%E, and %g/%G paths (the == 0.0 early-returns in the scientific and general helpers also needed the sign).

string.format("%f", -0.0) now returns "-0.000000" (matching C printf / PUC-Lua), and %e -> -0.000000e+00, %g -> -0. Positive zero stays unsigned. Added coverage in string_test.exs for both negative- and positive-zero across the float specifiers; full suite + lua53 suite pass. Commit ce6d8b0.

@davydog187 davydog187 merged commit 62d6afa into main Jun 1, 2026
5 checks passed
@davydog187 davydog187 deleted the perf/string-format-iolib-float branch June 1, 2026 22:14
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.

perf(stdlib): use io_lib.format for string.format float conversion

1 participant