Skip to content

Commit 11d9c25

Browse files
Merge pull request #42 from NVIDIA/bhagedorn/fix-unhashable-config-groupby
Fix unhashable (dict/list) config params crashing aggregate_data
2 parents a8e73c2 + d2e49d0 commit 11d9c25

2 files changed

Lines changed: 119 additions & 17 deletions

File tree

‎nsight/transformation.py‎

Lines changed: 37 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -49,20 +49,6 @@ 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
53-
def convert_non_sortable_columns(dframe: pd.DataFrame) -> pd.DataFrame:
54-
for col in dframe.columns:
55-
try:
56-
# Try sorting the column to check if it's sortable.
57-
sorted(dframe[col].dropna())
58-
except (TypeError, ValueError):
59-
# If sorting fails, convert the column to string
60-
dframe[col] = dframe[col].astype(str)
61-
return dframe
62-
63-
# Convert non-sortable columns before grouping
64-
df = convert_non_sortable_columns(df)
65-
6652
# Preserve original order by adding an index column
6753
df = df.reset_index(drop=True)
6854
df["_original_order"] = df.index
@@ -109,12 +95,41 @@ def convert_non_sortable_columns(dframe: pd.DataFrame) -> pd.DataFrame:
10995
)(col),
11096
)
11197

98+
# Group keys must be hashable (pandas factorizes them) and sortable
99+
# (groupby sorts them). Keys that are neither — e.g. dict- or list-valued
100+
# config parameters — are grouped on a temporary stringified key, and the
101+
# original objects are recovered with a "first" aggregation so the output
102+
# preserves them (a dict stays a dict instead of becoming its repr).
103+
def _is_groupable(series: pd.Series) -> bool:
104+
non_null = series.dropna()
105+
try:
106+
set(non_null)
107+
sorted(non_null)
108+
except (TypeError, ValueError):
109+
return False
110+
return True
111+
112+
sortkey_cols: list[str] = []
113+
group_keys: list[str] = []
114+
func_field_keys: list[str] = []
115+
for col in groupby_columns + func_fields:
116+
if _is_groupable(df[col]):
117+
key = col
118+
else:
119+
key = f"{col}__sortkey__"
120+
df[key] = df[col].astype(str)
121+
sortkey_cols.append(key)
122+
named_aggs[col] = (col, "first") # preserve the original objects
123+
group_keys.append(key)
124+
if col in func_fields:
125+
func_field_keys.append(key)
126+
112127
# Ensure Value column is numeric (explode can leave it as object dtype
113128
# even though the underlying values are always numeric from NCU metrics)
114129
df["Value"] = pd.to_numeric(df["Value"])
115130

116131
# Apply aggregation with named aggregation
117-
groupby_df = df.groupby(groupby_columns + func_fields, dropna=False)
132+
groupby_df = df.groupby(group_keys, dropna=False)
118133
agg_df = groupby_df.agg(**named_aggs).reset_index()
119134

120135
# Compute 95% confidence intervals
@@ -149,8 +164,9 @@ def convert_non_sortable_columns(dframe: pd.DataFrame) -> pd.DataFrame:
149164
normalize_against in agg_df["Annotation"].values
150165
), f"Annotation '{normalize_against}' not found in data."
151166

152-
# Columns of normalization dataframe to merge on
153-
merge_on = [*func_fields, "Metric"]
167+
# Columns of normalization dataframe to merge on (use the stringified
168+
# keys for any unhashable config columns so the merge can hash them)
169+
merge_on = [*func_field_keys, "Metric"]
154170

155171
# Create a DataFrame to hold the normalization values
156172
normalization_df = agg_df[agg_df["Annotation"] == normalize_against][
@@ -178,4 +194,8 @@ def convert_non_sortable_columns(dframe: pd.DataFrame) -> pd.DataFrame:
178194
)
179195
)
180196

197+
# Drop the temporary stringified group keys; the original columns remain.
198+
if sortkey_cols:
199+
agg_df = agg_df.drop(columns=sortkey_cols)
200+
181201
return agg_df

‎tests/test_transformation.py‎

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
# SPDX-FileCopyrightText: Copyright (c) 2026 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'". The output must keep the dict as a real dict.
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] == dims
38+
assert isinstance(result["config"].iloc[0], dict)
39+
assert result["config"].iloc[0]["M"] == 512 # usable as a dict, no parsing
40+
assert result["NumRuns"].iloc[0] == 1
41+
assert result["AvgValue"].iloc[0] == 1.0
42+
# The temporary sort key must not leak into the output.
43+
assert not any("sortkey" in c for c in result.columns)
44+
45+
46+
def test_dict_config_independent_of_run_count() -> None:
47+
# Bumping runs only masked the bug (it added rows); every count must work.
48+
dims = {"M": 256, "K": 256, "N": 256}
49+
for runs in (1, 2, 5):
50+
result = transformation.aggregate_data(
51+
_raw_df([dims] * runs), _one_arg, None, False
52+
)
53+
assert len(result) == 1
54+
assert result["NumRuns"].iloc[0] == runs
55+
assert result["config"].iloc[0] == dims
56+
57+
58+
def test_distinct_dict_configs_stay_distinct() -> None:
59+
# Different dicts form different groups, each preserved as a real dict.
60+
d1 = {"M": 512, "K": 512, "N": 512}
61+
d2 = {"M": 1024, "K": 1024, "N": 1024}
62+
result = transformation.aggregate_data(_raw_df([d1, d2]), _one_arg, None, False)
63+
assert len(result) == 2
64+
configs = list(result["config"])
65+
assert all(isinstance(c, dict) for c in configs)
66+
assert d1 in configs and d2 in configs
67+
68+
69+
def test_list_config_single_row_does_not_crash() -> None:
70+
# Lists are unhashable too, and must also be preserved as lists.
71+
shape = [512, 512]
72+
result = transformation.aggregate_data(_raw_df([shape]), _one_arg, None, False)
73+
assert len(result) == 1
74+
assert result["config"].iloc[0] == shape
75+
assert isinstance(result["config"].iloc[0], list)
76+
77+
78+
def test_numeric_config_keeps_native_dtype() -> None:
79+
# Sortable, hashable configs must not be needlessly coerced to strings.
80+
result = transformation.aggregate_data(_raw_df([128]), _one_arg, None, False)
81+
assert result["config"].iloc[0] == 128
82+
assert pd.api.types.is_integer_dtype(result["config"])

0 commit comments

Comments
 (0)