Skip to content

Commit aa93387

Browse files
Fix aggregate crashing on operations that report null metrics
An operation that produced no valid samples reports its metric fields as null. The geonames workload does this for `optimize`, which comes back with error_rate 1.0 and a fully null throughput. Aggregating any test run that contains such an operation fails: [ERROR] Cannot aggregate. '<' not supported between instances of 'NoneType' and 'NoneType'. calculate_weighted_average reached min()/max() with None values, and `value.get(metric_field, 0)` did not help because the key is present with a null value rather than absent. Null values are now left out of the min, the max, and the weighted mean instead of being coerced to 0, and the result is None when no test run contributed a value, so an unmeasured metric stays distinct from a measured zero. calculate_rsd has the same problem on a second, independent code path and returns "NA" when nothing was measured. The weighted-mean arithmetic moves into a helper because the divisor now depends on which runs contributed, so it can no longer be a single total computed up front. Signed-off-by: Serhiy Bzhezytskyy <me@serhiy-bzhezytskyy.com>
1 parent 212e1e3 commit aa93387

2 files changed

Lines changed: 69 additions & 8 deletions

File tree

osbenchmark/aggregator.py

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -213,7 +213,6 @@ def calculate_weighted_average(self, task_metrics: Dict[str, List[Any]], task_na
213213
# Get iterations for each test run
214214
iterations_per_run = [self.accumulated_iterations[test_id][task_name]
215215
for test_id in self.test_runs.keys()]
216-
total_iterations = sum(iterations_per_run)
217216

218217
for metric, values in task_metrics.items():
219218
if isinstance(values[0], dict):
@@ -222,23 +221,44 @@ def calculate_weighted_average(self, task_metrics: Dict[str, List[Any]], task_na
222221
if metric_field == 'unit':
223222
weighted_metrics[metric][metric_field] = values[0][metric_field]
224223
elif metric_field == 'min':
225-
weighted_metrics[metric]['overall_min'] = min(value.get(metric_field, 0) for value in values)
224+
item_values = [value.get(metric_field) for value in values]
225+
weighted_metrics[metric]['overall_min'] = min(
226+
(value for value in item_values if value is not None), default=None)
226227
elif metric_field == 'max':
227-
weighted_metrics[metric]['overall_max'] = max(value.get(metric_field, 0) for value in values)
228+
item_values = [value.get(metric_field) for value in values]
229+
weighted_metrics[metric]['overall_max'] = max(
230+
(value for value in item_values if value is not None), default=None)
228231
else:
229232
# for items like median or containing percentile values
230-
item_values = [value.get(metric_field, 0) for value in values]
231-
weighted_sum = sum(value * iterations for value, iterations in zip(item_values, iterations_per_run))
232-
weighted_metrics[metric][metric_field] = weighted_sum / total_iterations
233+
item_values = [value.get(metric_field) for value in values]
234+
weighted_metrics[metric][metric_field] = self.weighted_mean(item_values, iterations_per_run)
233235
else:
234-
weighted_sum = sum(value * iterations for value, iterations in zip(values, iterations_per_run))
235-
weighted_metrics[metric] = weighted_sum / total_iterations
236+
weighted_metrics[metric] = self.weighted_mean(values, iterations_per_run)
236237

237238
return weighted_metrics
238239

240+
@staticmethod
241+
def weighted_mean(values: List[Any], iterations_per_run: List[int]) -> Any:
242+
"""
243+
Weights each test run's value by that run's iteration count.
244+
Operations that produced no valid samples report their metrics as None; those are left out
245+
of both the sum and the divisor, and the result is None when no run contributed a value,
246+
so that "not measured" stays distinct from a measured zero
247+
"""
248+
contributions = [(value, iterations) for value, iterations in zip(values, iterations_per_run)
249+
if value is not None]
250+
total_iterations = sum(iterations for _, iterations in contributions)
251+
if not total_iterations:
252+
return None
253+
return sum(value * iterations for value, iterations in contributions) / total_iterations
254+
239255
def calculate_rsd(self, values: List[Union[int, float]], metric_name: str) -> Union[float, str]:
240256
if not values:
241257
raise ValueError(f"Cannot calculate RSD for metric '{metric_name}': empty list of values")
258+
# operations that produced no valid samples report None, which cannot contribute to a deviation
259+
values = [value for value in values if value is not None]
260+
if not values:
261+
return "NA" # no test run measured this metric
242262
if len(values) == 1:
243263
return "NA" # RSD is not applicable for a single value
244264
mean = statistics.mean(values)

tests/aggregator_test.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,47 @@ def test_update_config_object_reads_attributes_that_test_runs_actually_have(aggr
136136
aggregator.config.add.assert_any_call(config.Scope.applicationOverride, "builder",
137137
"cluster_config.params", {"heap_size": "6g"})
138138

139+
def test_calculate_weighted_average_with_null_metric_fields(aggregator):
140+
# An operation that produced no valid samples reports null metric fields, e.g. `optimize`
141+
# in the geonames workload, which reports error_rate 1.0 and a fully null throughput.
142+
task_metrics = {
143+
"throughput": [
144+
{"min": None, "mean": None, "median": None, "max": None, "unit": "ops/s"},
145+
{"min": None, "mean": None, "median": None, "max": None, "unit": "ops/s"}
146+
]
147+
}
148+
aggregator.accumulated_iterations = {"test1": {"op1": 1}, "test2": {"op1": 1}}
149+
aggregator.test_runs = {"test1": Mock(), "test2": Mock()}
150+
151+
result = aggregator.calculate_weighted_average(task_metrics, "op1")
152+
153+
# null is carried through rather than replaced by 0, which would read as a real
154+
# measurement of zero throughput
155+
assert result["throughput"]["overall_min"] is None
156+
assert result["throughput"]["overall_max"] is None
157+
assert result["throughput"]["mean"] is None
158+
assert result["throughput"]["median"] is None
159+
assert result["throughput"]["unit"] == "ops/s"
160+
161+
def test_calculate_weighted_average_with_partially_null_metric_fields(aggregator):
162+
# A metric that has samples in one test run but not another: the valid values are
163+
# still aggregated, weighted only by the runs that contributed them.
164+
task_metrics = {
165+
"throughput": [
166+
{"min": 10, "mean": 20, "median": 20, "max": 30, "unit": "ops/s"},
167+
{"min": None, "mean": None, "median": None, "max": None, "unit": "ops/s"}
168+
]
169+
}
170+
aggregator.accumulated_iterations = {"test1": {"op1": 2}, "test2": {"op1": 3}}
171+
aggregator.test_runs = {"test1": Mock(), "test2": Mock()}
172+
173+
result = aggregator.calculate_weighted_average(task_metrics, "op1")
174+
175+
assert result["throughput"]["overall_min"] == 10
176+
assert result["throughput"]["overall_max"] == 30
177+
assert result["throughput"]["mean"] == 20
178+
assert result["throughput"]["unit"] == "ops/s"
179+
139180
def test_calculate_rsd(aggregator):
140181
values = [1, 2, 3, 4, 5]
141182
rsd = aggregator.calculate_rsd(values, "test_metric")

0 commit comments

Comments
 (0)