Skip to content

Commit 817f11c

Browse files
Add heartbeat-based dynamic profiling system to intel repo (#1009)
* Add heartbeat-based dynamic profiling system This PR introduces a heartbeat protocol that enables dynamic profiling control. Agents periodically send heartbeats to a Performance Studio backend and receive start/stop profiling commands, allowing on-demand profiling without agent restarts. Key features: - HeartbeatClient for server communication - DynamicGProfilerManager for profiler lifecycle management - Command idempotency to prevent duplicate execution - Support for dynamic profiler configuration - PerfSpect hardware metrics integration - Comprehensive test suite with mock and live modes - Complete documentation with examples Files added: - gprofiler/heartbeat.py (627 lines) - docs/HEARTBEAT_SYSTEM_README.md (634 lines) - tests/test_heartbeat_system.py (358 lines) - tests/run_heartbeat_agent.py (136 lines) Files modified: - gprofiler/main.py (heartbeat initialization) Source: Pinterest's gprofiler repository Testing: Mock tests pass, live tests verified with backend * Remove metrics_publisher dependency for Intel compatibility Removed Pinterest-specific MetricsPublisher imports and calls. requiring additional Pinterest-specific dependencies. * WIP: Add cgroup support and upgrade PyInstaller * Enable cgroup-based profiling for perf restricted/aggressive modes * Address PR review comments: improve error handling and logging - Remove redundant upload_results validation check in main.py - Change heartbeat failure logging from warning to error for better monitoring - Separate success and error cases in send_heartbeat() response handling - Reduce duplicate logging: change redundant 'Received profiling command' to debug level - Validate command type before marking as executed to ensure proper idempotency - Add unified error handling for start/stop command execution with try-except - Ensure backend always receives command completion status (success or failure) - Keep command details logging for operational visibility Addresses all review comments from @mlim19 on PR #1009. * Fix linting issues for CI compliance - Fix import sorting and code formatting - Remove unused imports and variables - Add missing logging import to profiler_base.py * Fixed by ensuring -a comes before -- in the perf command * Improve heartbeat mode logging with clearer status messages after stop command
1 parent 37e6acc commit 817f11c

13 files changed

Lines changed: 2705 additions & 34 deletions

docs/HEARTBEAT_SYSTEM_README.md

Lines changed: 634 additions & 0 deletions
Large diffs are not rendered by default.

exe-requirements.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
1-
pyinstaller==6.12.0
1+
pyinstaller==6.17.0
22
staticx @ git+https://github.com/Granulate/staticx.git@33eefdadc72832d5aa67c0792768c9e76afb746d; platform.machine == "x86_64"

gprofiler/heartbeat.py

Lines changed: 609 additions & 0 deletions
Large diffs are not rendered by default.

gprofiler/main.py

Lines changed: 130 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@
3333
from granulate_utils.linux.ns import is_root, is_running_in_init_pid
3434
from granulate_utils.linux.process import is_process_running
3535
from granulate_utils.metadata.cloud import get_aws_execution_env
36-
from psutil import NoSuchProcess, Process
36+
from psutil import NoSuchProcess, Process, process_iter
3737
from requests import RequestException, Timeout
3838

3939
from gprofiler import __version__
@@ -48,6 +48,7 @@
4848
from gprofiler.diagnostics import log_diagnostics, set_diagnostics
4949
from gprofiler.exceptions import APIError, NoProfilersEnabledError
5050
from gprofiler.gprofiler_types import ProcessToProfileData, UserArgs, integers_list, positive_integer
51+
from gprofiler.heartbeat import DynamicGProfilerManager, HeartbeatClient
5152
from gprofiler.hw_metrics import HWMetricsMonitor, HWMetricsMonitorBase, NoopHWMetricsMonitor
5253
from gprofiler.log import RemoteLogsHandler, initial_root_logger_setup
5354
from gprofiler.merge import concatenate_from_external_file, concatenate_profiles, merge_profiles
@@ -167,6 +168,8 @@ def __init__(
167168
profiling_mode=profiling_mode,
168169
container_names_client=container_names_client,
169170
processes_to_profile=processes_to_profile,
171+
max_processes_per_profiler=user_args.get("max_processes_per_profiler", 0),
172+
max_system_processes_for_system_profilers=user_args.get("max_system_processes_for_system_profilers", 0),
170173
)
171174
self.system_profiler, self.process_profilers = get_profilers(user_args, profiler_state=self._profiler_state)
172175
self._usage_logger = usage_logger
@@ -279,8 +282,37 @@ def start(self) -> None:
279282
self._system_metrics_monitor.start()
280283
self._hw_metrics_monitor.start()
281284

285+
# Check if system should skip continuous profilers due to process count
286+
skip_system_profilers = False
287+
if self._profiler_state.max_system_processes_for_system_profilers > 0:
288+
try:
289+
total_processes = len(list(process_iter()))
290+
if total_processes > self._profiler_state.max_system_processes_for_system_profilers:
291+
skip_system_profilers = True
292+
logger.warning(
293+
f"Skipping system profilers (perf) - {total_processes} processes exceed threshold "
294+
f"of {self._profiler_state.max_system_processes_for_system_profilers}. "
295+
f"Runtime profilers (py-spy, Java, etc.) will continue normally."
296+
)
297+
else:
298+
logger.debug(
299+
f"System process count: {total_processes} "
300+
f"(threshold: {self._profiler_state.max_system_processes_for_system_profilers})"
301+
)
302+
except Exception as e:
303+
logger.warning(f"Could not count system processes, continuing with all profilers: {e}")
304+
282305
for prof in list(self.all_profilers):
283306
try:
307+
# Skip system profilers if threshold exceeded
308+
if (
309+
skip_system_profilers
310+
and hasattr(prof, "_is_system_wide_profiler")
311+
and prof._is_system_wide_profiler()
312+
):
313+
logger.info(f"Skipping {prof.__class__.__name__} due to high system process count")
314+
continue
315+
284316
prof.start()
285317
except Exception:
286318
# the SystemProfiler is handled separately - let the user run with '--perf-mode none' if they
@@ -594,6 +626,25 @@ def parse_cmd_args() -> configargparse.Namespace:
594626
help="Comma separated list of processes that will be filtered to profile,"
595627
" given multiple times will append pids to one list",
596628
)
629+
parser.add_argument(
630+
"--max-processes-runtime-profiler",
631+
dest="max_processes_per_profiler",
632+
type=positive_integer,
633+
default=0,
634+
help="Maximum number of processes to profile per runtime profiler (0=unlimited). "
635+
"When exceeded, profiles only the top N processes by CPU usage. "
636+
"Does not affect system-wide profilers (perf, eBPF). Default: %(default)s",
637+
)
638+
parser.add_argument(
639+
"--skip-system-profilers-above",
640+
dest="max_system_processes_for_system_profilers",
641+
type=positive_integer,
642+
default=0,
643+
help="Skip system-wide profilers (perf only) when total system processes exceed this threshold (0=unlimited). "
644+
"When exceeded, prevents perf profiler from starting to reduce resource usage on busy systems. "
645+
"PyPerf has its own threshold via --python-skip-pyperf-profiler-above. "
646+
"Runtime profilers (py-spy, Java, etc.) continue normally with --max-processes limiting. Default: %(default)s",
647+
)
597648
parser.add_argument(
598649
"--rootless",
599650
action="store_true",
@@ -861,6 +912,22 @@ def parse_cmd_args() -> configargparse.Namespace:
861912
"The file modification indicates the last snapshot time.",
862913
)
863914

915+
parser.add_argument(
916+
"--enable-heartbeat-server",
917+
action="store_true",
918+
dest="enable_heartbeat_server",
919+
default=False,
920+
help="Enable heartbeat communication with server for dynamic profiling commands",
921+
)
922+
923+
parser.add_argument(
924+
"--heartbeat-interval",
925+
type=positive_integer,
926+
dest="heartbeat_interval",
927+
default=30,
928+
help="Interval in seconds for sending heartbeats to server (default: %(default)s)",
929+
)
930+
864931
if is_linux() and not is_aarch64():
865932
hw_metrics_options = parser.add_argument_group("hardware metrics")
866933
hw_metrics_options.add_argument(
@@ -936,6 +1003,14 @@ def parse_cmd_args() -> configargparse.Namespace:
9361003
if args.profile_spawned_processes and args.pids_to_profile is not None:
9371004
parser.error("--pids is not allowed when profiling spawned processes")
9381005

1006+
if args.enable_heartbeat_server:
1007+
if not args.upload_results:
1008+
parser.error("--enable-heartbeat-server requires --upload-results to be enabled")
1009+
if not args.server_token:
1010+
parser.error("--enable-heartbeat-server requires --token to be provided")
1011+
if not args.service_name:
1012+
parser.error("--enable-heartbeat-server requires --service-name to be provided")
1013+
9391014
return args
9401015

9411016

@@ -1215,37 +1290,61 @@ def main() -> None:
12151290

12161291
ApplicationIdentifiers.init(enrichment_options)
12171292
set_diagnostics(args.diagnostics)
1218-
gprofiler = GProfiler(
1219-
output_dir=args.output_dir,
1220-
flamegraph=args.flamegraph,
1221-
rotating_output=args.rotating_output,
1222-
rootless=args.rootless,
1223-
profiler_api_client=profiler_api_client,
1224-
collect_metrics=args.collect_metrics,
1225-
collect_metadata=args.collect_metadata,
1226-
enrichment_options=enrichment_options,
1227-
state=state,
1228-
usage_logger=usage_logger,
1229-
user_args=args.__dict__,
1230-
duration=args.duration,
1231-
profile_api_version=args.profile_api_version,
1232-
profiling_mode=args.profiling_mode,
1233-
collect_hw_metrics=getattr(args, "collect_hw_metrics", False),
1234-
profile_spawned_processes=args.profile_spawned_processes,
1235-
remote_logs_handler=remote_logs_handler,
1236-
controller_process=controller_process,
1237-
processes_to_profile=processes_to_profile,
1238-
external_metadata_path=external_metadata_path,
1239-
heartbeat_file_path=heartbeat_file_path,
1240-
perfspect_path=perfspect_path,
1241-
perfspect_duration=getattr(args, "tool_perfspect_duration", 60),
1242-
verbose=args.verbose,
1243-
)
1244-
logger.info("gProfiler initialized and ready to start profiling")
1245-
if args.continuous:
1246-
gprofiler.run_continuous()
1293+
1294+
# Check if heartbeat server mode is enabled FIRST
1295+
if args.enable_heartbeat_server:
1296+
# Create heartbeat client
1297+
heartbeat_client = HeartbeatClient(
1298+
api_server=args.api_server,
1299+
service_name=args.service_name,
1300+
server_token=args.server_token,
1301+
verify=args.verify,
1302+
)
1303+
1304+
# Create dynamic profiler manager
1305+
manager = DynamicGProfilerManager(args, heartbeat_client)
1306+
manager.heartbeat_interval = args.heartbeat_interval
1307+
1308+
try:
1309+
logger.info("Starting heartbeat mode - waiting for server commands...")
1310+
manager.start_heartbeat_loop()
1311+
except KeyboardInterrupt:
1312+
logger.info("Received interrupt signal, stopping heartbeat mode...")
1313+
finally:
1314+
manager.stop()
12471315
else:
1248-
gprofiler.run_single()
1316+
# Normal profiling mode
1317+
gprofiler = GProfiler(
1318+
output_dir=args.output_dir,
1319+
flamegraph=args.flamegraph,
1320+
rotating_output=args.rotating_output,
1321+
rootless=args.rootless,
1322+
profiler_api_client=profiler_api_client,
1323+
collect_metrics=args.collect_metrics,
1324+
collect_metadata=args.collect_metadata,
1325+
enrichment_options=enrichment_options,
1326+
state=state,
1327+
usage_logger=usage_logger,
1328+
user_args=args.__dict__,
1329+
duration=args.duration,
1330+
profile_api_version=args.profile_api_version,
1331+
profiling_mode=args.profiling_mode,
1332+
collect_hw_metrics=getattr(args, "collect_hw_metrics", False),
1333+
profile_spawned_processes=args.profile_spawned_processes,
1334+
remote_logs_handler=remote_logs_handler,
1335+
controller_process=controller_process,
1336+
processes_to_profile=processes_to_profile,
1337+
external_metadata_path=external_metadata_path,
1338+
heartbeat_file_path=heartbeat_file_path,
1339+
perfspect_path=perfspect_path,
1340+
perfspect_duration=getattr(args, "tool_perfspect_duration", 60),
1341+
verbose=args.verbose,
1342+
)
1343+
logger.info("gProfiler initialized and ready to start profiling")
1344+
if args.continuous:
1345+
gprofiler.run_continuous()
1346+
else:
1347+
gprofiler.run_single()
12491348

12501349
except KeyboardInterrupt:
12511350
pass

gprofiler/profiler_state.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ class ProfilerState:
2525
profiling_mode: str
2626
container_names_client: Optional[ContainerNamesClient]
2727
processes_to_profile: Optional[List[Process]]
28+
max_processes_per_profiler: int
29+
max_system_processes_for_system_profilers: int
2830

2931
def __post_init__(self) -> None:
3032
self._temporary_dir = TemporaryDirectoryWithMode(dir=self.storage_dir, mode=0o755)

gprofiler/profilers/perf.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,30 @@ def add_highest_avg_depth_stacks_per_process(
125125
action="store_false",
126126
dest="perf_memory_restart",
127127
),
128+
ProfilerArgument(
129+
"--perf-use-cgroups",
130+
help="Use cgroup-based profiling instead of PID-based profiling for better reliability. "
131+
"Profiles the top N cgroups by resource usage, avoiding crashes from invalid PIDs.",
132+
action="store_true",
133+
default=False,
134+
dest="perf_use_cgroups",
135+
),
136+
ProfilerArgument(
137+
"--perf-max-cgroups",
138+
help="Maximum number of cgroups to profile when using --perf-use-cgroups. Default: %(default)s",
139+
type=int,
140+
default=50,
141+
dest="perf_max_cgroups",
142+
),
143+
ProfilerArgument(
144+
"--perf-max-docker-containers",
145+
help="Maximum number of individual Docker containers to profile instead of the broad 'docker' cgroup. "
146+
"When set, profiles the top N highest-resource individual containers rather than all containers together. "
147+
"Set to 0 to use the broad 'docker' cgroup (default behavior). Default: %(default)s",
148+
type=int,
149+
default=0,
150+
dest="perf_max_docker_containers",
151+
),
128152
],
129153
disablement_help="Disable the global perf of processes,"
130154
" and instead only concatenate runtime-specific profilers results",
@@ -138,6 +162,10 @@ class SystemProfiler(ProfilerBase):
138162
versions of Go processes.
139163
"""
140164

165+
def _is_system_wide_profiler(self) -> bool:
166+
"""Perf is a system-wide profiler that can be disabled on busy systems."""
167+
return True
168+
141169
def __init__(
142170
self,
143171
frequency: int,
@@ -148,6 +176,9 @@ def __init__(
148176
perf_inject: bool,
149177
perf_node_attach: bool,
150178
perf_memory_restart: bool,
179+
perf_use_cgroups: bool = False,
180+
perf_max_cgroups: int = 50,
181+
perf_max_docker_containers: int = 0,
151182
min_duration: int = 0,
152183
):
153184
super().__init__(frequency, duration, profiler_state, min_duration)
@@ -159,6 +190,12 @@ def __init__(
159190
self._node_processes: List[Process] = []
160191
self._node_processes_attached: List[Process] = []
161192
self._perf_memory_restart = perf_memory_restart
193+
self._perf_mode = perf_mode
194+
self._perf_dwarf_stack_size = perf_dwarf_stack_size
195+
self._perf_inject = perf_inject
196+
self._perf_use_cgroups = perf_use_cgroups
197+
self._perf_max_cgroups = perf_max_cgroups
198+
self._perf_max_docker_containers = perf_max_docker_containers
162199
switch_timeout_s = duration * 3 # allow gprofiler to be delayed up to 3 intervals before timing out.
163200
extra_args = []
164201
try:
@@ -184,6 +221,9 @@ def __init__(
184221
extra_args=extra_args,
185222
processes_to_profile=self._profiler_state.processes_to_profile,
186223
switch_timeout_s=switch_timeout_s,
224+
use_cgroups=self._perf_use_cgroups,
225+
max_cgroups=self._perf_max_cgroups,
226+
max_docker_containers=self._perf_max_docker_containers,
187227
)
188228
self._perfs.append(self._perf_fp)
189229
else:
@@ -200,6 +240,9 @@ def __init__(
200240
extra_args=extra_args,
201241
processes_to_profile=self._profiler_state.processes_to_profile,
202242
switch_timeout_s=switch_timeout_s,
243+
use_cgroups=self._perf_use_cgroups,
244+
max_cgroups=self._perf_max_cgroups,
245+
max_docker_containers=self._perf_max_docker_containers,
203246
)
204247
self._perfs.append(self._perf_dwarf)
205248
else:

0 commit comments

Comments
 (0)