Skip to content

[Perf]: ~1.9x faster parse via fast-paths and manual scanning#552

Open
homanp wants to merge 2 commits into
ljharb:mainfrom
homanp:perf/parse-fast-paths
Open

[Perf]: ~1.9x faster parse via fast-paths and manual scanning#552
homanp wants to merge 2 commits into
ljharb:mainfrom
homanp:perf/parse-fast-paths

Conversation

@homanp
Copy link
Copy Markdown

@homanp homanp commented Apr 16, 2026

Summary

~1.9x faster qs.parse via fast-paths for common cases and manual scanning to replace split/regex in hot loops. No behavior changes, no new deps. All 893 tests pass.

Benchmarks

Weighted throughput across 5 representative query string shapes, 3 fresh node processes (Node 23, macOS):

baseline this PR speedup
flat (a=1&b=2&c=3&d=4&e=5) 667k 1,540k 2.3x
nested (user[name]=alice&...) 355k 496k 1.4x
arrays (ids[]=1&ids[]=2&...) 353k 528k 1.5x
encoded (q=hello%20world&...) 848k 1,365k 1.6x
search (multi-field form) 322k 607k 1.9x
weighted 506k 947k 1.9x

Bench script in bench/workload.js for reproduction.

What changed

  1. Flat-key fast-path: when a key has no [, skip bracket-parsing entirely.
  2. Decoder skip: indexOf('%') + indexOf('+') up front — if neither present, return input unchanged.
  3. Manual delimiter scan: for single-char delimiters (default &), in-place scan instead of split().
  4. Skip compact for flat-only: compact() only needed when bracket parsing created sparse arrays.
  5. Manual bracket scanner: replaced bracket-matching regexes with indexOf in splitKeyIntoSegments.

Correctness

All 893 tape tests pass. Equivalence also verified on 25 additional adversarial inputs (empty strings, bare keys, duplicates, deep nesting, percent-encoded specials, + as space, mixed bracket styles).

Happy to split into separate commits per optimization if that helps review.

@homanp homanp changed the title perf: ~1.9x faster parse via fast-paths and manual scanning [Perf]: ~1.9x faster parse via fast-paths and manual scanning Apr 16, 2026
Copy link
Copy Markdown
Owner

@ljharb ljharb left a comment

Choose a reason for hiding this comment

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

This will need a rebase; it conflicts with some bugfixes i merged.

Comment thread lib/parse.js Outdated
Comment on lines +65 to +67
if (cleanStr.indexOf('%5') !== -1) {
cleanStr = cleanStr.replace(/%5B/gi, '[').replace(/%5D/gi, ']');
}
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

i'm surprised "indexOf + 2 replaces" being slower doesn't outweigh the benefit of the other branch being "indexOf instead of 2 replaced". it might be a good idea to do each change in a separate commit, and put the benchmark results in each commit message.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Ageee, Will make it clearer. Ty for feedback.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Done.

@ljharb ljharb marked this pull request as draft April 27, 2026 19:25
homanp added 2 commits April 27, 2026 22:51
…parts

Hoist decoder/defaultDecoder lookups out of the per-part loop.
Check whether each part actually contains '%' or '+' before
calling the decoder; plain ASCII key=value pairs are sliced
directly without entering the decode path.

Benchmark (weighted ops/sec): 540,502 → 696,526 (+28.9%)

  flat:    745,039 → 1,010,337
  nested:  394,463 → 481,302
  arrays:  356,643 → 534,676
  encoded: 846,351 → 859,280
  search:  332,500 → 492,732
When a key has no '[' (and no '.' when allowDots is set), assign
directly instead of going through parseKeys/splitKeyIntoSegments/
parseObject/merge. Also track whether any key needed the full path
to skip the compact() pass when it is unnecessary.

Benchmark (weighted ops/sec): 696,526 → 861,226 (+23.6%)

  flat:    1,010,337 → 1,430,967
  nested:  481,302 → 461,549
  arrays:  534,676 → 536,818
  encoded: 859,280 → 1,067,296
  search:  492,732 → 580,142
@homanp homanp force-pushed the perf/parse-fast-paths branch from 34b4179 to 7115f7e Compare April 27, 2026 20:52
@homanp
Copy link
Copy Markdown
Author

homanp commented Apr 27, 2026

@ljharb Rebased and split into separate commits with benchmarks in each.

After testing each optimization in isolation I ended up dropping two of them:

  • The indexOf('%5') guard before the %5B/%5D replace, you were right to flag it, only ~1% gain end-to-end. Just not worth it imo.
  • The manual indexOf delimiter splitting loop, ~2.5% gain for 17 extra lines. Same here not worth it.

@homanp homanp marked this pull request as ready for review April 27, 2026 20:58
@homanp homanp requested a review from ljharb April 27, 2026 20:58
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