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

Performance Enhancement #171

Closed
2 tasks
TimonPost opened this issue Jun 26, 2019 · 15 comments
Closed
2 tasks

Performance Enhancement #171

TimonPost opened this issue Jun 26, 2019 · 15 comments

Comments

@TimonPost
Copy link
Member

Task Description

Crossterm flushes the buffer each time action with an ANSI-code is performed. This is not realistic in the case of moving the cursor a lot and meanwhile print characters. When calling those types of functions a lot, cross-term might behave a bit slow.

  • Flushing is done here
  • Locking on stdout is done as well on each ANSI write to the terminal, however, flushing is way more performance bottleneck then the lock.

Task TODO

  • Research if it is possible to let the user control the flushing so that the user can control when to flush the ANSI-commands to the terminal. Issue Make crossterm support controlling multiple screen buffers #167 can be related to this issue. More explanation about this in the comments.
  • If we found out a way to make ANSI-codes more performant, we are left with one problem. WINAPI. The nice think about ANSI codes is that you can queue them up somewhere and push them into the stdout later on. With WinApi the user interacts directly with the terminal, and you can't queue them in the same way of ANSI-codes.

Example of what I mean:

winapi scenario of moving the cursor

Move cursor (10.10)
println!("abc");
Move cursor (10,10)

ansi scenario to move the cursor

println!("\x1B[{10};{10}H abc \x1B[{5};{5}H");

As you can see, in the case of ANSI codes we can store the codes and text of the user inside on string, however, with WinApi we don't store actions inside a string. But those are calls that need to be made to the console individually.

Somehow, we need to be able to store those WinApi commands in order ass well. So that if the user does a manual flush to the terminal, in the case of windows, we walk through all the WinApi commands queued and execute those, as well print the user input, done between those two cursor move events.

Notice

I do notice, Linux is way faster with console actions than windows is. I am not sure whether this is the problem of rust using winapi to write to the console window, winapi ANSI interpretation layer, crossterm performance issue. Windows is often forgotten in the Rust library, and would not be amazed if there is some performance issue there somewhere. But this needs to be proven of course.

@TimonPost
Copy link
Member Author

So related to #167.

I was thinking we can solve this by introducing a Screen type. This screen a queue that stores all commands, like ANSI, WinApi commands and the text the user has written in order. When the user does a flushes the screen we deque and execute those commands and write the contents of the queue to the screen. With ANSI we are able to write one big string to the console and then flush, with WinApi we need to execute the commands one by one and also write the text in order. Somehow this Screen type as referred in the other issue, needs to be accessible, from the different modules crossterm provides. So that cursor, terminal, styling actions can be written to this screen instead of directly flushed to the console.

@lukewilson2002
Copy link

lukewilson2002 commented Jun 28, 2019

I agree that making a queue of "commands" and then translating to a string, or just interpreting them directly is an excellent solution. Would they all fit inside of a single enum?

It is hard to say in what way this could all be implemented well, but I think it might be best to have a Command enum for the Screen trait to be passed through a sort of do function, for example:

use crossterm::ScreenCommand::*;

screen().do(Goto(0,0));
screen().do(Say("Uses commands, which are compiled into the"));
screen().do(Goto(0,1));
screen().do(Say("equivalent ANSI string, or translated for WINAPI."));

let s = screen();
do!(s, Goto(2, 4), Color("Allows for a vec!-like macro", crossterm::Color::Magenta));
s.flush();

I feel like this simply adds to the usability, and allows for manual, or even "low-level" control over how things are printed. This is also assuming the original methods still work, even though they are inefficient: println!("This is text") should still function the same as before, I am just presenting my thoughts on a low-level, command-based solution.

Edit: I notice now that do is a keyword, but it could be cmd or act, etc.

@TimonPost
Copy link
Member Author

Thanks for putting your idea in here. I was thinking about this as well. And had a similar idea with the commands. We need to try to make this a feature, which

I think it might be best to have a Command enum
I can think of the following enum values, which can all be queued in any order.

Goto
UP
Down
Left
Right
SavePos
ResetPos
Hide
Show
Blink

SetFg
SetBg
SetAttr

Reset
Clear
SetSize

Text

The macro idea
This looks like a cool idea, you pass in an implementation of Write, and it will write the command, where after you flush it to the screen.

trait TerminalCommand {
    fn ansi_code() -> &'static str;
}

pub struct Goto(pub u16, pub u16);

impl TerminalCommnad for Goto {
     fn ansi_code() -> &'static str {
         return format!("{};{}H", self.0, self.1)
    }
}

That was my initial thought. Each command has an ANSI escape code representation. The user queues an implementation of this trait. On that implementation, we can call ansi_code, which gives us the ANSI escape value back.

When we do the flush, there are two scenarios:

  1. UNIX + Windows 10, we can write all ANSI codes to the stdout at the same time. We need to figure out how to optimal write to the terminal. We can use BufWriter for this. Although, this flushes automatically and isn't really more performant with small amounts and big amounts, but can be with regularly writes, an idea to support this?
  2. Windows < 10, We need to execute the commands one by one. By interpreting the ANSI-commands, and executing the WinApi calls.

We can only improve the performance for Windows 10 and Unix systems because of those work with ANSI codes. And windows lower than 10 will be supported by this API, but won't notice any major performance difference.

Where do we put this code? Since we use all kinds of commands, there are two options: 1) crossterm level, 2) own crate, which will depend on various other crossterm crates.

@lukewilson2002
Copy link

lukewilson2002 commented Jul 5, 2019

I split my responses in two sections:

Optimal Writing

No idea honestly, I'm somehow completely ignorant in this area. I think it would be great if people could optionally implement their own method of writing, though.

A Separate Crate

I'd say put the trait and the interpreter in its own crate, but not crossterm- based, since it would totally be independent of crossterm. Some feature name like no-defaults could be used so that the crate does not compile in anything implementing that trait. This is something useful that could be exported back out to the community, and can remain simple. This would be the commands, ANSI code generation, and below Windows 10 command interpreter.

Commands implement a trait called, for example, ANSICommand which could be described like:

trait ANSICommand {
    fn ansi_code(&self) -> String;
    #[cfg(windows)] // Not sure if these are possible in traits.
    fn winapi_call(&self, hwnd: HWND) -> Result<(), SomeTypeOfError>;
}

*Note: I wrote hwnd: HWND as an example -- I don't know what would be required.

And the crate could come implemented with the default commands you wrote above, which could all be removed with that feature flag no-defaults I mentioned before.

I can imagine several applications seeking an easy method for cross-platform ANSI terminal colors and styles, without relying on a full "terminal library", with mouse support, custom input, etc.

@TimonPost
Copy link
Member Author

trait ANSICommand {
    fn ansi_code(&self) -> String;
    #[cfg(windows)] // Not sure if these are possible in traits.
    fn winapi_call(&self, hwnd: HWND) -> Result<(), SomeTypeOfError>;
}

I love this idea, I was thinking of matching the ANSI code at some WinApi translate layer, and then execute the WinApi command. But this work way better. Now we can let each crate provide a bunch of commands implementing this command trait. This trait can belong to crossterm_utils as the macro will be there as well. When we do that all crates have access to this trait and macro, and are able to implement it for their own commands.

So:

crossterm_cursor

Goto
UP
Down
Left
Right
SavePos
ResetPos
Hide
Show
Blink

crossterm_terminal:

Reset
Clear
SetSize

crossterm_style:

SetFg
SetBg
SetAttr

With this idea, when the user only uses crossterm_terminal he or she can only use Clear, Reset, SetSize. When working at crossterm level, the user is able to use all commands, at least for the flags he or she has enabled.

@lukewilson2002
Copy link

Great! I'm glad you like the trait idea.

@TimonPost
Copy link
Member Author

TimonPost commented Jul 7, 2019

I can experiment with this idea and will have a branch soon. Or if you like to do this I am okay with it as well. But I want to make sure that we don't work both on the same code :).

Also using the cfg attribute is possible within traits.

I do prefer another name for the do macro. Because it might not be executed directly. So the name is a bit vague. And do is actually not possible because it is reserved.

@TimonPost TimonPost mentioned this issue Jul 7, 2019
3 tasks
@TimonPost
Copy link
Member Author

You can check out #175 for a basic implementation for the cursor module.

@TimonPost
Copy link
Member Author

TimonPost commented Jul 9, 2019

So I experimented a bit further,

I propose to introduce two macro's that can be used to execute commands:

This macro takes in command(s) and in case of ANSI codes, they will be written to the given writable type. They will be flushed if and only if the user flushes the buffer or the OS detects that the buffer is to full.

schedule!(write, command, command, etc....)
schedule!(command, command, etc....)

This macro takes in command(s) and executes that command directly.

execute!(write, command, command, etc...)
execute!(command, command, etc...)

How those macros will be used:
schedule

 schedule!( stdout(), Goto(x, y), Output(String::from("#")));
 schedule!(Up, Right);

// do some stuff
stdout.flush() // ANSI codes will be written to the screen.

execute

 let mut stdout = stdout();
 execute!(stdout, Goto(x, y), Output(String::from("#")));
 execute!(Up, Right);

Other
All the commands crossterm provides, implement display as well. By doing that you are able to use those commands in a println!, write! etc.

#175 has a work in progress implementation (might not compile)

@TimonPost
Copy link
Member Author

I currently have a basic setup over here #175.

Benchmark scenario:

    let mut stdout = ::std::io::stdout();

    let instant1 = Instant::now();
    for i in 0..10 {
        for x in 0..200 {
            for y in 0..50 {
                schedule!(stdout, Goto(x, y), Hide, Output(y.to_string()));
            }
        }
    }

println!("Elapsed with new command API {}", instant1.elapsed());
  • Results UNIX:

Elapsed with new command API 399.167104ms
Elapsed with old API 660.173672ms
Performance improvement of 39%

  • Results Windows
    Elapsed with new command API 6.6762314s
    Elapsed with old API 16.453418s
    Performance improvement of 59,42%

The conclusion is that Linux seems to be way better in handling stdout stuff, and both platforms have a great performance improvement. The benchmark code can be found over in /crossteerm_cursor/examples/cursor.rs

@imdaveho
Copy link
Contributor

Possible alternative suggestion here?

Seems like the issues are

  1. flushing stdout on each operation
  2. most common operations that cause flushing (and resulting slowdown) are between cursor navigation and typing (letter output)

To solve (1), it seems the conversation above is talking about a separate "refresh" / "flush" function for the user to call.

For (2), I was thinking, instead of a clever (read: complex or unconventional) macro for a pipeline of actions, if we know that the issue mainly stems from positioning and printing to the screen, maybe we separate those two things?

Have a "front" buffer that contains the most recent output and a "back" buffer that contains the diffs. When "flush" is called, the backend performs a diff and prints to the screen. Any operations for cursor and text is changing a background data struct so it shouldn't impact visual performance.

For example,

  • if user input is something like this: (Goto(0,0), 'H', 'i', ',', ' ', 'B', o, b)
  • the "back" buffer is a matrix representing the terminal window (10w x 5h) = [ [10], [10], [10], [10], [10] ]

then the resulting back buffer is:
[ ['H', 'i', ',', ' ', 'B', o, b, (), (), ()], [10], [10], ... ]

CursorX = 6 (col 7)
CursorY = 0 (row 1)

if the terminal window was (5w x 3h):
[ ['H', 'i', ',', ' ', 'B'], [o, b, (), (), ()], [10] ]
CursorX = 1 (col 2)
CursorY = 1 (row 2)

Either way, when the user "flushes" what is in the back buffer gets rendered. All operations therefore update the back buffer.

In the case of a program loop, the user can enforce some kind of 60fps flush rate to keep a smooth visual response rate to a user's eyes. I believe this method is what 2d game engines also do to keep track of user input and what gets rendered on the screen.

Also, with this idea of "back" buffers, you then can also implement multiple alternative screens as well.

Thoughts?

I mean, I like the macro idea of a command pipeline, but I rather not have custom implementations when I'm sure there will be more complexity down the road...

@TimonPost
Copy link
Member Author

I will get back to you soon, #175 is merged by the way, and you might be interested to try it and read the book/command.md.

@TimonPost
Copy link
Member Author

TimonPost commented Jul 24, 2019

To solve (1), it seems the conversation above is talking about a separate "refresh" / "flush" function for the user to call.

First)

This sounds like an easy solution, but there's a big problem behind it. First of all, what is flush called for? As it was done, every command was written to 'stdout', and called here on flush. The problem with this is that some users don't want to write to the stdout. This problem can be solved by optimizing and controlling where commands are executed. The ideal situation would be if we let the user determine this. This is what termion does for example. All commands are written to a buffer specified by the user. This buffer is user-controlled and all termion does is write commands to this buffer.

This is what crossterm had before. In crossterm it was possible to give your own TerminalOutput. Commands were written to it and the user was in control of this type. All of them had a problem with putting it in an Arc, and this made the API very difficult to use.

second)
where do you want to place this refresh, flush? is each module going to have it? how are we going to call it and like said in one, where are we going to call it on?

In the API command, a Commando has an ANSI escape value and a WinAPI implementation. Crossterm has functions that accept a command and a type that implements Write. These functions will in case of Linux and Windows 10 write the ANSI escape string to the written type. And in case of Windows versions lower than 10 a WinAPI call will be made immediately.

You can check out this for more information. This API was just merged a short time before you made a comment. It is tested and implemented for TUI, cursive, and I think that it has everything that is currently missing

@TimonPost
Copy link
Member Author

TimonPost commented Jul 25, 2019

I mean, I like the macro idea of a command pipeline, but I rather not have custom implementations when I'm sure there will be more complexity down the road...

What do you mean with custom types and more complexity?

@TimonPost
Copy link
Member Author

Going to close this, it is no longer relevant, if there are new concerns, it should be done in a new issue.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

3 participants