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

Test environment for faster startup #3365

Draft
wants to merge 13 commits into
base: main
Choose a base branch
from

Conversation

EmberLightVFX
Copy link
Contributor

This is a PR for a test environment for this issue: #3356

I couldn't get a correct NiceGUI dev-environment up and running but I hope this PR works for you or at least shows you the setup.

@falkoschindler
Copy link
Contributor

Thanks for the pull request, @EmberLightVFX!

When I run _slow_startup.py or _fast_startup.py, I get this exception (after commenting out the if not optional_features.has('webview') block in native_mode.py):

NiceGUI ready to go on http://localhost:8000
ERROR:    Traceback (most recent call last):
  File "/Users/falko/Library/Caches/pypoetry/virtualenvs/nicegui-85pGnwEl-py3.11/lib/python3.11/site-packages/starlette/routing.py", line 743, in lifespan
    await receive()
  File "/Users/falko/Library/Caches/pypoetry/virtualenvs/nicegui-85pGnwEl-py3.11/lib/python3.11/site-packages/uvicorn/lifespan/on.py", line 137, in receive
    return await self.receive_queue.get()
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/opt/homebrew/Cellar/[email protected]/3.11.7/Frameworks/Python.framework/Versions/3.11/lib/python3.11/asyncio/queues.py", line 158, in get
    await getter
asyncio.exceptions.CancelledError

0.9242219924926758

And when I run a simple script like

from nicegui import ui

ui.label('Hello, world!')

ui.run(native=True)

I get

Process Process-1:
Traceback (most recent call last):
  File "/opt/homebrew/Cellar/[email protected]/3.11.7/Frameworks/Python.framework/Versions/3.11/lib/python3.11/multiprocessing/process.py", line 314, in _bootstrap
    self.run()
  File "/opt/homebrew/Cellar/[email protected]/3.11.7/Frameworks/Python.framework/Versions/3.11/lib/python3.11/multiprocessing/process.py", line 108, in run
    self._target(*self._args, **self._kwargs)
TypeError: _open_window() missing 3 required positional arguments: 'start_args', 'method_queue', and 'response_queue'
NiceGUI ready to go on http://localhost:8000
/opt/homebrew/Cellar/[email protected]/3.11.7/Frameworks/Python.framework/Versions/3.11/lib/python3.11/multiprocessing/resource_tracker.py:254: UserWarning: resource_tracker: There appear to be 6 leaked semaphore objects to clean up at shutdown                  
  warnings.warn('resource_tracker: There appear to be %d '

So I'm a bit lost here. Can you or anyone else help to get this working? Thanks!

@falkoschindler falkoschindler added help wanted Extra attention is needed enhancement New feature or request labels Jul 19, 2024
@falkoschindler falkoschindler linked an issue Jul 19, 2024 that may be closed by this pull request
@EmberLightVFX
Copy link
Contributor Author

@falkoschindler I most have accidentally pasted some arguments on the _open_window function. I removed them and it should work for you now.

@EmberLightVFX EmberLightVFX marked this pull request as draft July 21, 2024 09:32
@falkoschindler
Copy link
Contributor

I'm still gettting a CancelledError when running either of both scripts:

ERROR:    Traceback (most recent call last):
  File "/Users/falko/.pyenv/versions/3.11.7/lib/python3.11/site-packages/starlette/routing.py", line 743, in lifespan
    await receive()
  File "/Users/falko/.pyenv/versions/3.11.7/lib/python3.11/site-packages/uvicorn/lifespan/on.py", line 137, in receive
    return await self.receive_queue.get()
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/falko/.pyenv/versions/3.11.7/lib/python3.11/asyncio/queues.py", line 158, in get
    await getter
asyncio.exceptions.CancelledError

And there are several undefined symbols like window_args, settings and start_args in _open_window.

@EmberLightVFX
Copy link
Contributor Author

I have no idea how my files got so scrambled...
I re-did everything and I finally also got the development environment up and running so I could test it on that environment.

For some reason I had to comment out app.on_startup(runme) from my test scripts. They gave me a asyncio.exceptions.CancelledError error. I get this error on the latest main branch without any of my fixes.
Any way, as soon as the GUI window opens, close it and you will see the total time for the process to open and close everything in the terminal.

For me slow takes 13 seconds and fast takes 7 seconds to process.

@falkoschindler
Copy link
Contributor

@EmberLightVFX You seem to be measuring the time until ui.run terminates, which mainly depends on when the user closes the window.

When I measure the time within a startup handler

app.on_startup(lambda: print(time.time() - start))

there is no significant difference between the slow and the fast script. But I guess the measurement is still flawed, because the start time is set separately in each process. So it's hart to measure the true startup time.

@EmberLightVFX
Copy link
Contributor Author

@EmberLightVFX You seem to be measuring the time until ui.run terminates, which mainly depends on when the user closes the window.

That's why I had the "on_startup" command close the UI with app.shutdown() for me as soon as it showed up, but it was broken for me with the lateat main commits.

What you can do is to simply use your phones stopwatch and measure how long it takes from when you start the script till you see the UI. "Slow" will take around double the time to start.

@falkoschindler
Copy link
Contributor

Ok, I can confirm that _fast_startup.py is indeed faster than _slow_startup.py. It takes only 1-2 seconds on my machine, so it's hard to measure. But the difference is clearly noticeable.

I'm still struggling to understand why the location of window.py plays such a great role. What exactly is taking up so much time? Is it the import of webview? And how does it relate to window.py being located in a different package?

@EmberLightVFX
Copy link
Contributor Author

EmberLightVFX commented Jul 26, 2024

native_mode.py imports some things with from .. import xxxxx
What python does is check for a __import__.py file in .. (the root nicegui dir in this case) and crawls everything within it if found. One of the items that gets checked in the root __import__.py is app from nicegui.py
When python checks the nicegui.py file it will run every root-code within. That means core.app = app = App(default_response_class=NiceGUIJSONResponse, lifespan=_lifespan) and all other root-code runs resulting in NiceGUI re-initializes itself in the new thread that native_mode creates for WebView.

By moving all code that the new thread needs to run into a new package/library there is no need to import anything from NiceGUI as it doesn't need anything from NiceGUI, thus not re-initializing anything in the new thread, thus cutting the startup time in half.

@EmberLightVFX
Copy link
Contributor Author

EmberLightVFX commented Jul 26, 2024

Another solution could be to wrap all that root-code within a def initialize() function that the user needs to add before they start using NiceGUI in their code. This might be something for NiceGUI 2.0 as it would break everyones current code.

@falkoschindler
Copy link
Contributor

Very interesting... Unfortunately, we're currently focusing on some other issues that need to get done for the upcoming 2.0 release. The approach described in this PR doesn't feel quite right. Hopefully we can avoid splitting the NiceGUI package and still avoid re-loading everything. Maybe someone from the community likes to experiment with this branch and can come up with an idea how to speed up native mode more elegantly.

@EmberLightVFX
Copy link
Contributor Author

Yeah splitting the package into two packages isn't ideal.
Would requireing a ui.init() in the start of a users script be acceptable? I could whip something up like that

@falkoschindler
Copy link
Contributor

falkoschindler commented Aug 4, 2024

Would requireing a ui.init() in the start of a users script be acceptable?

We're still hoping for a solution without such an init call. And even if that's impossible, the need for ui.init() is barely acceptable, since it heavily breaks all existing apps and adds boilerplate code for everyone, just to speed-up the native mode. There has to be a better way... 🤞🏻

@EmberLightVFX
Copy link
Contributor Author

Got it! I'll try and figure something out!

@tgbl-mk
Copy link

tgbl-mk commented Nov 4, 2024

I've also been trying to find a workaround for this issue as I have some lengthy startup scripts and logic that I'd rather not run twice. A workaround I've found is to create a lock file after the first pass of the file, which can be used to block code execution on the second pass that occurs when native=True

I'll admit I'm not too familiar with what's happening under the hood in niceui, but with the code snippet below everything appears to be working as intended. (which is a significantly reduced example of the actuall application I'm working on).

This would obviously not work if reload=True, though some additional logic could resolve that.

from nicegui import ui
from pathlib import Path

class test_ui():
    def __init__(self, native: bool) -> None:
        print("Initialising UI")
        self.native = native
        print(f"native = {self.native}")

        self.lock_file = Path(__file__).resolve().parent / "app.lock"
        if self.native:
            if not self.lock_file.is_file():
                print("Lock file not present!")
                with open(self.lock_file, "w") as _:
                    pass
                self.compose()
            else:
                print("lock file present!")
                self.lock_file.unlink()
        else:
            self.compose()

    def compose(self) -> None:
        print("Composing UI")
        ui.label("Hello World!")
        ui.button("Ding!", on_click=self.on_ding)

    def on_ding(self) -> None:
        ui.notify("Dong!")

    def run(self) -> None:
        if (self.native and self.lock_file.is_file()) or not self.native:
            print("Running UI")
            ui.run(port= 1000, native=self.native, reload=False)


test_ui(True).run()

@EmberLightVFX
Copy link
Contributor Author

I took another stab at this.
In short: If anything NiceGUI related gets imported in webview process we will get double loading time when using native mode.
The way NiceGUI is built there is sadly nothing that can be done in its current state. The only real way would be to create a separate library for the webviewer part of NiceGUI and make that library a dependency for NiceGUI.

@EmberLightVFX
Copy link
Contributor Author

I cleaned up the code a little bit so there is now a folder called nicegui_webview, my idea of what the package could be named.
I'm not good on what needs to be done to set it up as a propper pip package, if it's possible to do in the same repo or if it needs to be moved to another one.

Ether nicegui_webview can be a dependencie for nicegui so it also will get downloaded when pip install nicegui, or change native_modes message to install pywebview to Please run "pip install nicegui_webview" to use it. while pywebview would be a dependency for nicegui_webview.

Thoughts on this? @falkoschindler

@EmberLightVFX
Copy link
Contributor Author

Forgot to add: You would still need to do something like

if __name__ == '__main__':
    from nicegui import app, ui

for any nicegui import in the code to prevent any nicegui part to be loaded on webview's side.

The only other way I can think of is to use subprocess.Popen to start up a completely different python process and python file that would only open pywebview. A lot would need to be re-written to use pipes instead of Queues to comunicate between the different processes

@falkoschindler
Copy link
Contributor

Ok, I finally found the time to look into this PR once more. I think I'm starting to understand what's happening. Here's Claude.ai's explanation which helped me quite a bit:

When mp.Process starts a new Python process and targets a function:

  1. If the function is from the nicegui package (from .window import open_window), Python needs to import and initialize the nicegui package in the new process to access that function
  2. But if the function is from a separate package (from nicegui_webview.window import open_window), Python only needs to import that specific package in the new process - it never touches the nicegui package initialization

This is fundamentally different from just moving code between modules within nicegui, because the multiprocessing behavior depends on which package the target function belongs to.

So the performance issue isn't about Python's normal import system or module caching - it's specifically about what needs to be imported and initialized in the new process created by multiprocessing. By moving open_window to a separate package, they ensure the WebView process can run without ever importing or initializing nicegui.

This is quite clever! The separate package isn't just an organizational choice - it's specifically exploiting multiprocessing's behavior to avoid unnecessary initialization.

One important point: We're talking about processes, not threads. Threads wouldn't need to re-import modules.

The problem seems related to #4353, where multiprocessing causes NiceGUI to be initialized multiple times. One proposed solution is to use environment variables to prevent nicegui from being imported again. Maybe this could help for this PR as well?

@EmberLightVFX
Copy link
Contributor Author

EmberLightVFX commented Feb 24, 2025

Yeah it's quite complicated and English isn't my native language so that doesn't help 😅

From reading the code and analyzing all startup executions with viztracer I don't see any way to not initialize anything that NiceGUI imports when importing nicegui.app/ui, but if someone knows how I'm all upp for it!

The problem I saw using viztracer is that multiple imports that NiceGUI does internally takes some time to start, like from fastapi import request. The only way to not do this is to place the NiceGUI import in your code after if __name__ == "__main__", thus not importing anything again, exactly what you guys saw in #4353

The problem that I'm getting when using native mode is that a new process starts up, I have all NiceGUI imports within the if __name__ == "__main__" check but because the webview code is within the NiceGUI package, everything will get re-initialized even tho the webview code doesn't use any NiceGUI code.

The only solution I found is to move the webview code out to another package, thus isolating it from any NiceGUI code.
For the end user there shouldn't be any difference if a nicegui_webview packages becomes a dependency for NiceGUI, it would only be for code-separation.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New feature or request help wanted Extra attention is needed
Projects
None yet
Development

Successfully merging this pull request may close these issues.

How to cut startup time in half while native=True.
3 participants