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

Speed up generation of output #564

Merged
merged 13 commits into from Jul 16, 2023
Merged

Conversation

AlCalzone
Copy link
Contributor

@AlCalzone AlCalzone commented Mar 19, 2023

This is a proof of concept for a different rendering approach that does not rely on sliceAnsi as heavily.

I tried this with my test component from #560, but with 50 spinners. Without this change, 50 spinners put one core of my CPU at 100%, and rendering lags heavily. With this change, I'm looking at ~40% utilization of one core, which is still not ideal, but renders fluently.

The output for 10s of profiling now looks like this, is idle half the time and most of the work is done in wasm, which is probably from yoga internals:
grafik

fixes: #560

This will need some more testing with emojis and clipping, but I wanted to get this out for some early feedback.

@AlCalzone
Copy link
Contributor Author

Ok, looks like emojis and some full-width characters are causing test failures, I'll look into that.

And there's probably an ANSI code in a different position than before - I don't see a visual difference here:
grafik

@vadimdemedes
Copy link
Owner

This is really impressive! Would you be willing to push this PR to completion?

@AlCalzone
Copy link
Contributor Author

Yeah, will do soon.

@AlCalzone AlCalzone marked this pull request as ready for review April 5, 2023 21:12
@AlCalzone
Copy link
Contributor Author

Tests are passing too now. Just for fun, I ran time npm run benchmark benchmark/simple to compare.
master

real    0m11,412s
user    0m13,342s
sys     0m0,604s

this PR

real    0m8,208s
user    0m9,051s
sys     0m0,330s

So roughly 32% faster rendering for even simple outputs.

@vadimdemedes vadimdemedes changed the title perf: optimize how ansi-codes are handled in Output.get() Speed up generation of output Apr 8, 2023
src/output.ts Outdated
// Some characters take up more than one column. In that case, the following
// pixels need to be cleared to avoid printing extra characters
const charWidth = char.fullWidth ? 2 : char.value.length;
for (let dx = 1; dx < charWidth; dx++) {
Copy link
Owner

Choose a reason for hiding this comment

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

Why does this skip the first character and starts from the second one?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Because the first char is the char that's actually written, so width 1 is already accounted for.

Copy link
Owner

Choose a reason for hiding this comment

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

Not sure I understand, could you elaborate the "that's actually written" part?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Line 201 writes the current character to the output array. Some characters are wider than a single column though, so we have to set the following columns (up to the character width) to "" so we don't end up with extra characters.
So if the char is a 2-column emoji, we write the emoji to column x and "" to column x+1. The character after the emoji will then correctly be at column x+2.

Copy link
Owner

Choose a reason for hiding this comment

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

Do we need a loop here then? Could it be simplified to this?

if (char.fullWidth) {
  currentLine[offsetX + 1] = /* empty char */
}

offsetX += character.fullWidth ? 2 : 1

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Aren't there some emojis which have length > 4?

Copy link
Contributor Author

@AlCalzone AlCalzone Apr 11, 2023

Choose a reason for hiding this comment

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

Nevermind, they have a visual width of 2. We can't fully go by the "fullWidth" property though, since some emojis with a visual width of 2 are not detected by https://github.com/sindresorhus/is-fullwidth-code-point
For example 🍦 (code point 0x1f366) and 🎉 (code point 0x1f389)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I've pushed a commit to eliminate the loop.

@vadimdemedes
Copy link
Owner

Superb work 🔥

src/output.ts Outdated
@@ -198,12 +198,16 @@ export default class Output {
const chars = styledCharsFromTokens(tokenize(line));
let offsetX = x;
for (const char of chars) {
if (offsetX >= this.width) break;
Copy link
Owner

Choose a reason for hiding this comment

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

How did you run into this issue where an out-of-bounds write would occur? Could you add a test for it to ensure it doesn't regress back?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done. In my case it was a pretty complicated nested layout, but I managed to reduce it to a <Text wrap="end"> that's longer than the stdout width.

Copy link
Owner

Choose a reason for hiding this comment

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

There's no wrap="end" value, but there's wrap="truncate-end". See https://github.com/vadimdemedes/ink#wrap. I updated it in the test you added, then commented out "prevent out-of-bounds writes" commit changes and it didn't crash for me. Looks like we don't need this code then?

Copy link
Contributor Author

@AlCalzone AlCalzone Apr 28, 2023

Choose a reason for hiding this comment

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

Hmm maybe the test case isn't correct then. I ended up with gaps in the output array, which crashed the function that converts tokens back to a string.

Copy link
Owner

Choose a reason for hiding this comment

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

Do you still see the same issue when using wrap="truncate-end" instead of wrap="end" (which is an invalid prop)?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Will try tomorrow or so.

Copy link
Owner

Choose a reason for hiding this comment

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

Hey @AlCalzone, do you still have the capacity to finish this PR? Would really love to ship it. No worries if you're busy though.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Sorry for the delay. I'll try to look at this soon. The problem is I'm actually not sure how to trigger the out-of-bounds write.
It did happen for me in a relatively complicated flexbox layout, which is why I added the checks.

Copy link
Owner

Choose a reason for hiding this comment

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

No worries, take your time 👍

@AlCalzone
Copy link
Contributor Author

@vadimdemedes I finally managed to reproduce this reliably. The issue is when trying to render boxes with borders that are at least 2 columns too wide for the terminal, e.g. this component on a terminal with width 10:

<Box width={12} height={10} borderStyle={'round'} flexDirection="row"></Box>

Not sure why this doesn't happen for width+1 though.

This case causes the following writes to output:

Write "╭──────────╮" at x=0, y=0, w=10
Write "│" at x=0, y=1, w=10
Write "│" at x=0, y=2, w=10
Write "│" at x=0, y=3, w=10
Write "│" at x=0, y=4, w=10
Write "│" at x=0, y=5, w=10
Write "│" at x=0, y=6, w=10
Write "│" at x=0, y=7, w=10
Write "│" at x=0, y=8, w=10
Write "" at x=0, y=9, w=10
Write "│" at x=11, y=1, w=10
Write "│" at x=11, y=2, w=10
Write "│" at x=11, y=3, w=10
Write "│" at x=11, y=4, w=10
Write "│" at x=11, y=5, w=10
Write "│" at x=11, y=6, w=10
Write "│" at x=11, y=7, w=10
Write "│" at x=11, y=8, w=10
Write "" at x=11, y=9, w=10
Write "╰──────────╯" at x=0, y=9, w=10

As a result, the arrays for lines 1...9 have an entry at index 11 that should not exist and index 10 is empty, essentially this:
grafik

This causes styledCharsToString to throw because it doesn't expect undefined entries in the input. By simply not setting the out-of-bounds items in the output array, this doesn't happen.

I added a better test case for this.

@vadimdemedes
Copy link
Owner

Thanks @AlCalzone, that was really helpful! Here's what I've found.

I took your Box example and rendered it in the terminal.

import React from 'react';
import {Box, render} from 'ink';

function Test() {
	return <Box width={12} height={10} borderStyle={'round'}></Box>;
}

process.stdout.columns = 10;
render(<Test />, {debug: true});
CleanShot 2023-07-16 at 11 13 06@2x

The right border is rendered in the wrong place, even though there are correct X and Y coordinates for all sides:

  • Top border has X = 0, Y = 0
  • Left border has X = 0, Y = 1
  • Right border has X = 11, Y = 1
  • Bottom border has X = 0, Y = 9

Here's where each line is written to output:

ink/src/output.ts

Lines 187 to 190 in 8a04760

output[y + offsetY] =
sliceAnsi(currentLine, 0, x) +
line +
sliceAnsi(currentLine, x + width);

I added this console.log to see what exactly gets written and where:

console.log({
	// X coordinate of the `line` to write to output
	x,
	// Existing output before X coordinate
	before: sliceAnsi(currentLine, 0, x),
	beforeWidth: sliceAnsi(currentLine, 0, x).length,
	// New text to write to output
	line,
	lineWidth: line.length,
	// Existing output after X coordinate + width of the `line`
	after: sliceAnsi(currentLine, x + width),
	afterWidth: sliceAnsi(currentLine, x + width).length
});

Here's the output for one of the lines for writing the right border:

{
  x: 11,
  before: '│         ',
  beforeWidth: 10,
  line: '│',
  lineWidth: 1,
  after: '',
  afterWidth: 0
}

Normally, sliceAnsi(currentLine, 0, x) should return a string where length === x, but here beforeWidth equals 10, not 11. The reason is currentLine comes from the output variable, which is initialized with strings that are as wide as the terminal (in our case terminal width is 10).

const currentLine = output[y + offsetY];

ink/src/output.ts

Lines 97 to 99 in 8a04760

for (let y = 0; y < this.height; y++) {
output.push(' '.repeat(this.width));
}

This creates a problem where output contains lines with the length of 10, so the index of the last character in each line is 9. At the same time, we're trying to write a character at index 11, which results in invalid output where character at index 10 is missing. As a result, right border is placed at X = 10, not X = 11.

It doesn't cause a crash in the current version of Ink, because sliceAnsi is used to extract string ranges, which doesn't fail if you run sliceAnsi(str, 0, 11) where str.length === 10. However, your PR no longer uses sliceAnsi and is sensitive to undefined items.

To sum up, this is an existing issue of Ink with content larger than the terminal and should be fixed separately from this PR. To avoid regressions in this PR though, we need to skip undefined array items to keep the rendering behavior the same as it is now.

@vadimdemedes
Copy link
Owner

Pushed a commit to keep existing behavior with out of bounds writes.

@vadimdemedes
Copy link
Owner

CI is green, merging!

Thank you for this incredible performance boost, @AlCalzone!

@vadimdemedes vadimdemedes merged commit 9ff4d20 into vadimdemedes:master Jul 16, 2023
3 checks passed
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.

Performance issue in sliceAnsi function
2 participants