Skip to content

Commit b049156

Browse files
committed
[3.15] gh-154060: Preserve replay duration and rate (GH-154101)
(cherry picked from commit 7f0ccd6) Co-authored-by: Pablo Galindo Salgado <Pablogsal@gmail.com>
1 parent d0484ab commit b049156

17 files changed

Lines changed: 602 additions & 18 deletions

Include/internal/pycore_global_objects_fini_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.

Include/internal/pycore_global_strings.h

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -445,6 +445,7 @@ struct _Py_global_strings {
445445
STRUCT_FOR_ID(dont_inherit)
446446
STRUCT_FOR_ID(dst)
447447
STRUCT_FOR_ID(dst_dir_fd)
448+
STRUCT_FOR_ID(duration_sec)
448449
STRUCT_FOR_ID(eager_start)
449450
STRUCT_FOR_ID(effective_ids)
450451
STRUCT_FOR_ID(element_factory)
@@ -458,6 +459,7 @@ struct _Py_global_strings {
458459
STRUCT_FOR_ID(endpos)
459460
STRUCT_FOR_ID(entrypoint)
460461
STRUCT_FOR_ID(env)
462+
STRUCT_FOR_ID(error_rate)
461463
STRUCT_FOR_ID(errors)
462464
STRUCT_FOR_ID(event)
463465
STRUCT_FOR_ID(eventmask)
@@ -641,6 +643,7 @@ struct _Py_global_strings {
641643
STRUCT_FOR_ID(milliseconds)
642644
STRUCT_FOR_ID(minute)
643645
STRUCT_FOR_ID(minutes)
646+
STRUCT_FOR_ID(missed_samples)
644647
STRUCT_FOR_ID(mod)
645648
STRUCT_FOR_ID(mode)
646649
STRUCT_FOR_ID(module)
@@ -768,6 +771,7 @@ struct _Py_global_strings {
768771
STRUCT_FOR_ID(rounding)
769772
STRUCT_FOR_ID(salt)
770773
STRUCT_FOR_ID(sample_interval_us)
774+
STRUCT_FOR_ID(sample_rate)
771775
STRUCT_FOR_ID(sched_priority)
772776
STRUCT_FOR_ID(scheduler)
773777
STRUCT_FOR_ID(script)

Include/internal/pycore_runtime_init_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.

Include/internal/pycore_unicodeobject_generated.h

Lines changed: 16 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: 49 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ by 10-50x compared to text formats while also enabling faster I/O.
3434

3535
## File Layout
3636

37-
The file consists of five sections:
37+
The file consists of five required sections and one optional extension:
3838

3939
```
4040
+------------------+ Offset 0
@@ -47,6 +47,8 @@ The file consists of five sections:
4747
| String Table | Variable size
4848
+------------------+ frame_table_offset
4949
| Frame Table | Variable size
50+
+------------------+ file_size - 64 (when stats are present)
51+
| Profile Stats | 32 bytes (optional)
5052
+------------------+ file_size - 32
5153
| Footer | 32 bytes (fixed)
5254
+------------------+ file_size
@@ -354,6 +356,38 @@ location. Zigzag encoding ensures these small negative values encode
354356
efficiently (−1 becomes 1, which is one byte) rather than requiring the
355357
maximum varint length.
356358

359+
## Profile Statistics
360+
361+
New files can store measured duration, sampling rate, error rate, and missed
362+
sample percentage in an optional 56-byte extension immediately before the
363+
footer. Older readers ignore these
364+
bytes after parsing the declared number of frame-table entries, and newer
365+
readers treat a missing extension as unavailable statistics.
366+
367+
```
368+
Offset Size Type Description
369+
+--------+------+---------+----------------------------------------+
370+
| 0 | 8 | double | Measured duration (seconds) |
371+
| 8 | 8 | double | Measured sample rate (samples/second) |
372+
| 16 | 8 | double | Failed sample percentage |
373+
| 24 | 8 | double | Missed sample percentage |
374+
| 32 | 4 | uint32 | Optional field presence flags |
375+
| 36 | 4 | uint32 | Reserved |
376+
| 40 | 8 | bytes | Signature ("TACHSTAT") |
377+
| 48 | 4 | uint32 | Extension version (1) |
378+
| 52 | 4 | uint32 | Extension size (56) |
379+
+--------+------+---------+----------------------------------------+
380+
```
381+
382+
Putting the signature, version, and size at the end lets readers discover
383+
the extension from its fixed position relative to the footer while allowing
384+
future versions to add fields before that trailer. Multi-byte values use the
385+
same native byte order as the rest of the file and are byte-swapped by
386+
cross-endian readers.
387+
388+
Readers also accept the original 32-byte extension, which only contains the
389+
duration and sampling rate.
390+
357391
## Footer
358392

359393
```
@@ -448,8 +482,9 @@ compress less; higher levels (6+) compress more but slow down writing. Level
448482
4. Flush remaining buffered data and finalize compression
449483
5. Write the string table (length-prefixed strings in index order)
450484
6. Write the frame table (varint-encoded entries in index order)
451-
7. Write the footer with final counts
452-
8. Seek to offset 0 and write the header with actual values
485+
7. Write measured profile statistics, when available
486+
8. Write the footer with final counts
487+
9. Seek to offset 0 and write the header with actual values
453488

454489
The writer maintains two dictionaries: one mapping strings to indices, one
455490
mapping (filename_idx, funcname_idx, lineno) tuples to frame indices. These
@@ -461,12 +496,13 @@ enable O(1) lookup during interning.
461496
if the magic appears byte-swapped)
462497
2. Validate version and read remaining header fields (byte-swapping if needed)
463498
3. Seek to end − 32 and read the footer (byte-swapping counts if needed)
464-
4. Allocate string array of `string_count` elements
465-
5. Parse the string table, populating the array
466-
6. Allocate frame array of `frame_count * 3` uint32 elements
467-
7. Parse the frame table, populating the array
468-
8. If compressed, decompress the sample data region
469-
9. Iterate through samples, resolving indices to strings/frames
499+
4. Read measured profile statistics when the optional extension is present
500+
5. Allocate string array of `string_count` elements
501+
6. Parse the string table, populating the array
502+
7. Allocate the frame array
503+
8. Parse the frame table, populating the array
504+
9. If compressed, decompress the sample data region
505+
10. Iterate through samples, resolving indices to strings/frames
470506
(byte-swapping thread_id and interpreter_id if needed)
471507

472508
The reader builds lookup arrays rather than dictionaries since it only needs
@@ -530,11 +566,10 @@ one write() call (or feeds through the compression stream).
530566

531567
## Future Considerations
532568

533-
The format reserves space for future extensions. The 12 reserved bytes in
534-
the header could hold additional metadata. The 16-byte checksum field in
535-
the footer is currently unused. The version field allows incompatible
536-
changes with graceful rejection. New compression types could be added
537-
(compression_type > 1).
569+
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).
538573

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

Lib/profiling/sampling/binary_collector.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,13 @@ def collect_failed_sample(self):
8787
"""Record a failed sample attempt (no-op for binary format)."""
8888
pass
8989

90+
def set_stats(self, sample_interval_usec, duration_sec, sample_rate,
91+
error_rate=None, missed_samples=None, **kwargs):
92+
"""Persist measured statistics for later replay."""
93+
self._writer.set_stats(
94+
duration_sec, sample_rate, error_rate, missed_samples
95+
)
96+
9097
def export(self, filename=None):
9198
"""Finalize and close the binary file.
9299

Lib/profiling/sampling/binary_reader.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,8 @@ def convert_binary_to_format(input_file, output_file, output_format,
125125

126126
# Replay samples through collector
127127
count = reader.replay_samples(collector, progress_callback)
128+
if hasattr(collector, "set_replay_stats"):
129+
collector.set_replay_stats(info)
128130

129131
# Export to target format
130132
collector.export(output_file)

Lib/profiling/sampling/cli.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -776,6 +776,8 @@ def progress_callback(current, total):
776776
)
777777

778778
count = reader.replay_samples(collector, progress_callback)
779+
if hasattr(collector, "set_replay_stats"):
780+
collector.set_replay_stats(info)
779781
print()
780782

781783
if args.format == "pstats":

Lib/profiling/sampling/stack_collector.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,6 @@ def collect(self, stack_frames, timestamps_us=None):
103103
"""Override to track thread status statistics before processing frames."""
104104
# Weight is number of timestamps (samples with identical stack)
105105
weight = len(timestamps_us) if timestamps_us else 1
106-
107106
# Increment sample count by weight
108107
self._sample_count += weight
109108

@@ -148,6 +147,21 @@ def set_stats(self, sample_interval_usec, duration_sec, sample_rate,
148147
"mode": mode
149148
}
150149

150+
def set_replay_stats(self, info):
151+
"""Restore measured statistics stored in a binary profile."""
152+
duration_sec = info.get("duration_sec")
153+
sample_rate = info.get("sample_rate")
154+
if duration_sec is None or sample_rate is None:
155+
return
156+
self.set_stats(
157+
self.sample_interval_usec,
158+
duration_sec,
159+
sample_rate,
160+
error_rate=info.get("error_rate"),
161+
missed_samples=info.get("missed_samples"),
162+
mode=self.stats.get("mode"),
163+
)
164+
151165
def export(self, filename):
152166
flamegraph_data = self._convert_to_flamegraph_format()
153167

0 commit comments

Comments
 (0)