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 and document hold utility #7474

Merged
merged 7 commits into from
Nov 8, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions doc/how_to/performance/hold.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# Batching updates with `hold`

When working with interactive dashboards and applications in Panel, you might encounter situations where updating multiple components simultaneously causes unnecessary re-renders. This is because Panel generally dispatches any change to a parameter immediately. This can lead to performance issues and a less responsive user experience because each individual update may trigger re-renders on the frontend. The `hold` utility in Panel allows you to batch updates to the frontend, reducing the number of re-renders and improving performance.

In this guide, we'll explore how to use hold both as a context manager and as a decorator to optimize your Panel applications.

## What is hold?

The `hold` function is a context manager and decorator that temporarily holds events on a Bokeh Document. When you update multiple components within a hold block, the events are collected and dispatched all at once when the block exits. This means that the frontend will only re-render once, regardless of how many updates were made, leading to a smoother and more efficient user experience.

## Using `hold`

If you have a function that updates components and you want to ensure that all updates are held, you can use hold as a decorator, e.g. here we update 100 components at once. If you do not hold then each of these events is sent and applied in series, potentially resulting in visible updates.

```{pyodide}
import panel as pn
from panel.io import hold

@hold()
def increment(e):
for obj in column:
obj.object = str(e.new)

column = pn.FlexBox(*['0']*100)
button = pn.widgets.Button(name='Increment', on_click=increment)

pn.Column(column, button).servable()
```

Applying the hold decorator means all the updates are sent in a single Websocket message and applied on the frontend simultaneously.

Alternatively the `hold` function can be used as a context manager, potentially giving you finer grained control over which events are batched and which are not:

```{pyodide}
import time

import panel as pn
from panel.io import hold

def increment(e):
with button.param.update(name='Incrementing...', disabled=True):
time.sleep(0.5)
with hold():
for obj in column:
obj.object = str(e.new)

column = pn.FlexBox(*['0']*100)
button = pn.widgets.Button(name='Increment', on_click=increment)

pn.Column(column, button).servable()
```

Here the updates to the `Button` are dispatched immediately while the updates to the counters are batched.
8 changes: 8 additions & 0 deletions doc/how_to/performance/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,13 @@ Discover how to reuse sessions to improve the start render time.
Discover how to enable throttling to reduce the number of events being processed.
:::

:::{grid-item-card} {octicon}`tab;2.5em;sd-mr-1 sd-animate-grow50` Batching Updates with `hold`
:link: hold
:link-type: doc

Discover how to improve performance by using the `hold` context manager and decorator to batch updates to multiple components.
:::

::::

```{toctree}
Expand All @@ -28,4 +35,5 @@ Discover how to enable throttling to reduce the number of events being processed

reuse_sessions
throttling
hold
```
56 changes: 53 additions & 3 deletions panel/io/document.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,15 @@
from ..config import config
from ..util import param_watchers
from .loading import LOADING_INDICATOR_CSS_CLASS
from .model import hold, monkeypatch_events # noqa: F401 API import
from .model import monkeypatch_events # noqa: F401 API import
from .state import curdoc_locked, state

if TYPE_CHECKING:
from bokeh.core.enums import HoldPolicyType
from bokeh.core.has_props import HasProps
from bokeh.protocol.message import Message
from bokeh.server.connection import ServerConnection
from pyviz_comms import Comm

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -431,11 +433,18 @@ def dispatch_django(
return futures

@contextmanager
def unlocked() -> Iterator:
def unlocked(policy: HoldPolicyType = 'combine') -> Iterator:
"""
Context manager which unlocks a Document and dispatches
ModelChangedEvents triggered in the context body to all sockets
on current sessions.

Arguments
---------
policy: Literal['combine' | 'collect']
One of 'combine' or 'collect' determining whether events
setting the same property are combined or accumulated to be
dispatched when the context manager exits.
"""
curdoc = state.curdoc
session_context = getattr(curdoc, 'session_context', None)
Expand All @@ -457,7 +466,7 @@ def unlocked() -> Iterator:
monkeypatch_events(curdoc.callbacks._held_events)
return

curdoc.hold()
curdoc.hold(policy=policy)
try:
yield
finally:
Expand Down Expand Up @@ -518,6 +527,47 @@ def unlocked() -> Iterator:
except RuntimeError:
curdoc.add_next_tick_callback(partial(retrigger_events, curdoc, retriggered_events))

@contextmanager
def hold(doc: Document | None = None, policy: HoldPolicyType = 'combine', comm: Comm | None = None):
"""
Context manager that holds events on a particular Document
allowing them all to be collected and dispatched when the context
manager exits. This allows multiple events on the same object to
be combined if the policy is set to 'combine'.

Arguments
---------
doc: Document
The Bokeh Document to hold events on.
policy: HoldPolicyType
One of 'combine', 'collect' or None determining whether events
setting the same property are combined or accumulated to be
dispatched when the context manager exits.
comm: Comm
The Comm to dispatch events on when the context manager exits.
"""
doc = doc or state.curdoc
if doc is None:
yield
return
held = doc.callbacks.hold_value
try:
if policy is None:
doc.unhold()
yield
else:
with unlocked(policy=policy):
if not doc.callbacks.hold_value:
doc.hold(policy)
yield
finally:
if held:
doc.callbacks._hold = held
else:
if comm is not None:
from .notebook import push
push(doc, comm)
doc.unhold()

@contextmanager
def immediate_dispatch(doc: Document | None = None):
Expand Down
28 changes: 8 additions & 20 deletions panel/io/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
from bokeh.models import ColumnDataSource, FlexBox, Model
from bokeh.protocol.messages.patch_doc import patch_doc

from ..util.warnings import deprecated
from .state import state

if TYPE_CHECKING:
Expand Down Expand Up @@ -174,7 +175,7 @@ def bokeh_repr(obj: Model, depth: int = 0, ignored: Optional[Iterable[str]] = No
return r

@contextmanager
def hold(doc: Document, policy: HoldPolicyType = 'combine', comm: Comm | None = None):
def hold(doc: Document | None = None, policy: HoldPolicyType = 'combine', comm: Comm | None = None):
"""
Context manager that holds events on a particular Document
allowing them all to be collected and dispatched when the context
Expand All @@ -192,22 +193,9 @@ def hold(doc: Document, policy: HoldPolicyType = 'combine', comm: Comm | None =
comm: Comm
The Comm to dispatch events on when the context manager exits.
"""
doc = doc or state.curdoc
if doc is None:
yield
return
held = doc.callbacks.hold_value
try:
if policy is None:
doc.unhold()
else:
doc.hold(policy)
yield
finally:
if held:
doc.callbacks._hold = held
else:
if comm is not None:
from .notebook import push
push(doc, comm)
doc.unhold()
deprecated(
'1.7.0', 'panel.io.model.hold', 'panel.io.document.hold',
warn_version='1.6.0'
)
from .document import hold
yield hold(doc, policy, comm)
3 changes: 1 addition & 2 deletions panel/layout/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,7 @@
from bokeh.models import Row as BkRow
from param.parameterized import iscoroutinefunction, resolve_ref

from ..io.document import freeze_doc
from ..io.model import hold
from ..io.document import freeze_doc, hold
from ..io.resources import CDN_DIST
from ..models import Column as PnColumn
from ..reactive import Reactive
Expand Down
3 changes: 1 addition & 2 deletions panel/layout/grid.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,7 @@

from bokeh.models import FlexBox as BkFlexBox, GridBox as BkGridBox

from ..io.document import freeze_doc
from ..io.model import hold
from ..io.document import freeze_doc, hold
from ..io.resources import CDN_DIST
from ..viewable import ChildDict
from .base import (
Expand Down
3 changes: 1 addition & 2 deletions panel/reactive.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,7 @@
resolve_ref, resolve_value,
)

from .io.document import unlocked
from .io.model import hold
from .io.document import hold, unlocked
from .io.notebook import push
from .io.resources import (
CDN_DIST, loading_css, patch_stylesheet, process_raw_css,
Expand Down
13 changes: 10 additions & 3 deletions panel/util/warnings.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,14 +59,21 @@ def deprecated(
remove_version: Version | str,
old: str,
new: str | None = None,
*,
extra: str | None = None,
philippjfr marked this conversation as resolved.
Show resolved Hide resolved
warn_version: Version | str | None = None
) -> None:
from .. import __version__

import panel as pn

current_version = Version(pn.__version__)
current_version = Version(__version__)
base_version = Version(current_version.base_version)

if warn_version:
if isinstance(warn_version, str):
warn_version = Version(warn_version)
if base_version < warn_version:
return

if isinstance(remove_version, str):
remove_version = Version(remove_version)

Expand Down
Loading