Skip to content

Commit c7fd791

Browse files
Fix unhashable (dict/list) config params crashing aggregate_data
A single-row config column (e.g. a dict config profiled with runs=1) crashed in groupby().agg() with "unhashable type: 'dict'". convert_non_sortable_columns only stringified a column when sorted() raised, but sorted() performs no comparison on a <=1-element column, so the raw dict/list survived into the groupby keys and pandas factorize failed. Probe hashability with set() too, independent of row count. Increasing `runs` previously "fixed" it only because the extra rows made sorted() compare two dicts and raise, triggering the stringify path. Add GPU-free unit tests for aggregate_data covering single-row dict/list configs, run-count independence, distinct-group preservation, and native-dtype preservation for scalar configs. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent a8e73c2 commit c7fd791

2 files changed

Lines changed: 80 additions & 4 deletions

File tree

‎nsight/transformation.py‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -49,14 +49,17 @@ def aggregate_data(
4949
# Note: When num_args=0, we need an empty list (not all columns via [-0:])
5050
func_fields = df.columns[-num_args:].tolist() if num_args > 0 else []
5151

52-
# Function to convert non-sortable columns to tuples or strings
52+
# Stringify columns that can't be groupby keys. Keys must be hashable
53+
# (pandas factorizes them) and sortable (groupby sorts them); sorted() alone
54+
# misses unhashable values in <=1-row columns since no comparison runs, so
55+
# probe hashability with set() too.
5356
def convert_non_sortable_columns(dframe: pd.DataFrame) -> pd.DataFrame:
5457
for col in dframe.columns:
58+
non_null = dframe[col].dropna()
5559
try:
56-
# Try sorting the column to check if it's sortable.
57-
sorted(dframe[col].dropna())
60+
set(non_null)
61+
sorted(non_null)
5862
except (TypeError, ValueError):
59-
# If sorting fails, convert the column to string
6063
dframe[col] = dframe[col].astype(str)
6164
return dframe
6265

‎tests/test_transformation.py‎

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
# SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
"""Unit tests for nsight.transformation.aggregate_data (no GPU required)."""
5+
6+
from typing import Any
7+
8+
import pandas as pd
9+
10+
from nsight import transformation
11+
12+
13+
def _one_arg(config: Any) -> None:
14+
"""A function with a single config parameter (num_args == 1)."""
15+
return None
16+
17+
18+
def _raw_df(config_values: list[Any], *, param_name: str = "config") -> pd.DataFrame:
19+
"""Synthetic raw frame; the config column is last, as aggregate_data expects."""
20+
n = len(config_values)
21+
data: dict[str, Any] = {
22+
"Annotation": ["matmul"] * n,
23+
"Metric": ["gpu__time_duration.sum"] * n,
24+
"Value": [float(i + 1) for i in range(n)],
25+
"Kernel": ["ampere_sgemm_128x128"] * n,
26+
param_name: config_values,
27+
}
28+
return pd.DataFrame(data)
29+
30+
31+
def test_dict_config_single_row_does_not_crash() -> None:
32+
# Regression: a single dict-config row used to crash groupby().agg() with
33+
# "unhashable type: 'dict'" because the dict was never stringified.
34+
dims = {"M": 512, "K": 512, "N": 512}
35+
result = transformation.aggregate_data(_raw_df([dims]), _one_arg, None, False)
36+
assert len(result) == 1
37+
assert result["config"].iloc[0] == str(dims)
38+
assert result["NumRuns"].iloc[0] == 1
39+
assert result["AvgValue"].iloc[0] == 1.0
40+
41+
42+
def test_dict_config_independent_of_run_count() -> None:
43+
# Bumping runs only masked the bug (it added rows); every count must work.
44+
dims = {"M": 256, "K": 256, "N": 256}
45+
for runs in (1, 2, 5):
46+
result = transformation.aggregate_data(
47+
_raw_df([dims] * runs), _one_arg, None, False
48+
)
49+
assert len(result) == 1
50+
assert result["NumRuns"].iloc[0] == runs
51+
52+
53+
def test_distinct_dict_configs_stay_distinct() -> None:
54+
# Stringification is per-row, so different dicts remain different groups.
55+
d1, d2 = {"M": 512}, {"M": 1024}
56+
result = transformation.aggregate_data(_raw_df([d1, d2]), _one_arg, None, False)
57+
assert len(result) == 2
58+
assert set(result["config"]) == {str(d1), str(d2)}
59+
60+
61+
def test_list_config_single_row_does_not_crash() -> None:
62+
# Lists are unhashable too.
63+
shape = [512, 512]
64+
result = transformation.aggregate_data(_raw_df([shape]), _one_arg, None, False)
65+
assert len(result) == 1
66+
assert result["config"].iloc[0] == str(shape)
67+
68+
69+
def test_numeric_config_keeps_native_dtype() -> None:
70+
# Sortable, hashable configs must not be needlessly coerced to strings.
71+
result = transformation.aggregate_data(_raw_df([128]), _one_arg, None, False)
72+
assert result["config"].iloc[0] == 128
73+
assert pd.api.types.is_integer_dtype(result["config"])

0 commit comments

Comments
 (0)