4040from .helpers import close_and_unlink
4141
4242
43+ def resolve_name (node , strings ):
44+ """Resolve a flamegraph node's name from the string table."""
45+ idx = node .get ("name" , 0 )
46+ if isinstance (idx , int ) and 0 <= idx < len (strings ):
47+ return strings [idx ]
48+ return str (idx )
49+
50+
51+ def find_child_by_name (children , strings , substr ):
52+ """Find a child node whose resolved name contains substr."""
53+ for child in children :
54+ if substr in resolve_name (child , strings ):
55+ return child
56+ return None
57+
58+
4359class TestSampleProfilerComponents (unittest .TestCase ):
4460 """Unit tests for individual profiler components."""
4561
@@ -397,13 +413,7 @@ def test_flamegraph_collector_basic(self):
397413 data = collector ._convert_to_flamegraph_format ()
398414 # With string table, name is now an index - resolve it using the strings array
399415 strings = data .get ("strings" , [])
400- name_index = data .get ("name" , 0 )
401- resolved_name = (
402- strings [name_index ]
403- if isinstance (name_index , int ) and 0 <= name_index < len (strings )
404- else str (name_index )
405- )
406- self .assertIn (resolved_name , ("No Data" , "No significant data" ))
416+ self .assertIn (resolve_name (data , strings ), ("No Data" , "No significant data" ))
407417
408418 # Test collecting sample data
409419 test_frames = [
@@ -422,26 +432,13 @@ def test_flamegraph_collector_basic(self):
422432 data = collector ._convert_to_flamegraph_format ()
423433 # Expect promotion: root is the single child (func2), with func1 as its only child
424434 strings = data .get ("strings" , [])
425- name_index = data .get ("name" , 0 )
426- name = (
427- strings [name_index ]
428- if isinstance (name_index , int ) and 0 <= name_index < len (strings )
429- else str (name_index )
430- )
431- self .assertIsInstance (name , str )
435+ name = resolve_name (data , strings )
432436 self .assertTrue (name .startswith ("Program Root: " ))
433437 self .assertIn ("func2 (file.py:20)" , name ) # formatted name
434438 children = data .get ("children" , [])
435439 self .assertEqual (len (children ), 1 )
436440 child = children [0 ]
437- child_name_index = child .get ("name" , 0 )
438- child_name = (
439- strings [child_name_index ]
440- if isinstance (child_name_index , int )
441- and 0 <= child_name_index < len (strings )
442- else str (child_name_index )
443- )
444- self .assertIn ("func1 (file.py:10)" , child_name ) # formatted name
441+ self .assertIn ("func1 (file.py:10)" , resolve_name (child , strings ))
445442 self .assertEqual (child ["value" ], 1 )
446443
447444 def test_flamegraph_collector_export (self ):
@@ -1234,14 +1231,9 @@ def test_diff_flamegraph_identical_profiles(self):
12341231 children = data .get ("children" , [])
12351232 self .assertEqual (len (children ), 1 )
12361233 child = children [0 ]
1237- child_name_index = child .get ("name" , 0 )
1238- child_name = (
1239- strings [child_name_index ]
1240- if isinstance (child_name_index , int )
1241- and 0 <= child_name_index < len (strings )
1242- else str (child_name_index )
1243- )
1244- self .assertIn ("func1" , child_name )
1234+ self .assertIn ("func1" , resolve_name (child , strings ))
1235+ self .assertEqual (child ["self_time" ], 3 )
1236+ self .assertAlmostEqual (child ["baseline" ], 3.0 )
12451237 self .assertAlmostEqual (child ["diff" ], 0.0 , places = 1 )
12461238 self .assertAlmostEqual (child ["diff_pct" ], 0.0 , places = 1 )
12471239
@@ -1276,30 +1268,66 @@ def test_diff_flamegraph_new_function(self):
12761268 children = data .get ("children" , [])
12771269 self .assertEqual (len (children ), 1 )
12781270 func1_node = children [0 ]
1279- func1_name_index = func1_node .get ("name" , 0 )
1280- func1_name = (
1281- strings [func1_name_index ]
1282- if isinstance (func1_name_index , int )
1283- and 0 <= func1_name_index < len (strings )
1284- else str (func1_name_index )
1285- )
1286- self .assertIn ("func1" , func1_name )
1271+ self .assertIn ("func1" , resolve_name (func1_node , strings ))
12871272
12881273 func1_children = func1_node .get ("children" , [])
12891274 self .assertEqual (len (func1_children ), 1 )
12901275 new_func_node = func1_children [0 ]
1291- new_func_name_index = new_func_node .get ("name" , 0 )
1292- new_func_name = (
1293- strings [new_func_name_index ]
1294- if isinstance (new_func_name_index , int )
1295- and 0 <= new_func_name_index < len (strings )
1296- else str (new_func_name_index )
1297- )
1298- self .assertIn ("new_func" , new_func_name )
1276+ self .assertIn ("new_func" , resolve_name (new_func_node , strings ))
12991277 self .assertEqual (new_func_node ["baseline" ], 0 )
13001278 self .assertGreater (new_func_node ["self_time" ], 0 )
1279+ self .assertEqual (new_func_node ["diff" ], new_func_node ["self_time" ])
13011280 self .assertAlmostEqual (new_func_node ["diff_pct" ], 100.0 )
13021281
1282+ def test_diff_flamegraph_changed_functions (self ):
1283+ """Functions with different sample counts should have correct diff and diff_pct."""
1284+ hot_leaf_sample = [
1285+ MockInterpreterInfo (0 , [
1286+ MockThreadInfo (1 , [
1287+ MockFrameInfo ("file.py" , 10 , "hot_leaf" ),
1288+ MockFrameInfo ("file.py" , 20 , "caller" ),
1289+ ])
1290+ ])
1291+ ]
1292+ cold_leaf_sample = [
1293+ MockInterpreterInfo (0 , [
1294+ MockThreadInfo (1 , [
1295+ MockFrameInfo ("file.py" , 30 , "cold_leaf" ),
1296+ MockFrameInfo ("file.py" , 20 , "caller" ),
1297+ ])
1298+ ])
1299+ ]
1300+
1301+ # Baseline: 2 samples, current: 4, scale = 2.0
1302+ diff = make_diff_collector_with_mock_baseline (
1303+ [hot_leaf_sample , cold_leaf_sample ]
1304+ )
1305+ for _ in range (3 ):
1306+ diff .collect (hot_leaf_sample )
1307+ diff .collect (cold_leaf_sample )
1308+
1309+ data = diff ._convert_to_flamegraph_format ()
1310+ strings = data .get ("strings" , [])
1311+ self .assertAlmostEqual (data ["stats" ]["baseline_scale" ], 2.0 )
1312+
1313+ children = data .get ("children" , [])
1314+ hot_node = find_child_by_name (children , strings , "hot_leaf" )
1315+ cold_node = find_child_by_name (children , strings , "cold_leaf" )
1316+ self .assertIsNotNone (hot_node )
1317+ self .assertIsNotNone (cold_node )
1318+
1319+ # hot_leaf regressed (+50%)
1320+ self .assertAlmostEqual (hot_node ["baseline" ], 2.0 )
1321+ self .assertEqual (hot_node ["self_time" ], 3 )
1322+ self .assertAlmostEqual (hot_node ["diff" ], 1.0 )
1323+ self .assertAlmostEqual (hot_node ["diff_pct" ], 50.0 )
1324+
1325+ # cold_leaf improved (-50%)
1326+ self .assertAlmostEqual (cold_node ["baseline" ], 2.0 )
1327+ self .assertEqual (cold_node ["self_time" ], 1 )
1328+ self .assertAlmostEqual (cold_node ["diff" ], - 1.0 )
1329+ self .assertAlmostEqual (cold_node ["diff_pct" ], - 50.0 )
1330+
13031331 def test_diff_flamegraph_scale_factor (self ):
13041332 """Scale factor adjusts when sample counts differ."""
13051333 baseline_frames = [
@@ -1318,6 +1346,14 @@ def test_diff_flamegraph_scale_factor(self):
13181346 data = diff ._convert_to_flamegraph_format ()
13191347 self .assertAlmostEqual (data ["stats" ]["baseline_scale" ], 4.0 )
13201348
1349+ children = data .get ("children" , [])
1350+ self .assertEqual (len (children ), 1 )
1351+ func1_node = children [0 ]
1352+ self .assertEqual (func1_node ["self_time" ], 4 )
1353+ self .assertAlmostEqual (func1_node ["baseline" ], 4.0 )
1354+ self .assertAlmostEqual (func1_node ["diff" ], 0.0 )
1355+ self .assertAlmostEqual (func1_node ["diff_pct" ], 0.0 )
1356+
13211357 def test_diff_flamegraph_elided_stacks (self ):
13221358 """Paths in baseline but not current produce elided stacks."""
13231359 baseline_frames_1 = [
@@ -1353,14 +1389,11 @@ def test_diff_flamegraph_elided_stacks(self):
13531389 children = elided .get ("children" , [])
13541390 self .assertEqual (len (children ), 1 )
13551391 child = children [0 ]
1356- child_name_index = child .get ("name" , 0 )
1357- child_name = (
1358- elided_strings [child_name_index ]
1359- if isinstance (child_name_index , int )
1360- and 0 <= child_name_index < len (elided_strings )
1361- else str (child_name_index )
1362- )
1363- self .assertIn ("old_func" , child_name )
1392+ self .assertIn ("old_func" , resolve_name (child , elided_strings ))
1393+ self .assertEqual (child ["self_time" ], 0 )
1394+ self .assertAlmostEqual (child ["diff_pct" ], - 100.0 )
1395+ self .assertGreater (child ["baseline" ], 0 )
1396+ self .assertAlmostEqual (child ["diff" ], - child ["baseline" ])
13641397
13651398 def test_diff_flamegraph_function_matched_despite_line_change (self ):
13661399 """Functions match by (filename, funcname), ignoring lineno."""
@@ -1387,18 +1420,10 @@ def test_diff_flamegraph_function_matched_despite_line_change(self):
13871420 data = diff ._convert_to_flamegraph_format ()
13881421 strings = data .get ("strings" , [])
13891422
1390- # func1 is child of promoted root func2
13911423 children = data .get ("children" , [])
13921424 self .assertEqual (len (children ), 1 )
13931425 child = children [0 ]
1394- child_name_index = child .get ("name" , 0 )
1395- child_name = (
1396- strings [child_name_index ]
1397- if isinstance (child_name_index , int )
1398- and 0 <= child_name_index < len (strings )
1399- else str (child_name_index )
1400- )
1401- self .assertIn ("func1" , child_name )
1426+ self .assertIn ("func1" , resolve_name (child , strings ))
14021427 self .assertGreater (child ["baseline" ], 0 )
14031428 self .assertGreater (child ["self_time" ], 0 )
14041429 self .assertAlmostEqual (child ["diff" ], 0.0 , places = 1 )
@@ -1522,15 +1547,86 @@ def test_diff_flamegraph_elided_preserves_metadata(self):
15221547 data = diff ._convert_to_flamegraph_format ()
15231548 elided = data ["stats" ]["elided_flamegraph" ]
15241549
1550+ self .assertTrue (elided ["stats" ]["is_differential" ])
15251551 self .assertIn ("thread_stats" , elided ["stats" ])
15261552 self .assertIn ("per_thread_stats" , elided ["stats" ])
1553+ self .assertIn ("baseline_samples" , elided ["stats" ])
1554+ self .assertIn ("current_samples" , elided ["stats" ])
1555+ self .assertIn ("baseline_strings" , elided )
15271556
15281557 elided_strings = elided .get ("strings" , [])
15291558 children = elided .get ("children" , [])
15301559 self .assertEqual (len (children ), 1 )
15311560 old_func_node = children [0 ]
15321561 if "opcodes" in old_func_node :
15331562 self .assertIn (200 , old_func_node ["opcodes" ])
1563+ self .assertEqual (old_func_node ["self_time" ], 0 )
1564+ self .assertAlmostEqual (old_func_node ["diff_pct" ], - 100.0 )
1565+
1566+ def test_diff_flamegraph_load_baseline (self ):
1567+ """Diff annotations work when baseline is loaded from a binary file."""
1568+ from profiling .sampling .binary_collector import BinaryCollector
1569+ from profiling .sampling .stack_collector import DiffFlamegraphCollector
1570+ from .test_binary_format import make_frame , make_thread , make_interpreter
1571+
1572+ hot_sample = [make_interpreter (0 , [make_thread (1 , [
1573+ make_frame ("file.py" , 10 , "hot_leaf" ),
1574+ make_frame ("file.py" , 20 , "caller" ),
1575+ ])])]
1576+ cold_sample = [make_interpreter (0 , [make_thread (1 , [
1577+ make_frame ("file.py" , 30 , "cold_leaf" ),
1578+ make_frame ("file.py" , 20 , "caller" ),
1579+ ])])]
1580+
1581+ # Baseline: 2 samples, current: 4, scale = 2.0
1582+ bin_file = tempfile .NamedTemporaryFile (suffix = ".bin" , delete = False )
1583+ self .addCleanup (close_and_unlink , bin_file )
1584+
1585+ writer = BinaryCollector (
1586+ bin_file .name , sample_interval_usec = 1000 , compression = 'none'
1587+ )
1588+ writer .collect (hot_sample )
1589+ writer .collect (cold_sample )
1590+ writer .export (None )
1591+
1592+ diff = DiffFlamegraphCollector (
1593+ 1000 , baseline_binary_path = bin_file .name
1594+ )
1595+ hot_mock = [MockInterpreterInfo (0 , [MockThreadInfo (1 , [
1596+ MockFrameInfo ("file.py" , 10 , "hot_leaf" ),
1597+ MockFrameInfo ("file.py" , 20 , "caller" ),
1598+ ])])]
1599+ cold_mock = [MockInterpreterInfo (0 , [MockThreadInfo (1 , [
1600+ MockFrameInfo ("file.py" , 30 , "cold_leaf" ),
1601+ MockFrameInfo ("file.py" , 20 , "caller" ),
1602+ ])])]
1603+ for _ in range (3 ):
1604+ diff .collect (hot_mock )
1605+ diff .collect (cold_mock )
1606+
1607+ data = diff ._convert_to_flamegraph_format ()
1608+ strings = data .get ("strings" , [])
1609+
1610+ self .assertTrue (data ["stats" ]["is_differential" ])
1611+ self .assertAlmostEqual (data ["stats" ]["baseline_scale" ], 2.0 )
1612+
1613+ children = data .get ("children" , [])
1614+ hot_node = find_child_by_name (children , strings , "hot_leaf" )
1615+ cold_node = find_child_by_name (children , strings , "cold_leaf" )
1616+ self .assertIsNotNone (hot_node )
1617+ self .assertIsNotNone (cold_node )
1618+
1619+ # hot_leaf regressed (+50%)
1620+ self .assertAlmostEqual (hot_node ["baseline" ], 2.0 )
1621+ self .assertEqual (hot_node ["self_time" ], 3 )
1622+ self .assertAlmostEqual (hot_node ["diff" ], 1.0 )
1623+ self .assertAlmostEqual (hot_node ["diff_pct" ], 50.0 )
1624+
1625+ # cold_leaf improved (-50%)
1626+ self .assertAlmostEqual (cold_node ["baseline" ], 2.0 )
1627+ self .assertEqual (cold_node ["self_time" ], 1 )
1628+ self .assertAlmostEqual (cold_node ["diff" ], - 1.0 )
1629+ self .assertAlmostEqual (cold_node ["diff_pct" ], - 50.0 )
15341630
15351631
15361632class TestRecursiveFunctionHandling (unittest .TestCase ):
0 commit comments