zauberzeug / nicegui

Create web-based user interfaces with Python. The nice way.
https://nicegui.io
MIT License
10.13k stars 605 forks source link

Large Memory in the browser #1089

Closed Kamil-Och closed 1 year ago

Kamil-Och commented 1 year ago

Description

Hi, I have an problem that when i inspect the browser the memory for the niceGui website is periodically rising from like 0.5 GB to 5 GB and its causing the website to crash. I don't really know what the problem is but I'm using ui.timer to get the data for the gui on 0.1 seconds timer.

falkoschindler commented 1 year ago

Hi @Kamil-Och! Can you provide some more details or a code example reproducing the problem?

Kamil-Och commented 1 year ago

Okay here is sample code based on my use case:

code ```py from nicegui import ui class Robot: def __init__(self) -> None: self.current_robot_state = 1 self.goal_robot_state = 1 self.frames_sent = 0 self.loop_counter_overtime_error = 0 self.gripper_type = 0 self.payload = 0.0 self.test_var = 0 class Joint: def __init__(self, goal_torque: float = 0.0, goal_fsm: int = 0, goal_position: float = 0.0, goal_id_torque: float = 0.0, goal_rl_torque: float = 0.0, goal_rl_pid: float = 0.0, goal_rl_friction: float = 0.0, current_position: float = 0.0, current_velocity: float = 0.0, goal_velocity: float = 0.0, current_torque: float = 0.0, current_fsm: int = 0, current_bearing_temperature: int = 0, current_motor_temperature: int = 0, current_warnings: int = 0, current_errors: int = 0, joint_registers: list [int] = [0]*256, joint_rcv_counter: int = 0, joint_rcv_counter_error: int = 0): self.goal_torque = goal_torque self.goal_fsm = goal_fsm self.goal_position = goal_position self.goal_id_torque = goal_id_torque self.goal_rl_torque = goal_rl_torque self.goal_rl_pid = goal_rl_pid self.goal_rl_friction = goal_rl_friction self.current_position = current_position self.current_velocity = current_velocity self.goal_velocity = goal_velocity self.current_torque = current_torque self.current_fsm = current_fsm self.current_bearing_temperature = current_bearing_temperature self.current_motor_temperature = current_motor_temperature self.current_warnings = current_warnings self.current_errors = current_errors self.joint_registers = joint_registers self.joint_rcv_counter = joint_rcv_counter self.joint_rcv_counter_error = joint_rcv_counter_error def updateData(): robot.current_robot_state += 1 robot.frames_sent += 1 robot.goal_robot_state += 1 robot.gripper_type += 1 robot.loop_counter_overtime_error += 1 robot.test_var += 1 robot.payload += 1 for joint in joints: joint.goal_torque += 1 joint.goal_fsm += 1 joint.goal_position += 1 joint.goal_id_torque += 1 joint.goal_rl_torque += 1 joint.goal_rl_pid += 1 joint.goal_rl_friction += 1 joint.current_position += 1 joint.current_velocity += 1 joint.goal_velocity += 1 joint.current_torque += 1 joint.current_fsm += 1 joint.current_bearing_temperature += 1 joint.current_motor_temperature += 1 joint.current_warnings += 1 joint.current_errors += 1 joint.joint_rcv_counter += 1 joint.joint_rcv_counter_error += 1 def reload_data_and_refresh(): updateData() refreshUI() def refreshUI(): UI.refresh() @ui.refreshable def UI(): ui.label("Robot") with ui.row(): ui.label("robot.current_robot_state") ui.label(robot.current_robot_state) ui.label("robot.frames_sent") ui.label(robot.frames_sent) ui.label("robot.goal_robot_state") ui.label(robot.goal_robot_state) ui.label("robot.gripper_type") ui.label(robot.gripper_type) ui.label("robot.loop_counter_overtime_error") ui.label(robot.loop_counter_overtime_error) ui.label("robot.test_var") ui.label(robot.test_var) ui.label("robot.payload") ui.label(robot.payload) ui.label("Joints") with ui.row(): for joint in joints: with ui.grid(columns=2): ui.label("joint.goal_torque") ui.label(joint.goal_torque) ui.label("joint.goal_fsm") ui.label(joint.goal_fsm) ui.label("joint.goal_position") ui.label(joint.goal_position) ui.label("joint.goal_id_torque") ui.label(joint.goal_id_torque) ui.label("joint.goal_rl_torque") ui.label(joint.goal_rl_torque) ui.label("joint.goal_rl_pid") ui.label(joint.goal_rl_pid) ui.label("joint.goal_rl_friction") ui.label(joint.goal_rl_friction) ui.label("joint.current_position") ui.label(joint.current_position) ui.label("joint.current_velocity") ui.label(joint.current_velocity) ui.label("joint.goal_velocity") ui.label(joint.goal_velocity) ui.label("joint.current_torque") ui.label(joint.current_torque) ui.label("joint.current_fsm") ui.label(joint.current_fsm) ui.label("joint.current_bearing_temperature") ui.label(joint.current_bearing_temperature) ui.label("joint.current_motor_temperature") ui.label(joint.current_motor_temperature) ui.label("joint.current_warnings") ui.label(joint.current_warnings) ui.label("joint.current_errors") ui.label(joint.current_errors) ui.label("joint.joint_rcv_counter") ui.label(joint.joint_rcv_counter) ui.label("joint.joint_rcv_counter_error") ui.label(joint.joint_rcv_counter_error) if __name__ in {"__main__", "__mp_main__"}: if __name__ == "__mp_main__": robot = Robot() joints = [Joint() for _ in range(6)] ui.run(title='HMI', show=False) try: ui_timer = ui.timer(0.1, lambda: reload_data_and_refresh()) UI() except KeyboardInterrupt: print('KeyboardInterrupt') exit(0) ```
falkoschindler commented 1 year ago

I can't reproduce the huge memory consumption you're describing. But apart from that you main block looks strange. Creating the UI should happen before calling ui.run():

if __name__ in {"__main__", "__mp_main__"}:
    if __name__ == "__mp_main__":
        robot = Robot()
        joints = [Joint() for _ in range(6)]

    UI()
    ui_timer = ui.timer(0.1, lambda: reload_data_and_refresh())

    ui.run(title='HMI', show=False)

Does the problem still occur?

Kamil-Och commented 1 year ago

yea it doesn't seams to change anything. When I check with firefox process Manager in the span of little over 2 minutes memory for this code goes from 200 mb to 4 gb and then ether crash tab or freeze browser. Considering that you can't reproduce the error could it be the problem with my setup or browser?

falkoschindler commented 1 year ago

Oh! Now I looked at the right place: The memory usage of "Google Chrome Helper (Renderer)" grows indeed about 1 GB per minute.

Here is a more compact reproduction:

import time
from nicegui import ui

@ui.refreshable
def labels():
    for _ in range(1000):
        ui.label(time.time())

labels()
ui_timer = ui.timer(0.1, labels.refresh)

ui.run()

Looks like ui.refreshable introduces a memory leak.

falkoschindler commented 1 year ago

But without ui.refreshable the problem still occurs:

import time
from nicegui import ui

def render():
    container.clear()
    with container:
        for _ in range(1000):
            ui.label(time.time())

container = ui.column()
ui_timer = ui.timer(0.1, render)

ui.run()
Kamil-Och commented 1 year ago

i just tried without the ui.refreshable and it works fine to be honest it shows me around 200 mb of memory

code ```py from nicegui import ui class Robot: def __init__(self) -> None: self.current_robot_state = 1 self.goal_robot_state = 1 self.frames_sent = 0 self.loop_counter_overtime_error = 0 self.gripper_type = 0 self.payload = 0.0 self.test_var = 0 class Joint: def __init__(self, goal_torque: float = 0.0, goal_fsm: int = 0, goal_position: float = 0.0, goal_id_torque: float = 0.0, goal_rl_torque: float = 0.0, goal_rl_pid: float = 0.0, goal_rl_friction: float = 0.0, current_position: float = 0.0, current_velocity: float = 0.0, goal_velocity: float = 0.0, current_torque: float = 0.0, current_fsm: int = 0, current_bearing_temperature: int = 0, current_motor_temperature: int = 0, current_warnings: int = 0, current_errors: int = 0, joint_registers: list [int] = [0]*256, joint_rcv_counter: int = 0, joint_rcv_counter_error: int = 0): self.goal_torque = goal_torque self.goal_fsm = goal_fsm self.goal_position = goal_position self.goal_id_torque = goal_id_torque self.goal_rl_torque = goal_rl_torque self.goal_rl_pid = goal_rl_pid self.goal_rl_friction = goal_rl_friction self.current_position = current_position self.current_velocity = current_velocity self.goal_velocity = goal_velocity self.current_torque = current_torque self.current_fsm = current_fsm self.current_bearing_temperature = current_bearing_temperature self.current_motor_temperature = current_motor_temperature self.current_warnings = current_warnings self.current_errors = current_errors self.joint_registers = joint_registers self.joint_rcv_counter = joint_rcv_counter self.joint_rcv_counter_error = joint_rcv_counter_error def updateData(): robot.current_robot_state += 1 robot.frames_sent += 1 robot.goal_robot_state += 1 robot.gripper_type += 1 robot.loop_counter_overtime_error += 1 robot.test_var += 1 robot.payload += 1 for joint in joints: joint.goal_torque += 1 joint.goal_fsm += 1 joint.goal_position += 1 joint.goal_id_torque += 1 joint.goal_rl_torque += 1 joint.goal_rl_pid += 1 joint.goal_rl_friction += 1 joint.current_position += 1 joint.current_velocity += 1 joint.goal_velocity += 1 joint.current_torque += 1 joint.current_fsm += 1 joint.current_bearing_temperature += 1 joint.current_motor_temperature += 1 joint.current_warnings += 1 joint.current_errors += 1 joint.joint_rcv_counter += 1 joint.joint_rcv_counter_error += 1 def reload_data_and_refresh(): updateData() refreshUI() def refreshUI(): current_robot_state.set_text(current_robot_state) frame_sent.set_text(robot.frames_sent) goal_robot_state.set_text(robot.goal_robot_state) gripper_type.set_text(robot.gripper_type) loop_counter_overtime_error.set_text(robot.loop_counter_overtime_error) test_var.set_text(robot.test_var) robot_payload.set_text(robot.payload) i = 0 for joint in joints: goal_torque[i].set_text(joint.goal_torque) goal_fsm[i].set_text(joint.goal_fsm) goal_position[i].set_text(joint.goal_position) goal_id_torque[i].set_text(joint.goal_id_torque) goal_rl_torque[i].set_text(joint.goal_rl_torque) goal_rl_pid[i].set_text(joint.goal_rl_pid) goal_rl_friction[i].set_text(joint.goal_rl_friction) current_position[i].set_text(joint.current_position) current_velocity[i].set_text(joint.current_velocity) goal_velocity[i].set_text(joint.goal_velocity) current_torque[i].set_text(joint.current_torque) current_fsm[i].set_text(joint.current_fsm) current_bearing_temperature[i].set_text(joint.current_bearing_temperature) current_motor_temperature[i].set_text(joint.current_motor_temperature) current_warning[i].set_text(joint.current_warnings) current_errors[i].set_text(joint.current_errors) joint_rcv_counter[i].set_text(joint.joint_rcv_counter) joint_rcv_counter_error[i].set_text(joint_rcv_counter_error) i += 1 if __name__ in {"__main__", "__mp_main__"}: robot = Robot() joints = [Joint() for _ in range(6)] i = 0 goal_torque = [] goal_fsm = [] goal_position = [] goal_id_torque = [] goal_rl_torque = [] goal_rl_pid = [] goal_rl_friction = [] current_position = [] current_velocity = [] goal_velocity = [] current_torque = [] current_fsm = [] current_bearing_temperature = [] current_motor_temperature = [] current_warning = [] current_errors = [] joint_rcv_counter =[] joint_rcv_counter_error = [] ui.label("Robot") with ui.row(): ui.label("robot.current_robot_state") current_robot_state = ui.label(robot.current_robot_state) ui.label("robot.frames_sent") frame_sent = ui.label(robot.frames_sent) ui.label("robot.goal_robot_state") goal_robot_state = ui.label(robot.goal_robot_state) ui.label("robot.gripper_type") gripper_type = ui.label(robot.gripper_type) ui.label("robot.loop_counter_overtime_error") loop_counter_overtime_error = ui.label(robot.loop_counter_overtime_error) ui.label("robot.test_var") test_var = ui.label(robot.test_var) ui.label("robot.payload") robot_payload = ui.label(robot.payload) ui.label("Joints") with ui.row(): for joint in joints: print(i) with ui.grid(columns=2): ui.label("joint.goal_torque") goal_torque.append(ui.label(joint.goal_torque)) ui.label("joint.goal_fsm") goal_fsm.append(ui.label(joint.goal_fsm)) ui.label("joint.goal_position") goal_position.append(ui.label(joint.goal_position)) ui.label("joint.goal_id_torque") goal_id_torque.append(ui.label(joint.goal_id_torque)) ui.label("joint.goal_rl_torque") goal_rl_torque.append(ui.label(joint.goal_rl_torque)) ui.label("joint.goal_rl_pid") goal_rl_pid.append(ui.label(joint.goal_rl_pid)) ui.label("joint.goal_rl_friction") goal_rl_friction.append(ui.label(joint.goal_rl_friction)) ui.label("joint.current_position") current_position.append(ui.label(joint.current_position)) ui.label("joint.current_velocity") current_velocity.append(ui.label(joint.current_velocity)) ui.label("joint.goal_velocity") goal_velocity.append(ui.label(joint.goal_velocity)) ui.label("joint.current_torque") current_torque.append(ui.label(joint.current_torque)) ui.label("joint.current_fsm") current_fsm.append(ui.label(joint.current_fsm)) ui.label("joint.current_bearing_temperature") current_bearing_temperature.append(ui.label(joint.current_bearing_temperature)) ui.label("joint.current_motor_temperature") current_motor_temperature.append(ui.label(joint.current_motor_temperature)) ui.label("joint.current_warnings") current_warning.append(ui.label(joint.current_warnings)) ui.label("joint.current_errors") current_errors.append(ui.label(joint.current_errors)) ui.label("joint.joint_rcv_counter") joint_rcv_counter.append(ui.label(joint.joint_rcv_counter)) ui.label("joint.joint_rcv_counter_error") joint_rcv_counter_error.append(ui.label(joint.joint_rcv_counter_error)) i += 1 ui_timer = ui.timer(0.1, lambda: reload_data_and_refresh()) ui.run(title='HMI', show=False) ```
falkoschindler commented 1 year ago

Yes, updating the existing elements is certainly the more efficient way to update the UI. Binding could also help to simplify the code.

But nonetheless, removing and re-creating elements should not leak any memory.

falkoschindler commented 1 year ago

I found at least one memory leak:

window.socket.on("update", (msg) => Object.entries(msg).forEach(([id, el]) => this.elements[el.id] = el));

We only add elements to this.elements but never remove them. But how to do that? We either need to find out which elements lost their parents during the update, or we need to send explicit delete messages.

falkoschindler commented 1 year ago

I pushed a fix for the leak from my previous comment https://github.com/zauberzeug/nicegui/issues/1089#issuecomment-1613676633 on the "memory" branch: https://github.com/zauberzeug/nicegui/compare/memory

For 1000 labels 10 times per second, the memory consumption still grows quite at bit. I assume something like the garbage collector having trouble to deal with so many objects.

But with reduced frequency to once per second the problem seems to be (almost?) gone. Even after many minutes the consumption oscillates around 500..600MB. I am, however, not 100% sure that there isn't any growth at all. Do we want to merge and close the issue for now, or should we keep investigating?

By the way: So far I didn't find a way to directly get number of Vue components or the memory profile of the Vue app. This would be a more definitive indicator of a memory leak.

falkoschindler commented 1 year ago

No, we should not merge. Tests are red because moving elements from one container to another does not work anymore. And it looks like the memory consumption is indeed still growing.

CrystalWindSnake commented 1 year ago

@falkoschindler I believe that it is only possible to accurately determine whether a component should be released in the backend.Is it possible to consider using weak references and finalize in Python to solve this problem?

like this?


class Element:
    def __del__(self):
        print("should be release")
        # Notify the frontend that this component should be released.
        # self.push_del_message()
falkoschindler commented 1 year ago

@CrystalWindSnake Yes, something like this might work. But I'm not sure if every element should notify the client about its removal independently, or if we better collect such element IDs as part of the update message.

CrystalWindSnake commented 1 year ago

Collecting and scheduling seems to be a better approach. it will also be easy to use different schedulers and make different removal dependency strategies.

falkoschindler commented 1 year ago

New code for experimenting with the client-side element count:

def add():
    with card:
        ui.label('Some text')

async def count():
    ui.notify(await ui.run_javascript('return Object.keys(window.app.elements).length'))

card = ui.card()
ui.button('Add element', on_click=add)
ui.button('Remove element', on_click=card.clear)
ui.button('Count', on_click=count)
falkoschindler commented 1 year ago

I think I fixed this issue in https://github.com/zauberzeug/nicegui/commit/be306649ee56af3d28cfa30c721194b4750dfea6:

This works well with the example from https://github.com/zauberzeug/nicegui/issues/1089#issuecomment-1609273322 and passes all pytests.

While experimenting with this stress test I noticed a huge performance bottleneck caused by the dependency loading function. It seems like calling the async function for every updated element (even without custom component or libraries) is much more expensive than checking for a component or library outside: https://github.com/zauberzeug/nicegui/commit/db18c9467ff81aacf08ffa0d5104543b6adb541e

With this change I can update 1000 elements almost 10 times per second on my machine. Of course, real-life scenarios should be much more conservative regarding performance and bandwidth.

@rodja Sorry for pushing to the main branch right before a big release. But I think we should include these changes.

rodja commented 1 year ago

Looks good.