Load on demand Tabs

Hi friends,

I have one report I’m trying to build but requires heavy computational power before rendering a set of tabs. Is there a way I can possibly render the pn.Tabs pane having the first tab displayed and allow the service to proceed computing the remaining dashboards? That way, I don’t have to wait to initialize everything and do the necessary computation and THEN render the whole pane of tabs. I’d rather have something displayed for the user to have and then visit the other tabs once they’re done. Would appreciate your help on this.
Thanks!

2 Likes

Hi @simonay,

Have you tried using the dynamic option, the tab will only try and render when you click on it rather than trying to load in background?

import panel as pn
from bokeh.plotting import figure
pn.extension()

p1 = figure(width=300, height=300, name='Scatter')
p1.scatter([0, 1, 2, 3, 4, 5, 6], [0, 1, 2, 3, 2, 1, 0])

p2 = figure(width=300, height=300, name='Line')
p2.line([0, 1, 2, 3, 4, 5, 6], [0, 1, 2, 3, 2, 1, 0])

p3 = figure(width=300, height=300, name='Square')
p3.square([0, 1, 2, 3, 4, 5, 6], [0, 1, 2, 3, 2, 1, 0], size=10)

tabs = pn.Tabs(p1, p2, p3, dynamic=True)
tabs

Yes, in my case it is already set as dynamic but the processing steps are taking too much time before initializing the tabs.

Example:

processing steps of tab 1

tab1 = pn.Row(…)

#processing steps of tab2
tab2 = pn.Row(…)

tabs = pn.Tab((‘Tab 1 title’, tab1), ('Tab 2 title ', tab2), dynamic = True)

Perhaps I can have tab2 initialized empty and leave the processing steps after I initialize tabs and modify it through tabs.objects

Yes, you can do something like pn.state.onload() and loop through each tab and replace it with contents you need.

Also, maybe you can include pn.cache somewhere.

Oh, I didn’t know about it. Is there a documentation to know how to use it?

Like this
https://panel.holoviz.org/how_to/callbacks/load.html

And building your own dynamic loader

def update_tab(active):
    tabs[active] = pn.pane.StaticText("Now updated!")

gif = pn.indicators.LoadingSpinner()
tabs = pn.Tabs(gif, gif.clone(), gif.clone())
pn.bind(update_tab, "active")

1 Like

Thanks for sharing, but I seem to be still struggling to make it work. In the code you shared, the tabs variable is initialized after creating the method that modifies the tabs variable. However, it inspired me to solve it close enough. My case is a bit different anyway and will share a simplified reproducible version of it. Code:

# create tab on-demand and flag once visited 
def create_tab1():
    return ('Hello Tab', 'hello')

def create_tab2():
    time.sleep(5)
    return ('Slow Tab', 'slooooooow')

tabs = pn.Tabs(('Homepage', 'Homepage'), create_tab1(), ('Slow Tab', 'Loading...'), dynamic = True)
tab_flag = [False for i in range(len(tabs.objects))]
print('tab_flag:', tab_flag)

@pn.depends(tabs.param.active, watch = True)
def update_tab(act):
    print('before tab_flag:', tab_flag)
    
    # print('vis:', vis)
    print('act:', act)
    tab_flag[act] = True
    
    if act == 2:
        tabs[act] = create_tab2()

    print('after tab_flag:', tab_flag)

pn.Row(tabs)

I understand that the code works this way, but it got me thinking of another case: Once I have the tabs variable initialized, I want it to start “pre-loading” the remaining heavy/slow tabs in the background while the user is still busy with the first 2 tabs. Perhaps an asynchronous programming solution would help. However, I can’t seem to solve it. I don’t want it to be a periodic_callback and neither triggered by a button event. I want to trigger the asynchronous method once the tabs variable is initialized. Would appreciate your input on this.

1 Like

Hi @simonay

You have some good questions :slight_smile:

You can apply different strategies to improve the user experience including lazy evaluation, generator functions and/ or async. You can convert sync work to async work using asyncer or Dask.

Without knowing more about your case I would probably be using async with asyncer (one of the examples below).

1 Like

Lazy Evaluation

This is probably the first thing you will try to do to improve the user experience. This approach is described in the dynamic section for the Tabs reference guide. It will postpone the loading until the user opens the tab.

import panel as pn
import time

pn.extension()

def create_tab1():
    return 'Wow. That was fast.'

def create_tab2():
    print("running slow process")
    time.sleep(5)
    return 'wow. That was slow.'

create_tab2_lazily = pn.param.ParamFunction(create_tab2, lazy=True, name='Slow Tab')

tabs = pn.Tabs(('Homepage', 'Homepage'), ('Fast Tab', create_tab1), create_tab2_lazily, dynamic = True)

pn.Row(tabs).servable()

Async Function

An alternative to a lazily evaluated function is an async function. This will start the slow process as soon as the app is loaded. And it will do so concurrently, i.e. the rest of the application will stay responsive.

import panel as pn
from asyncio import sleep

pn.extension()

def create_tab1():
    return 'Wow. That was fast.'

async def create_tab2():   
    await sleep(5)
    yield 'wow. That was slow.'

tabs = pn.Tabs(('Homepage', 'Homepage'), ('Fast Tab', create_tab1), pn.panel(create_tab2, name="Slow Tab"), dynamic = True)

pn.Row(tabs).servable()

Async Generator Function

You can improve the user experience by using a generator function that sends loading information to the user while the process is running.

import panel as pn
from asyncio import sleep

pn.extension()


def create_tab1():
    return "Wow. That was fast."


async def create_tab2():
    yield pn.indicators.LoadingSpinner(value=True, width=25, height=25)
    await sleep(3.5)
    yield "Almost there ..."
    await sleep(1.5)
    yield "wow. That was slow."


tabs = pn.Tabs(
    ("Homepage", "Homepage"),
    ("Fast Tab", create_tab1),
    pn.panel(create_tab2, name="Slow Tab", loading_indicator=True),
    dynamic=True,
)

pn.Row(tabs).servable()

Async Generator Function on sync work using Asyncer

If the code for your long running process is not async, you can easily make it async using Tiangolos Asyncer library.

Lets take an example where you are crunching a lot of numbers using numpy and pandas.

import panel as pn
from asyncer import asyncify
import numpy as np
import pandas as pd

pn.extension()


def create_tab1():
    return "Wow. That was fast."


def do_sync_work(n):
    data = np.random.rand(n,n)
    df_large = pd.DataFrame(data)
    df_small = df_large.sum().sum()
    return df_small

async def create_tab2():
    yield pn.indicators.LoadingSpinner(value=True, width=25, height=25)
    result = await asyncify(do_sync_work)(n=20000)
    yield f"wow. That was slow.\n\nThe sum is **{result:.2f}**"


tabs = pn.Tabs(
    ("Homepage", "Homepage"),
    ("Fast Tab", create_tab1),
    pn.panel(create_tab2, name="Slow Tab", loading_indicator=True),
    dynamic=True,
)

pn.Row(tabs).servable()

Async Generator Function on Async Work using Dask.

Using asyncer is easy. But it will be running your sync work on the Panel server. If you start having to do a lot of sync work it will still slow your Panel server down. If thats the case you can offload the work to a dask cluster.

This example is a bit more complicated because

  • Your tasks need to be in a separate file than the file that is served.
  • We use a separate file to start the Dask cluster.

tasks.py

import numpy as np
import pandas as pd

def do_sync_work(n, now):  
    result = 0
    for _ in range(n):
        data = np.random.rand(100,100)  
        df_large = pd.DataFrame(data)  
        result += df_large.sum().sum()
    return result

cluster.py

# cluster.py
from dask.distributed import LocalCluster

DASK_SCHEDULER_PORT = 64720
DASK_SCHEDULER_ADDRESS = f"tcp://127.0.0.1:{DASK_SCHEDULER_PORT}"
N_WORKERS = 4

if __name__ == '__main__':
    cluster = LocalCluster(scheduler_port=DASK_SCHEDULER_PORT, n_workers=N_WORKERS)
    print(cluster.scheduler_address)
    input()

app.py

import panel as pn  
from cluster import DASK_SCHEDULER_ADDRESS
from tasks import do_sync_work
from dask.distributed import Client
from datetime import datetime
pn.extension()

@pn.cache # only instantiate the client once for all users
async def get_client():
    return await Client(DASK_SCHEDULER_ADDRESS, asynchronous=True)

def create_tab1():  
    return "Wow. That was fast."  
  
  
async def create_tab2():  
    yield pn.indicators.LoadingSpinner(value=True, width=25, height=25)  
    client = await get_client()
    future = client.submit(do_sync_work, 10000, datetime.now())  # submit the work to the Dask client  
    result = await future  # get the result from the Dask client  
    yield f"wow. That was slow.\n\nThe sum is **{result:.2f}**"  
  
tabs = pn.Tabs(  
    ("Homepage", "Homepage"),  
    ("Fast Tab", create_tab1),  
    pn.panel(create_tab2, name="Slow Tab", loading_indicator=True),  
    dynamic=True,  
)  
  
pn.Row(tabs).servable()  
pip install dask[distributed]
python cluster.py
panel serve app.py

2 Likes

Great solutions @Marc !
Thanks for sharing.
I was able to use some of them to my application. A new problem came to mind. What if I load 2 tabs together under one asynchronous method? say [('Tab 3 slow', tab3), ('Tab 4 slow', tab4)].
The problem with yielding such a structure is that it cannot be unpacked with the * as it is returned as a function and not as an iterable. It would’ve been convenient given I’m reusing some of the widgets in both tabs to reduce development time and synchronize the filter widgets across all tabs. I think I can solve it depending on how I structure the code, but thought of sharing maybe there’s a workaround to it. Thanks!

1 Like

You could consider not doing unpacking with *, but instead doing something like

import panel as pn
from asyncio import sleep

layout1 = pn.Column("Loading 1 ...", name="Tab1")
layout2 = pn.Column("Loading 2 ...", name="Tab2")

async def update_tabs():
    await sleep(2)
    layout1[:]=["Tab 1 Finished"]
    await sleep(2)
    layout2[:]=["Tab 2 Finished"]

pn.state.onload(update_tabs)
pn.Tabs(layout1, layout2).servable()