Skip to content

Commit ebde16e

Browse files
authored
Add instrumentation for memory profiling in Python SDK (#38853)
* Introduce python memory profiling pipeline options. * Add memray and google-pprof dependencies * Parse profiler options in boot.go * Upload profiles to GCS. * Disable the profiler after a timeout reached. * Support --profiler_stop_after_crash * Add on-the-worker postprocessing support. * Generate also the --leaks flamegraph. * Profiler options: use seconds, update defaults. * Set a default profile location from temp location. * Refactor: move profiling pieces into a separate file. * Post-process profiles sequentially. * Simplify tcmalloc preloading to be compatible with ARM versions. * Use consistent mechanisms for periodic invocations of background tasks. * Formatting * Regenerate dependencies. * Update changes.md * Address comments. * Be more resilient to scenarios when postprocessing was interrupted. * Handle postprocessing decision for each report individually. * Remove none-handling since pipeline option validation overwrites empty value later anyway. Users can pass --profile_upload_interval_sec=0 to disable uploads. * Append Job ID and hostname if available in the local temp directory structure. * yapf
1 parent 6ffee9f commit ebde16e

20 files changed

Lines changed: 864 additions & 250 deletions

CHANGES.md

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -59,10 +59,10 @@
5959

6060
## Highlights
6161

62-
* New highly anticipated feature X added to Python SDK ([#X](https://github.com/apache/beam/issues/X)).
63-
* New highly anticipated feature Y added to Java SDK ([#Y](https://github.com/apache/beam/issues/Y)).
62+
* Python SDK now supports memory profiling with Memray ([#38853](https://github.com/apache/beam/issues/38853)).
6463
* (Python) Added [Qdrant](https://qdrant.tech/) VectorDatabaseWriteConfig implementation ([#38141](https://github.com/apache/beam/issues/38141)).
6564

65+
6666
## I/Os
6767

6868
* Support for reading from Delta Lake added (Java) ([#38551](https://github.com/apache/beam/issues/38551)).
@@ -71,10 +71,10 @@
7171
## New Features / Improvements
7272

7373
* (Java) Enabled state tag encoding v2 by default for new Dataflow Streaming Engine jobs. It can be disabled by passing `--experiments=disable_streaming_engine_state_tag_encoding_v2` or `--updateCompatibilityVersion=2.74.0` pipeline option. Note that the tag encoding version cannot change during a job update. Jobs using tag encoding v2 (enabled by default for new jobs on 2.75.0+) cannot be downgraded to Beam versions prior to 2.73.0, as only versions 2.73.0 and later support tag encoding v2. ([#38705](https://github.com/apache/beam/issues/38705)).
74+
* (Python) Added instrumentation to support off-the-shelf profiling agents when launching Python SDK Harness ([#38853](https://github.com/apache/beam/issues/38853)).
7475

7576
## Breaking Changes
7677

77-
* X behavior was changed ([#X](https://github.com/apache/beam/issues/X)).
7878
* (Python) Typehints of dataclass fields are honored during type inferences. To restore the behavior of fallback-to-any,
7979
use pipeline option `--exclude_infer_dataclass_field_type` ([#38797](https://github.com/apache/beam/issues/38797)).
8080
However fixing forward is recommended.
@@ -85,7 +85,6 @@
8585

8686
## Bugfixes
8787

88-
* Fixed X (Java/Python) ([#X](https://github.com/apache/beam/issues/X)).
8988
* Fixed IcebergIO writing manifest column bounds padded with trailing `0x00` bytes, which broke equality predicate pushdown in some query engines (Java) ([#38580](https://github.com/apache/beam/issues/38580)).
9089

9190
## Security Fixes

sdks/python/apache_beam/options/pipeline_options.py

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1658,6 +1658,82 @@ def _add_argparse_args(cls, parser):
16581658
default=1.0,
16591659
help='A number between 0 and 1 indicating the ratio '
16601660
'of bundles that should be profiled.')
1661+
parser.add_argument(
1662+
'--profiler_agent',
1663+
default=None,
1664+
help=(
1665+
'Specifies the profiling agent to launch the SDK worker harness '
1666+
'with (e.g., "memray", "tcmalloc", or a custom wrapper script/binary).'
1667+
))
1668+
parser.add_argument(
1669+
'--profiler_extra_arg',
1670+
'--profiler_extra_args',
1671+
dest='profiler_extra_args',
1672+
action=_CommaSeparatedListAction,
1673+
default=None,
1674+
help=
1675+
'Comma-separated list of extra arguments to pass to the profiler agent.'
1676+
)
1677+
parser.add_argument(
1678+
'--profiler_extra_env_var',
1679+
'--profiler_extra_env_vars',
1680+
dest='profiler_extra_env_vars',
1681+
action=_CommaSeparatedListAction,
1682+
default=None,
1683+
help=(
1684+
'Comma-separated list of environment variables required by the profiler agent '
1685+
'in format "KEY1=VAL1,KEY2=VAL2".'))
1686+
parser.add_argument(
1687+
'--profile_temp_location',
1688+
default=None,
1689+
help=(
1690+
'Directory path on the worker where local profiles are saved. '
1691+
'Defaults to ${semi_persist_dir}/profiles if not specified.'))
1692+
parser.add_argument(
1693+
'--profile_upload_interval_sec',
1694+
type=int,
1695+
default=300,
1696+
help=(
1697+
'Frequency (in seconds) at which the local profiles are uploaded to GCS. '
1698+
'Defaults to 300 (5 min).'))
1699+
parser.add_argument(
1700+
'--profiler_stop_after_sec',
1701+
type=int,
1702+
default=0,
1703+
help=(
1704+
'Time limit (in seconds) for profiling a single process. When exceeded, '
1705+
'the worker process is restarted without the profiler.'))
1706+
parser.add_argument(
1707+
'--profiler_stop_after_crash',
1708+
action='store_true',
1709+
default=False,
1710+
help=(
1711+
'If True, the profiling agent won\'t be re-enabled after a worker '
1712+
'process crash.'))
1713+
parser.add_argument(
1714+
'--profile_postprocess_interval_sec',
1715+
type=int,
1716+
default=600,
1717+
help=(
1718+
'Frequency (in seconds) at which the local profiles are post-processed '
1719+
'on-the-fly. Defaults to 600 (10 minutes). Set to 0 to disable.'))
1720+
1721+
def validate(self, validator):
1722+
errors = []
1723+
if self.profiler_agent:
1724+
if self.profile_cpu or self.profile_memory:
1725+
errors.append(
1726+
'--profiler_agent is mutually exclusive with --profile_cpu '
1727+
'and --profile_memory.')
1728+
1729+
if not self.profile_location:
1730+
temp_location = self.view_as(GoogleCloudOptions).temp_location
1731+
if temp_location:
1732+
self.profile_location = temp_location.rstrip('/') + '/profiles'
1733+
_LOGGER.info(
1734+
'Setting --profile_location to %s since profiling is enabled.',
1735+
self.profile_location)
1736+
return errors
16611737

16621738

16631739
class SetupOptions(PipelineOptions):

sdks/python/apache_beam/options/pipeline_options_test.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -669,6 +669,43 @@ def test_unknown_option_prefix(self):
669669
options = PipelineOptions(['--type_check_strictness', 'blahblah'])
670670
options.view_as(TypeOptions)
671671

672+
def test_profiling_agent_is_exclusive_with_legacy_profiling_options(self):
673+
options = PipelineOptions(['--profiler_agent=memray'])
674+
validator = PipelineOptionsValidator(options, None)
675+
self.assertEqual(validator.validate(), [])
676+
677+
options = PipelineOptions(['--profiler_agent=memray', '--profile_cpu'])
678+
validator = PipelineOptionsValidator(options, None)
679+
errors = validator.validate()
680+
self.assertTrue(
681+
any('--profiler_agent is mutually exclusive' in err for err in errors))
682+
683+
options = PipelineOptions(['--profiler_agent=memray', '--profile_memory'])
684+
validator = PipelineOptionsValidator(options, None)
685+
errors = validator.validate()
686+
self.assertTrue(
687+
any('--profiler_agent is mutually exclusive' in err for err in errors))
688+
689+
def test_profile_location_defaulting_and_opt_out(self):
690+
options = PipelineOptions(
691+
['--profiler_agent=memray', '--temp_location=gs://bucket/temp'])
692+
validator = PipelineOptionsValidator(options, None)
693+
self.assertEqual(validator.validate(), [])
694+
self.assertEqual(
695+
options.view_as(ProfilingOptions).profile_location,
696+
'gs://bucket/temp/profiles')
697+
698+
options = PipelineOptions([
699+
'--profiler_agent=memray',
700+
'--temp_location=gs://bucket/temp',
701+
'--profile_location=gs://other-bucket/custom_profiles'
702+
])
703+
validator = PipelineOptionsValidator(options, None)
704+
self.assertEqual(validator.validate(), [])
705+
self.assertEqual(
706+
options.view_as(ProfilingOptions).profile_location,
707+
'gs://other-bucket/custom_profiles')
708+
672709
def test_add_experiment(self):
673710
options = PipelineOptions([])
674711
options.view_as(DebugOptions).add_experiment('new_experiment')

sdks/python/apache_beam/options/pipeline_options_validator.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
from apache_beam.options.pipeline_options import DebugOptions
3131
from apache_beam.options.pipeline_options import GoogleCloudOptions
3232
from apache_beam.options.pipeline_options import PortableOptions
33+
from apache_beam.options.pipeline_options import ProfilingOptions
3334
from apache_beam.options.pipeline_options import SetupOptions
3435
from apache_beam.options.pipeline_options import StandardOptions
3536
from apache_beam.options.pipeline_options import TestOptions
@@ -55,6 +56,7 @@ class PipelineOptionsValidator(object):
5556
DebugOptions,
5657
GoogleCloudOptions,
5758
PortableOptions,
59+
ProfilingOptions,
5860
SetupOptions,
5961
StandardOptions,
6062
TestOptions,

sdks/python/container/Dockerfile

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,10 @@ RUN \
4545
ccache \
4646
# Required for using Beam Python SDK on ARM machines.
4747
libgeos-dev \
48+
# Required for memory profiling with tcmalloc.
49+
google-perftools \
4850
&& \
51+
4952
rm -rf /var/lib/apt/lists/* && \
5053

5154
pip install --upgrade pip setuptools wheel && \

sdks/python/container/base_image_requirements_manual.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ google-cloud-profiler;python_version<="3.12"
3838
# tests
3939
google-api-python-client;python_version<="3.13"
4040
guppy3
41+
# Explicitly pin memray to use a version compatible with postprocessing logic in Beam.
42+
memray==1.19.3
4143
mmh3 # Optimizes execution of some Beam codepaths. TODO: Make it Beam's dependency.
4244
nltk # Commonly used for natural language processing.
4345
google-crc32c

sdks/python/container/boot.go

Lines changed: 81 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import (
3232
"slices"
3333
"strings"
3434
"sync"
35+
"sync/atomic"
3536
"syscall"
3637
"time"
3738

@@ -133,7 +134,17 @@ type PipelineOptionsData struct {
133134
}
134135

135136
type OptionsData struct {
136-
Experiments []string `json:"experiments"`
137+
Experiments []string `json:"experiments"`
138+
ProfilerAgent string `json:"profiler_agent"`
139+
ProfilerExtraArgs []string `json:"profiler_extra_args"`
140+
ProfilerExtraEnvVars []string `json:"profiler_extra_env_vars"`
141+
ProfileLocation string `json:"profile_location"`
142+
ProfileTempLocation string `json:"profile_temp_location"`
143+
ProfileUploadIntervalSec int `json:"profile_upload_interval_sec"`
144+
ProfilerStopAfterSec int `json:"profiler_stop_after_sec"`
145+
ProfilerStopAfterCrash bool `json:"profiler_stop_after_crash"`
146+
ProfilePostprocessIntervalSec int `json:"profile_postprocess_interval_sec"`
147+
JobId string `json:"jobId,omitempty"`
137148
}
138149

139150
func getExperiments(options string) []string {
@@ -198,6 +209,14 @@ func launchSDKProcess() error {
198209
logger.Printf(ctx, "Build isolation disabled when installing packages with pip")
199210
}
200211

212+
var opts PipelineOptionsData
213+
if err := json.Unmarshal([]byte(options), &opts); err != nil {
214+
logger.Warnf(ctx, "Failed to unmarshal pipeline options for profiling config: %v", err)
215+
}
216+
217+
ctx = setupProfilerConfig(ctx, logger, &opts)
218+
startProfilerBackgroundTasks(ctx, logger)
219+
201220
// (2) Retrieve and install the staged packages.
202221
//
203222
// No log.Fatalf() from here on, otherwise deferred cleanups will not be called!
@@ -314,11 +333,6 @@ func launchSDKProcess() error {
314333
childPids.mu.Unlock()
315334
}()
316335

317-
args := []string{
318-
"-m",
319-
sdkHarnessEntrypoint,
320-
}
321-
322336
var wg sync.WaitGroup
323337
wg.Add(len(workerIds))
324338
for _, workerId := range workerIds {
@@ -333,12 +347,68 @@ func launchSDKProcess() error {
333347
childPids.mu.Unlock()
334348
return
335349
}
336-
logger.Printf(ctx, "Executing Python (worker %v): python %v", workerId, strings.Join(args, " "))
337-
cmd := StartCommandEnv(map[string]string{"WORKER_ID": workerId}, os.Stdin, bufLogger, bufLogger, "python", args...)
350+
351+
currentProg := "python"
352+
currentArgs := []string{"-m", sdkHarnessEntrypoint}
353+
currentEnv := map[string]string{"WORKER_ID": workerId}
354+
355+
profilingActive := false
356+
currentProg, currentArgs, currentEnv, profilingActive = maybeWithProfiler(
357+
ctx, logger, workerId, currentProg, currentArgs, currentEnv,
358+
)
359+
360+
var envStr string
361+
if len(currentEnv) > 0 {
362+
var envStrings []string
363+
for k, v := range currentEnv {
364+
envStrings = append(envStrings, k+"="+v)
365+
}
366+
slices.Sort(envStrings)
367+
envStr = strings.Join(envStrings, ", ")
368+
}
369+
370+
logger.Printf(ctx, "Executing Python (%v): %v %v", envStr, currentProg, strings.Join(currentArgs, " "))
371+
cmd := StartCommandEnv(currentEnv, os.Stdin, bufLogger, bufLogger, currentProg, currentArgs...)
338372
childPids.v = append(childPids.v, cmd.Process.Pid)
339373
childPids.mu.Unlock()
340374

341-
if err := cmd.Wait(); err != nil {
375+
var timer *time.Timer
376+
var profilingTimedOut atomic.Bool
377+
378+
pcfg := getProfilerConfig(ctx)
379+
if profilingActive && pcfg.StopAfterSec > 0 {
380+
duration := time.Duration(pcfg.StopAfterSec) * time.Second
381+
timer = time.AfterFunc(duration, func() {
382+
childPids.mu.Lock()
383+
defer childPids.mu.Unlock()
384+
if cmd.Process != nil {
385+
logger.Printf(ctx, "Profiling timeout of %d seconds reached. Sending SIGINT to worker %s",
386+
pcfg.StopAfterSec, workerId)
387+
profilingTimedOut.Store(true)
388+
syscall.Kill(-cmd.Process.Pid, syscall.SIGINT)
389+
}
390+
})
391+
}
392+
393+
err := cmd.Wait()
394+
if timer != nil {
395+
timer.Stop()
396+
}
397+
398+
if err != nil {
399+
if profilingTimedOut.Load() {
400+
stopProfiling(ctx)
401+
bufLogger.FlushAtDebug(ctx)
402+
logger.Printf(ctx, "Python worker %v terminated after profiling timeout. Restarting without profiler.", workerId)
403+
// Error is not counted toward error budget.
404+
continue
405+
}
406+
407+
if profilingActive && pcfg.StopAfterCrash {
408+
stopProfiling(ctx)
409+
logger.Printf(ctx, "Python worker %v crashed. Disabling profiler on subsequent restarts because --profiler_stop_after_crash is enabled.", workerId)
410+
}
411+
342412
// Retry on fatal errors, like OOMs and segfaults, not just
343413
// DoFns throwing exceptions.
344414
errorCount += 1
@@ -532,3 +602,5 @@ func logSubmissionEnvDependencies(ctx context.Context, bufLogger *tools.Buffered
532602
bufLogger.Printf(ctx, "%s", string(content))
533603
return nil
534604
}
605+
606+

0 commit comments

Comments
 (0)