Skip to content

Commit 1bbc856

Browse files
committed
hello, ndjson world
1 parent b5e4c46 commit 1bbc856

4 files changed

Lines changed: 275 additions & 5 deletions

File tree

Lib/profiling/sampling/__init__.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,15 @@
99
from .stack_collector import CollapsedStackCollector
1010
from .heatmap_collector import HeatmapCollector
1111
from .gecko_collector import GeckoCollector
12+
from .ndjson_collector import NdjsonCollector
1213
from .string_table import StringTable
1314

14-
__all__ = ("Collector", "PstatsCollector", "CollapsedStackCollector", "HeatmapCollector", "GeckoCollector", "StringTable")
15+
__all__ = (
16+
"Collector",
17+
"PstatsCollector",
18+
"CollapsedStackCollector",
19+
"HeatmapCollector",
20+
"GeckoCollector",
21+
"NdjsonCollector",
22+
"StringTable",
23+
)

Lib/profiling/sampling/binary_reader.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
from .gecko_collector import GeckoCollector
66
from .stack_collector import FlamegraphCollector, CollapsedStackCollector
7+
from .ndjson_collector import NdjsonCollector
78
from .pstats_collector import PstatsCollector
89

910

@@ -117,6 +118,8 @@ def convert_binary_to_format(input_file, output_file, output_format,
117118
collector = PstatsCollector(interval)
118119
elif output_format == 'gecko':
119120
collector = GeckoCollector(interval)
121+
elif output_format == 'ndjson':
122+
collector = NdjsonCollector(interval)
120123
else:
121124
raise ValueError(f"Unknown output format: {output_format}")
122125

Lib/profiling/sampling/cli.py

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
from .stack_collector import CollapsedStackCollector, FlamegraphCollector
2020
from .heatmap_collector import HeatmapCollector
2121
from .gecko_collector import GeckoCollector
22+
from .ndjson_collector import NdjsonCollector
2223
from .binary_collector import BinaryCollector
2324
from .binary_reader import BinaryReader
2425
from .constants import (
@@ -87,6 +88,7 @@ class CustomFormatter(
8788
"flamegraph": "html",
8889
"gecko": "json",
8990
"heatmap": "html",
91+
"ndjson": "ndjson",
9092
"binary": "bin",
9193
}
9294

@@ -96,6 +98,7 @@ class CustomFormatter(
9698
"flamegraph": FlamegraphCollector,
9799
"gecko": GeckoCollector,
98100
"heatmap": HeatmapCollector,
101+
"ndjson": NdjsonCollector,
99102
"binary": BinaryCollector,
100103
}
101104

@@ -467,6 +470,13 @@ def _add_format_options(parser, include_compression=True, include_binary=True):
467470
dest="format",
468471
help="Generate interactive HTML heatmap visualization with line-level sample counts",
469472
)
473+
format_group.add_argument(
474+
"--ndjson",
475+
action="store_const",
476+
const="ndjson",
477+
dest="format",
478+
help="Generate NDJSON snapshot output for external consumers",
479+
)
470480
if include_binary:
471481
format_group.add_argument(
472482
"--binary",
@@ -545,15 +555,17 @@ def _sort_to_mode(sort_choice):
545555
return sort_map.get(sort_choice, SORT_MODE_NSAMPLES)
546556

547557
def _create_collector(format_type, sample_interval_usec, skip_idle, opcodes=False,
548-
output_file=None, compression='auto'):
558+
mode=None, output_file=None, compression='auto'):
549559
"""Create the appropriate collector based on format type.
550560
551561
Args:
552-
format_type: The output format ('pstats', 'collapsed', 'flamegraph', 'gecko', 'heatmap', 'binary')
562+
format_type: The output format ('pstats', 'collapsed', 'flamegraph',
563+
'gecko', 'heatmap', 'ndjson', 'binary')
553564
sample_interval_usec: Sampling interval in microseconds
554565
skip_idle: Whether to skip idle samples
555566
opcodes: Whether to collect opcode information (only used by gecko format
556567
for creating interval markers in Firefox Profiler)
568+
mode: Profiling mode for collectors that expose it in metadata
557569
output_file: Output file path (required for binary format)
558570
compression: Compression type for binary format ('auto', 'zstd', 'none')
559571
@@ -577,6 +589,11 @@ def _create_collector(format_type, sample_interval_usec, skip_idle, opcodes=Fals
577589
skip_idle = False
578590
return collector_class(sample_interval_usec, skip_idle=skip_idle, opcodes=opcodes)
579591

592+
if format_type == "ndjson":
593+
return collector_class(
594+
sample_interval_usec, skip_idle=skip_idle, mode=mode
595+
)
596+
580597
return collector_class(sample_interval_usec, skip_idle=skip_idle)
581598

582599

@@ -951,7 +968,7 @@ def _handle_attach(args):
951968

952969
# Create the appropriate collector
953970
collector = _create_collector(
954-
args.format, args.sample_interval_usec, skip_idle, args.opcodes,
971+
args.format, args.sample_interval_usec, skip_idle, args.opcodes, mode,
955972
output_file=output_file,
956973
compression=getattr(args, 'compression', 'auto')
957974
)
@@ -1029,7 +1046,7 @@ def _handle_run(args):
10291046

10301047
# Create the appropriate collector
10311048
collector = _create_collector(
1032-
args.format, args.sample_interval_usec, skip_idle, args.opcodes,
1049+
args.format, args.sample_interval_usec, skip_idle, args.opcodes, mode,
10331050
output_file=output_file,
10341051
compression=getattr(args, 'compression', 'auto')
10351052
)
Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
1+
"""NDJSON collector."""
2+
3+
import json
4+
import uuid
5+
6+
from .constants import (
7+
PROFILING_MODE_ALL,
8+
PROFILING_MODE_CPU,
9+
PROFILING_MODE_EXCEPTION,
10+
PROFILING_MODE_GIL,
11+
PROFILING_MODE_WALL,
12+
)
13+
from .stack_collector import StackTraceCollector
14+
15+
16+
_CHUNK_SIZE = 1000
17+
18+
_MODE_NAMES = {
19+
PROFILING_MODE_WALL: "wall",
20+
PROFILING_MODE_CPU: "cpu",
21+
PROFILING_MODE_GIL: "gil",
22+
PROFILING_MODE_ALL: "all",
23+
PROFILING_MODE_EXCEPTION: "exception",
24+
}
25+
26+
27+
class NdjsonCollector(StackTraceCollector):
28+
"""Collector that exports finalized profiling data as NDJSON."""
29+
30+
def __init__(self, sample_interval_usec, *, skip_idle=False, mode=None):
31+
super().__init__(sample_interval_usec, skip_idle=skip_idle)
32+
self.run_id = uuid.uuid4().hex
33+
34+
self._string_to_id = {}
35+
self._strings = []
36+
37+
self._frame_to_id = {}
38+
self._frames = []
39+
40+
self._frame_self = {}
41+
self._frame_cumulative = {}
42+
self._samples_total = 0
43+
44+
self._mode = mode
45+
46+
def process_frames(self, frames, _thread_id, weight=1):
47+
if not frames:
48+
return
49+
50+
self._samples_total += weight
51+
52+
seen_frame_ids = set()
53+
leaf_frame_id = None
54+
55+
for index, (filename, location, funcname, _opcode) in enumerate(
56+
frames
57+
):
58+
frame_id = self._get_or_create_frame_id(
59+
filename, location, funcname
60+
)
61+
if index == 0:
62+
leaf_frame_id = frame_id
63+
if frame_id not in seen_frame_ids:
64+
seen_frame_ids.add(frame_id)
65+
self._frame_cumulative[frame_id] = (
66+
self._frame_cumulative.get(frame_id, 0) + weight
67+
)
68+
69+
if leaf_frame_id is not None:
70+
self._frame_self[leaf_frame_id] = (
71+
self._frame_self.get(leaf_frame_id, 0) + weight
72+
)
73+
74+
def export(self, filename):
75+
with open(filename, "w", encoding="utf-8") as output:
76+
self._write_message(output, self._build_meta_record())
77+
self._write_chunked_defs(output, "str_def", self._strings)
78+
self._write_chunked_defs(output, "frame_def", self._frames)
79+
self._write_chunked_agg(output, self._iter_agg_entries())
80+
self._write_message(
81+
output,
82+
{
83+
"type": "end",
84+
"v": 1,
85+
"run_id": self.run_id,
86+
"samples_total": self._samples_total,
87+
},
88+
)
89+
90+
print(f"NDJSON profile written to {filename}")
91+
92+
def _build_meta_record(self):
93+
record = {
94+
"type": "meta",
95+
"v": 1,
96+
"run_id": self.run_id,
97+
"sample_interval_usec": self.sample_interval_usec,
98+
}
99+
mode = self._mode
100+
if mode is not None:
101+
record["mode"] = _MODE_NAMES.get(mode, str(mode))
102+
return record
103+
104+
def _get_or_create_frame_id(self, filename, location, funcname):
105+
synthetic = location is None
106+
normalized = self._normalize_export_location(location)
107+
func_str_id = self._intern_string(funcname)
108+
path_str_id = self._intern_string(filename)
109+
110+
frame_key = (
111+
path_str_id,
112+
func_str_id,
113+
normalized["line"],
114+
normalized.get("end_line"),
115+
normalized.get("col"),
116+
normalized.get("end_col"),
117+
synthetic,
118+
)
119+
frame_id = self._frame_to_id.get(frame_key)
120+
if frame_id is not None:
121+
return frame_id
122+
123+
frame_id = len(self._frames) + 1
124+
frame_record = {
125+
"frame_id": frame_id,
126+
"path_str_id": path_str_id,
127+
"func_str_id": func_str_id,
128+
"line": normalized["line"],
129+
}
130+
if "end_line" in normalized:
131+
frame_record["end_line"] = normalized["end_line"]
132+
if "col" in normalized:
133+
frame_record["col"] = normalized["col"]
134+
if "end_col" in normalized:
135+
frame_record["end_col"] = normalized["end_col"]
136+
if synthetic:
137+
frame_record["synthetic"] = True
138+
139+
self._frame_to_id[frame_key] = frame_id
140+
self._frames.append(frame_record)
141+
return frame_id
142+
143+
def _intern_string(self, value):
144+
if not isinstance(value, str):
145+
value = str(value)
146+
147+
string_id = self._string_to_id.get(value)
148+
if string_id is not None:
149+
return string_id
150+
151+
string_id = len(self._strings) + 1
152+
self._string_to_id[value] = string_id
153+
self._strings.append(
154+
{
155+
"str_id": string_id,
156+
"value": value,
157+
}
158+
)
159+
return string_id
160+
161+
@staticmethod
162+
def _normalize_export_location(location):
163+
if location is None:
164+
return {"line": 0}
165+
166+
if isinstance(location, int):
167+
lineno = location
168+
end_lineno = None
169+
col_offset = -1
170+
end_col_offset = -1
171+
elif isinstance(location, tuple):
172+
lineno, end_lineno, col_offset, end_col_offset = location
173+
else:
174+
lineno = getattr(location, "lineno", 0)
175+
end_lineno = getattr(location, "end_lineno", lineno)
176+
col_offset = getattr(location, "col_offset", -1)
177+
end_col_offset = getattr(location, "end_col_offset", -1)
178+
179+
line = lineno if isinstance(lineno, int) and lineno > 0 else 0
180+
normalized = {"line": line}
181+
if line > 0 and isinstance(end_lineno, int) and end_lineno > 0:
182+
normalized["end_line"] = end_lineno
183+
if line > 0 and isinstance(col_offset, int) and col_offset >= 0:
184+
normalized["col"] = col_offset
185+
if (
186+
line > 0
187+
and isinstance(end_col_offset, int)
188+
and end_col_offset >= 0
189+
):
190+
normalized["end_col"] = end_col_offset
191+
return normalized
192+
193+
def _iter_agg_entries(self):
194+
entries = []
195+
for frame_record in self._frames:
196+
frame_id = frame_record["frame_id"]
197+
entries.append(
198+
{
199+
"frame_id": frame_id,
200+
"self": self._frame_self.get(frame_id, 0),
201+
"cumulative": self._frame_cumulative.get(frame_id, 0),
202+
}
203+
)
204+
return entries
205+
206+
def _write_chunked_defs(self, output, record_type, entries):
207+
for chunk in self._chunked(entries):
208+
self._write_message(
209+
output,
210+
{
211+
"type": record_type,
212+
"v": 1,
213+
"run_id": self.run_id,
214+
"defs": chunk,
215+
},
216+
)
217+
218+
def _write_chunked_agg(self, output, entries):
219+
for chunk in self._chunked(entries):
220+
self._write_message(
221+
output,
222+
{
223+
"type": "agg",
224+
"v": 1,
225+
"run_id": self.run_id,
226+
"kind": "frame",
227+
"scope": "final",
228+
"samples_total": self._samples_total,
229+
"entries": chunk,
230+
},
231+
)
232+
233+
@staticmethod
234+
def _chunked(entries):
235+
for index in range(0, len(entries), _CHUNK_SIZE):
236+
yield entries[index : index + _CHUNK_SIZE]
237+
238+
@staticmethod
239+
def _write_message(output, record):
240+
output.write(json.dumps(record, separators=(",", ":")))
241+
output.write("\n")

0 commit comments

Comments
 (0)