Skip to content

perf: JS % lowers to a libm fmod call — 7.3% of pipeline self time #8407

Description

@proggeramlug

Summary

pipeline spends 7.3% of self time in libm fmod. It currently runs at 1.26x Node
with an instruction ratio of 1.88.

Profile evidence

Measured on 8bfdac447, symbols kept, aggregated over 14 runs:

 28.7%  perry_method_pipeline_ts__u_Registry_$_fn_num__get$pshape   <- user code
 25.4%  perry_method_pipeline_ts__u_Registry_$_fn_num__set$pshape   <- user code
 16.8%  perry_fn_pipeline_ts__main                                  <- user code
  7.3%  fmod                                                        <- THIS
  5.5%  perry_method_pipeline_ts__u_Registry_$_str_num__get$pshape
  4.9%  js_implicit_this_set
  3.7%  js_closure_call1
  1.5%  js_string_concat_box
  1.5%  gc::layout_tables::layout_forget_object

Why this is worth fixing

A % on two doubles is being lowered to a libm fmod call. For the overwhelmingly common
case — both operands are integral and in a range where the result is exact — this can be a
handful of inline instructions instead of a function call into libm.

V8 does exactly this: it emits an inline integer modulo when it can prove or cheaply check
integrality, and falls back to fmod otherwise. The check is cheap relative to the call.

There is precedent in this codebase for the shape of the fix: #8395 gave the relational
operators the numeric early-out that + and === already had, worth −7.0% on interp and
−5.4% on iso_miss. The same "cheap runtime check before the general path" pattern applies
here.

Care needed on semantics — JavaScript % is truncated remainder, not Euclidean modulo,
and must preserve the sign of the dividend. -7 % 3 is -1, not 2. NaN, infinities, and
a zero divisor must all keep their current behaviour. A differential test against Node over
a spread of operand kinds (integral, fractional, negative, ±0, ±Infinity, NaN, very large
magnitudes where doubles lose integer precision) is the right way to pin that.

Where to look

  • the binary-op lowering for % in crates/perry-codegen/src/expr/
  • whatever runtime helper currently reaches fmod

Also visible in the same profile

js_implicit_this_set at 4.9% may be worth a separate look — it is called on a hot method
path. Note it also appeared in #8243's investigation as a default-off unmeasured suspect.

Acceptance

pipeline measurably improved (ideally < 1.00x Node), % semantics unchanged under a
differential test against Node, all 19 rows byte-exact, RSS not increased.


How to build and measure (shared setup)

Build with the static wrappers or you link a stale runtime and both A/B arms behave
identically — a vacuous result:

cargo build --release -p perry -p perry-runtime-static -p perry-stdlib-static
export PERRY_RUNTIME_DIR=<your target>/release

The benchmark corpus and Node-oracle expected output are staged at
/Users/amlug/projects/perry/sweep-artifacts-0819b/ (sources/*.ts, expected/*.stdout).

export PERRY_NO_AUTO_OPTIMIZE=1 PERRY_NO_CACHE=1
perry sources/<row>.ts -o /tmp/x --no-cache
cmp <(/tmp/x) expected/<row>.stdout      # verify BEFORE timing
/usr/bin/time -l /tmp/x                  # instructions retired + peak RSS

Profiling: Perry strips its output binaries, so sample attributes everything to
???. Set PERRY_KEEP_SYMBOLS=1 PERRY_DEBUG_SYMBOLS=1 at compile time — the binary keeps
~1900 symbols and stays byte-exact — then read sample's "Sort by top of stack" section.
These programs are short; aggregate across ~14 runs for a usable sample count.

Constraints that apply to any fix here

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions