Skip to content

Commit bb4129f

Browse files
committed
gh-138122: Extend binary profiling format with full source location and opcode
Add end_lineno, column, end_column, and opcode fields to frame entries. Uses delta encoding for end positions to minimize file size. reader, writer, tests, and documentation.
1 parent 5b5ee3c commit bb4129f

File tree

6 files changed

+419
-64
lines changed

6 files changed

+419
-64
lines changed

InternalDocs/profiling_binary_format.md

Lines changed: 69 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -272,33 +272,85 @@ byte.
272272

273273
## Frame Table
274274

275-
The frame table stores deduplicated frame entries:
275+
The frame table stores deduplicated frame entries with full source position
276+
information and bytecode opcode:
276277

277278
```
278-
+----------------------+
279-
| filename_idx: varint |
280-
| funcname_idx: varint |
281-
| lineno: svarint |
282-
+----------------------+ (repeated for each frame)
279+
+----------------------------+
280+
| filename_idx: varint |
281+
| funcname_idx: varint |
282+
| lineno: svarint |
283+
| end_lineno_delta: svarint |
284+
| column: svarint |
285+
| end_column_delta: svarint |
286+
| opcode: u8 |
287+
+----------------------------+ (repeated for each frame)
283288
```
284289

285-
Each unique (filename, funcname, lineno) combination gets one entry. Two
286-
calls to the same function at different line numbers produce different
287-
frame entries; two calls at the same line number share one entry.
290+
### Field Definitions
291+
292+
| Field | Type | Description |
293+
|-------|------|-------------|
294+
| filename_idx | varint | Index into string table for file name |
295+
| funcname_idx | varint | Index into string table for function name |
296+
| lineno | zigzag varint | Start line number (-1 for synthetic frames) |
297+
| end_lineno_delta | zigzag varint | Delta from lineno (end_lineno = lineno + delta) |
298+
| column | zigzag varint | Start column offset in UTF-8 bytes (-1 if not available) |
299+
| end_column_delta | zigzag varint | Delta from column (end_column = column + delta) |
300+
| opcode | u8 | Python bytecode opcode (0-254) or 255 for None |
301+
302+
### Delta Encoding
303+
304+
Position end values use delta encoding for efficiency:
305+
306+
- `end_lineno = lineno + end_lineno_delta`
307+
- `end_column = column + end_column_delta`
308+
309+
Typical values:
310+
- `end_lineno_delta`: Usually 0 (single-line expressions) → encodes to 1 byte
311+
- `end_column_delta`: Usually 5-20 (expression width) → encodes to 1 byte
312+
313+
This saves ~1-2 bytes per frame compared to absolute encoding. When the base
314+
value (lineno or column) is -1 (not available), the delta is stored as 0 and
315+
the reconstructed value is -1.
316+
317+
### Sentinel Values
318+
319+
- `opcode = 255`: No opcode captured
320+
- `lineno = -1`: Synthetic frame (no source location)
321+
- `column = -1`: Column offset not available
322+
323+
### Deduplication
324+
325+
Each unique (filename, funcname, lineno, end_lineno, column, end_column,
326+
opcode) combination gets one entry. This enables instruction-level profiling
327+
where multiple bytecode instructions on the same line can be distinguished.
288328

289329
Strings and frames are deduplicated separately because they have different
290330
cardinalities and reference patterns. A codebase might have hundreds of
291331
unique source files but thousands of unique functions. Many functions share
292332
the same filename, so storing the filename index in each frame entry (rather
293333
than the full string) provides an additional layer of deduplication. A frame
294-
entry is just three varints (typically 3-6 bytes) rather than two full
295-
strings plus a line number.
296-
297-
Line numbers use signed varint (zigzag encoding) rather than unsigned to
298-
handle edge cases. Synthetic frames—generated frames that don't correspond
299-
directly to Python source code, such as C extension boundaries or internal
300-
interpreter frames—use line number 0 or -1 to indicate the absence of a
301-
source location. Zigzag encoding ensures these small negative values encode
334+
entry is typically 7-9 bytes rather than two full strings plus location data.
335+
336+
### Size Analysis
337+
338+
Typical frame size with delta encoding:
339+
- file_idx: 1-2 bytes
340+
- func_idx: 1-2 bytes
341+
- lineno: 1-2 bytes
342+
- end_lineno_delta: 1 byte (usually 0)
343+
- column: 1 byte (usually < 64)
344+
- end_column_delta: 1 byte (usually < 64)
345+
- opcode: 1 byte
346+
347+
**Total: ~7-9 bytes per frame**
348+
349+
Line numbers and columns use signed varint (zigzag encoding) to handle
350+
sentinel values efficiently. Synthetic frames—generated frames that don't
351+
correspond directly to Python source code, such as C extension boundaries or
352+
internal interpreter frames—use -1 to indicate the absence of a source
353+
location. Zigzag encoding ensures these small negative values encode
302354
efficiently (−1 becomes 1, which is one byte) rather than requiring the
303355
maximum varint length.
304356

Lib/profiling/sampling/cli.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -649,7 +649,7 @@ def _validate_args(args, parser):
649649
)
650650

651651
# Validate --opcodes is only used with compatible formats
652-
opcodes_compatible_formats = ("live", "gecko", "flamegraph", "heatmap")
652+
opcodes_compatible_formats = ("live", "gecko", "flamegraph", "heatmap", "binary")
653653
if getattr(args, 'opcodes', False) and args.format not in opcodes_compatible_formats:
654654
parser.error(
655655
f"--opcodes is only compatible with {', '.join('--' + f for f in opcodes_compatible_formats)}."

Lib/test/test_profiling/test_sampling_profiler/test_binary_format.py

Lines changed: 158 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,17 @@
2929
)
3030

3131

32-
def make_frame(filename, lineno, funcname):
33-
"""Create a FrameInfo struct sequence."""
34-
location = LocationInfo((lineno, lineno, -1, -1))
35-
return FrameInfo((filename, location, funcname, None))
32+
def make_frame(filename, lineno, funcname, end_lineno=None, column=None,
33+
end_column=None, opcode=None):
34+
"""Create a FrameInfo struct sequence with full location info and opcode."""
35+
if end_lineno is None:
36+
end_lineno = lineno
37+
if column is None:
38+
column = 0
39+
if end_column is None:
40+
end_column = 0
41+
location = LocationInfo((lineno, end_lineno, column, end_column))
42+
return FrameInfo((filename, location, funcname, opcode))
3643

3744

3845
def make_thread(thread_id, frames, status=0):
@@ -54,6 +61,22 @@ def extract_lineno(location):
5461
return location
5562

5663

64+
def extract_location(location):
65+
"""Extract full location info as dict from location tuple or None."""
66+
if location is None:
67+
return {"lineno": 0, "end_lineno": 0, "column": 0, "end_column": 0}
68+
if isinstance(location, tuple) and len(location) >= 4:
69+
return {
70+
"lineno": location[0] if location[0] is not None else 0,
71+
"end_lineno": location[1] if location[1] is not None else 0,
72+
"column": location[2] if location[2] is not None else 0,
73+
"end_column": location[3] if location[3] is not None else 0,
74+
}
75+
# Fallback for old-style location
76+
lineno = location[0] if isinstance(location, tuple) else location
77+
return {"lineno": lineno or 0, "end_lineno": lineno or 0, "column": 0, "end_column": 0}
78+
79+
5780
class RawCollector:
5881
"""Collector that captures all raw data grouped by thread."""
5982

@@ -70,11 +93,16 @@ def collect(self, stack_frames, timestamps_us):
7093
for thread in interp.threads:
7194
frames = []
7295
for frame in thread.frame_info:
96+
loc = extract_location(frame.location)
7397
frames.append(
7498
{
7599
"filename": frame.filename,
76100
"funcname": frame.funcname,
77-
"lineno": extract_lineno(frame.location),
101+
"lineno": loc["lineno"],
102+
"end_lineno": loc["end_lineno"],
103+
"column": loc["column"],
104+
"end_column": loc["end_column"],
105+
"opcode": frame.opcode,
78106
}
79107
)
80108
key = (interp.interpreter_id, thread.thread_id)
@@ -95,11 +123,16 @@ def samples_to_by_thread(samples):
95123
for thread in interp.threads:
96124
frames = []
97125
for frame in thread.frame_info:
126+
loc = extract_location(frame.location)
98127
frames.append(
99128
{
100129
"filename": frame.filename,
101130
"funcname": frame.funcname,
102-
"lineno": extract_lineno(frame.location),
131+
"lineno": loc["lineno"],
132+
"end_lineno": loc["end_lineno"],
133+
"column": loc["column"],
134+
"end_column": loc["end_column"],
135+
"opcode": frame.opcode,
103136
}
104137
)
105138
key = (interp.interpreter_id, thread.thread_id)
@@ -206,6 +239,34 @@ def assert_samples_equal(self, expected_samples, collector):
206239
f"frame {j}: lineno mismatch "
207240
f"(expected {exp_frame['lineno']}, got {act_frame['lineno']})",
208241
)
242+
self.assertEqual(
243+
exp_frame["end_lineno"],
244+
act_frame["end_lineno"],
245+
f"Thread ({interp_id}, {thread_id}), sample {i}, "
246+
f"frame {j}: end_lineno mismatch "
247+
f"(expected {exp_frame['end_lineno']}, got {act_frame['end_lineno']})",
248+
)
249+
self.assertEqual(
250+
exp_frame["column"],
251+
act_frame["column"],
252+
f"Thread ({interp_id}, {thread_id}), sample {i}, "
253+
f"frame {j}: column mismatch "
254+
f"(expected {exp_frame['column']}, got {act_frame['column']})",
255+
)
256+
self.assertEqual(
257+
exp_frame["end_column"],
258+
act_frame["end_column"],
259+
f"Thread ({interp_id}, {thread_id}), sample {i}, "
260+
f"frame {j}: end_column mismatch "
261+
f"(expected {exp_frame['end_column']}, got {act_frame['end_column']})",
262+
)
263+
self.assertEqual(
264+
exp_frame["opcode"],
265+
act_frame["opcode"],
266+
f"Thread ({interp_id}, {thread_id}), sample {i}, "
267+
f"frame {j}: opcode mismatch "
268+
f"(expected {exp_frame['opcode']}, got {act_frame['opcode']})",
269+
)
209270

210271

211272
class TestBinaryRoundTrip(BinaryFormatTestBase):
@@ -484,6 +545,97 @@ def test_threads_interleaved_samples(self):
484545
self.assertEqual(count, 60)
485546
self.assert_samples_equal(samples, collector)
486547

548+
def test_full_location_roundtrip(self):
549+
"""Full source location (end_lineno, column, end_column) roundtrips."""
550+
frames = [
551+
make_frame("test.py", 10, "func1", end_lineno=12, column=4, end_column=20),
552+
make_frame("test.py", 20, "func2", end_lineno=20, column=8, end_column=45),
553+
make_frame("test.py", 30, "func3", end_lineno=35, column=0, end_column=100),
554+
]
555+
samples = [[make_interpreter(0, [make_thread(1, frames)])]]
556+
collector, count = self.roundtrip(samples)
557+
self.assertEqual(count, 1)
558+
self.assert_samples_equal(samples, collector)
559+
560+
def test_opcode_roundtrip(self):
561+
"""Opcode values roundtrip exactly."""
562+
opcodes = [0, 1, 50, 100, 150, 200, 254] # Valid Python opcodes
563+
samples = []
564+
for opcode in opcodes:
565+
frame = make_frame("test.py", 10, "func", opcode=opcode)
566+
samples.append([make_interpreter(0, [make_thread(1, [frame])])])
567+
collector, count = self.roundtrip(samples)
568+
self.assertEqual(count, len(opcodes))
569+
self.assert_samples_equal(samples, collector)
570+
571+
def test_opcode_none_roundtrip(self):
572+
"""Opcode=None (sentinel 255) roundtrips as None."""
573+
frame = make_frame("test.py", 10, "func", opcode=None)
574+
samples = [[make_interpreter(0, [make_thread(1, [frame])])]]
575+
collector, count = self.roundtrip(samples)
576+
self.assertEqual(count, 1)
577+
self.assert_samples_equal(samples, collector)
578+
579+
def test_mixed_location_and_opcode(self):
580+
"""Mixed full location and opcode data roundtrips."""
581+
frames = [
582+
make_frame("a.py", 10, "a", end_lineno=15, column=4, end_column=30, opcode=100),
583+
make_frame("b.py", 20, "b", end_lineno=20, column=0, end_column=50, opcode=None),
584+
make_frame("c.py", 30, "c", end_lineno=32, column=8, end_column=25, opcode=50),
585+
]
586+
samples = [[make_interpreter(0, [make_thread(1, frames)])]]
587+
collector, count = self.roundtrip(samples)
588+
self.assertEqual(count, 1)
589+
self.assert_samples_equal(samples, collector)
590+
591+
def test_delta_encoding_multiline(self):
592+
"""Multi-line spans (large end_lineno delta) roundtrip correctly."""
593+
# This tests the delta encoding: end_lineno = lineno + delta
594+
frames = [
595+
make_frame("test.py", 1, "small", end_lineno=1, column=0, end_column=10),
596+
make_frame("test.py", 100, "medium", end_lineno=110, column=0, end_column=50),
597+
make_frame("test.py", 1000, "large", end_lineno=1500, column=0, end_column=200),
598+
]
599+
samples = [[make_interpreter(0, [make_thread(1, frames)])]]
600+
collector, count = self.roundtrip(samples)
601+
self.assertEqual(count, 1)
602+
self.assert_samples_equal(samples, collector)
603+
604+
def test_column_positions_preserved(self):
605+
"""Various column positions are preserved exactly."""
606+
columns = [(0, 10), (4, 50), (8, 100), (100, 200)]
607+
samples = []
608+
for col, end_col in columns:
609+
frame = make_frame("test.py", 10, "func", column=col, end_column=end_col)
610+
samples.append([make_interpreter(0, [make_thread(1, [frame])])])
611+
collector, count = self.roundtrip(samples)
612+
self.assertEqual(count, len(columns))
613+
self.assert_samples_equal(samples, collector)
614+
615+
def test_same_line_different_opcodes(self):
616+
"""Same line with different opcodes creates distinct frames."""
617+
# This tests that opcode is part of the frame key
618+
frames = [
619+
make_frame("test.py", 10, "func", opcode=100),
620+
make_frame("test.py", 10, "func", opcode=101),
621+
make_frame("test.py", 10, "func", opcode=102),
622+
]
623+
samples = [[make_interpreter(0, [make_thread(1, [f])]) for f in frames]]
624+
collector, count = self.roundtrip(samples)
625+
# Verify all three opcodes are preserved distinctly
626+
self.assertEqual(count, 3)
627+
628+
def test_same_line_different_columns(self):
629+
"""Same line with different columns creates distinct frames."""
630+
frames = [
631+
make_frame("test.py", 10, "func", column=0, end_column=10),
632+
make_frame("test.py", 10, "func", column=15, end_column=25),
633+
make_frame("test.py", 10, "func", column=30, end_column=40),
634+
]
635+
samples = [[make_interpreter(0, [make_thread(1, [f])]) for f in frames]]
636+
collector, count = self.roundtrip(samples)
637+
self.assertEqual(count, 3)
638+
487639

488640
class TestBinaryEdgeCases(BinaryFormatTestBase):
489641
"""Tests for edge cases in binary format."""

Modules/_remote_debugging/binary_io.h

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@ extern "C" {
2525
#define BINARY_FORMAT_MAGIC_SWAPPED 0x48434154 /* Byte-swapped magic for endianness detection */
2626
#define BINARY_FORMAT_VERSION 1
2727

28+
/* Sentinel values for optional frame fields */
29+
#define OPCODE_NONE 255 /* No opcode captured (u8 sentinel) */
30+
#define LOCATION_NOT_AVAILABLE (-1) /* lineno/column not available (zigzag sentinel) */
31+
2832
/* Conditional byte-swap macros for cross-endian file reading.
2933
* Uses Python's optimized byte-swap functions from pycore_bitutils.h */
3034
#define SWAP16_IF(swap, x) ((swap) ? _Py_bswap16(x) : (x))
@@ -172,18 +176,28 @@ typedef struct {
172176
size_t compressed_buffer_size;
173177
} ZstdCompressor;
174178

175-
/* Frame entry - combines all frame data for better cache locality */
179+
/* Frame entry - combines all frame data for better cache locality.
180+
* Stores full source position (line, end_line, column, end_column) and opcode.
181+
* Delta values are computed during serialization for efficiency. */
176182
typedef struct {
177183
uint32_t filename_idx;
178184
uint32_t funcname_idx;
179-
int32_t lineno;
185+
int32_t lineno; /* Start line number (-1 for synthetic frames) */
186+
int32_t end_lineno; /* End line number (-1 if not available) */
187+
int32_t column; /* Start column in UTF-8 bytes (-1 if not available) */
188+
int32_t end_column; /* End column in UTF-8 bytes (-1 if not available) */
189+
uint8_t opcode; /* Python opcode (0-254) or OPCODE_NONE (255) */
180190
} FrameEntry;
181191

182-
/* Frame key for hash table lookup */
192+
/* Frame key for hash table lookup - includes all fields for proper deduplication */
183193
typedef struct {
184194
uint32_t filename_idx;
185195
uint32_t funcname_idx;
186196
int32_t lineno;
197+
int32_t end_lineno;
198+
int32_t column;
199+
int32_t end_column;
200+
uint8_t opcode;
187201
} FrameKey;
188202

189203
/* Pending RLE sample - buffered for run-length encoding */
@@ -305,8 +319,8 @@ typedef struct {
305319
PyObject **strings;
306320
uint32_t strings_count;
307321

308-
/* Parsed frame table: packed as [filename_idx, funcname_idx, lineno] */
309-
uint32_t *frame_data;
322+
/* Parsed frame table: array of FrameEntry structures */
323+
FrameEntry *frames;
310324
uint32_t frames_count;
311325

312326
/* Sample data region */

0 commit comments

Comments
 (0)