From e862d2d7718fbc4bb668a110fd86ed65f6d184be Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Thu, 7 Nov 2024 18:00:19 +0100 Subject: [PATCH 1/7] Improve and document hold utility --- doc/how_to/performance/hold.md | 53 +++++++++++++++++++++++++++++++++ doc/how_to/performance/index.md | 8 +++++ panel/io/document.py | 45 +++++++++++++++++++++++++++- panel/io/model.py | 25 ++++------------ 4 files changed, 110 insertions(+), 21 deletions(-) create mode 100644 doc/how_to/performance/hold.md diff --git a/doc/how_to/performance/hold.md b/doc/how_to/performance/hold.md new file mode 100644 index 0000000000..53fedf04b7 --- /dev/null +++ b/doc/how_to/performance/hold.md @@ -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. diff --git a/doc/how_to/performance/index.md b/doc/how_to/performance/index.md index 622c462ce5..fdbef1bb59 100644 --- a/doc/how_to/performance/index.md +++ b/doc/how_to/performance/index.md @@ -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} @@ -28,4 +35,5 @@ Discover how to enable throttling to reduce the number of events being processed reuse_sessions throttling +hold ``` diff --git a/panel/io/document.py b/panel/io/document.py index ea03602e23..9b79c0eff1 100644 --- a/panel/io/document.py +++ b/panel/io/document.py @@ -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__) @@ -519,6 +521,47 @@ def unlocked() -> Iterator: 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(): + 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): """ diff --git a/panel/io/model.py b/panel/io/model.py index 322af5748f..1614c7261d 100644 --- a/panel/io/model.py +++ b/panel/io/model.py @@ -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: @@ -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 @@ -192,22 +193,6 @@ 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') + from .document import hold + hold(doc, policy, comm) From 6d99326946a9e3210d8d4d0567c7c91d9e5113f9 Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Thu, 7 Nov 2024 19:11:18 +0100 Subject: [PATCH 2/7] Small tweaks --- panel/io/document.py | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/panel/io/document.py b/panel/io/document.py index 9b79c0eff1..33b890567f 100644 --- a/panel/io/document.py +++ b/panel/io/document.py @@ -433,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) @@ -459,7 +466,7 @@ def unlocked() -> Iterator: monkeypatch_events(curdoc.callbacks._held_events) return - curdoc.hold() + curdoc.hold(policy=policy) try: yield finally: @@ -520,7 +527,6 @@ 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): """ @@ -550,7 +556,9 @@ def hold(doc: Document | None = None, policy: HoldPolicyType = 'combine', comm: doc.unhold() yield else: - with unlocked(): + with unlocked(policy=policy): + if not doc.callbacks.hold_value: + doc.hold(policy) yield finally: if held: @@ -561,7 +569,6 @@ def hold(doc: Document | None = None, policy: HoldPolicyType = 'combine', comm: push(doc, comm) doc.unhold() - @contextmanager def immediate_dispatch(doc: Document | None = None): """ From 0ae934e958b719ac1c37f520b7ed82d4676e580d Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Fri, 8 Nov 2024 00:22:20 +0100 Subject: [PATCH 3/7] Update import --- panel/reactive.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/panel/reactive.py b/panel/reactive.py index 6d1e96a780..7dbc3d2041 100644 --- a/panel/reactive.py +++ b/panel/reactive.py @@ -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, From 00bfad03b4377bcbc168cb8c53f6157278610108 Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Fri, 8 Nov 2024 00:33:22 +0100 Subject: [PATCH 4/7] Update more imports --- panel/layout/base.py | 3 +-- panel/layout/grid.py | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/panel/layout/base.py b/panel/layout/base.py index d58286db4c..a7a639f3bf 100644 --- a/panel/layout/base.py +++ b/panel/layout/base.py @@ -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 diff --git a/panel/layout/grid.py b/panel/layout/grid.py index c594ae1244..2039786be1 100644 --- a/panel/layout/grid.py +++ b/panel/layout/grid.py @@ -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 ( From 6aae9a7ed2b2552cf0dd0c73132bf7066f5ef2f5 Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Fri, 8 Nov 2024 11:13:32 +0100 Subject: [PATCH 5/7] Wait to warn until 1.6.0 --- panel/io/model.py | 7 +++++-- panel/util/warnings.py | 11 +++++++++-- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/panel/io/model.py b/panel/io/model.py index 1614c7261d..f7a02874b9 100644 --- a/panel/io/model.py +++ b/panel/io/model.py @@ -193,6 +193,9 @@ def hold(doc: Document | None = None, policy: HoldPolicyType = 'combine', comm: comm: Comm The Comm to dispatch events on when the context manager exits. """ - deprecated('1.7.0', 'panel.io.model.hold', 'panel.io.document.hold') + deprecated( + '1.7.0', 'panel.io.model.hold', 'panel.io.document.hold', + warn_version='1.6.0' + ) from .document import hold - hold(doc, policy, comm) + yield hold(doc, policy, comm) diff --git a/panel/util/warnings.py b/panel/util/warnings.py index 9c6938b1df..f5c6060e6f 100644 --- a/panel/util/warnings.py +++ b/panel/util/warnings.py @@ -60,13 +60,20 @@ def deprecated( old: str, new: str | None = None, extra: str | None = None, + warn_version: Version | str | None = None ) -> None: - import panel as pn + from .. import __version__ - 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) From 07a846b85ab5c3750b966731703ee5e617b109ec Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Fri, 8 Nov 2024 11:45:04 +0100 Subject: [PATCH 6/7] Update panel/util/warnings.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Simon Høxbro Hansen --- panel/util/warnings.py | 1 + 1 file changed, 1 insertion(+) diff --git a/panel/util/warnings.py b/panel/util/warnings.py index f5c6060e6f..df90745421 100644 --- a/panel/util/warnings.py +++ b/panel/util/warnings.py @@ -59,6 +59,7 @@ def deprecated( remove_version: Version | str, old: str, new: str | None = None, + * extra: str | None = None, warn_version: Version | str | None = None ) -> None: From 665787ec36782dffef4483458068307e822ab1a0 Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Fri, 8 Nov 2024 11:56:49 +0100 Subject: [PATCH 7/7] Fix lint --- panel/util/warnings.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/panel/util/warnings.py b/panel/util/warnings.py index df90745421..5284046715 100644 --- a/panel/util/warnings.py +++ b/panel/util/warnings.py @@ -59,11 +59,10 @@ def deprecated( remove_version: Version | str, old: str, new: str | None = None, - * + *, extra: str | None = None, warn_version: Version | str | None = None ) -> None: - from .. import __version__ current_version = Version(__version__)