Skip to content

Commit a9eef6b

Browse files
committed
gh-154090: Store profiling mode and capture settings in binary profiles (GH-154105)
(cherry picked from commit d3663ef)
1 parent 2b428da commit a9eef6b

18 files changed

Lines changed: 418 additions & 46 deletions

‎Include/internal/pycore_global_objects_fini_generated.h‎

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎Include/internal/pycore_global_strings.h‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -364,6 +364,7 @@ struct _Py_global_strings {
364364
STRUCT_FOR_ID(canonical)
365365
STRUCT_FOR_ID(capath)
366366
STRUCT_FOR_ID(capitals)
367+
STRUCT_FOR_ID(capture_features)
367368
STRUCT_FOR_ID(category)
368369
STRUCT_FOR_ID(cb_type)
369370
STRUCT_FOR_ID(certfile)

‎Include/internal/pycore_runtime_init_generated.h‎

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎Include/internal/pycore_unicodeobject_generated.h‎

Lines changed: 4 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎InternalDocs/profiling_binary_format.md‎

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -84,15 +84,23 @@ with a single seek to `file_size - 32`, without first reading the header.
8484
| | | | reserved) |
8585
| 12 | 8 | uint64 | Start timestamp (microseconds) |
8686
| 20 | 8 | uint64 | Sample interval (microseconds) |
87-
| 28 | 4 | uint32 | Total sample count |
88-
| 32 | 4 | uint32 | Thread count |
89-
| 36 | 8 | uint64 | String table offset |
90-
| 44 | 8 | uint64 | Frame table offset |
91-
| 52 | 4 | uint32 | Compression type (0=none, 1=zstd) |
92-
| 56 | 8 | bytes | Reserved (zero-filled) |
87+
| 28 | 8 | uint64 | Total sample count |
88+
| 36 | 4 | uint32 | Thread count |
89+
| 40 | 8 | uint64 | String table offset |
90+
| 48 | 8 | uint64 | Frame table offset |
91+
| 56 | 4 | uint32 | Compression type (0=none, 1=zstd) |
92+
| 60 | 4 | uint32 | Profiling configuration bit field |
9393
+--------+------+---------+----------------------------------------+
9494
```
9595

96+
The low three configuration bits contain the
97+
`_remote_debugging.PROFILING_MODE_*` value plus one. Zero means that the mode
98+
was not recorded, which is also the value in binaries written before this
99+
field was defined. Bit 3 indicates that capture features are known; when set,
100+
bits 4 through 8 respectively record `--all-threads`, `--native`, GC frames,
101+
`--opcodes`, and `--blocking`. Remaining bits are reserved for future capture
102+
features.
103+
96104
The magic number `0x54414348` ("TACH" for Tachyon) identifies the file format
97105
and also serves as an **endianness marker**. When read on a system with
98106
different byte order than the writer, it appears as `0x48434154`. The reader
@@ -567,9 +575,10 @@ one write() call (or feeds through the compression stream).
567575
## Future Considerations
568576

569577
The optional profile-statistics block provides an extensible metadata area.
570-
The 16-byte checksum field in the footer is currently unused. The version
571-
field allows incompatible changes with graceful rejection. New compression
572-
types could be added (compression_type > 1).
578+
The Python-version field retains one reserved byte. The 16-byte checksum
579+
field in the footer is currently unused. The version field allows
580+
incompatible changes with graceful rejection. New compression types could
581+
be added (compression_type > 1).
573582

574583
Any changes that alter the meaning of existing fields or the parsing logic
575584
should increment the version number to prevent older readers from

‎Lib/profiling/sampling/binary_collector.py‎

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,23 @@
1010
COMPRESSION_NONE = 0
1111
COMPRESSION_ZSTD = 1
1212

13+
CAPTURE_FEATURES = {
14+
"all_threads": 1 << 0,
15+
"native": 1 << 1,
16+
"gc": 1 << 2,
17+
"opcodes": 1 << 3,
18+
"blocking": 1 << 4,
19+
}
20+
21+
22+
def encode_capture_config(capture_config):
23+
if capture_config is None:
24+
return -1
25+
return sum(
26+
bit for name, bit in CAPTURE_FEATURES.items()
27+
if capture_config.get(name, False)
28+
)
29+
1330

1431
def _resolve_compression(compression):
1532
"""Resolve compression type from string or int.
@@ -49,14 +66,17 @@ class BinaryCollector(Collector):
4966
"""
5067

5168
def __init__(self, filename, sample_interval_usec, *, skip_idle=False,
52-
compression='auto'):
69+
compression='auto', mode=None, capture_config=None):
5370
"""Create a new binary collector.
5471
5572
Args:
5673
filename: Path to output binary file
5774
sample_interval_usec: Sampling interval in microseconds
5875
skip_idle: If True, skip idle threads (not used in binary format)
5976
compression: 'auto', 'zstd', 'none', or int (0=none, 1=zstd)
77+
mode: Profiling mode, or None if unknown
78+
capture_config: Mapping of capture feature names to booleans, or
79+
None if the capture configuration is unknown
6080
"""
6181
self.filename = filename
6282
self.sample_interval_usec = sample_interval_usec
@@ -65,7 +85,10 @@ def __init__(self, filename, sample_interval_usec, *, skip_idle=False,
6585
compression_type = _resolve_compression(compression)
6686
start_time_us = int(time.monotonic() * 1_000_000)
6787
self._writer = _remote_debugging.BinaryWriter(
68-
filename, sample_interval_usec, start_time_us, compression=compression_type
88+
filename, sample_interval_usec, start_time_us,
89+
compression=compression_type,
90+
mode=-1 if mode is None else mode,
91+
capture_features=encode_capture_config(capture_config),
6992
)
7093

7194
def collect(self, stack_frames, timestamp_us=None):

‎Lib/profiling/sampling/binary_reader.py‎

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from .stack_collector import FlamegraphCollector, CollapsedStackCollector
77
from .jsonl_collector import JsonlCollector
88
from .pstats_collector import PstatsCollector
9+
from .binary_collector import CAPTURE_FEATURES
910

1011

1112
class BinaryReader:
@@ -50,10 +51,21 @@ def get_info(self):
5051
- string_count: Number of unique strings
5152
- frame_count: Number of unique frames
5253
- compression: Compression type used
54+
- mode: Profiling mode, or None if not recorded
55+
- capture_config: Capture feature mapping, or None if not
56+
recorded
5357
"""
5458
if self._reader is None:
5559
raise RuntimeError("Reader not open. Use as context manager.")
56-
return self._reader.get_info()
60+
info = self._reader.get_info()
61+
capture_features = info.pop("capture_features")
62+
info["capture_config"] = (
63+
None if capture_features is None else {
64+
name: bool(capture_features & bit)
65+
for name, bit in CAPTURE_FEATURES.items()
66+
}
67+
)
68+
return info
5769

5870
def replay_samples(self, collector, progress_callback=None):
5971
"""Replay samples from binary file through a collector.
@@ -119,14 +131,16 @@ def convert_binary_to_format(input_file, output_file, output_format,
119131
elif output_format == 'gecko':
120132
collector = GeckoCollector(interval)
121133
elif output_format == "jsonl":
122-
collector = JsonlCollector(interval)
134+
collector = JsonlCollector(interval, mode=info.get("mode"))
123135
else:
124136
raise ValueError(f"Unknown output format: {output_format}")
125137

126138
# Replay samples through collector
127139
count = reader.replay_samples(collector, progress_callback)
128140
if hasattr(collector, "set_replay_stats"):
129141
collector.set_replay_stats(info)
142+
if hasattr(collector, "set_mode"):
143+
collector.set_mode(info.get("mode"))
130144

131145
# Export to target format
132146
collector.export(output_file)

‎Lib/profiling/sampling/cli.py‎

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -629,9 +629,20 @@ def _sort_to_mode(sort_choice):
629629
}
630630
return sort_map.get(sort_choice, SORT_MODE_NSAMPLES)
631631

632+
633+
def _capture_config_from_args(args):
634+
return {
635+
"all_threads": args.all_threads,
636+
"native": args.native,
637+
"gc": args.gc,
638+
"opcodes": args.opcodes,
639+
"blocking": args.blocking,
640+
}
641+
642+
632643
def _create_collector(format_type, sample_interval_usec, skip_idle, opcodes=False,
633644
mode=None, output_file=None, compression='auto',
634-
diff_baseline=None):
645+
diff_baseline=None, capture_config=None):
635646
"""Create the appropriate collector based on format type.
636647
637648
Args:
@@ -645,6 +656,7 @@ def _create_collector(format_type, sample_interval_usec, skip_idle, opcodes=Fals
645656
output_file: Output file path (required for binary format)
646657
compression: Compression type for binary format ('auto', 'zstd', 'none')
647658
diff_baseline: Path to baseline binary file for differential flamegraph
659+
capture_config: Capture feature mapping for binary profiles and diffs
648660
649661
Returns:
650662
A collector instance of the appropriate type
@@ -661,15 +673,18 @@ def _create_collector(format_type, sample_interval_usec, skip_idle, opcodes=Fals
661673
return collector_class(
662674
sample_interval_usec,
663675
baseline_binary_path=diff_baseline,
664-
skip_idle=skip_idle
676+
skip_idle=skip_idle,
677+
mode=mode,
678+
capture_config=capture_config,
665679
)
666680

667681
# Binary format requires output file and compression
668682
if format_type == "binary":
669683
if output_file is None:
670684
raise ValueError("Binary format requires an output file")
671685
return collector_class(output_file, sample_interval_usec, skip_idle=skip_idle,
672-
compression=compression)
686+
compression=compression, mode=mode,
687+
capture_config=capture_config)
673688

674689
# Gecko format never skips idle (it needs both GIL and CPU data)
675690
# and is the only format that uses opcodes for interval markers
@@ -760,7 +775,9 @@ def _replay_with_reader(args, reader):
760775

761776
collector = _create_collector(
762777
args.format, interval, skip_idle=False,
763-
diff_baseline=args.diff_baseline
778+
mode=info.get("mode"),
779+
diff_baseline=args.diff_baseline,
780+
capture_config=info.get("capture_config"),
764781
)
765782

766783
def progress_callback(current, total):
@@ -778,6 +795,8 @@ def progress_callback(current, total):
778795
count = reader.replay_samples(collector, progress_callback)
779796
if hasattr(collector, "set_replay_stats"):
780797
collector.set_replay_stats(info)
798+
if hasattr(collector, "set_mode"):
799+
collector.set_mode(info.get("mode"))
781800
print()
782801

783802
if args.format == "pstats":
@@ -791,7 +810,8 @@ def progress_callback(current, total):
791810
sort_mode = _sort_to_mode(sort_choice)
792811
collector.print_stats(
793812
sort_mode, limit, not args.no_summary,
794-
PROFILING_MODE_WALL
813+
info.get("mode") if info.get("mode") is not None
814+
else PROFILING_MODE_WALL
795815
)
796816
else:
797817
filename = (
@@ -1179,7 +1199,8 @@ def _handle_attach(args):
11791199
args.format, args.sample_interval_usec, skip_idle, args.opcodes, mode,
11801200
output_file=output_file,
11811201
compression=getattr(args, 'compression', 'auto'),
1182-
diff_baseline=args.diff_baseline
1202+
diff_baseline=args.diff_baseline,
1203+
capture_config=_capture_config_from_args(args),
11831204
)
11841205

11851206
with _get_child_monitor_context(args, args.pid):
@@ -1286,7 +1307,8 @@ def _handle_run(args):
12861307
args.format, args.sample_interval_usec, skip_idle, args.opcodes, mode,
12871308
output_file=output_file,
12881309
compression=getattr(args, 'compression', 'auto'),
1289-
diff_baseline=args.diff_baseline
1310+
diff_baseline=args.diff_baseline,
1311+
capture_config=_capture_config_from_args(args),
12901312
)
12911313

12921314
with _get_child_monitor_context(args, process.pid):

‎Lib/profiling/sampling/stack_collector.py‎

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,8 @@ def set_replay_stats(self, info):
161161
missed_samples=info.get("missed_samples"),
162162
mode=self.stats.get("mode"),
163163
)
164+
def set_mode(self, mode):
165+
self.stats["mode"] = mode
164166

165167
def export(self, filename):
166168
flamegraph_data = self._convert_to_flamegraph_format()
@@ -565,13 +567,16 @@ def _create_flamegraph_html(self, data):
565567
class DiffFlamegraphCollector(FlamegraphCollector):
566568
"""Differential flamegraph collector that compares against a baseline binary profile."""
567569

568-
def __init__(self, sample_interval_usec, *, baseline_binary_path, skip_idle=False):
570+
def __init__(self, sample_interval_usec, *, baseline_binary_path,
571+
skip_idle=False, mode=None, capture_config=None):
569572
super().__init__(sample_interval_usec, skip_idle=skip_idle)
570573
if not os.path.exists(baseline_binary_path):
571574
raise ValueError(f"Baseline file not found: {baseline_binary_path}")
572575
self.baseline_binary_path = baseline_binary_path
573576
self._baseline_collector = None
574577
self._elided_paths = set()
578+
self.mode = mode
579+
self.capture_config = capture_config
575580

576581
def _load_baseline(self):
577582
"""Load baseline profile from binary file."""
@@ -580,6 +585,32 @@ def _load_baseline(self):
580585
with BinaryReader(self.baseline_binary_path) as reader:
581586
info = reader.get_info()
582587

588+
baseline_mode = info.get("mode")
589+
if (
590+
baseline_mode is not None
591+
and self.mode is not None
592+
and baseline_mode != self.mode
593+
):
594+
raise ValueError(
595+
"Baseline profiling mode does not match current mode"
596+
)
597+
598+
baseline_config = info.get("capture_config")
599+
if baseline_config is not None and self.capture_config is not None:
600+
names = baseline_config.keys() | self.capture_config.keys()
601+
mismatches = [
602+
name for name in names
603+
if baseline_config.get(name, False)
604+
!= self.capture_config.get(name, False)
605+
]
606+
else:
607+
mismatches = []
608+
if mismatches:
609+
raise ValueError(
610+
"Baseline capture configuration does not match current "
611+
f"configuration: {', '.join(sorted(mismatches))}"
612+
)
613+
583614
baseline_collector = FlamegraphCollector(
584615
sample_interval_usec=info['sample_interval_us'],
585616
skip_idle=self.skip_idle

0 commit comments

Comments
 (0)