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 the update performance for large UIs on slow machines #338

Closed
falkoschindler opened this issue Feb 2, 2023 · 6 comments
Closed
Assignees
Labels
enhancement New feature or request
Milestone

Comments

@falkoschindler
Copy link
Contributor

We noticed that large UIs can lead to suboptimal update behavior on slow machines.

Consider the following grid of buttons:

with ui.card():
    for r in range(10):
        with ui.row():
            for c in range(10):
                ui.button() \
                    .props('dense') \
                    .classes('w-10 h-10') \
                    .style(f'background-color: hsl({r * 25}, 100%, {c * 10}%) !important;')

Every .props, .classes, and .style call will trigger a UI update. After leaving the scope of a ui.row, the whole row will be updated. And after leaving the scope of the ui.card, the card will be updated including all rows and buttons.

We identified six ideas for improving the update mechanism:

  1. Update props, classes, and style without using the update method.

    Currently, these methods call update internally. This collects and transmits the complete element including all children. Restricting the transmission to the collection of props, classes, or style reduces the payload significantly.

  2. Introduce an update queue processed by a dedicated update loop.

    In order to avoid creating a new Task for every individual update, we could collect updates in a queue that is processed regularly by an update loop.

    This update loop could be even more efficient by transmitting the whole queue at once, excluding redundant elements: Like with the current implementation, updating an element involves collecting all its descendants. But having a list of queued elements, we can remove duplicates and combine all remaining elements in one update message.

  3. Using create_lazy instead of create to skip creating tasks for outdated updates.

    This will not apply after implementing (2).

  4. Skip an update if the parent element has not been transmitted to the client.

    This will probably be automatically the case after implementing (2).

  5. Use a different API for setting props, classes, and style.

    Without the need for parsing strings we could save some computing time. But an alternative API is still an open discussion How to simplify styling? #117.

  6. Replace shlex with a different parser for .props.

    In order to support quoted strings like .props('error-message="Invalid email"') or even .props('error-message="Invalid name \"Alice\""') we are using shlex. But processing the string seems to be the main bottleneck when calling .props. Maybe there is a faster alternative like regex that serves our needs.

@falkoschindler falkoschindler added the enhancement New feature or request label Feb 2, 2023
@falkoschindler
Copy link
Contributor Author

falkoschindler commented Feb 2, 2023

Regarding (6):

Trying to define your own regex to work like shlex.split is needlessly complicated, if it's even possible.
https://stackoverflow.com/a/34679668/3419103

That's not too hopeful. But it doesn't mean that there isn't a subset of shlex sufficient for this purpose.


Update: I opened #341 for this subtopic.

falkoschindler added a commit that referenced this issue Feb 3, 2023
@falkoschindler falkoschindler self-assigned this Feb 3, 2023
@falkoschindler
Copy link
Contributor Author

I started to implement the update queue (2). In principle it's not too hard and improves performance immediately. Due to the fact that NiceGUI elements always have larger IDs than their parents (because parents are always created first), we can easily sort the queue by element ID and skip traversing elements that already have been processed.

However, a difficulty arose for ui.chart and ui.table, which update the client by calling update() and then run_method(). Because update() only schedules the update, the method is run with outdated data. Of course, we could change this implementation detail, e.g. sending the new state with the run_method call (like ui.mermaid). Or we remove the need for the run_method call by introducing an on_update hook on the client side for updating the chart or table. But I think this indicates a larger problem: If we don't preserve the order of execution for update() and run_method() because an update is always postponed, the server-client communication gets messy and implementing components for NiceGUI gets difficult.

So I'm starting to think about a more holistic approach. There are (at the moment) five different socket messages emitted from the server to the client: "update", "run_method", "run_javascript", "open", "notify". In view of (1) there might be "update_classes", "update_style", "update_props", and maybe "update_text" in near future. Can we collect them all in one queue? Especially for ui.scene this is very interesting, since all updates happen by calling run_method. So the performance consideration from creating tasks for every update apply there as well.

Unfortunately, this complicates the algorithm for consuming the loop quite a bit. In general all messages only contain a name, some payload, and a room ID. But updates are special, because they can get replaced by newer updates of the same element (or a parent). In combination with run_method this doesn't hold anymore. Let's assume the following commands are called: set_content("A"), update(), run_method("X"), set_content("B"), update(). If the content is set synchronously and both update as well as run_method are put into the update queue, we can't skip any step. "A" needs to get sent before calling "X" on the new state. Only then we can send "B".

Should we forget about skipping updates? The update queue still saves a lot of task creations. And introducing dedicated updates for props, classes, and style (1) will reduce the payload significantly. But in the example above with the colored buttons there will still be up to seven messages per element. What a waste.

@falkoschindler
Copy link
Contributor Author

falkoschindler commented Feb 3, 2023

Regarding faster props parsing (6):

Together with ChatGPT I found the following implementation that seems to do what we need. Since it uses a compiled regular expression it should be pretty fast. But we should measure it on a production system.

The json module is only used for unescaping quoted string. The ast. literal_eval function might be a faster alternative, but could be a bit unsafe. And we use it only in rare cases, so json should be fine.

pattern = re.compile(r'(\w+)(?:=(?:("[^"\\]*(?:\\.[^"\\]*)*")|([\w-]+)))?(?:$|\s)')

def extract_dictionary(string):
    dictionary = {}
    for match in pattern.finditer(string):
        key = match.group(1)
        value = match.group(2) or match.group(3)
        if value and value.startswith('"') and value.endswith('"'):
            value = json.loads(value)
        dictionary[key] = value or True
    return dictionary

string = 'dark color=red label="First Name" hint="Your \\"given\\" name" input-style="{ color: #ff0000 }"'
props = extract_dictionary(string)
print(props)

Output:

{'dark': True, 'color': 'red', 'label': 'First Name', 'hint': 'Your "given" name', 'style': '{ color: #ff0000 }'}

Update: I opened #341 for this subtopic.

@falkoschindler
Copy link
Contributor Author

I opened a new issue #341 for the props parsing subtopic (6). it's rather independent of when and how to send updates to the client, so we shouldn't mix the discussion.

@falkoschindler
Copy link
Contributor Author

Wow, just now I noticed how bad our current update strategy actually is: While developing the update queue, I counted the number of times each element is sent to the client with the previous implementation. For the grid of colored buttons (#338 (comment)) that's up to 7 times for each button:

  • updating the props
  • updating the classes
  • updating the style
  • adding the button to the row
  • adding the row to the card
  • adding the card to the page content
  • and somehow I'm still missing one update

But that means: Nesting a collection of elements one layer deeper results in another transmission of all of them! That might be better if we add the card to the page content first, then add empty rows, and finally add buttons to the rows. But this is far from nice.

Luckily, our update queue (or more general: message queue) will improve this behavior significantly.

@rodja
Copy link
Member

rodja commented Feb 10, 2023

Merged!

@rodja rodja closed this as completed Feb 10, 2023
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New feature or request
Projects
None yet
Development

No branches or pull requests

2 participants