Source code for gigl.orchestration.kubeflow.kfp_orchestrator

from __future__ import annotations

from concurrent.futures import (
    ALL_COMPLETED,
    FIRST_EXCEPTION,
    Future,
    ThreadPoolExecutor,
    wait,
)
from pathlib import Path
from typing import Optional, Union

from google.cloud import aiplatform
from kfp.compiler import Compiler

import gigl.src.common.constants.local_fs as local_fs_constants
from gigl.common import LocalUri, Uri
from gigl.common.logger import Logger
from gigl.common.services.vertex_ai import PipelineRunStoppedError, VertexAIService
from gigl.common.types.resource_config import CommonPipelineComponentConfigs
from gigl.env.pipelines_config import get_resource_config
from gigl.orchestration.kubeflow.kfp_pipeline import ExitTask, generate_pipeline
from gigl.src.common.constants.components import GiGLComponents
from gigl.src.common.types import AppliedTaskIdentifier
from gigl.src.common.utils.file_loader import FileLoader
from gigl.src.common.utils.time import current_formatted_datetime
from gigl.src.validation_check.libs.name_checks import (
    check_if_kfp_pipeline_job_name_valid,
)

[docs] logger = Logger()
[docs] DEFAULT_PIPELINE_VERSION_NAME = ( f"gigl-pipeline-version-at-{current_formatted_datetime()}" )
[docs] DEFAULT_KFP_COMPILED_PIPELINE_DEST_PATH = LocalUri.join( local_fs_constants.get_project_root_directory(), "build", f"gigl_pipeline_gnn.yaml", )
[docs] DEFAULT_START_AT_COMPONENT = "config_populator"
def _is_cancelled(error: BaseException) -> bool: return ( isinstance(error, PipelineRunStoppedError) and error.state == aiplatform.gapic.PipelineState.PIPELINE_STATE_CANCELLED )
[docs] class KfpOrchestrator: """ Orchestration of Kubeflow Pipelines for GiGL. Methods: compile: Compiles the Kubeflow pipeline. run: Runs the Kubeflow pipeline. upload: Uploads the pipeline to KFP. wait_for_completion: Waits for the pipeline run to complete. """ @classmethod
[docs] def compile( cls, cuda_container_image: str, cpu_container_image: str, dataflow_container_image: str, dst_compiled_pipeline_path: Uri = DEFAULT_KFP_COMPILED_PIPELINE_DEST_PATH, additional_job_args: Optional[dict[GiGLComponents, dict[str, str]]] = None, tag: Optional[str] = None, exit_task: ExitTask = ExitTask.EMAIL, ) -> Uri: """ Compiles the GiGL Kubeflow pipeline. Args: cuda_container_image (str): Container image for CUDA (see: containers/Dockerfile.cuda). cpu_container_image (str): Container image for CPU. dataflow_container_image (str): Container image for Dataflow. dst_compiled_pipeline_path (Uri): Destination path for the compiled pipeline YAML file. Defaults to :data:`~gigl.constants.DEFAULT_KFP_COMPILED_PIPELINE_DEST_PATH`. additional_job_args (Optional[dict[GiGLComponents, dict[str, str]]]): Additional arguments to be passed into components, organized by component. tag (Optional[str]): Optional tag to include in the pipeline description. exit_task (ExitTask): What the pipeline runs when it exits. See :class:`ExitTask`. Returns: Uri: The URI of the compiled pipeline. """ local_pipeline_bundle_path: LocalUri = ( dst_compiled_pipeline_path if isinstance(dst_compiled_pipeline_path, LocalUri) else DEFAULT_KFP_COMPILED_PIPELINE_DEST_PATH ) Path(local_pipeline_bundle_path.uri).parent.mkdir(parents=True, exist_ok=True) logger.info(f"Compiling pipeline to {local_pipeline_bundle_path.uri}") common_pipeline_component_configs = CommonPipelineComponentConfigs( cuda_container_image=cuda_container_image, cpu_container_image=cpu_container_image, dataflow_container_image=dataflow_container_image, additional_job_args=additional_job_args or {}, ) Compiler().compile( generate_pipeline( common_pipeline_component_configs=common_pipeline_component_configs, tag=tag, exit_task=exit_task, ), local_pipeline_bundle_path.uri, ) logger.info(f"Compiled Kubeflow pipeline to {local_pipeline_bundle_path.uri}") logger.info(f"Uploading compiled pipeline to {dst_compiled_pipeline_path.uri}") if local_pipeline_bundle_path != dst_compiled_pipeline_path: logger.info(f"Will upload pipeline to {dst_compiled_pipeline_path.uri}") file_loader = FileLoader() file_loader.load_file( file_uri_src=local_pipeline_bundle_path, file_uri_dst=dst_compiled_pipeline_path, ) return dst_compiled_pipeline_path
[docs] def run( self, applied_task_identifier: AppliedTaskIdentifier, task_config_uri: Uri, resource_config_uri: Uri, start_at: str = DEFAULT_START_AT_COMPONENT, stop_after: Optional[str] = None, compiled_pipeline_path: Uri = DEFAULT_KFP_COMPILED_PIPELINE_DEST_PATH, labels: Optional[dict[str, str]] = None, notification_emails: Optional[list[str]] = None, ) -> aiplatform.PipelineJob: """ Runs the GiGL Kubeflow pipeline. Args: applied_task_identifier (AppliedTaskIdentifier): Identifier for the task. task_config_uri (Uri): URI of the task configuration file. resource_config_uri (Uri): URI of the resource configuration file. start_at (str): Component to start the pipeline at. Defaults to 'config_populator'. stop_after (Optional[str]): Component to stop the pipeline after. Defaults to None i.e. run entire pipeline. compiled_pipeline_path (Uri): Path to the compiled pipeline YAML file. labels (Optional[dict[str, str]]): Labels to associate with the run. notification_emails (Optional[list[str]]): Emails to send notification to. See https://cloud.google.com/vertex-ai/docs/pipelines/email-notifications for more details. Returns: aiplatform.PipelineJob: The created pipeline job. """ check_if_kfp_pipeline_job_name_valid(str(applied_task_identifier)) file_loader = FileLoader() assert file_loader.does_uri_exist(compiled_pipeline_path), ( f"Compiled pipeline path {compiled_pipeline_path} does not exist." ) logger.info(f"Skipping pipeline compilation; will use {compiled_pipeline_path}") run_keyword_args = { "job_name": applied_task_identifier, "start_at": start_at, "template_or_frozen_config_uri": task_config_uri.uri, "resource_config_uri": resource_config_uri.uri, } # We need to provide *some* notification emails, other wise the cleanup component will fail. # Ideally, we'd be able to provide None and have it handle it, but for whatever reason # that's not supported atm. Passing in None gives the below error: # Notification email "recipients" parameter must specify at least one recipient. if notification_emails: run_keyword_args["notification_emails"] = notification_emails else: run_keyword_args["notification_emails"] = [ get_resource_config( resource_config_uri=resource_config_uri ).service_account_email ] if stop_after is not None: run_keyword_args["stop_after"] = stop_after logger.info(f"Running pipeline with args: {run_keyword_args}") resource_config = get_resource_config(resource_config_uri=resource_config_uri) vertex_ai_service = VertexAIService( project=resource_config.project, location=resource_config.region, service_account=resource_config.service_account_email, staging_bucket=resource_config.temp_assets_regional_bucket_path.uri, ) run = vertex_ai_service.run_pipeline( display_name=str(applied_task_identifier), template_path=compiled_pipeline_path, run_keyword_args=run_keyword_args, job_id=str(applied_task_identifier).replace("_", "-"), labels=labels, ) return run
[docs] def wait_for_completion( self, run: Union[ str, aiplatform.PipelineJob, list[str], list[aiplatform.PipelineJob], ], fail_fast: bool = False, ) -> None: """ Waits for the completion of one or more pipeline runs. Args: run (Union[aiplatform.PipelineJob, str]): The pipeline job or its resource name. fail_fast (bool): If True, the first failed run cancels every run that is still waiting, then this waits for Vertex AI to report those runs as cancelled, which takes a few minutes. A run whose status request failed also gets a cancel, because it can still be running. A cancelled run compiled with ``ExitTask.CLEANUP``, as the e2e tests are, cancels its own Vertex AI, Dataflow, and Dataproc child jobs in its exit task; other runs leave their child jobs running. If False, every run is awaited. Either way the error names each failed run. Raises: RuntimeError: If any run failed, or a cancel could not be requested. The message names every failed run and every failed cancel with its error. A run is excused only if this call cancelled it and it then reached CANCELLED. """ resource_names: list[str] = [] if isinstance(run, str): resource_names = [run] elif isinstance(run, aiplatform.PipelineJob): resource_names = [run.resource_name] else: resource_names = [ r.resource_name if isinstance(r, aiplatform.PipelineJob) else r for r in run ] logger.info( f"Waiting for {len(resource_names)} pipeline runs to complete: {resource_names}" ) with ThreadPoolExecutor(max_workers=len(resource_names)) as executor: futures: dict[Future[None], str] = {} for resource_name in resource_names: future = executor.submit( VertexAIService.wait_for_run_completion, resource_name=resource_name ) futures[future] = resource_name done, not_done = wait( futures, return_when=FIRST_EXCEPTION if fail_fast else ALL_COMPLETED, ) cancelled_runs: set[str] = set() cancel_failures: dict[str, BaseException] = {} if fail_fast and any(future.exception() is not None for future in done): # A waiter also raises when a status request fails. Its run can still # be running, so it gets a cancel too. Its error still counts. failed_without_stopping = [ future for future in done if future.exception() is not None and not isinstance(future.exception(), PipelineRunStoppedError) ] runs_to_cancel = [*not_done, *failed_without_stopping] if runs_to_cancel: logger.warning( f"A pipeline run failed; cancelling the {len(runs_to_cancel)} " f"that can still be running: {[futures[f] for f in runs_to_cancel]}" ) for future in runs_to_cancel: resource_name = futures[future] try: # A run that ended on its own before the cancel landed keeps # its own outcome, so it is not marked as cancelled. if ( VertexAIService.cancel_pipeline_run(resource_name) and future in not_done ): cancelled_runs.add(resource_name) except Exception as e: logger.warning(f"Could not cancel {resource_name}: {e!r}") cancel_failures[resource_name] = e # Every wait must return before the executor can shut down. The # cancelled waits return once Vertex AI reports the runs as # cancelled; a run whose cancel failed is awaited until it ends. done, _ = wait(futures, return_when=ALL_COMPLETED) failures: dict[str, BaseException] = {} for future in done: resource_name = futures[future] error = future.exception() if error is None: logger.info(f"Pipeline run {resource_name} completed successfully.") elif resource_name in cancelled_runs and _is_cancelled(error): logger.info( f"Pipeline run {resource_name} was cancelled by fail fast." ) else: # A run that got a cancel but then failed, timed out, or lost its # status request is still a failure. failures[resource_name] = error if failures or cancel_failures: lines = [f" {name}: {error}" for name, error in failures.items()] lines.extend( f" cancel of {name} failed: {error}" for name, error in cancel_failures.items() ) raise RuntimeError( f"{len(failures)} of {len(resource_names)} pipeline runs failed" + ( f" and {len(cancel_failures)} cancel(s) failed" if cancel_failures else "" ) + ":\n" + "\n".join(lines) ) logger.info(f"All {len(resource_names)} pipeline runs completed successfully.")