Skip to content

Commit 39a4009

Browse files
Compress Tachyon string and frame tables
The file-wide string and frame tables can dominate captures with many unique frames, but they are currently always written as raw bytes. Build each table during finalization and compress it independently. Keep the raw representation when zstd does not make the table smaller. Prefix the stored data with its decoded size and encoding so the reader can validate and decode it without changing the rest of the file layout. Raw tables continue to point directly into the mapped file. Compressed tables reuse a temporary decode buffer before their entries are parsed.
1 parent a13cf97 commit 39a4009

5 files changed

Lines changed: 484 additions & 131 deletions

File tree

‎InternalDocs/profiling_binary_format.md‎

Lines changed: 33 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -333,11 +333,30 @@ in the array). This ordering works well with delta compression: function
333333
calls typically change frames at the end of the array, while shared frames
334334
remain at the start.
335335

336+
## Table Header
337+
338+
The string and frame tables both start with a header that describes how
339+
their entries are stored:
340+
341+
```
342+
+---------------------+-----------+----------------------------------------+
343+
| decoded_size | varint | Size of the decoded table |
344+
| encoding | 1 byte | 0=raw, 1=zstd |
345+
+---------------------+-----------+----------------------------------------+
346+
```
347+
348+
The stored table follows the header and extends to the start of the next
349+
section: the frame table for the string table, and the profile statistics
350+
or footer for the frame table. A raw table is stored as is, so its stored
351+
size equals `decoded_size`. A zstd table is a single zstd frame whose
352+
content size is `decoded_size`, at most 1 GiB; larger tables are stored raw.
353+
336354
## String Table
337355

338356
The string table stores deduplicated UTF-8 strings (filenames and function
339-
names). It begins at `string_table_offset` and contains entries in order of
340-
their assignment during writing:
357+
names). It begins at `string_table_offset` with a table header (see
358+
[Table Header](#table-header)), followed by the entries in order of their
359+
assignment during writing:
341360

342361
```
343362
+----------------+
@@ -356,7 +375,8 @@ byte.
356375
## Frame Table
357376

358377
The frame table stores deduplicated frame entries with full source position
359-
information and bytecode opcode:
378+
information and bytecode opcode. It begins at `frame_table_offset` with a
379+
table header, followed by the entries:
360380

361381
```
362382
+----------------------------+
@@ -540,10 +560,10 @@ smallest:
540560
stored-size varint is smaller than the raw column;
541561
- **raw** otherwise.
542562

543-
The string table, frame table, and footer remain uncompressed so readers can
544-
access metadata without decompressing the entire file. A tool that only needs
545-
to report "this file contains 50,000 samples of 3 threads" can read the header
546-
and footer without touching the compressed sample data.
563+
The string and frame tables are each stored as raw or zstd, whichever is
564+
smaller, and are decoded independently of the samples. The header and footer
565+
remain uncompressed, so a tool that only needs to report "this file contains
566+
50,000 samples of 3 threads" can read them without decoding anything else.
547567

548568
Zstd provides an excellent balance of compression ratio and speed. Profiling
549569
data compresses very well (often 5-10x) due to repetitive patterns: the same
@@ -572,6 +592,9 @@ independent, at the cost of restarting compression history at every block.
572592
8. Write the footer with final counts
573593
9. Seek to offset 0 and write the header with actual values
574594

595+
Each table is built in memory first, so its decoded size is known, and then
596+
written with its header as raw bytes or zstd.
597+
575598
The writer maintains two dictionaries: one mapping strings to indices, one
576599
mapping (filename_idx, funcname_idx, lineno) tuples to frame indices. These
577600
enable O(1) lookup during interning. To find `REUSE` candidates, it also keeps
@@ -590,9 +613,10 @@ column, and each column is encoded as described in
590613
3. Seek to end − 32 and read the footer (byte-swapping counts if needed)
591614
4. Read measured profile statistics when the optional extension is present
592615
5. Allocate string array of `string_count` elements
593-
6. Parse the string table, populating the array
616+
6. Decode the string table (raw tables are read in place) and parse it,
617+
populating the array
594618
7. Allocate the frame array
595-
8. Parse the frame table, populating the array
619+
8. Decode and parse the frame table, populating the array
596620
9. For each block, decode its columns into a reusable buffer and replay its
597621
samples in order, resolving indices to strings/frames (byte-swapping the
598622
stream table if needed)

‎Lib/test/test_profiling/test_sampling_profiler/test_binary_format.py‎

Lines changed: 173 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -127,18 +127,20 @@ def export(self, filename):
127127
pass
128128

129129

130+
def varint(data, offset):
131+
"""Decode an unsigned varint, returning (value, next offset)."""
132+
value = shift = 0
133+
while True:
134+
byte = data[offset]
135+
offset += 1
136+
value |= (byte & 0x7F) << shift
137+
shift += 7
138+
if byte < 0x80:
139+
return value, offset
140+
141+
130142
def read_block_columns(filename):
131143
"""Return the (decoded size, encoding) of each column of every block."""
132-
def varint(data, offset):
133-
value = shift = 0
134-
while True:
135-
byte = data[offset]
136-
offset += 1
137-
value |= (byte & 0x7F) << shift
138-
shift += 7
139-
if byte < 0x80:
140-
return value, offset
141-
142144
with open(filename, "rb") as raw:
143145
data = raw.read()
144146
sample_end = struct.unpack_from("=Q", data, 40)[0]
@@ -1443,13 +1445,14 @@ def test_replay_rejects_sample_count_mismatch(self):
14431445
def test_replay_rejects_trailing_partial_sample_header(self):
14441446
"""Replay rejects partial sample bytes instead of silently stopping."""
14451447
filename = self.create_binary_file([], compression="none")
1446-
sample_data_end = self.FILE_HEADER_PLACEHOLDER_SIZE + 1
1447-
1448-
with open(filename, "r+b") as raw:
1449-
raw.seek(self.HDR_OFF_STR_TABLE)
1450-
raw.write(struct.pack("=Q", sample_data_end))
1451-
raw.seek(self.HDR_OFF_FRAME_TABLE)
1452-
raw.write(struct.pack("=Q", sample_data_end))
1448+
data = bytearray(pathlib.Path(filename).read_bytes())
1449+
data[self.FILE_HEADER_PLACEHOLDER_SIZE:
1450+
self.FILE_HEADER_PLACEHOLDER_SIZE] = b"\0"
1451+
for offset in (self.HDR_OFF_STR_TABLE, self.HDR_OFF_FRAME_TABLE):
1452+
old_offset = struct.unpack_from("=Q", data, offset)[0]
1453+
struct.pack_into("=Q", data, offset, old_offset + 1)
1454+
struct.pack_into("=Q", data, len(data) - 24, len(data))
1455+
pathlib.Path(filename).write_bytes(data)
14531456

14541457
with BinaryReader(filename) as reader:
14551458
with self.assertRaises(ValueError) as cm:
@@ -1461,26 +1464,47 @@ def test_replay_rejects_trailing_partial_sample_header(self):
14611464
MIN_STRING_ENTRY_SIZE = 1
14621465
MIN_FRAME_ENTRY_SIZE = 7
14631466

1464-
def _read_offset(self, filename, hdr_off):
1465-
with open(filename, "rb") as raw:
1466-
raw.seek(hdr_off)
1467-
return struct.unpack("=Q", raw.read(8))[0]
1467+
def _table_bounds(self, data, hdr_off):
1468+
start = struct.unpack_from("=Q", data, hdr_off)[0]
1469+
if hdr_off == self.HDR_OFF_STR_TABLE:
1470+
end = struct.unpack_from("=Q", data, self.HDR_OFF_FRAME_TABLE)[0]
1471+
else:
1472+
end = len(data) - self.FILE_FOOTER_SIZE
1473+
return start, end
1474+
1475+
def _read_table_section(self, filename, hdr_off):
1476+
"""Return the decoded size, encoding and stored bytes of a table."""
1477+
data = pathlib.Path(filename).read_bytes()
1478+
start, end = self._table_bounds(data, hdr_off)
1479+
decoded_size, offset = varint(data, start)
1480+
return decoded_size, data[offset], data[offset + 1:end]
1481+
1482+
def _replace_table_section(self, filename, hdr_off, section):
1483+
data = bytearray(pathlib.Path(filename).read_bytes())
1484+
start, end = self._table_bounds(data, hdr_off)
1485+
data[start:end] = section
1486+
if hdr_off == self.HDR_OFF_STR_TABLE:
1487+
frame_offset = start + len(section)
1488+
struct.pack_into("=Q", data, self.HDR_OFF_FRAME_TABLE,
1489+
frame_offset)
1490+
struct.pack_into("=Q", data, len(data) - 24, len(data))
1491+
pathlib.Path(filename).write_bytes(data)
14681492

14691493
def _patch_footer_count(self, filename, ftr_off, value):
14701494
size = os.path.getsize(filename)
14711495
with open(filename, "r+b") as raw:
14721496
raw.seek(size - self.FILE_FOOTER_SIZE + ftr_off)
14731497
raw.write(struct.pack("=I", value))
14741498

1475-
def test_open_rejects_string_count_larger_than_file(self):
1476-
"""Open rejects a footer string count larger than the file."""
1499+
def test_open_rejects_string_count_larger_than_table(self):
1500+
"""Open rejects a footer string count larger than its table."""
14771501
samples = [[make_interpreter(0, [
14781502
make_thread(1, [make_frame("s.py", 10, "s")])
14791503
])]]
14801504
filename = self.create_binary_file(samples, compression="none")
1481-
size = os.path.getsize(filename)
1482-
str_off = self._read_offset(filename, self.HDR_OFF_STR_TABLE)
1483-
max_strings = (size - str_off) // self.MIN_STRING_ENTRY_SIZE
1505+
decoded_size, _, _ = self._read_table_section(
1506+
filename, self.HDR_OFF_STR_TABLE)
1507+
max_strings = decoded_size // self.MIN_STRING_ENTRY_SIZE
14841508
self._patch_footer_count(filename, self.FTR_OFF_STRINGS, 0xFFFFFFFF)
14851509

14861510
with self.assertRaises(ValueError) as cm:
@@ -1492,15 +1516,15 @@ def test_open_rejects_string_count_larger_than_file(self):
14921516
f"possible {max_strings}",
14931517
)
14941518

1495-
def test_open_rejects_frame_count_larger_than_file(self):
1496-
"""Open rejects a footer frame count larger than the file."""
1519+
def test_open_rejects_frame_count_larger_than_table(self):
1520+
"""Open rejects a footer frame count larger than its table."""
14971521
samples = [[make_interpreter(0, [
14981522
make_thread(1, [make_frame("f.py", 10, "f")])
14991523
])]]
15001524
filename = self.create_binary_file(samples, compression="none")
1501-
size = os.path.getsize(filename)
1502-
frame_off = self._read_offset(filename, self.HDR_OFF_FRAME_TABLE)
1503-
max_frames = (size - frame_off) // self.MIN_FRAME_ENTRY_SIZE
1525+
decoded_size, _, _ = self._read_table_section(
1526+
filename, self.HDR_OFF_FRAME_TABLE)
1527+
max_frames = decoded_size // self.MIN_FRAME_ENTRY_SIZE
15041528
self._patch_footer_count(filename, self.FTR_OFF_FRAMES, 0xFFFFFFFF)
15051529

15061530
# On a 32-bit build this count overflows the allocation size, so the
@@ -1516,14 +1540,14 @@ def test_open_rejects_frame_count_larger_than_file(self):
15161540
)
15171541

15181542
def test_open_accepts_frame_count_at_capacity_boundary(self):
1519-
"""A frame count at the file-size cap opens; one more is rejected."""
1543+
"""A frame count at the table-size cap opens; one more is rejected."""
15201544
samples = [[make_interpreter(0, [
15211545
make_thread(1, [make_frame("f.py", 10, "f")])
15221546
])]]
15231547
filename = self.create_binary_file(samples, compression="none")
1524-
size = os.path.getsize(filename)
1525-
frame_off = self._read_offset(filename, self.HDR_OFF_FRAME_TABLE)
1526-
max_frames = (size - frame_off) // self.MIN_FRAME_ENTRY_SIZE
1548+
decoded_size, _, _ = self._read_table_section(
1549+
filename, self.HDR_OFF_FRAME_TABLE)
1550+
max_frames = decoded_size // self.MIN_FRAME_ENTRY_SIZE
15271551

15281552
self._patch_footer_count(filename, self.FTR_OFF_FRAMES, max_frames)
15291553
with BinaryReader(filename):
@@ -1539,6 +1563,120 @@ def test_open_accepts_frame_count_at_capacity_boundary(self):
15391563
f"possible {max_frames}",
15401564
)
15411565

1566+
def test_raw_table_sections_record_decoded_size(self):
1567+
samples = [[make_interpreter(0, [
1568+
make_thread(1, [make_frame("raw.py", 10, "raw")])
1569+
])]]
1570+
filename = self.create_binary_file(samples, compression="none")
1571+
for hdr_off in (self.HDR_OFF_STR_TABLE, self.HDR_OFF_FRAME_TABLE):
1572+
with self.subTest(hdr_off=hdr_off):
1573+
decoded_size, encoding, payload = self._read_table_section(
1574+
filename, hdr_off)
1575+
self.assertEqual(encoding, 0)
1576+
self.assertEqual(decoded_size, len(payload))
1577+
1578+
@unittest.skipUnless(ZSTD_AVAILABLE, "zstd compression not available")
1579+
def test_repetitive_tables_use_zstd(self):
1580+
frames = [
1581+
make_frame(
1582+
f"package/with/repeated/prefix/module_{index:04}.py",
1583+
index + 1,
1584+
f"function_with_repeated_prefix_{index:04}",
1585+
)
1586+
for index in range(256)
1587+
]
1588+
samples = [[make_interpreter(0, [make_thread(1, frames)])]]
1589+
filename = self.create_binary_file(samples, compression="zstd")
1590+
for hdr_off in (self.HDR_OFF_STR_TABLE, self.HDR_OFF_FRAME_TABLE):
1591+
with self.subTest(hdr_off=hdr_off):
1592+
decoded_size, encoding, payload = self._read_table_section(
1593+
filename, hdr_off)
1594+
self.assertEqual(encoding, 1)
1595+
self.assertLess(len(payload), decoded_size)
1596+
1597+
def test_open_rejects_invalid_table_sections(self):
1598+
samples = [[make_interpreter(0, [
1599+
make_thread(1, [make_frame("table.py", 10, "table")])
1600+
])]]
1601+
cases = [
1602+
("Malformed string table header", self.HDR_OFF_STR_TABLE,
1603+
lambda size, payload: b""),
1604+
("Unknown frame table encoding: 255", self.HDR_OFF_FRAME_TABLE,
1605+
lambda size, payload: self._varint(size) + b"\xff" + payload),
1606+
("Raw string table size does not match its header",
1607+
self.HDR_OFF_STR_TABLE,
1608+
lambda size, payload: self._varint(size + 1) + b"\0" + payload),
1609+
("Frame table has trailing data", self.HDR_OFF_FRAME_TABLE,
1610+
lambda size, payload:
1611+
self._varint(size + 1) + b"\0" + payload + b"\0"),
1612+
("zstd frame table in an uncompressed file",
1613+
self.HDR_OFF_FRAME_TABLE,
1614+
lambda size, payload: self._varint(size) + b"\1" + payload),
1615+
]
1616+
for message, hdr_off, make_section in cases:
1617+
with self.subTest(message=message):
1618+
filename = self.create_binary_file(
1619+
samples, compression="none")
1620+
decoded_size, encoding, payload = self._read_table_section(
1621+
filename, hdr_off)
1622+
self.assertEqual(encoding, 0)
1623+
self._replace_table_section(
1624+
filename, hdr_off, make_section(decoded_size, payload))
1625+
with self.assertRaisesRegex(ValueError, message):
1626+
with BinaryReader(filename):
1627+
pass
1628+
1629+
@unittest.skipUnless(ZSTD_AVAILABLE, "zstd compression not available")
1630+
def test_open_rejects_invalid_zstd_table(self):
1631+
frames = [
1632+
make_frame(f"repeated/module_{index:04}.py", index + 1,
1633+
f"repeated_function_{index:04}")
1634+
for index in range(256)
1635+
]
1636+
samples = [[make_interpreter(0, [make_thread(1, frames)])]]
1637+
1638+
filename = self.create_binary_file(samples, compression="zstd")
1639+
decoded_size, encoding, payload = self._read_table_section(
1640+
filename, self.HDR_OFF_FRAME_TABLE)
1641+
self.assertEqual(encoding, 1)
1642+
self._replace_table_section(
1643+
filename, self.HDR_OFF_FRAME_TABLE,
1644+
self._varint(decoded_size) + bytes([encoding]) + payload[:-1])
1645+
with self.assertRaisesRegex(ValueError,
1646+
"Corrupted zstd frame header"):
1647+
with BinaryReader(filename):
1648+
pass
1649+
1650+
filename = self.create_binary_file(samples, compression="zstd")
1651+
decoded_size, encoding, payload = self._read_table_section(
1652+
filename, self.HDR_OFF_FRAME_TABLE)
1653+
self._replace_table_section(
1654+
filename, self.HDR_OFF_FRAME_TABLE,
1655+
self._varint(decoded_size + 1) + bytes([encoding]) + payload)
1656+
with self.assertRaisesRegex(
1657+
ValueError,
1658+
"zstd frame size does not match the decoded size"):
1659+
with BinaryReader(filename):
1660+
pass
1661+
1662+
@unittest.skipUnless(ZSTD_AVAILABLE, "zstd compression not available")
1663+
def test_open_rejects_oversized_zstd_table(self):
1664+
# A table claiming 2**40 bytes must be rejected before anything is
1665+
# allocated for it.
1666+
samples = [[make_interpreter(0, [
1667+
make_thread(1, [make_frame("table.py", 10, "table")])
1668+
])]]
1669+
filename = self.create_binary_file(samples, compression="zstd")
1670+
_, _, payload = self._read_table_section(
1671+
filename, self.HDR_OFF_FRAME_TABLE)
1672+
self._replace_table_section(
1673+
filename, self.HDR_OFF_FRAME_TABLE,
1674+
self._varint(1 << 40) + b"\1" + payload)
1675+
with self.assertRaisesRegex(
1676+
ValueError, "frame table exceeds the maximum decoded size"):
1677+
with BinaryReader(filename):
1678+
pass
1679+
15421680
def test_open_rejects_older_format_version(self):
15431681
filename = self.create_binary_file([], compression="none")
15441682
with BinaryReader(filename) as reader:

‎Modules/_remote_debugging/binary_io.h‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,9 @@ static_assert(FILE_HEADER_SIZE <= FILE_HEADER_PLACEHOLDER_SIZE,
7373
#define MAX_BLOCK_SIZE (1024 * 1024)
7474
#define MAX_BLOCK_STREAMS 4096
7575

76+
/* Maximum decoded size of a compressed string or frame table (1GB) */
77+
#define MAX_DECOMPRESS_SIZE (1ULL << 30)
78+
7679
/* Block columns */
7780
enum {
7881
COLUMN_STREAM_TABLE,

0 commit comments

Comments
 (0)