Skip to content

Shave a bit more performance#281

Open
Arkham wants to merge 3 commits into
stil4m:masterfrom
Arkham:arkham/shave-more-performance
Open

Shave a bit more performance#281
Arkham wants to merge 3 commits into
stil4m:masterfrom
Arkham:arkham/shave-more-performance

Conversation

@Arkham

@Arkham Arkham commented Apr 25, 2026

Copy link
Copy Markdown

Methodology

Bench corpus is ~/.elm/0.19.1/packages/ — 235 files, ~2.85 MB of Elm
source — using a Node harness that loads four ParserFast workers
side-by-side: plain master (baseline), master + commit 1,
master + commit 2, and master + both (this PR's HEAD). Each worker is
the same ParseMain.elm modified to NOT JSON-encode the AST so the
timing window only measures Elm.Parser.parseToFile itself, with no
contention between parse and encode for V8 tier-up budget.

Each worker runs over the corpus 40 times after 5 warmup iterations. The
table below shows median of 6 separate full bench runs to dampen
thermal/scheduler noise.

Per-iteration ratios are also reported because they are
thermal-noise-immune: each ratio uses a (variant + master) pair measured
in the same OS state, so when the CPU got hot they slowed down together.

Per-commit results vs master HEAD

min (ms) median (ms) speedup vs master per-iter ratio
master (baseline) 103-108 105-112 1.000
master + commit 1 100-104 103-106 ~2-3% 0.94-0.99 (median 0.97)
master + commit 2 102-103 104-106 ~1-3% 0.94-1.00 (median 0.98)
master + both (HEAD) 98-99 100-103 ~4-6% 0.91-0.96 (median 0.95)

What changed

Commit 1 — Layout: peek 1 char before paying for 2-char comment-start check
(src/Elm/Parser/Layout.elm)

whitespaceAndCommentsOrEmpty runs between every pair of tokens — easily
the hottest path in the parser. The previous code unconditionally sliced 2
chars and ran a multi-string case over them, even though the vast
majority of calls land in the no-comment branch. Replaced with a 1-char
slice + nested 1-char check, so the common path now allocates one less
substring per call.

Commit 2 — Force === for Int compares + empty-consume short-circuit
(src/ParserFast.elm, src/Elm/Parser/Expression.elm)

The Elm compiler only emits JS === for ==//= on Int when one side
is a literal integer. When both sides are computed (e.g.
charCode /= openCharCode, or newOffset == -1 — which is parsed as
Basics.negate 1, not a literal), it falls back to _Utils_eq, whose
outer loop allocates a stack = [] array on every call. This adds up
fast inside per-character parser loops.

Replaced a == b with a - b == 0 (and similar) at every site where the
compiled JS was emitting _Utils_eq on Ints:

  • isNotRelevant in the multi-line comment body scanner
  • infix non-associative precedence check in Expression.elm
  • anyChar / anyCharFollowedByWhileMap end-of-source markers (== -1, == -2)
  • the empty-consume short-circuit in whileWithoutLinebreakAnd2PartUtf16ToResultAndThen

That last one — short-circuiting when zero chars were consumed — is folded
in here because it's structurally tied to the same parser. It fires after
every expression not followed by an infix operator (the common case) and
skips an empty-string String.slice + callback dispatch + Bad /
ExpectingCustom allocation. On its own it was within noise; combined
with the Int-compare trick at the same call site it becomes part of a
clear measurable improvement.

Net result in the optimized JS: 9 distinct _Utils_eq call sites → 3
(only string comparisons in declaration-vs-signature name matching
remain — those can't easily use the same trick).

Tests / regressions

  • elm-test: 522/522 pass on each commit independently
  • node find-regressions src/: no AST regressions

Arkham added 2 commits April 25, 2026 09:56
In `whitespaceAndCommentsOrEmpty` (called between every pair of tokens —
the absolute hottest path), replace the 2-char `String.slice offset
(offset + 2) source` + multi-string `case` with a 1-char slice + nested
1-char check. The vast-majority no-comment path now allocates one less
substring per call; only the rare `-` or `{` positions continue to the
2-char comparison.
The Elm compiler only emits `===` for `==`/`/=` on Ints when one side is a
literal integer. When both sides are computed values (e.g.,
`charCode /= openCharCode`, `info.leftPrecedence == leftPrecedence`,
`newOffset == -1` — which is parsed as `Basics.negate 1`, not a literal),
the compiler falls back to `_Utils_eq`. `_Utils_eq`'s outer loop allocates
a `stack = []` array on every call, which adds up inside per-character
parser loops.

Fixed sites (all compile-verified to drop the `_Utils_eq` call in the
optimized JS):

* `isNotRelevant` in `nestableMultiCommentMapWithRange` (per char inside
  every multi-line comment body):
  `charCode /= openCharCode && charCode /= closeCharCode`
  → `(charCode - openCharCode) /= 0 && (charCode - closeCharCode) /= 0`.
* Infix non-associative precedence check in Expression.elm:
  `info.leftPrecedence == leftPrecedence`
  → `info.leftPrecedence - leftPrecedence == 0`.
* `anyChar` and `anyCharFollowedByWhileMap` end-of-source markers:
  `newOffset == -1` / `== -2` → `newOffset + 1 == 0` / `newOffset + 2 == 0`.

Also folds in a cheap but correlated improvement in
`whileWithoutLinebreakAnd2PartUtf16ToResultAndThen`: short-circuit when
zero chars were consumed. This fires after every expression not followed
by an infix operator (the common case) and skips an empty-string
`String.slice` + callback dispatch + `Bad`/`ExpectingCustom` allocation.
The short-circuit's `s1Offset == s0.offset` check is itself written as
`s1Offset - s0.offset == 0` so it also benefits from the `===` path.

Net result: 9 distinct `_Utils_eq` call sites in the compiled JS → 3
(only string comparisons in declaration-vs-signature name matching
remain).

@lue-bird lue-bird left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Awesome! I think there are also still some ++ ""s remaining in some equality checks if you want to add them to this PR for a few more percents

Comment thread src/ParserFast.elm
Comment thread src/Elm/Parser/Layout.elm
Cleanup suggestion from @lue-bird: after the `+ 1 == 0` (EOF = -1) branch
filters out -1, the only remaining negative is -2 (newline marker). `< 0`
is more idiomatic and matches the convention used elsewhere in this file
(see the `isSubCharWithoutLinebreak` docstring).
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.

2 participants