Skip to content

Commit 3c866c8

Browse files
committed
pythongh-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 14f1377 commit 3c866c8

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:
@@ -664,5 +668,324 @@ def tearDown(self) -> None:
664668
file.unlink()
665669

666670

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