Skip to content

Commit 5534711

Browse files
committed
gh-149800: Test the generated perf trampoline unwind data
Check the FDEs in jitdump files against their code load records, cover the generator's parsers, and validate the header structure from C.
1 parent b63f0d1 commit 5534711

3 files changed

Lines changed: 443 additions & 1 deletion

File tree

Lib/test/test_perf_profiler.py

Lines changed: 323 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import glob
2+
import struct
13
import unittest
24
import string
35
import subprocess
@@ -11,7 +13,9 @@
1113
assert_python_failure,
1214
assert_python_ok,
1315
)
16+
from test.support import import_helper
1417
from test.support.os_helper import temp_dir
18+
from test import test_tools
1519

1620

1721
if not support.has_subprocess_support:
@@ -685,5 +689,324 @@ def tearDown(self) -> None:
685689
file.unlink()
686690

687691

692+
JITDUMP_MAGIC = 0x4A695444 # "JiTD"
693+
JITDUMP_VERSION = 1
694+
PERF_LOAD = 0
695+
PERF_UNWINDING_INFO = 4
696+
JITDUMP_ENDIAN = "<" if sys.byteorder == "little" else ">"
697+
# Every jitdump record starts with event(u32), size(u32), timestamp(u64).
698+
JITDUMP_RECORD_HEADER_SIZE = 16
699+
# CodeLoadEvent: record header, pid(u32), tid(u32), vma(u64), code_addr(u64),
700+
# code_size(u64), code_id(u64), then the NUL-terminated name.
701+
CODE_LOAD_CODE_SIZE_OFFSET = JITDUMP_RECORD_HEADER_SIZE + 4 + 4 + 8 + 8
702+
CODE_LOAD_NAME_OFFSET = CODE_LOAD_CODE_SIZE_OFFSET + 8 + 8
703+
# CodeUnwindingInfoEvent: record header, unwind_data_size(u64),
704+
# eh_frame_hdr_size(u64), mapped_size(u64), then the .eh_frame bytes followed
705+
# by perf's 20-byte eh_frame_hdr (EhFrameHeader in perf_jit_trampoline.c),
706+
# whose "from" field is the signed distance back to the code.
707+
UNWIND_DATA_SIZE_OFFSET = JITDUMP_RECORD_HEADER_SIZE
708+
UNWIND_EH_FRAME_OFFSET = JITDUMP_RECORD_HEADER_SIZE + 3 * 8
709+
EH_FRAME_HDR_SIZE = 20
710+
EH_FRAME_HDR_FROM_OFFSET = 12
711+
# DWARF FDE pointer encodings: DW_EH_PE_pcrel | DW_EH_PE_sdata4 (ELF
712+
# assemblers) and DW_EH_PE_pcrel | DW_EH_PE_absptr (Darwin assemblers).
713+
DW_EH_PE_PCREL_SDATA4 = 0x1B
714+
DW_EH_PE_PCREL_ABSPTR = 0x10
715+
716+
717+
def _jitdump_records(data):
718+
"""Yield (event, offset, size) for each record of a jitdump file."""
719+
header_size = struct.unpack_from(f"{JITDUMP_ENDIAN}I", data, 8)[0]
720+
pos = header_size
721+
while pos < len(data):
722+
if pos + JITDUMP_RECORD_HEADER_SIZE > len(data):
723+
raise ValueError(f"truncated record header at offset {pos}")
724+
event, size = struct.unpack_from(f"{JITDUMP_ENDIAN}II", data, pos)
725+
if size < JITDUMP_RECORD_HEADER_SIZE or pos + size > len(data):
726+
raise ValueError(f"record at offset {pos} has a bad size {size}")
727+
yield event, pos, size
728+
pos += size
729+
730+
731+
def _fde_pointer_encoding(eh_frame):
732+
"""Return the FDE pointer encoding byte of a version 1 "zR" CIE."""
733+
cie_length = struct.unpack_from(f"{JITDUMP_ENDIAN}I", eh_frame, 0)[0]
734+
cie_total = 4 + cie_length
735+
pos = 12 # past length, CIE_id, version and "zR\0"
736+
for _ in range(2): # code and data alignment factors (LEB128)
737+
while eh_frame[pos] & 0x80:
738+
pos += 1
739+
pos += 1
740+
pos += 1 # return address column
741+
while eh_frame[pos] & 0x80: # augmentation data length (LEB128)
742+
pos += 1
743+
pos += 1
744+
if pos >= cie_total:
745+
raise ValueError("truncated CIE augmentation data")
746+
return eh_frame[pos]
747+
748+
749+
class TestJitdumpFileFormat(unittest.TestCase):
750+
"""Validate the jitdump written by -Xperf_jit without requiring perf."""
751+
752+
def _run_and_get_jitdump(self, code):
753+
# The child prints its pid so we open exactly its own jitdump file
754+
# rather than whatever another test worker left in /tmp.
755+
code = "import os, sys\nsys.stdout.write(str(os.getpid()))\n" + code
756+
rc, out, err = assert_python_ok("-Xperf_jit", "-c", code, PYTHON_JIT="0")
757+
path = f"/tmp/jit-{int(out)}.dump"
758+
if not os.path.exists(path):
759+
# perf_map_jit_init() gives up silently when it cannot create
760+
# the file (for example an unwritable /tmp).
761+
self.skipTest("jitdump file was not created")
762+
self.addCleanup(os.unlink, path)
763+
with open(path, "rb") as f:
764+
data = f.read()
765+
if not data:
766+
# The file is created before the executable mapping of the
767+
# jitdump; if that mapping fails (for example a noexec /tmp) the
768+
# backend gives up silently and never writes the header.
769+
self.skipTest("jitdump could not be initialized")
770+
return data
771+
772+
def _check_unwinding_records(self, data):
773+
"""Check every unwinding record against the code load record it
774+
describes; return {name: code_size} for the regions seen."""
775+
records = list(_jitdump_records(data))
776+
regions = {}
777+
for index, (event, pos, size) in enumerate(records):
778+
if event != PERF_UNWINDING_INFO:
779+
continue
780+
# The unwinding info record is immediately followed by the
781+
# code load record it describes.
782+
self.assertLess(index + 1, len(records))
783+
load_event, load_pos, load_size = records[index + 1]
784+
self.assertEqual(load_event, PERF_LOAD)
785+
code_size = struct.unpack_from(
786+
f"{JITDUMP_ENDIAN}Q", data, load_pos + CODE_LOAD_CODE_SIZE_OFFSET)[0]
787+
name_start = load_pos + CODE_LOAD_NAME_OFFSET
788+
name_end = data.find(b"\x00", name_start, load_pos + load_size)
789+
self.assertGreater(name_end, 0)
790+
name = data[name_start:name_end].decode("utf-8", errors="replace")
791+
# The machine code follows the name inside the load record.
792+
self.assertLessEqual(name_end + 1 + code_size, load_pos + load_size, name)
793+
unwind_data_size, eh_frame_hdr_size = struct.unpack_from(
794+
f"{JITDUMP_ENDIAN}QQ", data, pos + UNWIND_DATA_SIZE_OFFSET)
795+
self.assertEqual(eh_frame_hdr_size, EH_FRAME_HDR_SIZE)
796+
self.assertLessEqual(UNWIND_EH_FRAME_OFFSET + unwind_data_size, size)
797+
eh_frame_size = unwind_data_size - eh_frame_hdr_size
798+
self.assertGreater(eh_frame_size, 0)
799+
start = pos + UNWIND_EH_FRAME_OFFSET
800+
eh_frame = data[start:start + eh_frame_size]
801+
802+
cie_length, cie_id = struct.unpack_from(f"{JITDUMP_ENDIAN}II", eh_frame, 0)
803+
self.assertEqual(cie_id, 0, "first entry must be a CIE")
804+
self.assertEqual(eh_frame[8], 1, "CIE version must be 1")
805+
self.assertEqual(eh_frame[9:12], b"zR\x00")
806+
encoding = _fde_pointer_encoding(eh_frame)
807+
if encoding == DW_EH_PE_PCREL_SDATA4:
808+
fields = "iI"
809+
elif encoding == DW_EH_PE_PCREL_ABSPTR:
810+
fields = "qQ"
811+
else:
812+
self.fail(f"unexpected FDE pointer encoding {encoding:#x}")
813+
# jit_unwind.c patches initial_location and address_range for
814+
# perf's DSO layout, where the .eh_frame follows the code at
815+
# code_size rounded up to 8 bytes.
816+
pc_offset = 4 + cie_length + 8
817+
initial_location, address_range = struct.unpack_from(
818+
f"{JITDUMP_ENDIAN}{fields}", eh_frame, pc_offset)
819+
self.assertEqual(address_range, code_size, name)
820+
rounded_code_size = (code_size + 7) & ~7
821+
self.assertEqual(initial_location, -(rounded_code_size + pc_offset), name)
822+
# perf's eh_frame_hdr must point back at the code with the same
823+
# rounding as the FDE.
824+
hdr_from = struct.unpack_from(
825+
f"{JITDUMP_ENDIAN}i", data,
826+
start + eh_frame_size + EH_FRAME_HDR_FROM_OFFSET)[0]
827+
self.assertEqual(hdr_from, -(rounded_code_size + eh_frame_size), name)
828+
regions[name] = code_size
829+
self.assertTrue(regions, "no CodeUnwindingInfoEvent found")
830+
return regions
831+
832+
def test_jitdump_unwinding_info(self):
833+
"""Each region's .eh_frame is patched for that region's size."""
834+
data = self._run_and_get_jitdump("def my_test_func(): pass\nmy_test_func()")
835+
magic, version = struct.unpack_from(f"{JITDUMP_ENDIAN}II", data, 0)
836+
self.assertEqual((magic, version), (JITDUMP_MAGIC, JITDUMP_VERSION))
837+
regions = self._check_unwinding_records(data)
838+
self.assertTrue(any("my_test_func" in name for name in regions))
839+
840+
841+
try:
842+
with test_tools.imports_under_tool("jit"):
843+
import _trampoline_ehframe
844+
except ImportError:
845+
# Installed Python without the Tools directory.
846+
_trampoline_ehframe = None
847+
848+
849+
def _fake_cie(*, version=1, augmentation=b"zR", ra_column=16,
850+
encoding=DW_EH_PE_PCREL_SDATA4, cie_id=0):
851+
"""A CIE like the assembler's: code align 1, data align -8, one
852+
DW_CFA_def_cfa instruction, padded with DW_CFA_nop to 8 bytes."""
853+
body = bytes([version]) + augmentation + b"\x00"
854+
body += bytes([1, 0x78, ra_column, 1, encoding])
855+
body += bytes([0x0C, 7, 8]) # DW_CFA_def_cfa: r7 (rsp) ofs 8
856+
body += b"\x00" * (-(8 + len(body)) % 8)
857+
return struct.pack("<II", 4 + len(body), cie_id) + body
858+
859+
860+
def _fake_fde(cie_total, *, field_size=4, address_range=8,
861+
instructions=b"\x41\x0e\x10\x86\x02"):
862+
"""An FDE right after a CIE of cie_total bytes, padded to 8 bytes."""
863+
body = struct.pack("<I", cie_total + 4) # CIE pointer, relative to itself
864+
# initial_location as an assembler would leave it, the parser zeroes it.
865+
body += (-40).to_bytes(field_size, "little", signed=True)
866+
body += address_range.to_bytes(field_size, "little")
867+
body += b"\x00" # augmentation data length
868+
body += instructions
869+
body += b"\x00" * (-(4 + len(body)) % 8)
870+
return struct.pack("<I", len(body)) + body
871+
872+
873+
@unittest.skipIf(_trampoline_ehframe is None,
874+
"Tools/jit/_trampoline_ehframe.py not found")
875+
class TestTrampolineEhframeScript(unittest.TestCase):
876+
"""Tests for Tools/jit/_trampoline_ehframe.py."""
877+
878+
ehframe = _trampoline_ehframe
879+
880+
def parse(self, data, text_size=8):
881+
return self.ehframe.parse_ehframe(bytes(data), "<", text_size)
882+
883+
def test_parse(self):
884+
"""Both FDE pointer encodings: ELF sdata4 and Darwin absptr."""
885+
cases = [(DW_EH_PE_PCREL_SDATA4, 4, 16, 8), (DW_EH_PE_PCREL_ABSPTR, 8, 30, 20)]
886+
for encoding, field_size, ra_column, text_size in cases:
887+
with self.subTest(encoding=hex(encoding)):
888+
cie = _fake_cie(encoding=encoding, ra_column=ra_column)
889+
fde = _fake_fde(len(cie), field_size=field_size,
890+
address_range=text_size)
891+
result = self.parse(cie + fde, text_size)
892+
self.assertEqual(result.field_size, field_size)
893+
self.assertEqual(result.fde_pc_offset, len(cie) + 8)
894+
self.assertEqual(result.fde_range_offset, len(cie) + 8 + field_size)
895+
# Both patchable fields zeroed, everything else untouched.
896+
expected = bytearray(cie + fde)
897+
expected[len(cie) + 8:len(cie) + 8 + 2 * field_size] = bytes(2 * field_size)
898+
self.assertEqual(result.data, bytes(expected))
899+
900+
def test_parse_rejects_malformed(self):
901+
cie = _fake_cie()
902+
fde = _fake_fde(len(cie))
903+
cases = [
904+
("version", _fake_cie(version=3) + fde, 8),
905+
("augmentation", _fake_cie(augmentation=b"zPLR") + fde, 8),
906+
("encoding", _fake_cie(encoding=0x1A) + fde, 8),
907+
("exactly one FDE", cie + fde + fde, 8),
908+
("address_range", cie + fde, 12),
909+
("no FDE", cie, 8),
910+
]
911+
for message, data, text_size in cases:
912+
with self.subTest(message):
913+
with self.assertRaisesRegex(ValueError, message):
914+
self.parse(data, text_size)
915+
916+
def _build_trampoline_objects(self):
917+
"""The object(s) the Makefile fed to the generator."""
918+
builddir = sysconfig.get_config_var("abs_builddir") or "."
919+
universal2 = os.path.join(builddir, "Python", "asm_trampoline_universal2.o")
920+
if os.path.exists(universal2):
921+
return [universal2]
922+
return sorted(
923+
path for path in glob.glob(
924+
os.path.join(builddir, "Python", "asm_trampoline_*.o"))
925+
if "apple-darwin" not in os.path.basename(path))
926+
927+
def test_macho_thin_and_fat(self):
928+
"""Mach-O objects and fat containers are parsed with no external tools."""
929+
E = self.ehframe
930+
931+
def macho(cputype, text, eh_frame):
932+
# A minimal MH_OBJECT: one __TEXT segment with __text and
933+
# __eh_frame sections, section data right after the load command.
934+
segment_size = 72 + 2 * 80
935+
text_offset = 32 + segment_size
936+
eh_offset = text_offset + len(text)
937+
sections = b""
938+
for name, size, offset in (("__text", len(text), text_offset),
939+
("__eh_frame", len(eh_frame), eh_offset)):
940+
sections += struct.pack("<16s16sQQIIIIIIII", name.encode(),
941+
b"__TEXT", 0, size, offset,
942+
0, 0, 0, 0, 0, 0, 0)
943+
segment = struct.pack("<II16sQQQQIIII", E._LC_SEGMENT_64,
944+
segment_size, b"__TEXT", 0,
945+
len(text) + len(eh_frame), text_offset,
946+
len(text) + len(eh_frame), 7, 5, 2, 0)
947+
header = struct.pack("<IIIIIIII", E._MH_MAGIC_64, cputype, 0,
948+
1, 1, segment_size, 0, 0)
949+
return header + segment + sections + text + eh_frame
950+
951+
x86 = macho(E._CPU_TYPE_X86_64, b"\x55\xc3", b"x86 eh_frame")
952+
arm = macho(E._CPU_TYPE_ARM64, b"\xc0\x03\x5f\xd6", b"arm64 eh_frame")
953+
# The fat header and its fat_arch entries are big-endian.
954+
blobs = [(E._CPU_TYPE_X86_64, x86), (E._CPU_TYPE_ARM64, arm)]
955+
offset = 8 + 20 * len(blobs)
956+
entries = b""
957+
body = b""
958+
for cputype, blob in blobs:
959+
entries += struct.pack(">IIIII", cputype, 0, offset + len(body),
960+
len(blob), 0)
961+
body += blob
962+
fat = struct.pack(">II", E._FAT_MAGIC, len(blobs)) + entries + body
963+
964+
with temp_dir() as tmp:
965+
thin_path = os.path.join(tmp, "thin.o")
966+
fat_path = os.path.join(tmp, "fat.o")
967+
with open(thin_path, "wb") as f:
968+
f.write(arm)
969+
with open(fat_path, "wb") as f:
970+
f.write(fat)
971+
(thin,) = E.load_object(thin_path)
972+
fat_slices = E.load_object(fat_path)
973+
974+
self.assertEqual(thin.arch_macro, "__aarch64__")
975+
self.assertEqual(thin.sections[".text"], b"\xc0\x03\x5f\xd6")
976+
self.assertEqual(thin.sections[".eh_frame"], b"arm64 eh_frame")
977+
self.assertEqual([s.arch_macro for s in fat_slices],
978+
["__x86_64__", "__aarch64__"])
979+
self.assertEqual(fat_slices[0].sections[".eh_frame"], b"x86 eh_frame")
980+
self.assertEqual(fat_slices[1].sections[".text"], b"\xc0\x03\x5f\xd6")
981+
982+
def test_generated_header_is_current(self):
983+
"""The header in the build directory matches a fresh generation."""
984+
objects = self._build_trampoline_objects()
985+
builddir = sysconfig.get_config_var("abs_builddir") or "."
986+
header = os.path.join(builddir, "trampoline_ehframe.h")
987+
if not objects or not os.path.exists(header):
988+
self.skipTest("trampoline object or generated header not found")
989+
with open(header) as f:
990+
current = f.read()
991+
with temp_dir() as tmp:
992+
fresh_path = os.path.join(tmp, "trampoline_ehframe.h")
993+
self.ehframe.generate(objects, fresh_path)
994+
with open(fresh_path) as f:
995+
fresh = f.read()
996+
self.assertEqual(current, fresh)
997+
998+
999+
class TestTrampolineEhframeHeader(unittest.TestCase):
1000+
"""Structural checks on the generated trampoline_ehframe.h data."""
1001+
1002+
def test_generated_header_structure(self):
1003+
_testinternalcapi = import_helper.import_module("_testinternalcapi")
1004+
check = getattr(_testinternalcapi, "test_trampoline_ehframe", None)
1005+
if check is None:
1006+
self.skipTest("_testinternalcapi built without the perf trampoline")
1007+
# Raises AssertionError describing the first failed check.
1008+
check()
1009+
1010+
6881011
if __name__ == "__main__":
6891012
unittest.main()

Makefile.pre.in

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3471,7 +3471,7 @@ MODULE__SOCKET_DEPS=$(srcdir)/Modules/socketmodule.h $(srcdir)/Modules/addrinfo.
34713471
MODULE__SSL_DEPS=$(srcdir)/Modules/_ssl.h $(srcdir)/Modules/_openssl_mem.h $(srcdir)/Modules/_ssl/cert.c $(srcdir)/Modules/_ssl/debughelpers.c $(srcdir)/Modules/_ssl/misc.c $(srcdir)/Modules/_ssl_data_111.h $(srcdir)/Modules/_ssl_data_300.h $(srcdir)/Modules/socketmodule.h
34723472
MODULE__TESTCAPI_DEPS=$(srcdir)/Modules/_testcapi/parts.h $(srcdir)/Modules/_testcapi/util.h
34733473
MODULE__TESTLIMITEDCAPI_DEPS=$(srcdir)/Modules/_testlimitedcapi/testcapi_long.h $(srcdir)/Modules/_testlimitedcapi/parts.h $(srcdir)/Modules/_testlimitedcapi/util.h
3474-
MODULE__TESTINTERNALCAPI_DEPS=$(srcdir)/Modules/_testinternalcapi/parts.h $(srcdir)/Parser/tokenizer/cursor.h $(srcdir)/Parser/tokenizer/source.h $(srcdir)/Python/ceval.h $(srcdir)/Modules/_testinternalcapi/test_targets.h $(srcdir)/Modules/_testinternalcapi/test_cases.c.h
3474+
MODULE__TESTINTERNALCAPI_DEPS=$(srcdir)/Modules/_testinternalcapi/parts.h $(srcdir)/Parser/tokenizer/cursor.h $(srcdir)/Parser/tokenizer/source.h $(srcdir)/Python/ceval.h $(srcdir)/Modules/_testinternalcapi/test_targets.h $(srcdir)/Modules/_testinternalcapi/test_cases.c.h $(TRAMPOLINE_EHFRAME_H)
34753475
MODULE__SQLITE3_DEPS=$(srcdir)/Modules/_sqlite/connection.h $(srcdir)/Modules/_sqlite/cursor.h $(srcdir)/Modules/_sqlite/microprotocols.h $(srcdir)/Modules/_sqlite/module.h $(srcdir)/Modules/_sqlite/prepare_protocol.h $(srcdir)/Modules/_sqlite/row.h $(srcdir)/Modules/_sqlite/util.h
34763476
MODULE__ZSTD_DEPS=$(srcdir)/Modules/_zstd/_zstdmodule.h $(srcdir)/Modules/_zstd/buffer.h $(srcdir)/Modules/_zstd/zstddict.h
34773477

0 commit comments

Comments
 (0)