-
Notifications
You must be signed in to change notification settings - Fork 74
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
perf(base64): Replace random access with iteration #1982
base: master
Are you sure you want to change the base?
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.
Something seems fishy with the bench results. From what I gather, under node.js, the encodes
and JS encodes
should be using the same implementation, yet they have drastically different results.
Node.js -encodes 10775.74909090909 characters per millisecond +encodes 9801.535832414553 characters per millisecond -JS encodes 23130.176470588234 characters per millisecond +JS encodes 18329.573806881242 characters per millisecond
packages/base64/src/decode.js
Outdated
i += 1; | ||
quantum += 6; | ||
} | ||
|
||
if (i < string.length) { |
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.
Cache string.length
at the beginning. But then in which case can there ever be garbage at the end?
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.
When a non-alphabet character is encountered, or anything after expected padding.
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.
Don't we throw if an invalid character is encountered?
One case where the number of characters iterated would differ from the string length is if any surrogate pairs are encountered, but again these would be invalid characters, and we'd have thrown already.
I maintain that this condition can never be true.
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.
Don't we throw if an invalid character is encountered?
We throw when encountering a non-alphabet character, but this catches anything after expected padding:
$ node --input-type=module -e '
import "@endo/init";
import { jsDecodeBase64 } from "./src/decode.js";
console.log("valid", jsDecodeBase64("QQ=="));
console.log("invalid");
jsDecodeBase64("QQ===");
'
valid Uint8Array(1) [ 65 ]
invalid
(Error#1)
Error#1: Base64 string has trailing garbage = in string <unknown>
at jsDecodeBase64 (packages/base64/src/decode.js:62:11)
at packages/base64/[eval1]:6:3
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.
Oh right I missed the new break logic from the loop. I believe there's actually a bug allowing input like QQ=*
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.
Oh and it doesn't seem to check the register is empty once padding is reached
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.
It looks like according to the RFC, a decoder "MAY" reject if the padding bits are not 0, but doesn't have to. I still believe this implementation will accept QQ=*
for example, which is not compliant.
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 believe there's actually a bug allowing input like
QQ=*
Nice catch! Opened #1991 to add coverage and rebased this PR on top of its test changes.
Thank you for digging into this and validating the hypothesis that |
Updated the benchmark script to clarify with better output: $ node test/bench-main.js
[pass 1] encodes 8378.88124410933 characters per millisecond
[pass 2] encodes 8523.48322147651 characters per millisecond
[pass 1] JS short-string encodes 18514.513452914798 characters per millisecond
[pass 2] JS short-string encodes 18514.513452914798 characters per millisecond
[pass 1] decodes 45357.107142857145 bytes per millisecond
[pass 2] decodes 51212.96780487805 bytes per millisecond
[pass 1] JS short-string decodes 43996.02797202797 bytes per millisecond
[pass 2] JS short-string decodes 43261.155206286836 bytes per millisecond
Makes sense. Should I open a new PR for the benchmarking script improvements? |
That would be excellent. |
packages/base64/test/bench-main.js
Outdated
const now = (() => { | ||
try { | ||
// eslint-disable-next-line no-undef,global-require | ||
return require('perf_hooks').performance.now || Date.now; |
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.
Node does not define require
in ESM code. I believe the only way is to try to dynamically import perf_hooks
which will force an async prelude. Not optimal.
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.
Eh, I just went ahead with moving to async.
packages/base64/src/decode.js
Outdated
i += 1; | ||
quantum += 6; | ||
} | ||
|
||
if (i < string.length) { |
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.
Don't we throw if an invalid character is encountered?
One case where the number of characters iterated would differ from the string length is if any surrogate pairs are encountered, but again these would be invalid characters, and we'd have thrown already.
I maintain that this condition can never be true.
I don't understand how the new changes you made dropped the performance in node even more. |
a75208e
to
0165aea
Compare
|
I am confused about the separation between this PR and #1986 , Both contain all three files. I would have expected one (that one) to define the benchmarking framework, and the other (this one) to be staged on it and use it. |
0165aea
to
1beb766
Compare
98a3482
to
2274382
Compare
XS is O(n) for the former (not that it really matters here because they also supply a native base64 implementation). ```console $ echo "Node.js" && node test/bench-main.js && echo "XS" && ~/.esvu/bin/xs -m test/bench-main.js Node.js encodes 9801.535832414553 characters per millisecond JS encodes 18329.573806881242 characters per millisecond decodes 45848.339350180504 bytes per millisecond JS decodes 45448.94117647059 bytes per millisecond XS encodes 578303.1160714285 characters per millisecond JS encodes 1700.2734761120264 characters per millisecond decodes 393870.26373626373 bytes per millisecond JS decodes 1946.3762376237623 bytes per millisecond ```
2274382
to
881d14c
Compare
Ref #1982 Speeds up by 21% decoding a representation of the following CopyRecord: ``` { primitives: [undefined, null, true, false, 0, 1, 0n, 1n, "foo", Symbol.for("bar")], arrays: [[], [{}]], records: { empty: {}, singular: { array: [] } }, } ``` (and speeds up decoding a CopyArray containing 10 copies thereof by about 100%)
Ref #1982 Speeds up XS by 21% decoding a representation of the following CopyRecord: ``` { primitives: [undefined, null, true, false, 0, 1, 0n, 1n, "foo", Symbol.for("bar")], arrays: [[], [{}]], records: { empty: {}, singular: { array: [] } }, } ``` (and speeds up decoding a CopyArray containing 10 copies thereof by about 100%)
Ref #1982 Speeds up XS by 21% decoding a representation of the following CopyRecord: ``` { primitives: [undefined, null, true, false, 0, 1, 0n, 1n, "foo", Symbol.for("bar")], arrays: [[], [{}]], records: { empty: {}, singular: { array: [] } }, } ``` (and speeds up decoding a CopyArray containing 10 copies thereof by about 100%)
Ref #1982 Speeds up XS by 21% decoding a representation of the following CopyRecord: ``` { primitives: [undefined, null, true, false, 0, 1, 0n, 1n, "foo", Symbol.for("bar")], arrays: [[], [{}]], records: { empty: {}, singular: { array: [] } }, } ``` (and speeds up decoding a CopyArray containing 10 copies thereof by about 100%)
Ref #1982 Speeds up XS by 21% decoding a representation of the following CopyRecord: ``` { primitives: [undefined, null, true, false, 0, 1, 0n, 1n, "foo", Symbol.for("bar")], arrays: [[], [{}]], records: { empty: {}, singular: { array: [] } }, } ``` (and speeds up decoding a CopyArray containing 10 copies thereof by about 100%)
@gibson042 Are you shooting to improve performance on both Node.js and XS (and assuming this will still fall thru to the native version on XS)? |
@kriskowal Since the two are at odds and the JS implementation isn't even used by XS, I'm comfortable with getting this PR clean and then closing it without merge as you suggested in #1982 (comment)
|
Description
We recently discovered that in XS, random access to string characters is O(n). This PR updates the JS base64 implementation to instead use iterators. It improves the performance in XS as expected, but irrelevantly so because XS provides a native implementation which is much faster still. There's a slight penalty in Node.js, which may or may not be tolerable (but note that apples-to-apples comparison across implementations is necessary because the JS input is substantially shorter). I'm comfortable with rejection of this PR (or at least everything after the first commit), but felt that opening it was important for transparency.
Security Considerations
n/a
Scaling Considerations
As noted above. Here are the measured effects:
Documentation Considerations
n/a
Testing Considerations
n/a
Upgrade Considerations
n/a