-
Notifications
You must be signed in to change notification settings - Fork 77
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
~30-40% perf win using 'charCodeAt' in CParser.write() #57
Conversation
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hi @evan-king and @dscape - Any interest in working w/me to get this PR accepted? The perf improvement seems substantial.
.vscode/settings.json
Outdated
@@ -0,0 +1,4 @@ | |||
{ |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
(Ditto re: this VS Code config file to set tab indentation to 2 spaces.)
@@ -0,0 +1,117 @@ | |||
const { Suite } = require("benchmark"); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is the benchmark I used to measure the impact of the change. It measures the four .json files under "../samples" and for me shows ~30+% speedup under 'node 8.11.4'. The speedup was significant enough that I didn't bother disabling turbo boost, sleep states, etc. on the CPU.
benchmark/index.js
Outdated
|
||
parser.onkey = name => { | ||
this.key++; | ||
assert(name !== "𝓥𝓸𝓵𝓭𝓮𝓶𝓸𝓻𝓽"); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
"He Who Must Not Be Named" ;-)
package.json
Outdated
"test": "mocha -r should -t 10000 -s 2000 test/clarinet.js test/npm.js test/utf8-chunks.js test/position.js" | ||
"test": "mocha -r should -t 10000 -s 2000 test/parser.spec.js test/clarinet.js test/npm.js test/utf8-chunks.js test/position.js", | ||
"bench": "node benchmark/index.js", | ||
"postinstall": "niv clarinet@latest --destination clarinet-last-published" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
'niv' is a tool for installing multiple versions of a package side-by-side under node_modules, used by the new benchmark to compare the development version with the last published version. As an alternative, we could check in a snapshot of the previous 'clarinet.js' under './benchmark/'.
"should": "1.0.x", | ||
"underscore": "1.2.3" | ||
}, | ||
"scripts": { | ||
"test": "mocha -r should -t 10000 -s 2000 test/clarinet.js test/npm.js test/utf8-chunks.js test/position.js" | ||
"test": "mocha -r should -t 10000 -s 2000 test/parser.spec.js test/clarinet.js test/npm.js test/utf8-chunks.js test/position.js", |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Just FYI - I don't believe the 'should' package is still used?
@@ -1,4 +1,6 @@ | |||
;(function (clarinet) { | |||
"use strict"; | |||
|
|||
// non node-js needs to set clarinet debug on root | |||
var env | |||
, fastlist |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Just FYI - I don't believe 'fastlist' is used?
} | ||
c = chunk.charAt(i++); | ||
c = chunk.charCodeAt(i++); | ||
parser.position++; | ||
starti = i-1; | ||
if (!c) break; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm not a unicode expert, but I believe surrogates are still processed correctly because:
- The hi/lo surrogates won't match any of the char codes we handle explicitly, so...
- They'll fall through to the pre-existing code that appends them via substring (just below the fold)
@@ -548,93 +638,83 @@ else env = window; | |||
continue; | |||
|
|||
case S.TRUE: | |||
if (c==='') continue; // strange buffers |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Not sure these "strange buffers" can be reproduced? If so, charCodeAt(..) probably returns 'undefined'. I could put these back defensively, but I'd be curious to see if this case still occurs.
@@ -361,25 +454,25 @@ else env = window; | |||
|
|||
if (clarinet.DEBUG) console.log(i,c,clarinet.STATE[parser.state]); | |||
parser.position ++; | |||
if (c === "\n") { | |||
if (c === Char.lineFeed) { | |||
parser.line ++; | |||
parser.column = 0; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
FYI - I'm not sure 'column' is updated every time we advance a character position. It might be more reliable to store the 'columnStart' position and then calculate the column on demand with:
get column() { return this.position - this.columnStart; }
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thank you for following up on the PR. Though I've been quite busy of late, it hadn't slipped in unnoticed and I've been pondering it for a while. Especially given my lack of time to address any issues that arise in a timely manner, my top priority is stability.
With that in mind (and in addition to the other trivial feedback given), there are 2 things I'd like to know:
- how much of the performance gain came from simply removing all the empty string checks
- whether anything useful can be unearthed about why they were once needed
The latter is a tall order, as the git history is pretty sparse and seems to lack any higher level summaries/explanations. But I'd be a lot more comfortable removing them if I could identify what situation did require them, and thus rationalize treating them as dead code.
If they don't significantly impact performance, leaving them in would be the easier way to just get this pushed through.
@@ -0,0 +1,178 @@ | |||
"use strict"; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I rather favor test suites set up in this way (filthy ES6 classes/OOP notwithstanding ;)), and especially being able to run more focused tests for debugging, so I'm happy to keep it.
I was lazy about analyzing the empty string checks. Looking again, it's pretty easy to convince oneself that the removed empty string checks were previously unreachable. The normal control flow is to advance When Since |
I'm pretty comfortable with that analysis. However, it does still leave the question of how much performance gain came from removing extraneous conditional checks and not from the use of string literal comparison vs character value constants. The former makes more sense to me as a cause of sub-optimal performance (especially post-Spectre). With that in mind, I want to be certain the larger change is justified by a meaningful impact. Otherwise, significant changes are being introduced for still-unquantified benefit that may not even remain relevant as future engine-level optimizations come down the pipeline, and future maintainers will be as afraid to disrupt or remove performance-related code as I am to introduce it. To be clear, what I'm asking is for rough benchmark numbers for removed conditionals alone, and optimized code with conditionals preserved, alongsize current vanilla and fully optimized code. If the |
I understand... The perf gain is 100% from switching to 'charCodeAt()'. The reason for the performance improvement is that 'charCodeAt()' returns a number, which can be compared in a single machine op and involves zero heap allocation or resulting GC tax. Calling 'charAt()' on the other hand semantically allocates a 1-character string on each invocation. This allocation can potentially be optimized away, but since the string escapes the module (via My only motivation for removing the empty string checks was that they were either unreachable or my understanding of the code was incorrect. Removing them felt more honest than mechanically converting them, as removal would attract scrutiny in review. :-) Here are the benchmark results from removing the conditional only:
And here are the results w/the conversion to character codes:
PS - In tweaking the benchmark to get these measurements, I ran into this issue with the 'npm-install-version' package I added. (I pushed a change to avoid the dependency.) |
Thanks for the thorough PR support. Approved and merging. |
Uses character codes (i.e., numbers) instead of strings comparison inside of the CParser.write() state machine for a significant perf win (measured on node v8.11.4).
Also includes:
npm i && npm run bench
)