perf: skip the decoders in utils.decode when there is no percent sign - #583
Open
zhangj23 wants to merge 1 commit into
Open
perf: skip the decoders in utils.decode when there is no percent sign#583zhangj23 wants to merge 1 commit into
zhangj23 wants to merge 1 commit into
Conversation
`decode` runs once per key and once per value on every parsed query string, so it is the hottest per-token function in the library. It always builds `str.replace(/\+/g, ' ')` and then calls `decodeURIComponent` (or `unescape` for iso-8859-1), even when the input contains nothing to decode. A string with no `%` has no escape to expand, in either charset: `unescape` has nothing to match, and `decodeURIComponent` is the identity on such a string and cannot throw. So only `+` to space remains, and often not even that. I checked that premise rather than assuming it: over 23,720 strings, spanning the codepoint range plus lone surrogates, surrogate pairs, the replacement character, a 10,000-character string and the reserved delimiters, every `%`-free input is byte-identical under both `decodeURIComponent` and `unescape`, with no throws. Measured on node v24.18.0, min and median of 31 ABBA-interleaved rounds against a separately built copy of `main`, with an A/A control on a byte-identical third copy (A/A stayed within 0.975x to 1.020x): shape min median typical query string 1.451x 1.404x urlencoded form body 1.259x 1.304x nested bracket notation 1.263x 1.210x 40 plain pairs 1.389x 1.389x 400 pairs 1.357x 1.373x percent-heavy 1.058x 1.051x The percent-heavy row is the honest floor: when most tokens really do contain `%`, the fast path cannot fire and all that is left is one extra `indexOf` per token, which is why it is 1.05x rather than 1.00x. For scale, an empirical ceiling measured by reducing `decode` to `return str` (removing the `+` replace and the decoder entirely) is about 1.64x on a realistic mixed corpus, so this captures roughly 80 percent of what is available without changing behaviour. Behaviour is unchanged. The suite passes with 1045 assertions and `# ok`, matching `main`.
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.
utils.decoderuns once per key and once per value for every parsed query string, so it is the hottest per-token function in the library. Today it always buildsstr.replace(/\+/g, ' ')and then callsdecodeURIComponent(orunescapefor iso-8859-1), even when there is nothing to decode.A string with no
%has no escape to expand, in either charset.unescapehas nothing to match, anddecodeURIComponentis the identity on such a string and cannot throw. So only+to space remains, and often not even that.I did not want to take that on faith, so I checked it: across 23,720 strings spanning the codepoint range plus lone surrogates, surrogate pairs, the replacement character, a 10,000-character string and the reserved delimiters, every
%-free input is byte-identical under bothdecodeURIComponentandunescape, with no throws.Numbers
Node v24.18.0, min and median of 31 ABBA-interleaved rounds against a separately built copy of
main, with an A/A control on a byte-identical third copy:The percent-heavy row is the honest floor. When most tokens really do contain
%the fast path cannot fire, and all that is left is one extraindexOfper token, which is why it is 1.06x rather than 1.00x. I could not construct an input where it is slower than the control.Trivial input does not regress: an empty query string is 1.003x, and a single
a=1pair is already 1.32x.For scale, I measured an empirical ceiling by reducing
decodetoreturn str, removing the+replace and the decoder entirely. That is about 1.64x on a realistic mixed corpus, so this change captures roughly 80 percent of what is available without altering behaviour.Why the shapes above
I deliberately built the corpus around what express and body-parser actually hand this function, rather than a best case. Browsers percent-escape aggressively, so a realistic corpus has to include percent-heavy tokens, and the two rows most representative of real traffic are the form body at 1.27x and the typical query string at 1.47x.
Behaviour
The change adds no gate on user-supplied objects, no cache and no early exit from a loop. It performs the same
+replacement when a+is present and skips only the decoder call that provably cannot change the string.The test suite passes with 1045 assertions and
# ok, matchingmain.Worth noting explicitly: the fast path returns the input string unchanged when there is neither
%nor+. That is the same valuedecodeURIComponentwould have returned, and since strings are immutable there is no aliasing concern, but I mention it because it means the common case now returns the identical string object rather than a fresh one.