From 28c7c52169926fb5f3c4ece1cd70b6ef9eb8a33c Mon Sep 17 00:00:00 2001 From: Ernest Provo Date: Sun, 22 Feb 2026 09:39:49 -0500 Subject: [PATCH 1/4] Fix Total CPU % on /workers tab to normalize by total nthreads The WorkerTable's Total CPU % divided by len(workers) instead of sum(nthreads), producing incorrect values when workers have multiple threads (e.g., 400% instead of 50%). This aligns the "cpu" column with the already-correct "cpu_fraction" column, matching the same fix pattern as PR #3897 for memory_percent. Closes #8490 --- distributed/dashboard/components/scheduler.py | 2 +- distributed/dashboard/tests/test_scheduler_bokeh.py | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/distributed/dashboard/components/scheduler.py b/distributed/dashboard/components/scheduler.py index 859c55c57a..d04793d530 100644 --- a/distributed/dashboard/components/scheduler.py +++ b/distributed/dashboard/components/scheduler.py @@ -4308,7 +4308,7 @@ def update(self): total_data = ( sum(ws.metrics["cpu"] for ws in self.scheduler.workers.values()) / 100 - / len(self.scheduler.workers.values()) + / sum(ws.nthreads for ws in self.scheduler.workers.values()) ) elif name == "cpu_fraction": total_data = ( diff --git a/distributed/dashboard/tests/test_scheduler_bokeh.py b/distributed/dashboard/tests/test_scheduler_bokeh.py index bf2f843028..83381d3ec9 100644 --- a/distributed/dashboard/tests/test_scheduler_bokeh.py +++ b/distributed/dashboard/tests/test_scheduler_bokeh.py @@ -564,6 +564,14 @@ async def test_WorkerTable(c, s, a, b): assert all(nthreads) assert nthreads[0] == nthreads[1] + nthreads[2] + # Total CPU should be normalized by sum(nthreads), not len(workers) + cpu = wt.source.data["cpu"] + total_nthreads = sum(ws.nthreads for ws in s.workers.values()) + expected_cpu_total = ( + sum(ws.metrics["cpu"] for ws in s.workers.values()) / 100 / total_nthreads + ) + assert cpu[0] == expected_cpu_total + @gen_cluster(client=True) async def test_WorkerTable_custom_metrics(c, s, a, b): From 1fe4af22faaa81c8193aaff343fd6f6c0a89afb7 Mon Sep 17 00:00:00 2001 From: Ernest Provo Date: Mon, 23 Feb 2026 21:58:35 -0500 Subject: [PATCH 2/4] Show total CPU as raw core count instead of percentage Per review feedback: the Total row now displays a raw core count (e.g. 3.5 = 3.5 cores busy) while per-worker rows keep showing the familiar 0-400% style. Uses HTMLTemplateFormatter with a hidden _is_total column for conditional rendering in the same Bokeh DataTable column. --- distributed/dashboard/components/scheduler.py | 11 +++++++++-- distributed/dashboard/tests/test_scheduler_bokeh.py | 13 ++++++++----- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/distributed/dashboard/components/scheduler.py b/distributed/dashboard/components/scheduler.py index d04793d530..0e3760e4e5 100644 --- a/distributed/dashboard/components/scheduler.py +++ b/distributed/dashboard/components/scheduler.py @@ -4076,6 +4076,7 @@ def __init__(self, scheduler, **kwargs): "host_disk_io.read_bps", "host_disk_io.write_bps", "cpu_fraction", + "_is_total", ] workers = self.scheduler.workers.values() self.extra_names = sorted( @@ -4128,7 +4129,10 @@ def __init__(self, scheduler, **kwargs): } formatters = { - "cpu": NumberFormatter(format="0 %"), + "cpu": HTMLTemplateFormatter( + template='<% if (_is_total) { %><%= (value).toFixed(1) %>' + '<% } else { %><%= Math.round(value * 100) %> %<% } %>' + ), "memory_percent": NumberFormatter(format="0.0 %"), "memory": NumberFormatter(format="0.0 b"), "memory_limit": NumberFormatter(format="0.0 b"), @@ -4281,11 +4285,15 @@ def update(self): data["cpu"][-1] = ws.metrics["cpu"] / 100.0 data["cpu_fraction"][-1] = ws.metrics["cpu"] / 100.0 / ws.nthreads data["nthreads"][-1] = ws.nthreads + data["_is_total"][-1] = False for name in self.names + self.extra_names: if name == "name": data[name].insert(0, f"Total ({len(data[name])})") continue + if name == "_is_total": + data[name].insert(0, True) + continue try: if len(self.scheduler.workers) == 0: total_data = None @@ -4308,7 +4316,6 @@ def update(self): total_data = ( sum(ws.metrics["cpu"] for ws in self.scheduler.workers.values()) / 100 - / sum(ws.nthreads for ws in self.scheduler.workers.values()) ) elif name == "cpu_fraction": total_data = ( diff --git a/distributed/dashboard/tests/test_scheduler_bokeh.py b/distributed/dashboard/tests/test_scheduler_bokeh.py index 83381d3ec9..2836e4fa75 100644 --- a/distributed/dashboard/tests/test_scheduler_bokeh.py +++ b/distributed/dashboard/tests/test_scheduler_bokeh.py @@ -564,14 +564,17 @@ async def test_WorkerTable(c, s, a, b): assert all(nthreads) assert nthreads[0] == nthreads[1] + nthreads[2] - # Total CPU should be normalized by sum(nthreads), not len(workers) + # Total CPU should show raw core count (sum of all worker CPU / 100) cpu = wt.source.data["cpu"] - total_nthreads = sum(ws.nthreads for ws in s.workers.values()) - expected_cpu_total = ( - sum(ws.metrics["cpu"] for ws in s.workers.values()) / 100 / total_nthreads - ) + expected_cpu_total = sum(ws.metrics["cpu"] for ws in s.workers.values()) / 100 assert cpu[0] == expected_cpu_total + # _is_total flag should be set correctly + is_total = wt.source.data["_is_total"] + assert is_total[0] is True + assert is_total[1] is False + assert is_total[2] is False + @gen_cluster(client=True) async def test_WorkerTable_custom_metrics(c, s, a, b): From 5ac5b2644fa3663e3b2abe1856c1a4897466419c Mon Sep 17 00:00:00 2001 From: Ernest Provo Date: Thu, 26 Feb 2026 21:20:43 -0500 Subject: [PATCH 3/4] fix black formatting: use double quotes in HTMLTemplateFormatter template --- distributed/dashboard/components/scheduler.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/distributed/dashboard/components/scheduler.py b/distributed/dashboard/components/scheduler.py index 0e3760e4e5..1edf31f350 100644 --- a/distributed/dashboard/components/scheduler.py +++ b/distributed/dashboard/components/scheduler.py @@ -4130,8 +4130,8 @@ def __init__(self, scheduler, **kwargs): formatters = { "cpu": HTMLTemplateFormatter( - template='<% if (_is_total) { %><%= (value).toFixed(1) %>' - '<% } else { %><%= Math.round(value * 100) %> %<% } %>' + template="<% if (_is_total) { %><%= (value).toFixed(1) %>" + "<% } else { %><%= Math.round(value * 100) %> %<% } %>" ), "memory_percent": NumberFormatter(format="0.0 %"), "memory": NumberFormatter(format="0.0 b"), From 5fcaa6c063ac17dbc9a9d0178ada5d00cf375d36 Mon Sep 17 00:00:00 2001 From: Guido Imperiale Date: Mon, 2 Mar 2026 12:19:31 +0000 Subject: [PATCH 4/4] Apply suggestions from code review --- distributed/dashboard/components/scheduler.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/distributed/dashboard/components/scheduler.py b/distributed/dashboard/components/scheduler.py index 1edf31f350..31224d5c12 100644 --- a/distributed/dashboard/components/scheduler.py +++ b/distributed/dashboard/components/scheduler.py @@ -4129,6 +4129,10 @@ def __init__(self, scheduler, **kwargs): } formatters = { + # Use a pure number (0 to nthreads) on the total line and a % + # (e.g. 0 to 400% for 4 threads per worker ) on the individual workers. + # It would be very confusing to read e.g. 9000% on the total, whereas + # seeing that ~90 CPU equivalents are being fully used is more meaningful. "cpu": HTMLTemplateFormatter( template="<% if (_is_total) { %><%= (value).toFixed(1) %>" "<% } else { %><%= Math.round(value * 100) %> %<% } %>"