Shave a bit more performance#281
Open
Arkham wants to merge 3 commits into
Open
Conversation
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
approved these changes
Apr 25, 2026
lue-bird
left a comment
Contributor
There was a problem hiding this comment.
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
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).
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.
Methodology
Bench corpus is
~/.elm/0.19.1/packages/— 235 files, ~2.85 MB of Elmsource — using a Node harness that loads four
ParserFastworkersside-by-side: plain
master(baseline),master + commit 1,master + commit 2, andmaster + both(this PR's HEAD). Each worker isthe same
ParseMain.elmmodified to NOT JSON-encode the AST so thetiming window only measures
Elm.Parser.parseToFileitself, with nocontention 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
masterHEADmaster(baseline)master+ commit 1master+ commit 2master+ both (HEAD)What changed
Commit 1 —
Layout: peek 1 char before paying for 2-char comment-start check(
src/Elm/Parser/Layout.elm)whitespaceAndCommentsOrEmptyruns between every pair of tokens — easilythe hottest path in the parser. The previous code unconditionally sliced 2
chars and ran a multi-string
caseover them, even though the vastmajority 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==//=onIntwhen one sideis a literal integer. When both sides are computed (e.g.
charCode /= openCharCode, ornewOffset == -1— which is parsed asBasics.negate 1, not a literal), it falls back to_Utils_eq, whoseouter loop allocates a
stack = []array on every call. This adds upfast inside per-character parser loops.
Replaced
a == bwitha - b == 0(and similar) at every site where thecompiled JS was emitting
_Utils_eqon Ints:isNotRelevantin the multi-line comment body scannerExpression.elmanyChar/anyCharFollowedByWhileMapend-of-source markers (== -1,== -2)whileWithoutLinebreakAnd2PartUtf16ToResultAndThenThat 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/ExpectingCustomallocation. On its own it was within noise; combinedwith 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_eqcall 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 independentlynode find-regressions src/: no AST regressions