Skip to content
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

Improve performance of arrayBufferToBinaryString #3121

Conversation

Pantura
Copy link
Contributor

@Pantura Pantura commented Mar 22, 2021

Thanks for the awesome library which seems to be the only way we can output snapshot of a DOM node reliably with extra headers and custom scaling options.

However we stumbled on a problem with CSP rules regarding dataUrls when using image with image dataUrl. I transferred to use a blob url of the image but performance suffered very badly.

After some performance monitoring the worst culprit was found which is fixed in this PR.

This PR has two improvements over the old functionality

Skip expensive buffer overflows

After blob file size starts growing, so will the time spent in trying the direct conversion of the buffer. For a buffer with a size of 14M, my 2019 core i7 Mac takes 100ms before throwing an exception. For 44M that takes 400+ms. Skip trying altogether for massive buffers. The limit could be tuned but this is roughly where the processing times start increasing and overflows begin.
The image in question is about 3840x2160 px when being added to PDF.

Use TextDecoder when possible to speed up conversion

Along with the fix above using TextDecoder is the best option for binary string conversion. I tested direct string concatenation (worst), using predefined array size with final .join (depending on GC cycles, below and above original solution).

Code in this PR runs arrayBufferToBinaryString about 18 times faster (4700 vs 251ms and 3000 vs 153 for two of my test pages).
Old:
Screenshot 2021-03-22 at 15 01 08

Updated:
Screenshot 2021-03-22 at 15 01 28

The total time for adding the image is still suffering from decodePixels performance but I couldn't think of a solution to fix that.

* Skip expensive buffer overflows
* Use TextDecoder when possible to speed up conversion
@Pantura Pantura force-pushed the feature/arraybuffer-to-binary-string-improvement branch from 2f0b78c to 38638d7 Compare March 22, 2021 13:31
@Pantura
Copy link
Contributor Author

Pantura commented Mar 22, 2021

I'm unable to run unit tests on my Mac (10.14) even before my changes.

@HackbrettXXX
Copy link
Collaborator

Thanks for this PR. Is String.fromCharCode or TextDecoder.decode faster for small buffers? If TextDecoder is faster I think we can use it also for small buffers if available.

@Pantura
Copy link
Contributor Author

Pantura commented Mar 23, 2021

I was thinking about that as well but didn't have time check it yet. I can test that as well soon.

@Pantura
Copy link
Contributor Author

Pantura commented Mar 23, 2021

One thing to note though, support for TextDecoder is kind of new so it isn't possible to rely solely on that. And the old functionality processed through 100x20px image in sub-milliseconds.

Copy link
Contributor

@101arrowz 101arrowz left a comment

Choose a reason for hiding this comment

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

There are a few things I think should be worked out. I've worked with binary strings before, and they should be avoided at all costs, but since it's a bit hard to remove all the uses of binary strings in jsPDF, it's best to maximize performance as much as possible.

src/modules/addimage.js Outdated Show resolved Hide resolved
Comment on lines 747 to 748
var decoder = new TextDecoder("ascii");
return decoder.decode(buffer);
Copy link
Contributor

Choose a reason for hiding this comment

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

Actually, for binary strings, this is not going to work. ASCII only covers 127 code points, i.e. 0x00 through 0x7F. We need 0x00 through 0xFF (the entire byte range). For example, this fails:

const inputBuffer = new Uint8Array([252, 129, 187, 147]);
const binStr = new TextDecoder('ascii').decode(inputBuffer.buffer);
console.log(binStr.split('').map(str => str.charCodeAt(0)));
// [252, 129, 187, 8220]

As it turns out, TextDecoder doesn't work well for binary string encoding, and this case should probably be removed entirely.

Copy link
Contributor

Choose a reason for hiding this comment

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

I'm not sure how this passed the test cases actually...Any ideas? Since that simple example yielded malformed output I would expect something to have broken.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Not sure how it passed library tests but it passed my own visual validation because the color palette was so small. In the end I did find it produced malformed data as some grays were going to green.
For some reason I had in mind ascii was 8 bit as well. And after some research TextDecoder still isn't a choice with any given text encoding to replace fromCharCode...

Comment on lines 738 to 743
if (buffer.length < ARRAY_BUFFER_LIMIT) {
try {
return atob(btoa(String.fromCharCode.apply(null, buffer)));
} catch (e) {
// Buffer was overflown, fall back to other methods
}
Copy link
Contributor

@101arrowz 101arrowz Mar 24, 2021

Choose a reason for hiding this comment

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

I don't think this works (or at least it shouldn't if the second argument is an ArrayBuffer). ArrayBuffer cannot be indexed and therefore cannot be used in .apply. I'm pretty sure you need to convert to Uint8Array first.

Copy link
Contributor

Choose a reason for hiding this comment

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

Also, I know atob(btoa(str)) was in the previous code, but I don't think atob(btoa(str)) is any different from str. Is there an example that differs?

Copy link
Collaborator

Choose a reason for hiding this comment

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

Was wondering about that as well.

Comment on lines 46 to 49
// Heuristic limit after which String.fromCharCode will start to overflow.
// Need to change to StringDecoder.
var ARRAY_BUFFER_LIMIT = 6000000;

Copy link
Contributor

@101arrowz 101arrowz Mar 24, 2021

Choose a reason for hiding this comment

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

This figure doesn't seem right. The max frame size is 1MB to begin with, so 6MB is too big for sure. On my device, the limit is around 100kB, but I'm pretty sure going for around 16K is safe. Could you verify this in the console?

Copy link
Collaborator

Choose a reason for hiding this comment

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

Seems a bit large for me, too. 16k sounds good.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Some benchmarks from different batch sizes: https://jsbench.me/x5kmnswnmz/1. Sweet spot at least for Chromium 89 seems to be at 4096-8192 slight degradation at 16k but slowing down rapidly after that. Manual testing shows minor GCs going to major GC.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Then let's take 8k? :)

@101arrowz
Copy link
Contributor

101arrowz commented Mar 24, 2021

By the way @Pantura, have you verified you're using the latest version of jsPDF? A function getBytes() is taking quite a bit in your image, but the newest version AFAIK is using a function unzlibSync from fflate. Since fflate is faster, maybe image adding will be faster once you update?

@Pantura
Copy link
Contributor Author

Pantura commented Mar 24, 2021

By the way @Pantura, have you verified you're using the latest version of jsPDF? A function getBytes() is taking quite a bit in your image, but the newest version AFAIK is using a function unzlibSync from fflate. Since fflate is faster, maybe image adding will be faster once you update?

I did update and it didn't really improve that much. I actually took yet another approach to the data format as I noticed it was doing a PNG encode/decode for nothing. There will soon be a PR about possibility to input RGBA Array. This format wouldn't allow passing through image size (or width would be enough). Would it be ok to add it as an optional parameter?
(this is with statically defined size but performance is good)

Blob input to PNG to decode PNG:
Screenshot 2021-03-24 at 14 51 57

Direct RBGA input with alpha channel extraction:
Screenshot 2021-03-24 at 14 51 40

@HackbrettXXX
Copy link
Collaborator

@Pantura a PR with an RGBA array/buffer as input would be appreciated.

src/modules/addimage.js Outdated Show resolved Hide resolved
src/modules/addimage.js Outdated Show resolved Hide resolved
Copy link
Collaborator

@HackbrettXXX HackbrettXXX left a comment

Choose a reason for hiding this comment

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

Alright. Thanks for the PR, I will merge it now.

@HackbrettXXX HackbrettXXX merged commit f41a18f into parallax:master May 4, 2021
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.

4 participants