Skip to content

Speed up the Parameter get/set hot paths - #1167

Open
philippjfr wants to merge 5 commits into
mainfrom
perf-hot-paths
Open

Speed up the Parameter get/set hot paths#1167
philippjfr wants to merge 5 commits into
mainfrom
perf-hot-paths

Conversation

@philippjfr

Copy link
Copy Markdown
Member

Summary

Parameter.__get__ and Parameter.__set__ are the hottest code in param: every
attribute read and write on a Parameterized instance goes through them, and
downstream libraries like Panel do that millions of times in a session. This PR
adds asv coverage for those paths (there was none), then removes two sources of
redundant work that the coverage exposed.

Reads of a Number parameter are now roughly 2x faster. Reads of other
parameter types improve by 17-38%, and writes by 3-14%. No behaviour changes, no
API changes, no new slots, and the full test suite passes unchanged
(1514 passed, 4 skipped, 2 xfailed).

Changes

bench: Add asv benchmarks for parameter value get/set hot paths

The existing suite covered class creation, .param access, depends and watcher
triggering, but had no benchmark for plain parameter value get/set. Adds four
suites: ParameterizedGetValueSuite and ParameterizedSetValueSuite
(parameterized over Parameter, Number, String, Boolean, at both class and
instance level), ParameterizedFirstSetValueSuite (the one-off cost of the first
set, which triggers the per-instance Parameter copy), and
ParameterUnboundSlotSuite (slot reads on an unbound Parameter, with and without
_slot_defaults fallback).

The get/set suites warm up in setup so they measure the steady state rather
than first-touch instantiation.

perf: Avoid redundant Parameter slot reads in __get__ and __set__

Slot reads on a Parameter are not free. Parameter.__getattribute__ is
overridden (parameterized.py:1888) to resolve Undefined slots against
_slot_defaults, which costs about 55ns per read instead of the ~13ns an
unoverridden attribute read would cost, because a Python-level
__getattribute__ defeats CPython's adaptive LOAD_ATTR specialization.

__get__ read self.name twice and __set__ read it five times. Both now bind
it to a local once. __set__ also read self.default eagerly even on the common
path where a stored value already exists, and re-read obj._param__private
repeatedly; the default read now happens only in the KeyError branch where
there is no stored value, and _param__private is bound to a local.

perf: Resolve Number value and dynamism in a single pass

Number.__get__ called super().__get__() to resolve the value, then
_value_is_dynamic() to decide whether to re-validate, and that second call
walked the entire descriptor chain a second time. There was already a maintainer
TODO on the method saying as much.

Adds Dynamic._resolve_dynamic(), which returns (value, is_dynamic) from a
single traversal. Dynamic.__get__ is now a thin wrapper over it, and
Number.__get__ uses it directly. The dynamism probe also checks callable(gen)
before hasattr(gen, '_Dynamic_last'), since generators are always callable and
the cheap check short-circuits the common static-value case.

_value_is_dynamic is untouched and still used by its other callers.

Benchmarks

asv on macOS / arm64, each commit built and run in its own isolated
environment. Baseline is the commit that adds the benchmarks.

Reads:

Benchmark Before After Ratio
GetValue.time_instance('Number') 688±50ns 334±20ns 0.49
GetValue.time_class('Number') 709±20ns 417±50ns 0.59
GetValue.time_instance('Boolean') 271±20ns 167±20ns ~0.62
GetValue.time_instance('Parameter') 250±30ns 208±20ns ~0.83
GetValue.time_instance('String') 250±20ns 209±40ns ~0.83
UnboundSlot.time_undefined_slot 250±20ns 208±20ns ~0.83

Writes:

Benchmark Before After Ratio
SetValue.time_instance('Parameter') 1.52±0.04μs 1.31±0.04μs ~0.86
SetValue.time_instance('String') 1.58±0.1μs 1.38±0.02μs ~0.87
SetValue.time_instance('Boolean') 1.42±0.02μs 1.35±0.04μs 0.96
SetValue.time_instance('Number') 2.02±0.06μs 1.96±0.04μs 0.97
SetValue.time_class('Number') 3.75±0.1μs 3.58±0.06μs 0.96
WatcherSuite.time_trigger 9.73±0.2μs 9.25±0.2μs 0.95

Attribution: the slot-read commit is the broad but shallow win (every read
0.77-0.83, Number reads 688 to 563ns, set('Parameter') 1.52 to 1.31μs). The
_resolve_dynamic commit is where the Number halving comes from (563 to 334ns
instance, 688 to 417ns class).

No regressions. ParameterizedParamAccessSuite.time_class reports 125 to 166ns
in the second step, but that operation measures 28.6ns in-process, well below
asv's ~42ns timer granularity on this machine, and it was flat across the first
step. That granularity is also why so many sub-microsecond figures land on
exactly 83/125/167/208/250ns; the Number read result is the one sub-microsecond
number comfortably outside it, and it is corroborated in-process at 671 to 206ns.

AI Disclosure

PR created with heavy help from Claude Opus 5.

philippjfr and others added 5 commits August 12, 2026 15:27
The existing suite covered class creation, .param access, depends and
watcher triggering, but not the plain value get/set descriptor paths,
which are the hottest operations in param.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Parameter.__getattribute__ is a Python-level override, so every slot read
on a Parameter is materially more expensive than a plain attribute read
(it also defeats CPython's adaptive LOAD_ATTR specialization). __get__
read 'name' twice and __set__ read it five times.

Bind 'name' and 'readonly' to locals, hoist the repeated _param__private
descriptor lookups, and only read the 'default' slot when there is no
stored value to fall back from.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Number.__get__ called super().__get__() to resolve the value and then
_value_is_dynamic(), which walked the whole descriptor chain a second
time just to test the stored value for a generator. This addresses the
existing TODO on that method.

Add Dynamic._resolve_dynamic(), which returns (value, is_dynamic) from a
single fetch, and use it from both Dynamic.__get__ and Number.__get__.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@codecov

codecov Bot commented Aug 12, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 97.61905% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 86.75%. Comparing base (bd43b28) to head (c9d028e).

Files with missing lines Patch % Lines
param/parameterized.py 97.05% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #1167      +/-   ##
==========================================
+ Coverage   86.73%   86.75%   +0.02%     
==========================================
  Files           9        9              
  Lines        5321     5330       +9     
==========================================
+ Hits         4615     4624       +9     
  Misses        706      706              

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@philippjfr

Copy link
Copy Markdown
Member Author

Full asv benchmark output comparing before (the commit adding more benchmarks) and the final commit:

| Change   | Before [bc93512a] <perf-hot-paths~3>   | After [3bee86e5] <perf-hot-paths>   | Ratio   | Benchmark (Parameter)                                                                      |
|----------|----------------------------------------|-------------------------------------|---------|--------------------------------------------------------------------------------------------|
|          | 22.0±0.6ms                             | 22.2±0.6ms                          | 1.01    | benchmarks.ImportSuite.timeraw_import_param                                                |
|          | 826±7ns                                | 835±20ns                            | 1.01    | benchmarks.ParameterSuite.time_instantiation                                               |
|          | 104±20ns                               | 83.4±0.2ns                          | ~0.80   | benchmarks.ParameterUnboundSlotSuite.time_concrete_slot                                    |
|          | 208±0.5ns                              | 208±20ns                            | 1.00    | benchmarks.ParameterUnboundSlotSuite.time_undefined_slot                                   |
|          | 7.81±2μs                               | 8.31±1μs                            | 1.06    | benchmarks.ParameterizedDependsInstantiateSuite.time_declarative_10_parameters_separate_cb |
|          | 8.46±0.9μs                             | 8.63±0.5μs                          | 1.02    | benchmarks.ParameterizedDependsInstantiateSuite.time_declarative_10_parameters_shared_cb   |
|          | 7.94±0.4μs                             | 8.65±0.9μs                          | 1.09    | benchmarks.ParameterizedDependsInstantiateSuite.time_declarative_1_parameter               |
|          | 144±3μs                                | 146±5μs                             | 1.01    | benchmarks.ParameterizedDependsInstantiateSuite.time_watch_10_parameters_separate_cb       |
|          | 114±4μs                                | 116±2μs                             | 1.01    | benchmarks.ParameterizedDependsInstantiateSuite.time_watch_10_parameters_shared_cb         |
|          | 33.2±2μs                               | 31.9±1μs                            | 0.96    | benchmarks.ParameterizedDependsInstantiateSuite.time_watch_1_parameter                     |
|          | 302±4μs                                | 305±2μs                             | 1.01    | benchmarks.ParameterizedDependsSuite.time_declarative_10_parameters_separate_cb            |
|          | 271±3μs                                | 277±5μs                             | 1.02    | benchmarks.ParameterizedDependsSuite.time_declarative_10_parameters_shared_cb              |
|          | 45.9±1μs                               | 44.6±0.2μs                          | 0.97    | benchmarks.ParameterizedDependsSuite.time_declarative_1_parameter                          |
| -        | 352±20μs                               | 308±4μs                             | 0.87    | benchmarks.ParameterizedDependsSuite.time_watch_10_parameters_separate_cb                  |
|          | 282±10μs                               | 273±2μs                             | 0.97    | benchmarks.ParameterizedDependsSuite.time_watch_10_parameters_shared_cb                    |
|          | 47.0±0.4μs                             | 45.8±0.3μs                          | 0.97    | benchmarks.ParameterizedDependsSuite.time_watch_1_parameter                                |
|          | 24.2±0.8μs                             | 22.7±0.6μs                          | 0.94    | benchmarks.ParameterizedFirstSetValueSuite.time_instantiate_and_first_set                  |
|          | 250±0ns                                | 250±20ns                            | 1.00    | benchmarks.ParameterizedGetValueSuite.time_class('Boolean')                                |
| -        | 708±0.2ns                              | 437±20ns                            | 0.62    | benchmarks.ParameterizedGetValueSuite.time_class('Number')                                 |
|          | 250±20ns                               | 250±0ns                             | 1.00    | benchmarks.ParameterizedGetValueSuite.time_class('Parameter')                              |
|          | 271±40ns                               | 291±20ns                            | 1.08    | benchmarks.ParameterizedGetValueSuite.time_class('String')                                 |
|          | 250±20ns                               | 250±20ns                            | 1.00    | benchmarks.ParameterizedGetValueSuite.time_instance('Boolean')                             |
| -        | 729±20ns                               | 375±30ns                            | 0.51    | benchmarks.ParameterizedGetValueSuite.time_instance('Number')                              |
|          | 250±20ns                               | 209±20ns                            | ~0.84   | benchmarks.ParameterizedGetValueSuite.time_instance('Parameter')                           |
|          | 270±20ns                               | 250±40ns                            | 0.92    | benchmarks.ParameterizedGetValueSuite.time_instance('String')                              |
|          | 26.1±2μs                               | 28.2±2μs                            | 1.08    | benchmarks.ParameterizedInstantiateSuite.time_100_parameters                               |
|          | 11.6±0.7μs                             | 13.6±4μs                            | ~1.17   | benchmarks.ParameterizedInstantiateSuite.time_10_parameters                                |
|          | 10.8±2μs                               | 10.4±2μs                            | 0.97    | benchmarks.ParameterizedInstantiateSuite.time_1_parameters                                 |
|          | 83.1±20ns                              | 125±20ns                            | ~1.50   | benchmarks.ParameterizedParamAccessSuite.time_class                                        |
|          | 229±20ns                               | 208±0.6ns                           | ~0.91   | benchmarks.ParameterizedParamAccessSuite.time_instance                                     |
|          | 291±30ns                               | 250±20ns                            | ~0.86   | benchmarks.ParameterizedParamContainsSuite.time_class                                      |
|          | 416±20ns                               | 375±20ns                            | ~0.90   | benchmarks.ParameterizedParamContainsSuite.time_instance                                   |
|          | 2.27±0.1μs                             | 2.25±0.04μs                         | 0.99    | benchmarks.ParameterizedSetValueSuite.time_class('Boolean')                                |
|          | 3.63±0.08μs                            | 3.77±0.1μs                          | 1.04    | benchmarks.ParameterizedSetValueSuite.time_class('Number')                                 |
|          | 2.21±0.04μs                            | 2.08±0.08μs                         | 0.94    | benchmarks.ParameterizedSetValueSuite.time_class('Parameter')                              |
|          | 2.17±0.02μs                            | 2.19±0.09μs                         | 1.01    | benchmarks.ParameterizedSetValueSuite.time_class('String')                                 |
|          | 1.46±0.07μs                            | 1.40±0.07μs                         | 0.96    | benchmarks.ParameterizedSetValueSuite.time_instance('Boolean')                             |
|          | 2.10±0.07μs                            | 1.98±0.04μs                         | 0.94    | benchmarks.ParameterizedSetValueSuite.time_instance('Number')                              |
|          | 1.44±0.04μs                            | 1.42±0.05μs                         | 0.99    | benchmarks.ParameterizedSetValueSuite.time_instance('Parameter')                           |
|          | 1.60±0.1μs                             | 1.48±0.06μs                         | 0.92    | benchmarks.ParameterizedSetValueSuite.time_instance('String')                              |
|          | 2.10±0.04μs                            | 2.08±0.1μs                          | 0.99    | benchmarks.ParameterizedSetattrSuite.time_class                                            |
|          | 12.9±0.3μs                             | 12.8±0.6μs                          | 1.00    | benchmarks.ParameterizedSetattrSuite.time_instance                                         |
|          | 11.9±0.2μs                             | 12.0±0.3μs                          | 1.01    | benchmarks.ParameterizedSuite.time_class_bare                                              |
|          | 2.29±0.01ms                            | 2.35±0.03ms                         | 1.02    | benchmarks.ParameterizedSuite.time_class_with_100_parameter                                |
|          | 246±2μs                                | 244±20μs                            | 0.99    | benchmarks.ParameterizedSuite.time_class_with_10_parameter                                 |
|          | 38.0±7μs                               | 37.4±1μs                            | 0.98    | benchmarks.ParameterizedSuite.time_class_with_1_parameter                                  |
|          | 9.50±0.4μs                             | 10.4±0.4μs                          | 1.10    | benchmarks.WatcherSuite.time_trigger                                                       |

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant