From ec208f52a6f01b64c112d66a5d1ef25027c9db55 Mon Sep 17 00:00:00 2001 From: Alexej Penner Date: Thu, 29 Feb 2024 11:40:35 +0100 Subject: [PATCH 01/45] Implemented bitbucket webhook event source --- .../webhooks/base_webhook_event_source.py | 4 +- src/zenml/integrations/__init__.py | 1 + src/zenml/integrations/bitbucket/__init__.py | 42 ++ .../bitbucket/plugins/__init__.py | 20 + .../bitbucket_webhook_event_source_flavor.py | 42 ++ .../plugins/event_sources/__init__.py | 0 .../bitbucket_webhook_event_source.py | 466 ++++++++++++++++++ src/zenml/integrations/constants.py | 1 + .../github_webhook_event_source_flavor.py | 2 +- 9 files changed, 575 insertions(+), 3 deletions(-) create mode 100644 src/zenml/integrations/bitbucket/__init__.py create mode 100644 src/zenml/integrations/bitbucket/plugins/__init__.py create mode 100644 src/zenml/integrations/bitbucket/plugins/bitbucket_webhook_event_source_flavor.py create mode 100644 src/zenml/integrations/bitbucket/plugins/event_sources/__init__.py create mode 100644 src/zenml/integrations/bitbucket/plugins/event_sources/bitbucket_webhook_event_source.py diff --git a/src/zenml/event_sources/webhooks/base_webhook_event_source.py b/src/zenml/event_sources/webhooks/base_webhook_event_source.py index 6cba216659b..5e467336c50 100644 --- a/src/zenml/event_sources/webhooks/base_webhook_event_source.py +++ b/src/zenml/event_sources/webhooks/base_webhook_event_source.py @@ -153,10 +153,10 @@ def _validate_webhook_event_signature( Raises: AuthorizationException: If the signature validation fails. """ - signature_header = headers.get("x-hub-signature-256") + signature_header = headers.get("x-hub-signature-256") or headers.get("x-hub-signature") if not signature_header: raise AuthorizationException( - "x-hub-signature-256 header is missing!" + "x-hub-signature-256 or x-hub-signature header is missing!" ) if not self.is_valid_signature( diff --git a/src/zenml/integrations/__init__.py b/src/zenml/integrations/__init__.py index 786e4d86f77..f48109e43e9 100644 --- a/src/zenml/integrations/__init__.py +++ b/src/zenml/integrations/__init__.py @@ -23,6 +23,7 @@ from zenml.integrations.aws import AWSIntegration # noqa from zenml.integrations.azure import AzureIntegration # noqa from zenml.integrations.bentoml import BentoMLIntegration # noqa +from zenml.integrations.bitbucket import BitbucketIntegration # noqa from zenml.integrations.deepchecks import DeepchecksIntegration # noqa from zenml.integrations.discord import DiscordIntegration # noqa from zenml.integrations.evidently import EvidentlyIntegration # noqa diff --git a/src/zenml/integrations/bitbucket/__init__.py b/src/zenml/integrations/bitbucket/__init__.py new file mode 100644 index 00000000000..770f355a2ee --- /dev/null +++ b/src/zenml/integrations/bitbucket/__init__.py @@ -0,0 +1,42 @@ +# Copyright (c) ZenML GmbH 2022. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at: +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express +# or implied. See the License for the specific language governing +# permissions and limitations under the License. +"""Initialization of the bitbucket ZenML integration.""" +from typing import List, Type + +from zenml.integrations.constants import BITBUCKET +from zenml.integrations.integration import Integration +from zenml.plugins.base_plugin_flavor import BasePluginFlavor + +BITBUCKET_EVENT_FLAVOR = "bitbucket" + + +class BitbucketIntegration(Integration): + """Definition of bitbucket integration for ZenML.""" + + NAME = BITBUCKET + REQUIREMENTS: List[str] = [] + + @classmethod + def plugin_flavors(cls) -> List[Type[BasePluginFlavor]]: + """Declare the event flavors for the bitbucket integration. + + Returns: + List of stack component flavors for this integration. + """ + from zenml.integrations.bitbucket.plugins import BitbucketWebhookEventSourceFlavor + + return [BitbucketWebhookEventSourceFlavor] + + +BitbucketIntegration.check_installation() diff --git a/src/zenml/integrations/bitbucket/plugins/__init__.py b/src/zenml/integrations/bitbucket/plugins/__init__.py new file mode 100644 index 00000000000..c5eb3accaed --- /dev/null +++ b/src/zenml/integrations/bitbucket/plugins/__init__.py @@ -0,0 +1,20 @@ +# Copyright (c) ZenML GmbH 2024. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at: +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express +# or implied. See the License for the specific language governing +# permissions and limitations under the License. +"""Bitbucket event flavors.""" + +from zenml.integrations.bitbucket.plugins.bitbucket_webhook_event_source_flavor import BitbucketWebhookEventSourceFlavor + +__all__ = [ + "BitbucketWebhookEventSourceFlavor" +] \ No newline at end of file diff --git a/src/zenml/integrations/bitbucket/plugins/bitbucket_webhook_event_source_flavor.py b/src/zenml/integrations/bitbucket/plugins/bitbucket_webhook_event_source_flavor.py new file mode 100644 index 00000000000..643b18e8708 --- /dev/null +++ b/src/zenml/integrations/bitbucket/plugins/bitbucket_webhook_event_source_flavor.py @@ -0,0 +1,42 @@ +# Copyright (c) ZenML GmbH 2024. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at: +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express +# or implied. See the License for the specific language governing +# permissions and limitations under the License. +"""Bitbucket webhook event source flavor.""" +from typing import ClassVar, Type + +from zenml.event_sources.webhooks.base_webhook_event_source import ( + BaseWebhookEventSourceFlavor, +) +from zenml.integrations.bitbucket import BITBUCKET_EVENT_FLAVOR +from zenml.integrations.bitbucket.plugins.event_sources.bitbucket_webhook_event_source import ( + BitbucketWebhookEventFilterConfiguration, + BitbucketWebhookEventSourceConfiguration, + BitbucketWebhookEventSourceHandler, +) + + +class BitbucketWebhookEventSourceFlavor(BaseWebhookEventSourceFlavor): + """Enables users to configure Bitbucket event sources.""" + + FLAVOR: ClassVar[str] = BITBUCKET_EVENT_FLAVOR + PLUGIN_CLASS: ClassVar[ + Type[BitbucketWebhookEventSourceHandler] + ] = BitbucketWebhookEventSourceHandler + + # EventPlugin specific + EVENT_SOURCE_CONFIG_CLASS: ClassVar[ + Type[BitbucketWebhookEventSourceConfiguration] + ] = BitbucketWebhookEventSourceConfiguration + EVENT_FILTER_CONFIG_CLASS: ClassVar[ + Type[BitbucketWebhookEventFilterConfiguration] + ] = BitbucketWebhookEventFilterConfiguration diff --git a/src/zenml/integrations/bitbucket/plugins/event_sources/__init__.py b/src/zenml/integrations/bitbucket/plugins/event_sources/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/zenml/integrations/bitbucket/plugins/event_sources/bitbucket_webhook_event_source.py b/src/zenml/integrations/bitbucket/plugins/event_sources/bitbucket_webhook_event_source.py new file mode 100644 index 00000000000..cadb83c5793 --- /dev/null +++ b/src/zenml/integrations/bitbucket/plugins/event_sources/bitbucket_webhook_event_source.py @@ -0,0 +1,466 @@ +# Copyright (c) ZenML GmbH 2024. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at: +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express +# or implied. See the License for the specific language governing +# permissions and limitations under the License. +"""Implementation of the Bitbucket webhook event source.""" +import urllib +from typing import Any, Dict, List, Optional, Type, Union +from uuid import UUID + +from pydantic import BaseModel, Extra, Field + +from zenml.enums import SecretScope +from zenml.event_sources.base_event import ( + BaseEvent, +) +from zenml.event_sources.base_event_source import EventSourceConfig +from zenml.event_sources.webhooks.base_webhook_event_source import ( + BaseWebhookEventSourceFlavor, + BaseWebhookEventSourceHandler, + WebhookEventFilterConfig, + WebhookEventSourceConfig, +) +from zenml.exceptions import AuthorizationException +from zenml.logger import get_logger +from zenml.models import ( + EventSourceRequest, + EventSourceResponse, + EventSourceUpdate, + SecretRequest, + SecretUpdate, +) +from zenml.utils.enum_utils import StrEnum +from zenml.utils.string_utils import random_str + +logger = get_logger(__name__) + +# -------------------- Utils ----------------------------------- + + +class BitbucketEventType(StrEnum): + """Collection of all possible Bitbucket Events.""" + + PUSH_EVENT = "push_event" + TAG_EVENT = "tag_event" + + +# -------------------- Bitbucket Event Models ---------------------------------- + + +class User(BaseModel): + name: Optional[str] + email: Optional[str] + username: Optional[str] + + +class Commit(BaseModel): + hash: str + message: str + links: dict + author: User + + +class Repository(BaseModel): + uuid: str + name: str + full_name: str + links: dict + + +class PushChange(BaseModel): + new: Optional[dict] + old: Optional[dict] + commits: List[Commit] + + +class Push(BaseModel): + changes: List[PushChange] + + +class BitbucketEvent(BaseModel): + actor: User + repository: Repository + push: Push + + class Config: + """Pydantic configuration class.""" + + extra = Extra.allow + + @property + def branch(self) -> Optional[str]: + """The branch the event happened on. + + Returns: + The branch name. + """ + if self.push.changes[0].new: + return self.push.changes[0].new.get('name', None) + return None + + @property + def event_type(self) -> Union[BitbucketEventType, str]: + """The type of Bitbucket event. + + Args: + The type of the event based on Bitbucket specific fields. + + Returns: + The type of the event. + """ + is_push_event = all([change.new is not None for change in self.push.changes]) + is_tag_event = all([change.new.get('type') == 'tag' for change in self.push.changes if change.new]) + + if is_push_event: + return BitbucketEventType.PUSH_EVENT + elif is_tag_event: + return BitbucketEventType.TAG_EVENT + else: + return "unknown" + + +# -------------------- Configuration Models ---------------------------------- + + +class BitbucketWebhookEventFilterConfiguration(WebhookEventFilterConfig): + """Configuration for Bitbucket event filters.""" + + repo: Optional[str] + branch: Optional[str] + event_type: Optional[BitbucketEventType] + + def event_matches_filter(self, event: BaseEvent) -> bool: + """Checks the filter against the inbound event. + + Args: + event: The incoming event + + Returns: + Whether the event matches the filter + """ + if not isinstance(event, BitbucketEvent): + return False + if self.event_type and event.event_type != self.event_type: + # Mismatch for the action + return False + if self.repo and event.repository.full_name != self.repo: + # Mismatch for the repository + return False + if self.branch and event.branch != self.branch: + # Mismatch for the branch + return False + return True + + +class BitbucketWebhookEventSourceConfiguration(WebhookEventSourceConfig): + """Configuration for Bitbucket source filters.""" + + webhook_secret: Optional[str] = Field( + default=None, + title="The webhook secret for the event source.", + ) + webhook_secret_id: Optional[UUID] = Field( + default=None, + description="The ID of the secret containing the webhook secret.", + ) + rotate_secret: Optional[bool] = Field( + default=None, description="Set to rotate the webhook secret." + ) + + +# -------------------- Bitbucket Webhook Plugin ----------------------------------- + + +class BitbucketWebhookEventSourceHandler(BaseWebhookEventSourceHandler): + """Handler for all Bitbucket events.""" + + @property + def config_class(self) -> Type[BitbucketWebhookEventSourceConfiguration]: + """Returns the webhook event source configuration class. + + Returns: + The configuration. + """ + return BitbucketWebhookEventSourceConfiguration + + @property + def filter_class(self) -> Type[BitbucketWebhookEventFilterConfiguration]: + """Returns the webhook event filter configuration class. + + Returns: + The event filter configuration class. + """ + return BitbucketWebhookEventFilterConfiguration + + @property + def flavor_class(self) -> Type[BaseWebhookEventSourceFlavor]: + """Returns the flavor class of the plugin. + + Returns: + The flavor class of the plugin. + """ + from zenml.integrations.bitbucket.plugins.bitbucket_webhook_event_source_flavor import ( + BitbucketWebhookEventSourceFlavor, + ) + + return BitbucketWebhookEventSourceFlavor + + def _interpret_event(self, event: Dict[str, Any]) -> BitbucketEvent: + """Converts the generic event body into a event-source specific pydantic model. + + Args: + event: The generic event body + + Returns: + An instance of the event source specific pydantic model. + + Raises: + ValueError: If the event body can not be parsed into the pydantic model. + """ + try: + Bitbucket_event = BitbucketEvent(**event) + except ValueError: + raise ValueError("Event did not match the pydantic model.") + else: + return Bitbucket_event + + def _get_webhook_secret( + self, event_source: EventSourceResponse + ) -> Optional[str]: + """Get the webhook secret for the event source. + + Args: + event_source: The event source to retrieve the secret for. + + Returns: + The webhook secret associated with the event source, or None if a + secret is not applicable. + + Raises: + AuthorizationException: If the secret value could not be retrieved. + """ + # Temporary solution to get the secret value for the Event Source + config = self.validate_event_source_configuration( + event_source.configuration + ) + assert isinstance(config, BitbucketWebhookEventSourceConfiguration) + webhook_secret_id = config.webhook_secret_id + if webhook_secret_id is None: + raise AuthorizationException( + f"Webhook secret ID is missing from the event source " + f"configuration for event source '{event_source.id}'." + ) + try: + return self.zen_store.get_secret( + secret_id=webhook_secret_id + ).secret_values["webhook_secret"] + except KeyError: + logger.exception( + f"Could not retrieve secret value for webhook secret id " + f"'{webhook_secret_id}'" + ) + raise AuthorizationException( + "Could not retrieve webhook signature." + ) + + def _validate_event_source_request( + self, event_source: EventSourceRequest, config: EventSourceConfig + ) -> None: + """Validate an event source request before it is created in the database. + + The `webhook_secret`, `webhook_secret_id`, and `rotate_secret` + fields are not allowed in the request. + + Args: + event_source: Event source request. + config: Event source configuration instantiated from the request. + + Raises: + ValueError: If any of the disallowed fields are present in the + request. + """ + assert isinstance(config, BitbucketWebhookEventSourceConfiguration) + for field in ["webhook_secret", "webhook_secret_id", "rotate_secret"]: + if getattr(config, field) is not None: + raise ValueError( + f"The `{field}` field is not allowed in the event source " + "request." + ) + + def _process_event_source_request( + self, event_source: EventSourceResponse, config: EventSourceConfig + ) -> None: + """Process an event source request after it is created in the database. + + Generates a webhook secret and stores it in a secret in the database, + then attaches the secret ID to the event source configuration. + + Args: + event_source: Newly created event source + config: Event source configuration instantiated from the response. + """ + assert isinstance(config, BitbucketWebhookEventSourceConfiguration) + assert ( + event_source.user is not None + ), "User is not set for event source" + + secret_key_value = random_str(12) + webhook_secret = SecretRequest( + name=f"event_source-{str(event_source.id)}-{random_str(4)}".lower(), + values={"webhook_secret": secret_key_value}, + workspace=event_source.workspace.id, + user=event_source.user.id, + scope=SecretScope.WORKSPACE, + ) + secret = self.zen_store.create_secret(webhook_secret) + + # Store the secret ID in the event source configuration in the database + event_source_update = EventSourceUpdate.from_response(event_source) + assert event_source_update.configuration is not None + event_source_update.configuration["webhook_secret_id"] = str(secret.id) + + self.zen_store.update_event_source( + event_source_id=event_source.id, + event_source_update=event_source_update, + ) + + # Set the webhook secret in the configuration returned to the user + config.webhook_secret = secret_key_value + # Remove hidden field from the response + config.rotate_secret = None + config.webhook_secret_id = None + + def _validate_event_source_update( + self, + event_source: EventSourceResponse, + config: EventSourceConfig, + event_source_update: EventSourceUpdate, + config_update: EventSourceConfig, + ) -> None: + """Validate an event source update before it is reflected in the database. + + Ensure the webhook secret ID is preserved in the updated event source + configuration. + + Args: + event_source: Original event source before the update. + config: Event source configuration instantiated from the original + event source. + event_source_update: Event source update request. + config_update: Event source configuration instantiated from the + updated event source. + """ + assert isinstance(config, BitbucketWebhookEventSourceConfiguration) + assert isinstance(config_update, BitbucketWebhookEventSourceConfiguration) + + config_update.webhook_secret_id = config.webhook_secret_id + + def _process_event_source_update( + self, + event_source: EventSourceResponse, + config: EventSourceConfig, + previous_event_source: EventSourceResponse, + previous_config: EventSourceConfig, + ) -> None: + """Process an event source after it is updated in the database. + + If the `rotate_secret` field is set to `True`, the webhook secret is + rotated and the new secret ID is attached to the event source + configuration. + + Args: + event_source: Event source after the update. + config: Event source configuration instantiated from the updated + event source. + previous_event_source: Original event source before the update. + previous_config: Event source configuration instantiated from the + original event source. + """ + assert isinstance(config, BitbucketWebhookEventSourceConfiguration) + assert isinstance( + previous_config, BitbucketWebhookEventSourceConfiguration + ) + assert config.webhook_secret_id is not None + + if config.rotate_secret: + # In case the secret is being rotated + secret_key_value = random_str(12) + webhook_secret = SecretUpdate( # type: ignore[call-arg] + values={"webhook_secret": secret_key_value} + ) + self.zen_store.update_secret( + secret_id=config.webhook_secret_id, + secret_update=webhook_secret, + ) + + # Remove the `rotate_secret` field from the configuration stored + # in the database + event_source_update = EventSourceUpdate.from_response(event_source) + assert event_source_update.configuration is not None + event_source_update.configuration.pop("rotate_secret") + self.zen_store.update_event_source( + event_source_id=event_source.id, + event_source_update=event_source_update, + ) + + # Set the new secret in the configuration returned to the user + config.webhook_secret = secret_key_value + + # Remove hidden fields from the response + config.rotate_secret = None + config.webhook_secret_id = None + + def _process_event_source_delete( + self, + event_source: EventSourceResponse, + config: EventSourceConfig, + force: Optional[bool] = False, + ) -> None: + """Process an event source before it is deleted from the database. + + Deletes the associated secret from the database. + + Args: + event_source: Event source before the deletion. + config: Validated instantiated event source configuration before + the deletion. + force: Whether to force deprovision the event source. + """ + assert isinstance(config, BitbucketWebhookEventSourceConfiguration) + if config.webhook_secret_id is not None: + try: + self.zen_store.delete_secret( + secret_id=config.webhook_secret_id + ) + except KeyError: + pass + + # Remove hidden fields from the response + config.rotate_secret = None + config.webhook_secret_id = None + + def _process_event_source_response( + self, event_source: EventSourceResponse, config: EventSourceConfig + ) -> None: + """Process an event source response before it is returned to the user. + + Removes hidden fields from the configuration. + + Args: + event_source: Event source response. + config: Event source configuration instantiated from the response. + """ + assert isinstance(config, BitbucketWebhookEventSourceConfiguration) + # Remove hidden fields from the response + config.rotate_secret = None + config.webhook_secret_id = None + config.webhook_secret = None diff --git a/src/zenml/integrations/constants.py b/src/zenml/integrations/constants.py index a833d08e2d3..bc397273686 100644 --- a/src/zenml/integrations/constants.py +++ b/src/zenml/integrations/constants.py @@ -18,6 +18,7 @@ AZURE = "azure" AZUREML = "azureml" BENTOML = "bentoml" +BITBUCKET = "bitbucket" DASH = "dash" DEEPCHECKS = "deepchecks" DISCORD = "discord" diff --git a/src/zenml/integrations/github/plugins/github_webhook_event_source_flavor.py b/src/zenml/integrations/github/plugins/github_webhook_event_source_flavor.py index da528d746d9..b6e6d2ab6d1 100644 --- a/src/zenml/integrations/github/plugins/github_webhook_event_source_flavor.py +++ b/src/zenml/integrations/github/plugins/github_webhook_event_source_flavor.py @@ -11,7 +11,7 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express # or implied. See the License for the specific language governing # permissions and limitations under the License. -"""Example file of what an event Plugin could look like.""" +"""Github webhook event source flavor.""" from typing import ClassVar, Type from zenml.event_sources.webhooks.base_webhook_event_source import ( From 62320aefdff4cbf0b9604124ca9aee93473772a6 Mon Sep 17 00:00:00 2001 From: Alexej Penner Date: Wed, 6 Mar 2024 09:29:32 +0100 Subject: [PATCH 02/45] Added docstrings --- .../plugins/event_sources/bitbucket_webhook_event_source.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/zenml/integrations/bitbucket/plugins/event_sources/bitbucket_webhook_event_source.py b/src/zenml/integrations/bitbucket/plugins/event_sources/bitbucket_webhook_event_source.py index cadb83c5793..25b7a1e3551 100644 --- a/src/zenml/integrations/bitbucket/plugins/event_sources/bitbucket_webhook_event_source.py +++ b/src/zenml/integrations/bitbucket/plugins/event_sources/bitbucket_webhook_event_source.py @@ -57,12 +57,14 @@ class BitbucketEventType(StrEnum): class User(BaseModel): + """Bitbucket User""" name: Optional[str] email: Optional[str] username: Optional[str] class Commit(BaseModel): + """Bitbucket Commit""" hash: str message: str links: dict @@ -70,6 +72,7 @@ class Commit(BaseModel): class Repository(BaseModel): + """Bitbucket Repository""" uuid: str name: str full_name: str @@ -77,16 +80,19 @@ class Repository(BaseModel): class PushChange(BaseModel): + """Bitbucket Push Change""" new: Optional[dict] old: Optional[dict] commits: List[Commit] class Push(BaseModel): + """Bitbucket Push""" changes: List[PushChange] class BitbucketEvent(BaseModel): + """Bitbucket Event""" actor: User repository: Repository push: Push From 8e7814ef0df4e9f495cc6336a4404c942584a8e7 Mon Sep 17 00:00:00 2001 From: Stefan Nica Date: Thu, 14 Mar 2024 09:14:01 +0100 Subject: [PATCH 03/45] Fix the pagination in the database backup (#2522) * Fix the pagination in the database backup * Add ID as a tie-breaker to the generic pagination and sorting algorithm --- src/zenml/zen_stores/migrations/utils.py | 14 +++++++++++--- src/zenml/zen_stores/sql_zen_store.py | 9 +++++++-- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/src/zenml/zen_stores/migrations/utils.py b/src/zenml/zen_stores/migrations/utils.py index f1300946ee5..6ee4af7b3b5 100644 --- a/src/zenml/zen_stores/migrations/utils.py +++ b/src/zenml/zen_stores/migrations/utils.py @@ -236,9 +236,17 @@ def backup_database_to_storage( # correct order, since some tables have inner foreign key # constraints. if "created" in table.columns: - order_by = table.columns["created"] + order_by = [table.columns["created"]] else: - order_by = None + order_by = [] + if "id" in table.columns: + # If the table has an `id` column, we also use it to sort + # the rows in the table, even if we already use "created" + # to sort the rows. We need a unique field to sort the rows, + # to break the tie between rows with the same "created" + # date, otherwise the same entry might end up multiple times + # in subsequent pages. + order_by.append(table.columns["id"]) # Fetch the number of rows in the table row_count = conn.scalar( @@ -250,7 +258,7 @@ def backup_database_to_storage( for i in range(0, row_count, batch_size): rows = conn.execute( table.select() - .order_by(order_by) + .order_by(*order_by) .limit(batch_size) .offset(i) ).fetchall() diff --git a/src/zenml/zen_stores/sql_zen_store.py b/src/zenml/zen_stores/sql_zen_store.py index 5feb10d93a2..20450cc57d1 100644 --- a/src/zenml/zen_stores/sql_zen_store.py +++ b/src/zenml/zen_stores/sql_zen_store.py @@ -870,9 +870,14 @@ def filter_and_paginate( # Sorting column, operand = filter_model.sorting_params if operand == SorterOps.DESCENDING: - query = query.order_by(desc(getattr(table, column))) + sort_clause = desc(getattr(table, column)) else: - query = query.order_by(asc(getattr(table, column))) + sort_clause = asc(getattr(table, column)) + + # We always add the `id` column as a tiebreaker to ensure a stable, + # repeatable order of items, otherwise subsequent pages might contain + # the same items. + query = query.order_by(sort_clause, asc(table.id)) # Get the total amount of pages in the database for a given query if total == 0: From df3b20e9531e7225654e4495193851e750b8a348 Mon Sep 17 00:00:00 2001 From: Christian Versloot Date: Thu, 14 Mar 2024 10:49:58 +0100 Subject: [PATCH 04/45] Bump mlflow to 2.11.1 (#2524) --- src/zenml/integrations/mlflow/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/zenml/integrations/mlflow/__init__.py b/src/zenml/integrations/mlflow/__init__.py index c7cc82e790d..fd7ecee6580 100644 --- a/src/zenml/integrations/mlflow/__init__.py +++ b/src/zenml/integrations/mlflow/__init__.py @@ -35,7 +35,7 @@ class MlflowIntegration(Integration): # does not pin it. They fixed this in a later version, so we can probably # remove this once we update the mlflow version. REQUIREMENTS = [ - "mlflow>=2.1.1,<=2.10.2", + "mlflow>=2.1.1,<=2.11.1", "mlserver>=1.3.3", "mlserver-mlflow>=1.3.3", # TODO: remove this requirement once rapidjson is fixed From ef97008395303e202506e5d39800c19cac6de0dc Mon Sep 17 00:00:00 2001 From: Michael Schuster Date: Thu, 14 Mar 2024 10:51:41 +0100 Subject: [PATCH 05/45] Add docs for uv installation (#2527) * Add docs for uv installation * Apply suggestions from code review Co-authored-by: Alex Strick van Linschoten --------- Co-authored-by: Alex Strick van Linschoten --- .../containerize-your-pipeline.md | 20 ++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/docs/book/user-guide/advanced-guide/infrastructure-management/containerize-your-pipeline.md b/docs/book/user-guide/advanced-guide/infrastructure-management/containerize-your-pipeline.md index 388b89c54be..09859f812ff 100644 --- a/docs/book/user-guide/advanced-guide/infrastructure-management/containerize-your-pipeline.md +++ b/docs/book/user-guide/advanced-guide/infrastructure-management/containerize-your-pipeline.md @@ -192,7 +192,7 @@ def my_pipeline(...): ... ``` -* Specify a list of pip requirements in code: +* Specify a list of requirements in code: ```python docker_settings = DockerSettings(requirements=["torch==1.12.0", "torchvision"]) @@ -201,7 +201,7 @@ def my_pipeline(...): def my_pipeline(...): ... ``` -* Specify a pip requirements file: +* Specify a requirements file: ```python docker_settings = DockerSettings(requirements="/path/to/requirements.txt") @@ -253,7 +253,7 @@ def my_training_step(...): ``` {% hint style="info" %} -You can combine these methods but do make sure that your list of pip requirements does not overlap with the ones specified explicitly in the docker settings. +You can combine these methods but do make sure that your list of requirements does not overlap with the ones specified explicitly in the Docker settings. {% endhint %} Depending on the options specified in your Docker settings, ZenML installs the requirements in the following order (each step optional): @@ -262,6 +262,20 @@ Depending on the options specified in your Docker settings, ZenML installs the r * The packages specified via the `requirements` attribute (step level overwrites pipeline level) * The packages specified via the `required_integrations` and potentially stack requirements +* **Experimental**: If you want to use [`uv`](https://github.com/astral-sh/uv) for faster resolving and installation of your Python packages, you can use by it as follows: + +```python +docker_settings = DockerSettings(python_package_installer="uv") + +@pipeline(settings={"docker": docker_settings}) +def my_pipeline(...): + ... +``` +{% hint style="info" %} +`uv` is a relatively new project and not as stable as `pip` yet, which might lead to errors during package installation. +If this happens, try switching the installer back to `pip` and see if that solves the issue. +{% endhint %} + ### Using a custom parent image By default, ZenML performs all the steps described above on top of the [official ZenML image](https://hub.docker.com/r/zenmldocker/zenml/) for the Python and ZenML version in the active Python environment. To have more control over the entire environment used to execute your pipelines, you can either specify a custom pre-built parent image or a Dockerfile that ZenML uses to build a parent image for you. From 67260d604ae7925f46b2b94f5fcc8e2b205192be Mon Sep 17 00:00:00 2001 From: Christian Versloot Date: Thu, 14 Mar 2024 21:30:40 +0100 Subject: [PATCH 06/45] Fix bug in HyperAI orchestrator depends_on parallelism (#2523) * Allow for multiple upstream steps to be included in Docker Compose dependency * Ruff format * Ruff format with ruff==0.3.2 --- .../orchestrators/hyperai_orchestrator.py | 26 ++++++++++++------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/src/zenml/integrations/hyperai/orchestrators/hyperai_orchestrator.py b/src/zenml/integrations/hyperai/orchestrators/hyperai_orchestrator.py index 0f90eec205b..a0ab70d6ce1 100644 --- a/src/zenml/integrations/hyperai/orchestrators/hyperai_orchestrator.py +++ b/src/zenml/integrations/hyperai/orchestrators/hyperai_orchestrator.py @@ -230,17 +230,25 @@ def prepare_or_run_pipeline( # Add dependency on upstream steps if applicable upstream_steps = step.spec.upstream_steps - for upstream_step_name in upstream_steps: - upstream_container_name = ( - f"{deployment_id}-{upstream_step_name}" - ) + + if len(upstream_steps) > 0: compose_definition["services"][container_name][ "depends_on" - ] = { - upstream_container_name: { - "condition": "service_completed_successfully" - } - } + ] = {} + + for upstream_step_name in upstream_steps: + upstream_container_name = ( + f"{deployment_id}-{upstream_step_name}" + ) + compose_definition["services"][container_name][ + "depends_on" + ].update( + { + upstream_container_name: { + "condition": "service_completed_successfully" + } + } + ) # Convert into yaml logger.info("Finalizing Docker Compose definition.") From 9c501c42b732268b19762cfcb6cc02dc4b0204c9 Mon Sep 17 00:00:00 2001 From: Michael Schuster Date: Fri, 15 Mar 2024 06:28:23 +0100 Subject: [PATCH 07/45] Upgrade pip in docker images (#2528) --- docker/base.Dockerfile | 3 +++ docker/zenml-dev.Dockerfile | 3 +++ docker/zenml-server-dev.Dockerfile | 2 ++ 3 files changed, 8 insertions(+) diff --git a/docker/base.Dockerfile b/docker/base.Dockerfile index 5da6ab98df4..da53fbe8c69 100644 --- a/docker/base.Dockerfile +++ b/docker/base.Dockerfile @@ -10,6 +10,9 @@ ENV PYTHONFAULTHANDLER=1 \ ARG ZENML_VERSION +# Upgrade pip to the latest version +RUN pip install --upgrade pip + # install the given zenml version (default to latest) RUN pip install zenml${ZENML_VERSION:+==$ZENML_VERSION} diff --git a/docker/zenml-dev.Dockerfile b/docker/zenml-dev.Dockerfile index 6931d4db792..bc91b4ab112 100644 --- a/docker/zenml-dev.Dockerfile +++ b/docker/zenml-dev.Dockerfile @@ -21,5 +21,8 @@ COPY src/zenml/__init__.py ./src/zenml/ ENV ZENML_DEBUG=true \ ZENML_ANALYTICS_OPT_IN=false +# Upgrade pip to the latest version +RUN pip install --upgrade pip + RUN pip install -e . COPY src src \ No newline at end of file diff --git a/docker/zenml-server-dev.Dockerfile b/docker/zenml-server-dev.Dockerfile index d775817edb3..b7c5efa9559 100644 --- a/docker/zenml-server-dev.Dockerfile +++ b/docker/zenml-server-dev.Dockerfile @@ -31,6 +31,8 @@ COPY README.md pyproject.toml ./ # copying our source files which would invalidate caching COPY src/zenml/__init__.py ./src/zenml/ +# Upgrade pip to the latest version +RUN pip install --upgrade pip RUN pip install -e .[server,secrets-aws,secrets-gcp,secrets-azure,secrets-hashicorp,s3fs,gcsfs,adlfs,connectors-aws,connectors-gcp,connectors-azure] COPY src src From 4db75291a0ea1f7b99de81a10203e10fe42ee810 Mon Sep 17 00:00:00 2001 From: Stefan Nica Date: Fri, 15 Mar 2024 11:08:49 +0100 Subject: [PATCH 08/45] Fix node selector and other fields for DB job in helm chart (#2531) --- .../deploy/helm/templates/server-db-job.yaml | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/src/zenml/zen_server/deploy/helm/templates/server-db-job.yaml b/src/zenml/zen_server/deploy/helm/templates/server-db-job.yaml index 9af1688e6ea..e71d4fd1d49 100644 --- a/src/zenml/zen_server/deploy/helm/templates/server-db-job.yaml +++ b/src/zenml/zen_server/deploy/helm/templates/server-db-job.yaml @@ -110,16 +110,16 @@ spec: envFrom: - secretRef: name: {{ include "zenml.fullname" . }}-db-migration - {{- with .Values.resources }} - resources: - {{- toYaml . | nindent 12 }} + {{- with .Values.resources }} + resources: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} {{- end }} - {{- with .Values.tolerations }} - tolerations: - {{- toYaml . | nindent 8 }} - {{- end }} - {{- with .Values.nodeSelector }} - nodeSelector: - {{- toYaml . | nindent 8 }} - {{- end }} {{- end }} \ No newline at end of file From 34d624fe75b8b376a76884c75f8d6ca0cd27df57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bar=C4=B1=C5=9F=20Can=20Durak?= <36421093+bcdurak@users.noreply.github.com> Date: Fri, 15 Mar 2024 18:00:21 +0300 Subject: [PATCH 09/45] Revert "Upgrading SQLModel to the latest version" (#2515) * Revert "Upgrading SQLModel to the latest version (#2452)" This reverts commit e93c687f3fb61823446b0e044ca43df35e9f49c3. * Auto-update of Starter template --------- Co-authored-by: GitHub Actions Co-authored-by: Alex Strick van Linschoten --- pyproject.toml | 6 ++-- src/zenml/integrations/airflow/__init__.py | 11 ++----- src/zenml/integrations/evidently/__init__.py | 8 +---- .../great_expectations/__init__.py | 4 --- .../zen_server/deploy/local/local_provider.py | 1 + .../zen_stores/schemas/artifact_schemas.py | 6 ++-- .../schemas/artifact_visualization_schemas.py | 6 ++-- .../zen_stores/schemas/component_schemas.py | 6 ++-- .../zen_stores/schemas/device_schemas.py | 10 +++--- .../zen_stores/schemas/flavor_schemas.py | 6 ++-- .../schemas/pipeline_run_schemas.py | 10 +++--- .../schemas/run_metadata_schemas.py | 4 +-- .../zen_stores/schemas/secret_schemas.py | 11 +++---- .../zen_stores/schemas/step_run_schemas.py | 14 +++++---- src/zenml/zen_stores/schemas/tag_schemas.py | 6 +--- src/zenml/zen_stores/sql_zen_store.py | 31 +++++++------------ 16 files changed, 55 insertions(+), 85 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index f86386c9498..3ef62fc0d45 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -60,8 +60,8 @@ python = ">=3.8,<3.12" python-dateutil = "^2.8.1" pyyaml = ">=6.0.1" rich = { extras = ["jupyter"], version = ">=12.0.0" } -sqlalchemy_utils = "0.41.1" -sqlmodel = ">=0.0.9, <=0.0.16" +sqlalchemy_utils = "0.38.3" +sqlmodel = "0.0.8" importlib_metadata = { version = "<=7.0.0", python = "<3.10" } # Optional dependencies for the ZenServer @@ -69,6 +69,7 @@ fastapi = { version = ">=0.75,<0.100", optional = true } uvicorn = { extras = ["standard"], version = ">=0.17.5", optional = true } python-multipart = { version = "~0.0.5", optional = true } pyjwt = { extras = ["crypto"], version = "2.7.*", optional = true } +fastapi-utils = { version = "~0.2.1", optional = true } orjson = { version = "~3.8.3", optional = true } Jinja2 = { version = "*", optional = true } ipinfo = { version = ">=4.4.3", optional = true } @@ -439,6 +440,7 @@ module = [ "bentoml.*", "multipart.*", "jose.*", + "fastapi_utils.*", "sqlalchemy_utils.*", "sky.*", "copier.*", diff --git a/src/zenml/integrations/airflow/__init__.py b/src/zenml/integrations/airflow/__init__.py index ddf8a79a1fd..7446a195a61 100644 --- a/src/zenml/integrations/airflow/__init__.py +++ b/src/zenml/integrations/airflow/__init__.py @@ -17,7 +17,7 @@ orchestrator. You can enable it by registering the Airflow orchestrator with the CLI tool, then bootstrap using the ``zenml orchestrator up`` command. """ -from typing import List, Type +from typing import List, Optional, Type from zenml.integrations.constants import AIRFLOW from zenml.integrations.integration import Integration @@ -32,14 +32,7 @@ class AirflowIntegration(Integration): NAME = AIRFLOW # remove pendulum version requirement once Airflow supports # pendulum>-3.0.0 - REQUIREMENTS = [ - "apache-airflow~=2.4.0", - "pendulum<3.0.0", - # We need to add this as an extra dependency to manually downgrade - # SQLModel. Otherwise, the initial installation of ZenML installs - # a higher version SQLModel and a version mismatch is created. - "sqlmodel>=0.0.9,<=0.0.16", - ] + REQUIREMENTS = ["apache-airflow~=2.4.0", "pendulum<3.0.0"] @classmethod def flavors(cls) -> List[Type[Flavor]]: diff --git a/src/zenml/integrations/evidently/__init__.py b/src/zenml/integrations/evidently/__init__.py index 00e0e42b6d1..6912a9ef516 100644 --- a/src/zenml/integrations/evidently/__init__.py +++ b/src/zenml/integrations/evidently/__init__.py @@ -54,13 +54,7 @@ class EvidentlyIntegration(Integration): """[Evidently](https://github.com/evidentlyai/evidently) integration for ZenML.""" NAME = EVIDENTLY - REQUIREMENTS = [ - "evidently>0.2.6,<0.4.5", # supports pyyaml 6 - # We need to add this as an extra dependency to manually downgrade - # SQLModel. Otherwise, the initial installation of ZenML installs - # a higher version SQLModel and a version mismatch is created. - "sqlmodel>=0.0.9,<=0.0.16" - ] + REQUIREMENTS = ["evidently>0.2.6,<0.4.5"] # supports pyyaml 6 @classmethod def flavors(cls) -> List[Type[Flavor]]: diff --git a/src/zenml/integrations/great_expectations/__init__.py b/src/zenml/integrations/great_expectations/__init__.py index 4a4e630d9fa..500b197a93f 100644 --- a/src/zenml/integrations/great_expectations/__init__.py +++ b/src/zenml/integrations/great_expectations/__init__.py @@ -35,10 +35,6 @@ class GreatExpectationsIntegration(Integration): "great-expectations>=0.15.0,<=0.15.47", # typing_extensions 4.6.0 and above doesn't work with GE "typing_extensions<4.6.0", - # We need to add this as an extra dependency to manually downgrade - # SQLModel. Otherwise, the initial installation of ZenML installs - # a higher version SQLModel and a version mismatch is created. - "sqlmodel>=0.0.9,<=0.0.16", ] @staticmethod diff --git a/src/zenml/zen_server/deploy/local/local_provider.py b/src/zenml/zen_server/deploy/local/local_provider.py index 3d8a9b8fe45..26583e48266 100644 --- a/src/zenml/zen_server/deploy/local/local_provider.py +++ b/src/zenml/zen_server/deploy/local/local_provider.py @@ -61,6 +61,7 @@ def check_local_server_dependencies() -> None: try: # Make sure the ZenML Server dependencies are installed import fastapi # noqa + import fastapi_utils # noqa import jwt # noqa import multipart # noqa import uvicorn # noqa diff --git a/src/zenml/zen_stores/schemas/artifact_schemas.py b/src/zenml/zen_stores/schemas/artifact_schemas.py index 40bb32d2a57..6aeebd556ca 100644 --- a/src/zenml/zen_stores/schemas/artifact_schemas.py +++ b/src/zenml/zen_stores/schemas/artifact_schemas.py @@ -171,7 +171,7 @@ class ArtifactVersionSchema(BaseSchema, table=True): # Fields version: str version_number: Optional[int] - type: str + type: ArtifactType uri: str = Field(sa_column=Column(TEXT, nullable=False)) materializer: str = Field(sa_column=Column(TEXT, nullable=False)) data_type: str = Field(sa_column=Column(TEXT, nullable=False)) @@ -277,7 +277,7 @@ def from_request( artifact_store_id=artifact_version_request.artifact_store_id, workspace_id=artifact_version_request.workspace, user_id=artifact_version_request.user, - type=artifact_version_request.type.value, + type=artifact_version_request.type, uri=artifact_version_request.uri, materializer=artifact_version_request.materializer.json(), data_type=artifact_version_request.data_type.json(), @@ -328,7 +328,7 @@ def to_model( version=self.version_number or self.version, user=self.user.to_model() if self.user else None, uri=self.uri, - type=ArtifactType(self.type), + type=self.type, materializer=materializer, data_type=data_type, created=self.created, diff --git a/src/zenml/zen_stores/schemas/artifact_visualization_schemas.py b/src/zenml/zen_stores/schemas/artifact_visualization_schemas.py index 79862fc0376..6447bf93d25 100644 --- a/src/zenml/zen_stores/schemas/artifact_visualization_schemas.py +++ b/src/zenml/zen_stores/schemas/artifact_visualization_schemas.py @@ -37,7 +37,7 @@ class ArtifactVisualizationSchema(BaseSchema, table=True): __tablename__ = "artifact_visualization" # Fields - type: str + type: VisualizationType uri: str = Field(sa_column=Column(TEXT, nullable=False)) # Foreign Keys @@ -71,7 +71,7 @@ def from_model( The `ArtifactVisualizationSchema`. """ return cls( - type=artifact_visualization_request.type.value, + type=artifact_visualization_request.type, uri=artifact_visualization_request.uri, artifact_version_id=artifact_version_id, ) @@ -95,7 +95,7 @@ def to_model( The `Visualization`. """ body = ArtifactVisualizationResponseBody( - type=VisualizationType(self.type), + type=self.type, uri=self.uri, created=self.created, updated=self.updated, diff --git a/src/zenml/zen_stores/schemas/component_schemas.py b/src/zenml/zen_stores/schemas/component_schemas.py index ca50ac29e37..f3b5b44ea72 100644 --- a/src/zenml/zen_stores/schemas/component_schemas.py +++ b/src/zenml/zen_stores/schemas/component_schemas.py @@ -49,7 +49,7 @@ class StackComponentSchema(NamedSchema, table=True): __tablename__ = "stack_component" - type: str + type: StackComponentType flavor: str configuration: bytes labels: Optional[bytes] @@ -127,8 +127,6 @@ def update( self.labels = base64.b64encode( json.dumps(component_update.labels).encode("utf-8") ) - elif field == "type": - self.type = component_update.type.value else: setattr(self, field, value) @@ -153,7 +151,7 @@ def to_model( A `ComponentModel` """ body = ComponentResponseBody( - type=StackComponentType(self.type), + type=self.type, flavor=self.flavor, user=self.user.to_model() if self.user else None, created=self.created, diff --git a/src/zenml/zen_stores/schemas/device_schemas.py b/src/zenml/zen_stores/schemas/device_schemas.py index abb9e6551c7..93ebc69556f 100644 --- a/src/zenml/zen_stores/schemas/device_schemas.py +++ b/src/zenml/zen_stores/schemas/device_schemas.py @@ -44,7 +44,7 @@ class OAuthDeviceSchema(BaseSchema, table=True): client_id: UUID user_code: str device_code: str - status: str + status: OAuthDeviceStatus failed_auth_attempts: int = 0 expires: Optional[datetime] = None last_login: Optional[datetime] = None @@ -121,7 +121,7 @@ def from_request( client_id=request.client_id, user_code=hashed_user_code, device_code=hashed_device_code, - status=OAuthDeviceStatus.PENDING.value, + status=OAuthDeviceStatus.PENDING, failed_auth_attempts=0, expires=now + timedelta(seconds=request.expires_in), os=request.os, @@ -153,9 +153,9 @@ def update(self, device_update: OAuthDeviceUpdate) -> "OAuthDeviceSchema": setattr(self, field, value) if device_update.locked is True: - self.status = OAuthDeviceStatus.LOCKED.value + self.status = OAuthDeviceStatus.LOCKED elif device_update.locked is False: - self.status = OAuthDeviceStatus.ACTIVE.value + self.status = OAuthDeviceStatus.ACTIVE self.updated = datetime.utcnow() return self @@ -233,7 +233,7 @@ def to_model( client_id=self.client_id, expires=self.expires, trusted_device=self.trusted_device, - status=OAuthDeviceStatus(self.status), + status=self.status, os=self.os, ip_address=self.ip_address, hostname=self.hostname, diff --git a/src/zenml/zen_stores/schemas/flavor_schemas.py b/src/zenml/zen_stores/schemas/flavor_schemas.py index 7ace6f97716..edb9c3d8b37 100644 --- a/src/zenml/zen_stores/schemas/flavor_schemas.py +++ b/src/zenml/zen_stores/schemas/flavor_schemas.py @@ -46,7 +46,7 @@ class FlavorSchema(NamedSchema, table=True): __tablename__ = "flavor" - type: str + type: StackComponentType source: str config_schema: str = Field(sa_column=Column(TEXT, nullable=False)) integration: Optional[str] = Field(default="") @@ -98,8 +98,6 @@ def update(self, flavor_update: "FlavorUpdate") -> "FlavorSchema": ).items(): if field == "config_schema": setattr(self, field, json.dumps(value)) - elif field == "type": - setattr(self, field, value.value) else: setattr(self, field, value) @@ -125,7 +123,7 @@ def to_model( """ body = FlavorResponseBody( user=self.user.to_model() if self.user else None, - type=StackComponentType(self.type), + type=self.type, integration=self.integration, logo_url=self.logo_url, created=self.created, diff --git a/src/zenml/zen_stores/schemas/pipeline_run_schemas.py b/src/zenml/zen_stores/schemas/pipeline_run_schemas.py index 966952d4416..6966a0ccf81 100644 --- a/src/zenml/zen_stores/schemas/pipeline_run_schemas.py +++ b/src/zenml/zen_stores/schemas/pipeline_run_schemas.py @@ -68,7 +68,7 @@ class PipelineRunSchema(NamedSchema, table=True): orchestrator_run_id: Optional[str] = Field(nullable=True) start_time: Optional[datetime] = Field(nullable=True) end_time: Optional[datetime] = Field(nullable=True, default=None) - status: str = Field(nullable=False) + status: ExecutionStatus = Field(nullable=False) orchestrator_environment: Optional[str] = Field( sa_column=Column(TEXT, nullable=True) ) @@ -203,7 +203,7 @@ def from_request( orchestrator_run_id=request.orchestrator_run_id, orchestrator_environment=orchestrator_environment, start_time=request.start_time, - status=request.status.value, + status=request.status, pipeline_id=request.pipeline, deployment_id=request.deployment, trigger_execution_id=request.trigger_execution_id, @@ -277,7 +277,7 @@ def to_model( body = PipelineRunResponseBody( user=self.user.to_model() if self.user else None, - status=ExecutionStatus(self.status), + status=self.status, stack=stack, pipeline=pipeline, build=build, @@ -322,7 +322,7 @@ def update(self, run_update: "PipelineRunUpdate") -> "PipelineRunSchema": The updated `PipelineRunSchema`. """ if run_update.status: - self.status = run_update.status.value + self.status = run_update.status self.end_time = run_update.end_time self.updated = datetime.utcnow() @@ -367,7 +367,7 @@ def update_placeholder( self.orchestrator_run_id = request.orchestrator_run_id self.orchestrator_environment = orchestrator_environment - self.status = request.status.value + self.status = request.status self.updated = datetime.utcnow() diff --git a/src/zenml/zen_stores/schemas/run_metadata_schemas.py b/src/zenml/zen_stores/schemas/run_metadata_schemas.py index f84e210d97d..ade0bb1449a 100644 --- a/src/zenml/zen_stores/schemas/run_metadata_schemas.py +++ b/src/zenml/zen_stores/schemas/run_metadata_schemas.py @@ -109,7 +109,7 @@ class RunMetadataSchema(BaseSchema, table=True): key: str value: str = Field(sa_column=Column(TEXT, nullable=False)) - type: str + type: MetadataTypeEnum def to_model( self, @@ -134,7 +134,7 @@ def to_model( created=self.created, updated=self.updated, value=json.loads(self.value), - type=MetadataTypeEnum(self.type), + type=self.type, ) metadata = None if include_metadata: diff --git a/src/zenml/zen_stores/schemas/secret_schemas.py b/src/zenml/zen_stores/schemas/secret_schemas.py index 94059c6b102..468318c87c8 100644 --- a/src/zenml/zen_stores/schemas/secret_schemas.py +++ b/src/zenml/zen_stores/schemas/secret_schemas.py @@ -55,7 +55,7 @@ class SecretSchema(NamedSchema, table=True): __tablename__ = "secret" - scope: str + scope: SecretScope values: Optional[bytes] = Field(sa_column=Column(TEXT, nullable=True)) @@ -177,7 +177,7 @@ def from_request( assert secret.user is not None, "User must be set for secret creation." return cls( name=secret.name, - scope=secret.scope.value, + scope=secret.scope, workspace_id=secret.workspace, user_id=secret.user, # Don't store secret values implicitly in the secret. The @@ -204,10 +204,7 @@ def update( for field, value in secret_update.dict( exclude_unset=True, exclude={"workspace", "user", "values"} ).items(): - if field == "scope": - setattr(self, field, value.value) - else: - setattr(self, field, value) + setattr(self, field, value) self.updated = datetime.utcnow() return self @@ -242,7 +239,7 @@ def to_model( user=self.user.to_model() if self.user else None, created=self.created, updated=self.updated, - scope=SecretScope(self.scope), + scope=self.scope, ) return SecretResponse( id=self.id, diff --git a/src/zenml/zen_stores/schemas/step_run_schemas.py b/src/zenml/zen_stores/schemas/step_run_schemas.py index 4ae1d111f90..8ba628fc92a 100644 --- a/src/zenml/zen_stores/schemas/step_run_schemas.py +++ b/src/zenml/zen_stores/schemas/step_run_schemas.py @@ -27,6 +27,8 @@ from zenml.enums import ( ExecutionStatus, MetadataResourceTypes, + StepRunInputArtifactType, + StepRunOutputArtifactType, ) from zenml.models import ( StepRunRequest, @@ -58,7 +60,7 @@ class StepRunSchema(NamedSchema, table=True): # Fields start_time: Optional[datetime] = Field(nullable=True) end_time: Optional[datetime] = Field(nullable=True) - status: str = Field(nullable=False) + status: ExecutionStatus = Field(nullable=False) docstring: Optional[str] = Field(sa_column=Column(TEXT, nullable=True)) cache_key: Optional[str] = Field(nullable=True) @@ -163,7 +165,7 @@ def from_request(cls, request: StepRunRequest) -> "StepRunSchema": user_id=request.user, start_time=request.start_time, end_time=request.end_time, - status=request.status.value, + status=request.status, original_step_run_id=request.original_step_run_id, pipeline_run_id=request.pipeline_run_id, deployment_id=request.deployment, @@ -223,7 +225,7 @@ def to_model( body = StepRunResponseBody( user=self.user.to_model() if self.user else None, - status=ExecutionStatus(self.status), + status=self.status, inputs=input_artifacts, outputs=output_artifacts, created=self.created, @@ -268,7 +270,7 @@ def update(self, step_update: "StepRunUpdate") -> "StepRunSchema": exclude_unset=True, exclude_none=True ).items(): if key == "status": - self.status = value.value + self.status = value if key == "end_time": self.end_time = value @@ -310,7 +312,7 @@ class StepRunInputArtifactSchema(SQLModel, table=True): # Fields name: str = Field(nullable=False, primary_key=True) - type: str + type: StepRunInputArtifactType # Foreign keys step_id: UUID = build_foreign_key_field( @@ -346,7 +348,7 @@ class StepRunOutputArtifactSchema(SQLModel, table=True): # Fields name: str - type: str + type: StepRunOutputArtifactType # Foreign keys step_id: UUID = build_foreign_key_field( diff --git a/src/zenml/zen_stores/schemas/tag_schemas.py b/src/zenml/zen_stores/schemas/tag_schemas.py index 1cfbfc29c55..803a53805c5 100644 --- a/src/zenml/zen_stores/schemas/tag_schemas.py +++ b/src/zenml/zen_stores/schemas/tag_schemas.py @@ -108,11 +108,7 @@ def update(self, update: TagUpdate) -> "TagSchema": The updated `TagSchema`. """ for field, value in update.dict(exclude_unset=True).items(): - if field == "color": - setattr(self, field, value.value) - else: - setattr(self, field, value) - + setattr(self, field, value) self.updated = datetime.utcnow() return self diff --git a/src/zenml/zen_stores/sql_zen_store.py b/src/zenml/zen_stores/sql_zen_store.py index 20450cc57d1..d7e0447fb76 100644 --- a/src/zenml/zen_stores/sql_zen_store.py +++ b/src/zenml/zen_stores/sql_zen_store.py @@ -36,7 +36,6 @@ TypeVar, Union, cast, - get_origin, ) from uuid import UUID @@ -48,7 +47,7 @@ IntegrityError, NoResultFound, ) -from sqlalchemy.orm import Mapped, noload +from sqlalchemy.orm import noload from sqlmodel import ( Session, SQLModel, @@ -861,10 +860,10 @@ def filter_and_paginate( custom_fetch_result = custom_fetch(session, query, filter_model) total = len(custom_fetch_result) else: - total = ( - session.query(func.count()) - .select_from(query.options(noload("*")).subquery()) - .scalar() + total = session.scalar( + select([func.count("*")]).select_from( + query.options(noload("*")).subquery() + ) ) # Sorting @@ -1368,7 +1367,9 @@ def migrate_database(self) -> None: # identity table with needed info. logger.info("Creating database tables") with self.engine.begin() as conn: - SQLModel.metadata.create_all(conn) + conn.run_callable( + SQLModel.metadata.create_all # type: ignore[arg-type] + ) with Session(self.engine) as session: session.add( IdentitySchema( @@ -2571,9 +2572,7 @@ def update_stack_component( if existing_component.name != component_update.name: self._fail_if_component_with_name_type_exists( name=component_update.name, - component_type=StackComponentType( - existing_component.type - ), + component_type=existing_component.type, workspace_id=existing_component.workspace_id, session=session, ) @@ -6870,9 +6869,7 @@ def _update_pipeline_run_status( assert pipeline_run.deployment num_steps = len(pipeline_run.deployment.to_model().step_configurations) new_status = get_pipeline_run_status( - step_statuses=[ - ExecutionStatus(step_run.status) for step_run in step_runs - ], + step_statuses=[step_run.status for step_run in step_runs], num_steps=num_steps, ) @@ -7282,8 +7279,6 @@ def _get_resource_references( for resource_attr in resource_attrs: # Extract the target schema from the annotation annotation = UserSchema.__annotations__[resource_attr] - if get_origin(annotation) == Mapped: - annotation = annotation.__args__[0] # The annotation must be of the form # `typing.List[ForwardRef('')]` @@ -7341,13 +7336,11 @@ def _account_owns_resources( resource_attrs = self._get_resource_references() for schema, resource_attr in resource_attrs: # Check if the user owns any resources of this type - count = ( - session.query(func.count()) + count = session.scalar( + select([func.count("*")]) .select_from(schema) .where(getattr(schema, resource_attr) == account.id) - .scalar() ) - if count > 0: logger.debug( f"User {account.name} owns {count} resources of type " From e1b72191be0280e42506c3444680d40f30bb21d5 Mon Sep 17 00:00:00 2001 From: Alexej Penner Date: Mon, 18 Mar 2024 08:47:08 +0100 Subject: [PATCH 10/45] Handle event processing async and ensure return of status code 200 --- src/zenml/zen_server/routers/webhook_endpoints.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/zenml/zen_server/routers/webhook_endpoints.py b/src/zenml/zen_server/routers/webhook_endpoints.py index 2f115a535b1..cce9b7e1cd6 100644 --- a/src/zenml/zen_server/routers/webhook_endpoints.py +++ b/src/zenml/zen_server/routers/webhook_endpoints.py @@ -12,10 +12,10 @@ # or implied. See the License for the specific language governing # permissions and limitations under the License. """Endpoint definitions for webhooks.""" - +from typing import Dict from uuid import UUID -from fastapi import APIRouter, Depends, Request +from fastapi import APIRouter, Depends, Request, BackgroundTasks from zenml.constants import API, VERSION_1, WEBHOOKS from zenml.enums import PluginSubType, PluginType @@ -52,18 +52,21 @@ async def get_body(request: Request) -> bytes: @router.post( "/{event_source_id}", + response_model=Dict[str, str], ) @handle_exceptions def webhook( event_source_id: UUID, request: Request, + background_tasks: BackgroundTasks, raw_body: bytes = Depends(get_body), -) -> None: +) -> Dict[str, str]: """Webhook to receive events from external event sources. Args: event_source_id: The event_source_id request: The request object + background_tasks: Background task handler raw_body: The raw request body Raises: @@ -111,8 +114,11 @@ def webhook( ) # Pass the raw event and headers to the plugin - plugin.process_webhook_event( + background_tasks.add_task( + plugin.process_webhook_event, event_source=event_source, raw_body=raw_body, headers=dict(request.headers.items()), ) + + return {"status": "Event Received."} From 2b3074c073e11eceb5aab26573aa1d0d93f58c45 Mon Sep 17 00:00:00 2001 From: Alexej Penner Date: Mon, 18 Mar 2024 08:50:49 +0100 Subject: [PATCH 11/45] Use background tasks --- .../bitbucket_webhook_event_source_flavor.py | 6 +++--- .../bitbucket_webhook_event_source.py | 18 ++++++++++++------ .../zen_server/routers/webhook_endpoints.py | 2 +- 3 files changed, 16 insertions(+), 10 deletions(-) diff --git a/src/zenml/integrations/bitbucket/plugins/bitbucket_webhook_event_source_flavor.py b/src/zenml/integrations/bitbucket/plugins/bitbucket_webhook_event_source_flavor.py index a389b6677ee..2d0836058d7 100644 --- a/src/zenml/integrations/bitbucket/plugins/bitbucket_webhook_event_source_flavor.py +++ b/src/zenml/integrations/bitbucket/plugins/bitbucket_webhook_event_source_flavor.py @@ -30,9 +30,9 @@ class BitbucketWebhookEventSourceFlavor(BaseWebhookEventSourceFlavor): """Enables users to configure Bitbucket event sources.""" FLAVOR: ClassVar[str] = BITBUCKET_EVENT_FLAVOR - PLUGIN_CLASS: ClassVar[Type[BitbucketWebhookEventSourceHandler]] = ( - BitbucketWebhookEventSourceHandler - ) + PLUGIN_CLASS: ClassVar[ + Type[BitbucketWebhookEventSourceHandler] + ] = BitbucketWebhookEventSourceHandler # EventPlugin specific EVENT_SOURCE_CONFIG_CLASS: ClassVar[ diff --git a/src/zenml/integrations/bitbucket/plugins/event_sources/bitbucket_webhook_event_source.py b/src/zenml/integrations/bitbucket/plugins/event_sources/bitbucket_webhook_event_source.py index 9d8da78fea6..c6e4feec81a 100644 --- a/src/zenml/integrations/bitbucket/plugins/event_sources/bitbucket_webhook_event_source.py +++ b/src/zenml/integrations/bitbucket/plugins/event_sources/bitbucket_webhook_event_source.py @@ -57,14 +57,16 @@ class BitbucketEventType(StrEnum): class User(BaseModel): - """Bitbucket User""" + """Bitbucket User.""" + name: Optional[str] email: Optional[str] username: Optional[str] class Commit(BaseModel): - """Bitbucket Commit""" + """Bitbucket Commit.""" + hash: str message: str links: dict @@ -72,7 +74,8 @@ class Commit(BaseModel): class Repository(BaseModel): - """Bitbucket Repository""" + """Bitbucket Repository.""" + uuid: str name: str full_name: str @@ -80,19 +83,22 @@ class Repository(BaseModel): class PushChange(BaseModel): - """Bitbucket Push Change""" + """Bitbucket Push Change.""" + new: Optional[dict] old: Optional[dict] commits: List[Commit] class Push(BaseModel): - """Bitbucket Push""" + """Bitbucket Push.""" + changes: List[PushChange] class BitbucketEvent(BaseModel): - """Bitbucket Event""" + """Bitbucket Event.""" + actor: User repository: Repository push: Push diff --git a/src/zenml/zen_server/routers/webhook_endpoints.py b/src/zenml/zen_server/routers/webhook_endpoints.py index cce9b7e1cd6..2e945b8d2b4 100644 --- a/src/zenml/zen_server/routers/webhook_endpoints.py +++ b/src/zenml/zen_server/routers/webhook_endpoints.py @@ -15,7 +15,7 @@ from typing import Dict from uuid import UUID -from fastapi import APIRouter, Depends, Request, BackgroundTasks +from fastapi import APIRouter, BackgroundTasks, Depends, Request from zenml.constants import API, VERSION_1, WEBHOOKS from zenml.enums import PluginSubType, PluginType From 4b7770b651d7baab9080b384a3347de3691552fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mo=C3=A9sio=20Filho?= Date: Mon, 18 Mar 2024 09:56:25 +0100 Subject: [PATCH 12/45] Add `pod_running_timeout` attribute to `Kaniko` image builder (#2509) * Add `pod_running_timeout` attribute to `Kaniko` image builder - Add attribute `pod_running_timeout` to `KanikoImageBuilderConfig` with `NonNegativeInt` number type; - Add default value `DEFAULT_KANIKO_POD_RUNNING_TIMEOUT` (defaults to 300) for new attribute `pod_running_timeout` in `KanikoImageBuilderConfig`; - Update method `_run_kaniko_build` in `KanikoImageBuilder` to run `kubectl run` with the argument `--pod-running-timeout`; * Update documention for `KanikoImageBuilder` - Add description for the `pod_running_timeout` attribute; * Update styles for `KanikoImageBuilder` and `KanikoImageBuilderConfig` - Update styles using `scripts/format.sh`; * Fix `pod_running_timeout` attribute type in `KanikoImageBuilderConfig` - Change `NonNegativeInt` to `PositveInt`: `pod_running_timeout` can't be zero, it must be a positive integer to work correctly with `kubectl run` (https://kubernetes.io/docs/reference/generated/kubectl/kubectl-commands#run); * Update docs/book/stacks-and-components/component-guide/image-builders/kaniko.md - Update `pod_running_timeout`; Co-authored-by: Andrei Vishniakov <31008759+avishniakov@users.noreply.github.com> --------- Co-authored-by: Alex Strick van Linschoten Co-authored-by: Andrei Vishniakov <31008759+avishniakov@users.noreply.github.com> --- .../component-guide/image-builders/kaniko.md | 2 ++ .../kaniko/flavors/kaniko_image_builder_flavor.py | 6 +++++- .../kaniko/image_builders/kaniko_image_builder.py | 2 ++ 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/docs/book/stacks-and-components/component-guide/image-builders/kaniko.md b/docs/book/stacks-and-components/component-guide/image-builders/kaniko.md index dcb26b9960a..6aaad396c2a 100644 --- a/docs/book/stacks-and-components/component-guide/image-builders/kaniko.md +++ b/docs/book/stacks-and-components/component-guide/image-builders/kaniko.md @@ -34,6 +34,7 @@ To use the Kaniko image builder, we need: transfer the build context by storing it in the artifact store, you need to register it with the `store_context_in_artifact_store` attribute set to `True`. In this case, you also need a [remote artifact store](../artifact-stores/artifact-stores.md) as part of your stack. +* Optionally, you can change the timeout (in seconds) until the Kaniko pod is running in the orchestrator using the `pod_running_timeout` attribute. We can then register the image builder and use it in our active stack: @@ -41,6 +42,7 @@ We can then register the image builder and use it in our active stack: zenml image-builder register \ --flavor=kaniko \ --kubernetes_context= + [ --pod_running_timeout= ] # Register and activate a stack with the new image builder zenml stack register -i ... --set diff --git a/src/zenml/integrations/kaniko/flavors/kaniko_image_builder_flavor.py b/src/zenml/integrations/kaniko/flavors/kaniko_image_builder_flavor.py index 7bc785dff46..09e13cad35e 100644 --- a/src/zenml/integrations/kaniko/flavors/kaniko_image_builder_flavor.py +++ b/src/zenml/integrations/kaniko/flavors/kaniko_image_builder_flavor.py @@ -16,7 +16,7 @@ import json from typing import TYPE_CHECKING, Any, Dict, List, Optional, Type, Union -from pydantic import validator +from pydantic import PositiveInt, validator from zenml.image_builders import BaseImageBuilderConfig, BaseImageBuilderFlavor from zenml.integrations.kaniko import KANIKO_IMAGE_BUILDER_FLAVOR @@ -29,6 +29,7 @@ DEFAULT_KANIKO_EXECUTOR_IMAGE = ( f"gcr.io/kaniko-project/executor:{KANIKO_EXECUTOR_IMAGE_TAG}" ) +DEFAULT_KANIKO_POD_RUNNING_TIMEOUT = 300 class KanikoImageBuilderConfig(BaseImageBuilderConfig): @@ -47,6 +48,8 @@ class KanikoImageBuilderConfig(BaseImageBuilderConfig): Kaniko pod. This namespace will not be created and must already exist. executor_image: The image of the Kaniko executor to use. + pod_running_timeout: The timeout to wait until the pod is running + in seconds. Defaults to `300`. env: `env` section of the Kubernetes container spec. env_from: `envFrom` section of the Kubernetes container spec. volume_mounts: `volumeMounts` section of the Kubernetes container spec. @@ -67,6 +70,7 @@ class KanikoImageBuilderConfig(BaseImageBuilderConfig): kubernetes_context: str kubernetes_namespace: str = "zenml-kaniko" executor_image: str = DEFAULT_KANIKO_EXECUTOR_IMAGE + pod_running_timeout: PositiveInt = DEFAULT_KANIKO_POD_RUNNING_TIMEOUT env: List[Dict[str, Any]] = [] env_from: List[Dict[str, Any]] = [] diff --git a/src/zenml/integrations/kaniko/image_builders/kaniko_image_builder.py b/src/zenml/integrations/kaniko/image_builders/kaniko_image_builder.py index 314e31f0657..ebb3f09fefa 100644 --- a/src/zenml/integrations/kaniko/image_builders/kaniko_image_builder.py +++ b/src/zenml/integrations/kaniko/image_builders/kaniko_image_builder.py @@ -257,6 +257,8 @@ def _run_kaniko_build( self.config.executor_image, "--overrides", json.dumps(spec_overrides), + "--pod-running-timeout", + f"{self.config.pod_running_timeout}s", ] logger.debug("Running Kaniko build with command: %s", command) with subprocess.Popen( From ae08b3715692ff8687281284ef47cfa30cbfa3b8 Mon Sep 17 00:00:00 2001 From: Alex Strick van Linschoten Date: Mon, 18 Mar 2024 10:00:02 +0100 Subject: [PATCH 13/45] Add test to install dashboard script (#2521) * add test to install dashboard script * add the gitignore test to slow CI * remove redundant code --- .github/workflows/ci-slow.yml | 2 ++ scripts/install-dashboard.sh | 14 +++++++++++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci-slow.yml b/.github/workflows/ci-slow.yml index 470a4f21046..229f247b9e0 100644 --- a/.github/workflows/ci-slow.yml +++ b/.github/workflows/ci-slow.yml @@ -112,6 +112,8 @@ jobs: run: | uv pip install --system alembic bash scripts/check-alembic-branches.sh + - name: Install latest dashboard (test gitignore) + run: bash scripts/install-dashboard.sh custom-ubuntu-unit-test: if: github.event.pull_request.draft == false needs: run-slow-ci-label-is-set diff --git a/scripts/install-dashboard.sh b/scripts/install-dashboard.sh index 7c4d492a694..445097ff0d9 100755 --- a/scripts/install-dashboard.sh +++ b/scripts/install-dashboard.sh @@ -25,6 +25,17 @@ verifySupported() { fi } +# checkGitIgnore checks if the dashboard directories are ignored by Git +checkGitIgnore() { + if [ -f ".gitignore" ]; then + if grep -q -E "(^|\/)dashboard($|\/)" ".gitignore" || grep -q -E "(^|\/)src\/zenml\/zen_server\/dashboard($|\/)" ".gitignore"; then + echo "Error: The '/dashboard' or 'src/zenml/zen_server/dashboard' directory is ignored by Git." + echo "Please remove the corresponding entries from the .gitignore file to proceed with the installation." + exit 1 + fi + fi +} + # checkTagProvided checks whether TAG has provided as an environment variable # so we can skip checkLatestVersion checkTagProvided() { @@ -143,10 +154,11 @@ done set +u verifySupported +checkGitIgnore checkTagProvided || checkLatestVersion if [[ ! -z "$TAG" ]]; then downloadFile verifyFile installFile fi -cleanup \ No newline at end of file +cleanup From 4aa5ad330c6c3d7baea76fc399ab90718ef81007 Mon Sep 17 00:00:00 2001 From: Michael Schuster Date: Mon, 18 Mar 2024 10:05:04 +0100 Subject: [PATCH 14/45] Sort pipeline namespaces by last run (#2514) * Sort pipeline namespaces by last run * Auto-update of Starter template * Sort by updated time --------- Co-authored-by: GitHub Actions --- src/zenml/zen_stores/sql_zen_store.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/zenml/zen_stores/sql_zen_store.py b/src/zenml/zen_stores/sql_zen_store.py index d7e0447fb76..cc8a7c3bb75 100644 --- a/src/zenml/zen_stores/sql_zen_store.py +++ b/src/zenml/zen_stores/sql_zen_store.py @@ -3324,6 +3324,7 @@ def _custom_fetch( PipelineRunSchema.created == max_date_subquery.c.max_created, ) + .order_by(desc(PipelineRunSchema.updated)) ) return self.filter_and_paginate( From 350e7da55ee1464da6e5050136bac350f11f3fca Mon Sep 17 00:00:00 2001 From: Michael Schuster Date: Mon, 18 Mar 2024 10:52:10 +0100 Subject: [PATCH 15/45] Add support for LLM template (#2519) * Add support for LLM template * Add to docs TOC * Add initial example version * Optimised images with calibre/image-actions * Update template version * Add spelling exclude * Auto-update of LLM Finetuning template * Add linting exclude * Optimised images with calibre/image-actions * Auto-update of LLM Finetuning template --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: GitHub Actions --- .../update-templates-to-examples.yml | 72 + .typos.toml | 1 + docs/book/toc.md | 1 + examples/llm_finetuning/.assets/model.png | Bin 0 -> 542896 bytes examples/llm_finetuning/.copier-answers.yml | 15 + examples/llm_finetuning/.dockerignore | 9 + examples/llm_finetuning/LICENSE | 15 + examples/llm_finetuning/README.md | 128 ++ examples/llm_finetuning/configs/eval.yaml | 21 + .../configs/feature-alpaca.yaml | 16 + .../configs/feature-custom.yaml | 19 + .../configs/finetune-alpaca.yaml | 35 + .../configs/finetune-from-dataset.yaml | 33 + examples/llm_finetuning/configs/merge.yaml | 19 + .../evaluate/lm_eval_harness.py | 231 +++ examples/llm_finetuning/finetune/adapter.py | 451 +++++ .../llm_finetuning/finetune/adapter_v2.py | 451 +++++ examples/llm_finetuning/finetune/full.py | 442 +++++ examples/llm_finetuning/finetune/lora.py | 483 ++++++ examples/llm_finetuning/generate/adapter.py | 159 ++ .../llm_finetuning/generate/adapter_v2.py | 159 ++ examples/llm_finetuning/generate/base.py | 244 +++ examples/llm_finetuning/generate/full.py | 153 ++ examples/llm_finetuning/generate/lora.py | 178 ++ .../llm_finetuning/generate/sequentially.py | 301 ++++ examples/llm_finetuning/generate/tp.py | 287 ++++ examples/llm_finetuning/lit_gpt/__init__.py | 29 + examples/llm_finetuning/lit_gpt/adapter.py | 206 +++ examples/llm_finetuning/lit_gpt/adapter_v2.py | 269 +++ examples/llm_finetuning/lit_gpt/args.py | 85 + examples/llm_finetuning/lit_gpt/config.py | 1487 +++++++++++++++++ examples/llm_finetuning/lit_gpt/lora.py | 816 +++++++++ examples/llm_finetuning/lit_gpt/model.py | 501 ++++++ .../llm_finetuning/lit_gpt/packed_dataset.py | 274 +++ examples/llm_finetuning/lit_gpt/rmsnorm.py | 40 + examples/llm_finetuning/lit_gpt/tokenizer.py | 136 ++ examples/llm_finetuning/lit_gpt/utils.py | 477 ++++++ .../llm_finetuning/materializers/__init__.py | 16 + .../materializers/directory_materializer.py | 71 + examples/llm_finetuning/pipelines/__init__.py | 21 + examples/llm_finetuning/pipelines/evaluate.py | 33 + .../pipelines/feature_engineering.py | 33 + .../llm_finetuning/pipelines/finetuning.py | 44 + examples/llm_finetuning/pipelines/merge.py | 33 + examples/llm_finetuning/requirements.txt | 17 + examples/llm_finetuning/run.py | 132 ++ .../scripts/convert_hf_checkpoint.py | 377 +++++ .../scripts/convert_lit_checkpoint.py | 284 ++++ .../scripts/convert_pretrained_checkpoint.py | 88 + examples/llm_finetuning/scripts/download.py | 106 ++ examples/llm_finetuning/scripts/merge_lora.py | 94 ++ .../llm_finetuning/scripts/prepare_alpaca.py | 169 ++ .../llm_finetuning/scripts/prepare_csv.py | 157 ++ .../llm_finetuning/scripts/prepare_dolly.py | 163 ++ .../llm_finetuning/scripts/prepare_flan.py | 249 +++ .../llm_finetuning/scripts/prepare_lima.py | 198 +++ .../scripts/prepare_longform.py | 153 ++ .../scripts/prepare_openwebtext.py | 100 ++ .../scripts/prepare_redpajama.py | 185 ++ .../scripts/prepare_slimpajama.py | 68 + .../scripts/prepare_starcoder.py | 78 + examples/llm_finetuning/steps/__init__.py | 21 + examples/llm_finetuning/steps/evaluate.py | 143 ++ .../steps/feature_engineering.py | 89 + examples/llm_finetuning/steps/finetune.py | 249 +++ examples/llm_finetuning/steps/merge.py | 124 ++ examples/llm_finetuning/steps/params.py | 32 + examples/llm_finetuning/steps/utils.py | 54 + pyproject.toml | 6 + src/zenml/cli/base.py | 10 +- 70 files changed, 11837 insertions(+), 3 deletions(-) create mode 100644 examples/llm_finetuning/.assets/model.png create mode 100644 examples/llm_finetuning/.copier-answers.yml create mode 100644 examples/llm_finetuning/.dockerignore create mode 100644 examples/llm_finetuning/LICENSE create mode 100644 examples/llm_finetuning/README.md create mode 100644 examples/llm_finetuning/configs/eval.yaml create mode 100644 examples/llm_finetuning/configs/feature-alpaca.yaml create mode 100644 examples/llm_finetuning/configs/feature-custom.yaml create mode 100644 examples/llm_finetuning/configs/finetune-alpaca.yaml create mode 100644 examples/llm_finetuning/configs/finetune-from-dataset.yaml create mode 100644 examples/llm_finetuning/configs/merge.yaml create mode 100644 examples/llm_finetuning/evaluate/lm_eval_harness.py create mode 100644 examples/llm_finetuning/finetune/adapter.py create mode 100644 examples/llm_finetuning/finetune/adapter_v2.py create mode 100644 examples/llm_finetuning/finetune/full.py create mode 100644 examples/llm_finetuning/finetune/lora.py create mode 100644 examples/llm_finetuning/generate/adapter.py create mode 100644 examples/llm_finetuning/generate/adapter_v2.py create mode 100644 examples/llm_finetuning/generate/base.py create mode 100644 examples/llm_finetuning/generate/full.py create mode 100644 examples/llm_finetuning/generate/lora.py create mode 100644 examples/llm_finetuning/generate/sequentially.py create mode 100644 examples/llm_finetuning/generate/tp.py create mode 100644 examples/llm_finetuning/lit_gpt/__init__.py create mode 100644 examples/llm_finetuning/lit_gpt/adapter.py create mode 100644 examples/llm_finetuning/lit_gpt/adapter_v2.py create mode 100644 examples/llm_finetuning/lit_gpt/args.py create mode 100644 examples/llm_finetuning/lit_gpt/config.py create mode 100644 examples/llm_finetuning/lit_gpt/lora.py create mode 100644 examples/llm_finetuning/lit_gpt/model.py create mode 100644 examples/llm_finetuning/lit_gpt/packed_dataset.py create mode 100644 examples/llm_finetuning/lit_gpt/rmsnorm.py create mode 100644 examples/llm_finetuning/lit_gpt/tokenizer.py create mode 100644 examples/llm_finetuning/lit_gpt/utils.py create mode 100644 examples/llm_finetuning/materializers/__init__.py create mode 100644 examples/llm_finetuning/materializers/directory_materializer.py create mode 100644 examples/llm_finetuning/pipelines/__init__.py create mode 100644 examples/llm_finetuning/pipelines/evaluate.py create mode 100644 examples/llm_finetuning/pipelines/feature_engineering.py create mode 100644 examples/llm_finetuning/pipelines/finetuning.py create mode 100644 examples/llm_finetuning/pipelines/merge.py create mode 100644 examples/llm_finetuning/requirements.txt create mode 100644 examples/llm_finetuning/run.py create mode 100644 examples/llm_finetuning/scripts/convert_hf_checkpoint.py create mode 100644 examples/llm_finetuning/scripts/convert_lit_checkpoint.py create mode 100644 examples/llm_finetuning/scripts/convert_pretrained_checkpoint.py create mode 100644 examples/llm_finetuning/scripts/download.py create mode 100644 examples/llm_finetuning/scripts/merge_lora.py create mode 100644 examples/llm_finetuning/scripts/prepare_alpaca.py create mode 100644 examples/llm_finetuning/scripts/prepare_csv.py create mode 100644 examples/llm_finetuning/scripts/prepare_dolly.py create mode 100644 examples/llm_finetuning/scripts/prepare_flan.py create mode 100644 examples/llm_finetuning/scripts/prepare_lima.py create mode 100644 examples/llm_finetuning/scripts/prepare_longform.py create mode 100644 examples/llm_finetuning/scripts/prepare_openwebtext.py create mode 100644 examples/llm_finetuning/scripts/prepare_redpajama.py create mode 100644 examples/llm_finetuning/scripts/prepare_slimpajama.py create mode 100644 examples/llm_finetuning/scripts/prepare_starcoder.py create mode 100644 examples/llm_finetuning/steps/__init__.py create mode 100644 examples/llm_finetuning/steps/evaluate.py create mode 100644 examples/llm_finetuning/steps/feature_engineering.py create mode 100644 examples/llm_finetuning/steps/finetune.py create mode 100644 examples/llm_finetuning/steps/merge.py create mode 100644 examples/llm_finetuning/steps/params.py create mode 100644 examples/llm_finetuning/steps/utils.py diff --git a/.github/workflows/update-templates-to-examples.yml b/.github/workflows/update-templates-to-examples.yml index ee4683c29dd..0d02152757a 100644 --- a/.github/workflows/update-templates-to-examples.yml +++ b/.github/workflows/update-templates-to-examples.yml @@ -246,3 +246,75 @@ jobs: repo: context.repo.repo, body: 'Quickstart template updates in `examples/quickstart` have been pushed.' }) + update-llm-finetuning-template-to-examples: + name: update-llm-finetuning-template-to-examples + runs-on: ${{ inputs.os }} + env: + ZENML_DEBUG: 1 + ZENML_ANALYTICS_OPT_IN: false + PYTHONIOENCODING: utf-8 + OBJC_DISABLE_INITIALIZE_FORK_SAFETY: 'YES' + if: github.event_name == 'pull_request' && ! startsWith(github.event.head_commit.message, + 'GitBook:') + defaults: + run: + shell: bash + steps: + - name: Run template tests for zenml-io/template-llm-finetuning + uses: zenml-io/template-llm-finetuning/.github/actions/llm_finetuning_template_test@main + with: + python-version: ${{ inputs.python-version }} + stack-name: local + ref-zenml: ${{ github.ref }} + ref-template: 2024.03.18 # Make sure it is aligned with ZENML_PROJECT_TEMPLATES from src/zenml/cli/base.py + - name: Clean-up + run: | + rm -rf ./local_checkout + - name: message-on-error + if: failure() + run: | + echo "::error title=zenml-io/template-llm-finetuning project template testing failed with new version of ZenML core!::\ + Breaking changes affecting templates have been introduced. To mitigate this issue,\ + please make the code in zenml-io/template-llm-finetuning compatible with new version of\ + ZenML core, release it and update release tag in zenml.cli.base.ZENML_PROJECT_TEMPLATES" + - uses: actions/checkout@v4.1.1 + with: + ref: ${{ github.event.pull_request.head.ref }} + - name: Check-out fresh LLM Finetuning template + run: | + rm -rf examples/llm_finetuning + mkdir -p examples/llm_finetuning + printf 'info@zenml.io' | zenml init --path examples/llm_finetuning --template llm_finetuning --template-with-defaults + pip install yamlfix + bash scripts/format.sh examples/llm_finetuning + - name: Check for changes + id: check_changes + run: | + if git diff --quiet "origin/${{ github.event.pull_request.head.ref }}"; then + echo "No active Git changes found." + echo "changes=false" >> $GITHUB_OUTPUT + else + echo "vvv Active Git changes found vvv" + echo "changes=true" >> $GITHUB_OUTPUT + git diff "origin/${{ github.event.pull_request.head.ref }}" + fi + - name: Commit and push template + if: steps.check_changes.outputs.changes == 'true' + run: | + git config --global user.name "GitHub Actions" + git config --global user.email "actions@github.com" + git add . + git commit -am "Auto-update of LLM Finetuning template" + git push origin HEAD:${{ github.event.pull_request.head.ref }} + - name: Create PR comment + if: steps.check_changes.outputs.changes == 'true' + uses: actions/github-script@v7.0.1 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: |- + github.rest.issues.createComment({ + issue_number: ${{ github.event.pull_request.number }}, + owner: context.repo.owner, + repo: context.repo.repo, + body: 'LLM Finetuning template updates in `examples/llm_finetuning` have been pushed.' + }) diff --git a/.typos.toml b/.typos.toml index 39266b19df2..fba9255c023 100644 --- a/.typos.toml +++ b/.typos.toml @@ -7,6 +7,7 @@ extend-exclude = [ "tests/unit/materializers/test_built_in_materializer.py", "tests/integration/functional/cli/test_pipeline.py", "src/zenml/zen_server/dashboard/", + "examples/llm_finetuning/lit_gpt/" ] [default.extend-identifiers] diff --git a/docs/book/toc.md b/docs/book/toc.md index 136e440e1b6..f1b6dfbc2e9 100644 --- a/docs/book/toc.md +++ b/docs/book/toc.md @@ -171,6 +171,7 @@ * [🚀 Quickstart](https://github.com/zenml-io/zenml/blob/main/examples/quickstart) * [🔏 End-to-End Batch Inference](https://github.com/zenml-io/zenml/tree/main/examples/e2e) * [📚 Basic NLP with BERT](https://github.com/zenml-io/zenml/tree/main/examples/e2e_nlp) +* [📖 LLM Finetuning](https://github.com/zenml-io/zenml/tree/main/examples/llm_finetuning) * [🧩 More Projects...](https://github.com/zenml-io/zenml-projects) ## Reference diff --git a/examples/llm_finetuning/.assets/model.png b/examples/llm_finetuning/.assets/model.png new file mode 100644 index 0000000000000000000000000000000000000000..2201c2e9e9418614335b99d1d5aad591a68f2926 GIT binary patch literal 542896 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

pg(1+Od-I?=^>_Zg+-Lx#~sqm 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 e_ME%iw+X|V9_MzFNfY*ddb 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

{3aw;P%YXlSf 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

p#0Mp(JI*g(KGj#*JO!LnJ0Q$`(y>>) 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 literal 0 HcmV?d00001 diff --git a/examples/llm_finetuning/.copier-answers.yml b/examples/llm_finetuning/.copier-answers.yml new file mode 100644 index 00000000000..d87c3c5df5b --- /dev/null +++ b/examples/llm_finetuning/.copier-answers.yml @@ -0,0 +1,15 @@ +# Changes here will be overwritten by Copier +_commit: 2024.03.18 +_src_path: gh:zenml-io/template-llm-finetuning +cuda_version: cuda11.8 +email: '' +from_safetensors: false +full_name: ZenML GmbH +huggingface_adapter_model_repository: '' +huggingface_merged_model_repository: '' +model_repository: mistralai/Mistral-7B-Instruct-v0.1 +open_source_license: apache +product_name: llm_lora +project_name: ZenML LLM Finetuning project +version: 0.1.0 +zenml_server_url: '' diff --git a/examples/llm_finetuning/.dockerignore b/examples/llm_finetuning/.dockerignore new file mode 100644 index 00000000000..496552c8c5f --- /dev/null +++ b/examples/llm_finetuning/.dockerignore @@ -0,0 +1,9 @@ +* +!/pipelines/** +!/steps/** +!/materializers/** +!/evaluate/** +!/finetune/** +!/generate/** +!/lit_gpt/** +!/scripts/** diff --git a/examples/llm_finetuning/LICENSE b/examples/llm_finetuning/LICENSE new file mode 100644 index 00000000000..75d01fb4544 --- /dev/null +++ b/examples/llm_finetuning/LICENSE @@ -0,0 +1,15 @@ +Apache Software License 2.0 + +Copyright (c) ZenML GmbH 2024. All rights reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/examples/llm_finetuning/README.md b/examples/llm_finetuning/README.md new file mode 100644 index 00000000000..9426738597f --- /dev/null +++ b/examples/llm_finetuning/README.md @@ -0,0 +1,128 @@ +# ☮️ Fine-tuning open source LLMs using MLOps pipelines + +Welcome to your newly generated "ZenML LLM Finetuning project" project! This is +a great way to get hands-on with ZenML using production-like template. +The project contains a collection of ZenML steps, pipelines and other artifacts +and useful resources that can serve as a solid starting point for finetuning open-source LLMs using ZenML. + +Using these pipelines, we can run the data-preparation and model finetuning with a single command while using YAML files for [configuration](https://docs.zenml.io/user-guide/production-guide/configure-pipeline) and letting ZenML take care of tracking our metadata and [containerizing our pipelines](https://docs.zenml.io/user-guide/advanced-guide/infrastructure-management/containerize-your-pipeline). + +

+ +## :earth_americas: Inspiration and Credit + +This project heavily relies on the [Lit-GPT project](https://github.com/Lightning-AI/litgpt) of the amazing people at Lightning AI. We used [this blogpost](https://lightning.ai/pages/community/lora-insights/#toc14) to get started with LoRA and QLoRA and modified the commands they recommend to make them work using ZenML. + +## 🏃 How to run + +In this project we provide a few predefined configuration files for finetuning models on the [Alpaca](https://huggingface.co/datasets/tatsu-lab/alpaca) dataset. Before we're able to run any pipeline, we need to set up our environment as follows: + +```bash +# Set up a Python virtual environment, if you haven't already +python3 -m venv .venv +source .venv/bin/activate + +# Install requirements +pip install -r requirements.txt +``` + +### Combined feature engineering and finetuning pipeline + +The easiest way to get started with just a single command is to run the finetuning pipeline with the `finetune-alpaca.yaml` configuration file, which will do both feature engineering and finetuning: + +```shell +python run.py --finetuning-pipeline --config finetune-alpaca.yaml +``` + +When running the pipeline like this, the trained adapter will be stored in the ZenML artifact store. You can optionally upload the adapter, the merged model or both by specifying the `adapter_output_repo` and `merged_output_repo` parameters in the configuration file. + + +### Evaluation pipeline + +Before running this pipeline, you will need to fill in the `adapter_repo` in the `eval.yaml` configuration file. This should point to a huggingface repository that contains the finetuned adapter you got by running the finetuning pipeline. + +```shell +python run.py --eval-pipeline --config eval.yaml +``` + +### Merging pipeline + +In case you have trained an adapter using the finetuning pipeline, you can merge it with the base model by filling in the `adapter_repo` and `output_repo` parameters in the `merge.yaml` file, and then running: + +```shell +python run.py --merge-pipeline --config merge.yaml +``` + +### Feature Engineering followed by Finetuning + +If you want to finetune your model on a different dataset, you can do so by running the feature engineering pipeline followed by the finetuning pipeline. To define your dataset, take a look at the `scripts/prepare_*` scripts and set the dataset name in the `feature-alpaca.yaml` config file. + +```shell +python run.py --feature-pipeline --config feature-alpaca.yaml +python run.py --finetuning-pipeline --config finetune-from-dataset.yaml +``` + +## ☁️ Running with a remote stack + +To finetune an LLM on remote infrastructure, you can either use a remote orchestrator or a remote step operator. Follow these steps to set up a complete remote stack: +- Register the [orchestrator](https://docs.zenml.io/stacks-and-components/component-guide/orchestrators) (or [step operator](https://docs.zenml.io/stacks-and-components/component-guide/step-operators)) and make sure to configure it in a way so that the finetuning step has access to a GPU with at least 24GB of VRAM. Check out our docs for more [details](https://docs.zenml.io/stacks-and-components/component-guide). + - To access GPUs with this amount of VRAM, you might need to increase your GPU quota ([AWS](https://docs.aws.amazon.com/servicequotas/latest/userguide/request-quota-increase.html), [GCP](https://console.cloud.google.com/iam-admin/quotas), [Azure](https://learn.microsoft.com/en-us/azure/machine-learning/how-to-manage-quotas?view=azureml-api-2#request-quota-and-limit-increases)). + - The GPU instance that your finetuning will be running on will have CUDA drivers of a specific version installed. If that CUDA version is not compatible with the one provided by the default Docker image of the finetuning pipeline, you will need to modify it in the configuration file. See [here](https://hub.docker.com/r/pytorch/pytorch/tags) for a list of available PyTorch images. + - If you're running out of memory, you can experiment with quantized LoRA (QLoRA) by setting a different value for the `quantize` parameter in the configuration, or reduce the `global_batch_size`/`micro_batch_size`. +- Register a remote [artifact store](https://docs.zenml.io/stacks-and-components/component-guide/artifact-stores) and [container registry](https://docs.zenml.io/stacks-and-components/component-guide/container-registries). +- Register a stack with all these components + ```shell + zenml stack register llm-finetuning-stack -o \ + -a \ + -c \ + [-s ] + ``` + +## 💾 Running with custom data + +To finetune a model with your custom data, you will need to convert it to a CSV file with the columns described +[here](https://github.com/Lightning-AI/litgpt/blob/main/tutorials/prepare_dataset.md#preparing-custom-datasets-from-a-csv-file). + +Next, update the `configs/feature-custom.yaml` file and set the value of the `csv_path` parameter to that CSV file. +With all that in place, you can now run the feature engineering pipeline to convert your CSV into the correct format for training and then run the finetuning pipeline as follows: +```shell +python run.py --feature-pipeline --config feature-custom.yaml +python run.py --finetuning-pipeline --config finetune-from-dataset.yaml +``` + +## 📜 Project Structure + +The project loosely follows [the recommended ZenML project structure](https://docs.zenml.io/user-guide/starter-guide/follow-best-practices): + +``` +. +├── configs # pipeline configuration files +│ ├── eval.yaml # configuration for the evaluation pipeline +│ ├── feature-alpaca.yaml # configuration for the feature engineering pipeline +│ ├── feature-custom.yaml # configuration for the feature engineering pipeline +│ ├── finetune-alpaca.yaml # configuration for the finetuning pipeline +│ ├── finetune-from-dataset.yaml # configuration for the finetuning pipeline +│ └── merge.yaml # configuration for the merging pipeline +├── pipelines # `zenml.pipeline` implementations +│ ├── evaluate.py # Evaluation pipeline +│ ├── feature_engineering.py # Feature engineering pipeline +│ ├── finetuning.py # Finetuning pipeline +│ └── merge.py # Merging pipeline +├── steps # logically grouped `zenml.steps` implementations +│ ├── evaluate.py # evaluate model performance +│ ├── feature_engineering.py # preprocess data +│ ├── finetune.py # finetune a model +│ ├── merge.py # merge model and adapter +│ ├── params.py # shared parameters for steps +│ └── utils.py # utility functions +├── .dockerignore +├── README.md # this file +├── requirements.txt # extra Python dependencies +└── run.py # CLI tool to run pipelines on ZenML Stack +``` diff --git a/examples/llm_finetuning/configs/eval.yaml b/examples/llm_finetuning/configs/eval.yaml new file mode 100644 index 00000000000..03a73adbd7f --- /dev/null +++ b/examples/llm_finetuning/configs/eval.yaml @@ -0,0 +1,21 @@ +model: + name: llm_lora-Mistral-7B-Instruct-v0.1 + description: "Fine-tune `mistralai/Mistral-7B-Instruct-v0.1`." + tags: + - llm + - lora + - mistralai/Mistral-7B-Instruct-v0.1 + +settings: + docker: + parent_image: pytorch/pytorch:2.2.0-cuda11.8-cudnn8-runtime + +steps: + evaluate: + enable_step_logs: False + parameters: + config: + model_repo: mistralai/Mistral-7B-Instruct-v0.1 + from_safetensors: False + adapter_repo: + precision: bf16-true \ No newline at end of file diff --git a/examples/llm_finetuning/configs/feature-alpaca.yaml b/examples/llm_finetuning/configs/feature-alpaca.yaml new file mode 100644 index 00000000000..2a0ca7f85c4 --- /dev/null +++ b/examples/llm_finetuning/configs/feature-alpaca.yaml @@ -0,0 +1,16 @@ +model: + name: llm_lora-Mistral-7B-Instruct-v0.1 + description: "Fine-tune `mistralai/Mistral-7B-Instruct-v0.1`." + tags: + - llm + - lora + - mistralai/Mistral-7B-Instruct-v0.1 + - alpaca + +steps: + feature_engineering: + enable_step_logs: False + parameters: + config: + model_repo: mistralai/Mistral-7B-Instruct-v0.1 + dataset_name: alpaca diff --git a/examples/llm_finetuning/configs/feature-custom.yaml b/examples/llm_finetuning/configs/feature-custom.yaml new file mode 100644 index 00000000000..6611ede26be --- /dev/null +++ b/examples/llm_finetuning/configs/feature-custom.yaml @@ -0,0 +1,19 @@ +model: + name: llm_lora-Mistral-7B-Instruct-v0.1 + description: "Fine-tune `mistralai/Mistral-7B-Instruct-v0.1`." + tags: + - llm + - lora + - mistralai/Mistral-7B-Instruct-v0.1 + +steps: + feature_engineering: + enable_step_logs: False + parameters: + config: + model_repo: mistralai/Mistral-7B-Instruct-v0.1 + dataset_name: csv + prepare_kwargs: + # REQUIRED: Path the the .csv file containing the data. Format must be as described here + # https://github.com/Lightning-AI/litgpt/blob/main/tutorials/prepare_dataset.md#preparing-custom-datasets-from-a-csv-file + csv_path: null diff --git a/examples/llm_finetuning/configs/finetune-alpaca.yaml b/examples/llm_finetuning/configs/finetune-alpaca.yaml new file mode 100644 index 00000000000..6e9a4502c69 --- /dev/null +++ b/examples/llm_finetuning/configs/finetune-alpaca.yaml @@ -0,0 +1,35 @@ +model: + name: llm_lora-Mistral-7B-Instruct-v0.1 + description: "Fine-tune `mistralai/Mistral-7B-Instruct-v0.1`." + tags: + - llm + - lora + - mistralai/Mistral-7B-Instruct-v0.1 + - alpaca + +settings: + docker: + parent_image: pytorch/pytorch:2.2.0-cuda11.8-cudnn8-runtime + +steps: + finetune: + # Uncomment and set value to use a step operator for this step + # step_operator: + enable_step_logs: False + parameters: + config: + base_model_repo: mistralai/Mistral-7B-Instruct-v0.1 + from_safetensors: False + precision: bf16-true + quantize: bnb.nf4 # Enable quantization with 4-bit normal float + # OPTIONAL: Configure Huggingface repository to which the merged model should be pushed + # merged_output_repo: + # OPTIONAL: Configure Huggingface repository to which the adapter should be pushed + # adapter_output_repo: + training: + save_interval: 1 + epochs: 5 + epoch_size: 50000 + global_batch_size: 128 + micro_batch_size: 4 + learning_rate: 3e-4 diff --git a/examples/llm_finetuning/configs/finetune-from-dataset.yaml b/examples/llm_finetuning/configs/finetune-from-dataset.yaml new file mode 100644 index 00000000000..653bcbecaa2 --- /dev/null +++ b/examples/llm_finetuning/configs/finetune-from-dataset.yaml @@ -0,0 +1,33 @@ +parameters: + dataset_artifact_name: dataset + +model: + name: llm_lora-Mistral-7B-Instruct-v0.1 + version: latest + +settings: + docker: + parent_image: pytorch/pytorch:2.2.0-cuda11.8-cudnn8-runtime + +steps: + finetune: + # Uncomment and set value to use a step operator for this step + # step_operator: + enable_step_logs: False + parameters: + config: + base_model_repo: mistralai/Mistral-7B-Instruct-v0.1 + from_safetensors: False + precision: bf16-true + quantize: bnb.nf4 # Enable quantization with 4-bit normal float + # OPTIONAL: Configure Huggingface repository to which the merged model should be pushed + # merged_output_repo: + # OPTIONAL: Configure Huggingface repository to which the adapter should be pushed + # adapter_output_repo: + training: + save_interval: 1 + epochs: 5 + epoch_size: 50000 + global_batch_size: 128 + micro_batch_size: 4 + learning_rate: 3e-4 diff --git a/examples/llm_finetuning/configs/merge.yaml b/examples/llm_finetuning/configs/merge.yaml new file mode 100644 index 00000000000..2f7326ebe7d --- /dev/null +++ b/examples/llm_finetuning/configs/merge.yaml @@ -0,0 +1,19 @@ +model: + name: llm_lora-Mistral-7B-Instruct-v0.1 + description: "Fine-tune `mistralai/Mistral-7B-Instruct-v0.1`." + tags: + - llm + - lora + - mistralai/Mistral-7B-Instruct-v0.1 + +steps: + merge: + parameters: + config: + base_model_repo: mistralai/Mistral-7B-Instruct-v0.1 + from_safetensors: False + # REQUIRED: Huggingface repository in which to adapter is stored + adapter_repo: null + # REQUIRED: Huggingface repository to which the merged model should be pushed + output_repo: null + precision: bf16-true \ No newline at end of file diff --git a/examples/llm_finetuning/evaluate/lm_eval_harness.py b/examples/llm_finetuning/evaluate/lm_eval_harness.py new file mode 100644 index 00000000000..6f90c19f14e --- /dev/null +++ b/examples/llm_finetuning/evaluate/lm_eval_harness.py @@ -0,0 +1,231 @@ +# Copyright Lightning AI. Licensed under the Apache License 2.0, see LICENSE file. + +import json +import sys +from pathlib import Path +from typing import Dict, List, Literal, Optional + +import lightning as L +import torch +from lightning.fabric.plugins import BitsandbytesPrecision +from lm_eval import base, evaluator, tasks +from lm_eval.base import BaseLM + +# support running without installing as a package +wd = Path(__file__).parent.parent.resolve() +sys.path.append(str(wd)) + +from generate.base import generate +from lit_gpt import GPT, Config, Tokenizer +from lit_gpt.utils import ( + CLI, + check_valid_checkpoint_dir, + get_default_supported_precision, + load_checkpoint, +) + + +class EvalHarnessBase(BaseLM): + # Credits: + # https://github.com/EleutherAI/gpt-neox/blob/main/eval_tasks/eval_adapter.py + def __init__( + self, + fabric: L.Fabric, + model: GPT, + tokenizer: Tokenizer, + batch_size: int, + ): + super().__init__() + self.fabric = fabric + self.model = model + self.tokenizer = tokenizer + self.batch_size_per_gpu = batch_size + with fabric.init_tensor(): + model.set_kv_cache(batch_size=batch_size) + + @classmethod + def create_from_arg_string(cls, arg_string, additional_config=None): + kwargs = { + el.split("=")[0]: el.split("=")[1] for el in arg_string.split(",") + } + return cls(**kwargs, **additional_config) + + @property + def eot_token_id(self): + # we use EOT because end of *text* is more accurate for what we're doing than end of *sentence* + return self.tokenizer.eos_id + + @property + def max_length(self): + return self.model.max_seq_length + + @property + def vocab_size(self): + return self.tokenizer.vocab_size + + @property + def max_gen_toks(self): + return 256 + + @property + def batch_size(self): + return self.batch_size_per_gpu * self.fabric.world_size + + @property + def device(self): + return self.fabric.device + + def tok_encode(self, string: str) -> List[int]: + return self.tokenizer.encode(string, bos=False, eos=False).tolist() + + def tok_decode(self, tokens: List[int]) -> str: + t = torch.tensor(tokens) + return self.tokenizer.decode(t) + + @torch.inference_mode() + def _model_call(self, inps): + return self.model(inps) + + @torch.inference_mode() + def _model_generate( + self, context, max_length, eos_token_id + ) -> torch.Tensor: + # this only supports batch size 1 + assert context.shape[0] == 1 + out = generate(self.model, context[0], max_length, eos_id=eos_token_id) + for block in self.model.transformer.h: + block.attn.kv_cache.reset_parameters() + return out.unsqueeze(0) + + @torch.inference_mode() + def run_eval( + self, + eval_tasks: List[str], + num_fewshot: int, + limit: Optional[int], + bootstrap_iters: int, + no_cache: bool, + ) -> Dict: + # Returns a list containing all values of the task registry that + # match at least one of the patterns + import fnmatch + + def pattern_match(patterns, source_list): + task_names = set() + for pattern in patterns: + for matching in fnmatch.filter(source_list, pattern): + task_names.add(matching) + return list(task_names) + + eval_tasks = pattern_match(eval_tasks, tasks.ALL_TASKS) + print(f"Found tasks: {eval_tasks}") + + # **HACK INCOMING**: + # first get task dict on local main rank + # the tasks are downloaded *as they are initialized*, and the downloads don't like multithreading. + # so we download them once on the local main rank, wait, and then initialize them on all other ranks, which *should* load from the cache. + if self.fabric.local_rank == 0: + tasks.get_task_dict(eval_tasks) + # torch barrier + self.fabric.barrier() + tasks.get_task_dict(eval_tasks) + + lm = self + if not no_cache: + lm = base.CachingLM(lm, "lm_cache/lit-gpt.db") + + results = evaluator.evaluate( + lm=lm, + task_dict=tasks.get_task_dict(eval_tasks), + num_fewshot=num_fewshot, + limit=limit, + bootstrap_iters=bootstrap_iters, + ) + results["config"] = dict( + model=self.model.config.name, + batch_size=self.batch_size, + device=str(self.device), + num_fewshot=num_fewshot, + limit=limit, + bootstrap_iters=bootstrap_iters, + no_cache=no_cache, + ) + return results + + +@torch.inference_mode() +def run_eval_harness( + checkpoint_dir: Path, + precision: Optional[str] = None, + quantize: Optional[ + Literal["bnb.nf4", "bnb.nf4-dq", "bnb.fp4", "bnb.fp4-dq", "bnb.int8"] + ] = None, + eval_tasks: List[str] = [ + "arc_challenge", + "piqa", + "hellaswag", + "hendrycksTest-*", + ], + save_filepath: Optional[Path] = None, + num_fewshot: int = 0, + limit: Optional[int] = None, + bootstrap_iters: int = 100000, + no_cache: bool = True, +): + if precision is None: + precision = get_default_supported_precision(training=False) + + plugins = None + if quantize is not None and quantize.startswith("bnb."): + if "mixed" in precision: + raise ValueError( + "Quantization and mixed precision is not supported." + ) + dtype = { + "16-true": torch.float16, + "bf16-true": torch.bfloat16, + "32-true": torch.float32, + }[precision] + plugins = BitsandbytesPrecision(quantize[4:], dtype) + precision = None + + fabric = L.Fabric(devices=1, precision=precision, plugins=plugins) + + check_valid_checkpoint_dir(checkpoint_dir) + tokenizer = Tokenizer(checkpoint_dir) + + config = Config.from_json(checkpoint_dir / "lit_config.json") + + checkpoint_path = checkpoint_dir / "lit_model.pth" + + print( + f"Loading model {str(checkpoint_path)!r} with {config.__dict__}", + file=sys.stderr, + ) + with fabric.init_module(empty_init=True): + model = GPT(config) + + model.eval() + model = fabric.setup_module(model) + + load_checkpoint(fabric, model, checkpoint_path) + + eval_harness = EvalHarnessBase(fabric, model, tokenizer, 1) + + results = eval_harness.run_eval( + eval_tasks, num_fewshot, limit, bootstrap_iters, no_cache + ) + if save_filepath is None: + print(results) + else: + print(f"Saving results to {str(save_filepath)!r}") + save_filepath.parent.mkdir(parents=True, exist_ok=True) + data = json.dumps(results) + with open(save_filepath, "w") as fw: + fw.write(data) + + +if __name__ == "__main__": + torch.set_float32_matmul_precision("high") + + CLI(run_eval_harness) diff --git a/examples/llm_finetuning/finetune/adapter.py b/examples/llm_finetuning/finetune/adapter.py new file mode 100644 index 00000000000..acf8f6d414d --- /dev/null +++ b/examples/llm_finetuning/finetune/adapter.py @@ -0,0 +1,451 @@ +# Copyright Lightning AI. Licensed under the Apache License 2.0, see LICENSE file. +import dataclasses +import os +import sys +import time +from pathlib import Path +from typing import Dict, List, Literal, Optional, Tuple + +import lightning as L +import torch +from lightning.fabric.loggers import CSVLogger +from lightning.fabric.plugins import BitsandbytesPrecision +from lightning.fabric.strategies import FSDPStrategy +from lightning.fabric.utilities import ThroughputMonitor + +# support running without installing as a package +wd = Path(__file__).parent.parent.resolve() +sys.path.append(str(wd)) + +from generate.base import generate +from lit_gpt.adapter import ( + GPT, + Block, + Config, + adapter_filter, + mark_only_adapter_as_trainable, +) +from lit_gpt.args import EvalArgs, IOArgs, TrainArgs +from lit_gpt.tokenizer import Tokenizer +from lit_gpt.utils import ( + CLI, + check_valid_checkpoint_dir, + chunked_cross_entropy, + get_default_supported_precision, + load_checkpoint, + num_parameters, +) + +from scripts.prepare_alpaca import generate_prompt + + +def setup( + precision: Optional[str] = None, + quantize: Optional[ + Literal[ + "bnb.nf4", + "bnb.nf4-dq", + "bnb.fp4", + "bnb.fp4-dq", + "bnb.int8-training", + ] + ] = None, + devices: int = 1, + io: IOArgs = IOArgs( + train_data_dir=Path("data/alpaca"), + val_data_dir=Path("data/alpaca"), + checkpoint_dir=Path("checkpoints/stabilityai/stablelm-base-alpha-3b"), + out_dir=Path("out/adapter/alpaca"), + ), + train: TrainArgs = TrainArgs( + save_interval=1000, + log_interval=1, + global_batch_size=64, + micro_batch_size=4, + lr_warmup_steps=100, + epochs=5, + epoch_size=50000, + learning_rate=1e-3, + max_seq_length=None, + ), + eval: EvalArgs = EvalArgs(interval=600, max_new_tokens=100, max_iters=100), +) -> None: + print(locals()) + precision = precision or get_default_supported_precision(training=True) + + plugins = None + if quantize is not None and quantize.startswith("bnb."): + if "mixed" in precision: + raise ValueError( + "Quantization and mixed precision is not supported." + ) + dtype = { + "16-true": torch.float16, + "bf16-true": torch.bfloat16, + "32-true": torch.float32, + }[precision] + plugins = BitsandbytesPrecision(quantize[4:], dtype) + precision = None + + if devices > 1: + if quantize: + raise NotImplementedError( + "Quantization is currently not supported for multi-GPU training. Please set devices=1 when using the" + " --quantize flag." + ) + strategy = FSDPStrategy( + auto_wrap_policy={Block}, + activation_checkpointing_policy={Block}, + state_dict_type="full", + limit_all_gathers=True, + cpu_offload=False, + ) + else: + strategy = "auto" + + logger = CSVLogger( + io.out_dir.parent, + io.out_dir.name, + flush_logs_every_n_steps=train.log_interval, + ) + fabric = L.Fabric( + devices=devices, + strategy=strategy, + precision=precision, + loggers=logger, + plugins=plugins, + ) + fabric.launch( + main, + devices, + Config.from_name(name=io.checkpoint_dir.name), + io, + train, + eval, + ) + + +def main( + fabric: L.Fabric, + devices: int, + config: Config, + io: IOArgs, + train: TrainArgs, + eval: EvalArgs, +) -> None: + validate_args(io, train, eval) + + steps_per_epoch = train.epoch_size // devices // train.batch_size(devices) + lr_max_steps = train.epochs * steps_per_epoch + + check_valid_checkpoint_dir(io.checkpoint_dir) + + fabric.seed_everything( + 1337 + ) # same seed for every process to init model (FSDP) + + if fabric.global_rank == 0: + os.makedirs(io.out_dir, exist_ok=True) + + train_data = torch.load(io.train_data_dir / "train.pt") + val_data = torch.load(io.val_data_dir / "test.pt") + + checkpoint_path = io.checkpoint_dir / "lit_model.pth" + fabric.print( + f"Loading model {str(checkpoint_path)!r} with {config.__dict__}" + ) + with fabric.init_module(empty_init=(devices > 1)): + model = GPT(config) + mark_only_adapter_as_trainable(model) + + fabric.print( + f"Number of trainable parameters: {num_parameters(model, requires_grad=True):,}" + ) + fabric.print( + f"Number of non trainable parameters: {num_parameters(model, requires_grad=False):,}" + ) + + model = fabric.setup_module(model) + + trainable_params = [p for p in model.parameters() if p.requires_grad] + if isinstance(fabric.strategy.precision, BitsandbytesPrecision): + import bitsandbytes as bnb + + optimizer_cls = bnb.optim.PagedAdamW + else: + optimizer_cls = torch.optim.AdamW + optimizer = optimizer_cls( + trainable_params, + lr=train.learning_rate, + weight_decay=train.weight_decay, + betas=(train.beta1, train.beta2), + ) + optimizer = fabric.setup_optimizers(optimizer) + scheduler = get_lr_scheduler( + optimizer, warmup_steps=train.lr_warmup_steps, max_steps=lr_max_steps + ) + + # strict=False because missing keys due to Adapter weights not contained in state dict + load_checkpoint(fabric, model, checkpoint_path, strict=False) + + fabric.seed_everything(1337 + fabric.global_rank) + + train_time = time.perf_counter() + fit( + fabric, + model, + optimizer, + scheduler, + train_data, + val_data, + devices, + io, + train, + eval, + ) + fabric.print(f"Training time: {(time.perf_counter()-train_time):.2f}s") + if fabric.device.type == "cuda": + fabric.print( + f"Memory used: {torch.cuda.max_memory_allocated() / 1e9:.02f} GB" + ) + + # Save the final checkpoint at the end of training + save_path = io.out_dir / "lit_model_adapter_finetuned.pth" + save_adapter_checkpoint(fabric, model, save_path) + + +def fit( + fabric: L.Fabric, + model: GPT, + optimizer: torch.optim.Optimizer, + scheduler: torch.optim.lr_scheduler, + train_data: List[Dict], + val_data: List[Dict], + devices: int, + io: IOArgs, + train: TrainArgs, + eval: EvalArgs, +) -> None: + tokenizer = Tokenizer(io.checkpoint_dir) + longest_seq_length, longest_seq_ix = get_longest_seq_length(train_data) + model.max_seq_length = min( + longest_seq_length, train.max_seq_length or float("inf") + ) + fabric.print( + f"The longest sequence length in the train data is {longest_seq_length}, the model's maximum sequence length is" + f" {model.max_seq_length} and context length is {model.config.block_size}" + ) + + validate( + fabric, + model, + val_data, + tokenizer, + dataclasses.replace(eval, max_iters=2), + train, + ) # sanity check + + throughput = ThroughputMonitor(fabric, window_size=50) + step_count = 0 + total_lengths = 0 + total_t0 = time.perf_counter() + + for iter_num in range(1, train.max_iters(devices) + 1): + iter_t0 = time.perf_counter() + + input_ids, targets = get_batch( + fabric, + train_data, + train.micro_batch_size, + train.max_seq_length, + longest_seq_ix if iter_num == 1 else None, + ) + + is_accumulating = ( + iter_num % train.gradient_accumulation_iters(devices) != 0 + ) + with fabric.no_backward_sync(model, enabled=is_accumulating): + logits = model(input_ids, lm_head_chunk_size=128) + # shift the targets such that output n predicts token n+1 + logits[-1] = logits[-1][..., :-1, :] + loss = chunked_cross_entropy(logits, targets[..., 1:]) + fabric.backward(loss / train.gradient_accumulation_iters(devices)) + + if not is_accumulating: + optimizer.step() + optimizer.zero_grad() + scheduler.step() + step_count += 1 + + total_lengths += input_ids.numel() + if iter_num % train.log_interval == 0: + loss_item = loss.item() # expensive device-to-host synchronization + t1 = time.perf_counter() + throughput.update( + time=t1 - total_t0, + batches=iter_num, + samples=iter_num * train.micro_batch_size, + lengths=total_lengths, + ) + throughput.compute_and_log(step=iter_num) + fabric.print( + f"iter {iter_num} | step {step_count}: loss {loss_item:.4f}, iter time:" + f" {(t1 - iter_t0) * 1000:.2f} ms{' (optimizer.step)' if not is_accumulating else ''}" + ) + + if not is_accumulating and step_count % eval.interval == 0: + t0 = time.perf_counter() + val_loss = validate( + fabric, model, val_data, tokenizer, eval, train + ) + t1 = time.perf_counter() - t0 + fabric.print( + f"iter {iter_num}: val loss {val_loss.item():.4f}, val time: {t1 * 1000:.2f} ms" + ) + fabric.barrier() + if not is_accumulating and step_count % train.save_interval == 0: + checkpoint_path = io.out_dir / f"iter-{iter_num:06d}-ckpt.pth" + save_adapter_checkpoint(fabric, model, checkpoint_path) + + +# the adapter "kv cache" cannot be initialized under `inference_mode` +@torch.no_grad() +def validate( + fabric: L.Fabric, + model: GPT, + val_data: List[Dict], + tokenizer: Tokenizer, + eval: EvalArgs, + train: TrainArgs, +) -> torch.Tensor: + fabric.print("Validating ...") + model.eval() + losses = torch.zeros(eval.max_iters) + for k in range(eval.max_iters): + input_ids, targets = get_batch( + fabric, val_data, train.micro_batch_size, train.max_seq_length + ) + logits = model(input_ids) + losses[k] = chunked_cross_entropy( + logits[..., :-1, :], targets[..., 1:], chunk_size=0 + ) + val_loss = losses.mean() + + # produce an example: + instruction = "Recommend a movie for me to watch during the weekend and explain the reason." + fabric.print(instruction) + sample = {"instruction": instruction, "input": ""} + prompt = generate_prompt(sample) + encoded = tokenizer.encode(prompt, device=fabric.device) + with fabric.init_tensor(): + # do not set `max_seq_length=max_returned_token` because memory is not a concern here + model.set_kv_cache(batch_size=1) + output = generate( + model, + encoded, + max_returned_tokens=len(encoded) + eval.max_new_tokens, + temperature=0.8, + eos_id=tokenizer.eos_id, + ) + model.clear_kv_cache() + output = tokenizer.decode(output) + fabric.print(output) + + model.train() + return val_loss + + +def get_batch( + fabric: L.Fabric, + data: List[Dict], + micro_batch_size: int, + max_seq_length: Optional[int], + longest_seq_ix: Optional[int] = None, +) -> Tuple[torch.Tensor, torch.Tensor]: + ix = torch.randint(len(data), (micro_batch_size,)) + if longest_seq_ix is not None: + # force the longest sample at the beginning so potential OOMs happen right away + ix[0] = longest_seq_ix + + input_ids = [data[i]["input_ids"].type(torch.int64) for i in ix] + labels = [data[i]["labels"].type(torch.int64) for i in ix] + + # this could be `longest_seq_length` to have a fixed size for all batches + max_len = max(len(s) for s in input_ids) + + def pad_right(x, pad_id): + # pad right based on the longest sequence + n = max_len - len(x) + return torch.cat((x, torch.full((n,), pad_id, dtype=x.dtype))) + + x = torch.stack([pad_right(x, pad_id=0) for x in input_ids]) + y = torch.stack([pad_right(x, pad_id=-1) for x in labels]) + + # Truncate if needed + if max_seq_length: + x = x[:, :max_seq_length] + y = y[:, :max_seq_length] + + if fabric.device.type == "cuda" and x.device.type == "cpu": + x, y = fabric.to_device((x.pin_memory(), y.pin_memory())) + else: + x, y = fabric.to_device((x, y)) + return x, y + + +def get_lr_scheduler(optimizer, warmup_steps: int, max_steps: int): + # linear warmup followed by cosine annealing + scheduler1 = torch.optim.lr_scheduler.LambdaLR( + optimizer, lambda step: step / warmup_steps + ) + scheduler2 = torch.optim.lr_scheduler.CosineAnnealingLR( + optimizer, T_max=(max_steps - warmup_steps) + ) + return torch.optim.lr_scheduler.SequentialLR( + optimizer, [scheduler1, scheduler2], milestones=[warmup_steps] + ) + + +def get_longest_seq_length(data: List[Dict]) -> Tuple[int, int]: + # find out the minimum max_seq_length required during fine-tuning (saves memory!) + lengths = [len(d["input_ids"]) for d in data] + longest_seq_length = max(lengths) + longest_seq_ix = lengths.index(longest_seq_length) + return longest_seq_length, longest_seq_ix + + +def save_adapter_checkpoint( + fabric: L.Fabric, model: torch.nn.Module, file_path: Path +) -> None: + fabric.print(f"Saving adapter weights to {str(file_path)!r}") + fabric.save(file_path, {"model": model}, filter={"model": adapter_filter}) + + +def validate_args(io: IOArgs, train: TrainArgs, eval: EvalArgs) -> None: + issues = [] + unsupported = [(train, ["max_tokens", "max_norm"])] + for args, names in unsupported: + for name in names: + if getattr(args, name) is not None: + issues.append( + f"{__file__} doesn't support the {name!r} argument. This is set in {args}" + ) + required = [ + (io, ["checkpoint_dir", "train_data_dir", "val_data_dir"]), + (train, ["epoch_size", "epochs"]), + (eval, ["max_new_tokens"]), + ] + for args, names in required: + for name in names: + if getattr(args, name) is None: + issues.append( + f"{__file__} requires the {name!r} argument. This is set in {args}" + ) + if issues: + raise ValueError("\n".join(issues)) + + +if __name__ == "__main__": + torch.set_float32_matmul_precision("high") + + CLI(setup) diff --git a/examples/llm_finetuning/finetune/adapter_v2.py b/examples/llm_finetuning/finetune/adapter_v2.py new file mode 100644 index 00000000000..ac7de327a49 --- /dev/null +++ b/examples/llm_finetuning/finetune/adapter_v2.py @@ -0,0 +1,451 @@ +# Copyright Lightning AI. Licensed under the Apache License 2.0, see LICENSE file. +import dataclasses +import os +import sys +import time +from pathlib import Path +from typing import Dict, List, Literal, Optional, Tuple + +import lightning as L +import torch +from lightning.fabric.loggers import CSVLogger +from lightning.fabric.plugins import BitsandbytesPrecision +from lightning.fabric.strategies import FSDPStrategy +from lightning.fabric.utilities import ThroughputMonitor + +# support running without installing as a package +wd = Path(__file__).parent.parent.resolve() +sys.path.append(str(wd)) + +from generate.base import generate +from lit_gpt.adapter_v2 import ( + GPT, + Block, + Config, + adapter_filter, + mark_only_adapter_v2_as_trainable, +) +from lit_gpt.args import EvalArgs, IOArgs, TrainArgs +from lit_gpt.tokenizer import Tokenizer +from lit_gpt.utils import ( + CLI, + check_valid_checkpoint_dir, + chunked_cross_entropy, + get_default_supported_precision, + load_checkpoint, + num_parameters, +) + +from scripts.prepare_alpaca import generate_prompt + + +def setup( + precision: Optional[str] = None, + quantize: Optional[ + Literal[ + "bnb.nf4", + "bnb.nf4-dq", + "bnb.fp4", + "bnb.fp4-dq", + "bnb.int8-training", + ] + ] = None, + devices: int = 1, + io: IOArgs = IOArgs( + train_data_dir=Path("data/alpaca"), + val_data_dir=Path("data/alpaca"), + checkpoint_dir=Path("checkpoints/stabilityai/stablelm-base-alpha-3b"), + out_dir=Path("out/adapter_v2/alpaca"), + ), + train: TrainArgs = TrainArgs( + save_interval=1000, + log_interval=1, + global_batch_size=128, + micro_batch_size=2, + lr_warmup_steps=100, + epochs=5, + epoch_size=50000, + learning_rate=1e-3, + max_seq_length=None, + ), + eval: EvalArgs = EvalArgs(interval=600, max_new_tokens=100, max_iters=100), +) -> None: + print(locals()) + precision = precision or get_default_supported_precision(training=True) + + plugins = None + if quantize is not None and quantize.startswith("bnb."): + if "mixed" in precision: + raise ValueError( + "Quantization and mixed precision is not supported." + ) + dtype = { + "16-true": torch.float16, + "bf16-true": torch.bfloat16, + "32-true": torch.float32, + }[precision] + plugins = BitsandbytesPrecision(quantize[4:], dtype) + precision = None + + if devices > 1: + if quantize: + raise NotImplementedError( + "Quantization is currently not supported for multi-GPU training. Please set devices=1 when using the" + " --quantize flag." + ) + strategy = FSDPStrategy( + auto_wrap_policy={Block}, + activation_checkpointing_policy={Block}, + state_dict_type="full", + limit_all_gathers=True, + cpu_offload=False, + ) + else: + strategy = "auto" + + logger = CSVLogger( + io.out_dir.parent, + io.out_dir.name, + flush_logs_every_n_steps=train.log_interval, + ) + fabric = L.Fabric( + devices=devices, + strategy=strategy, + precision=precision, + loggers=logger, + plugins=plugins, + ) + fabric.launch( + main, + devices, + Config.from_name(name=io.checkpoint_dir.name), + io, + train, + eval, + ) + + +def main( + fabric: L.Fabric, + devices: int, + config: Config, + io: IOArgs, + train: TrainArgs, + eval: EvalArgs, +) -> None: + validate_args(io, train, eval) + + steps_per_epoch = train.epoch_size // devices // train.batch_size(devices) + lr_max_steps = train.epochs * steps_per_epoch + + check_valid_checkpoint_dir(io.checkpoint_dir) + + fabric.seed_everything( + 1337 + ) # same seed for every process to init model (FSDP) + + if fabric.global_rank == 0: + os.makedirs(io.out_dir, exist_ok=True) + + train_data = torch.load(io.train_data_dir / "train.pt") + val_data = torch.load(io.val_data_dir / "test.pt") + + checkpoint_path = io.checkpoint_dir / "lit_model.pth" + fabric.print( + f"Loading model {str(checkpoint_path)!r} with {config.__dict__}" + ) + with fabric.init_module(empty_init=(devices > 1)): + model = GPT(config) + mark_only_adapter_v2_as_trainable(model) + + fabric.print( + f"Number of trainable parameters: {num_parameters(model, requires_grad=True):,}" + ) + fabric.print( + f"Number of non trainable parameters: {num_parameters(model, requires_grad=False):,}" + ) + + model = fabric.setup_module(model) + + trainable_params = [p for p in model.parameters() if p.requires_grad] + if isinstance(fabric.strategy.precision, BitsandbytesPrecision): + import bitsandbytes as bnb + + optimizer_cls = bnb.optim.PagedAdamW + else: + optimizer_cls = torch.optim.AdamW + optimizer = optimizer_cls( + trainable_params, + lr=train.learning_rate, + weight_decay=train.weight_decay, + betas=(train.beta1, train.beta2), + ) + optimizer = fabric.setup_optimizers(optimizer) + scheduler = get_lr_scheduler( + optimizer, warmup_steps=train.lr_warmup_steps, max_steps=lr_max_steps + ) + + # strict=False because missing keys due to Adapter weights not contained in state dict + load_checkpoint(fabric, model, checkpoint_path, strict=False) + + fabric.seed_everything(1337 + fabric.global_rank) + + train_time = time.perf_counter() + fit( + fabric, + model, + optimizer, + scheduler, + train_data, + val_data, + devices, + io, + train, + eval, + ) + fabric.print(f"Training time: {(time.perf_counter()-train_time):.2f}s") + if fabric.device.type == "cuda": + fabric.print( + f"Memory used: {torch.cuda.max_memory_allocated() / 1e9:.02f} GB" + ) + + # Save the final checkpoint at the end of training + save_path = io.out_dir / "lit_model_adapter_finetuned.pth" + save_adapter_v2_checkpoint(fabric, model, save_path) + + +def fit( + fabric: L.Fabric, + model: GPT, + optimizer: torch.optim.Optimizer, + scheduler: torch.optim.lr_scheduler, + train_data: List[Dict], + val_data: List[Dict], + devices: int, + io: IOArgs, + train: TrainArgs, + eval: EvalArgs, +) -> None: + tokenizer = Tokenizer(io.checkpoint_dir) + longest_seq_length, longest_seq_ix = get_longest_seq_length(train_data) + model.max_seq_length = min( + longest_seq_length, train.max_seq_length or float("inf") + ) + fabric.print( + f"The longest sequence length in the train data is {longest_seq_length}, the model's maximum sequence length is" + f" {model.max_seq_length} and context length is {model.config.block_size}" + ) + + validate( + fabric, + model, + val_data, + tokenizer, + dataclasses.replace(eval, max_iters=2), + train, + ) # sanity check + + throughput = ThroughputMonitor(fabric, window_size=50) + step_count = 0 + total_lengths = 0 + total_t0 = time.perf_counter() + + for iter_num in range(1, train.max_iters(devices) + 1): + iter_t0 = time.perf_counter() + + input_ids, targets = get_batch( + fabric, + train_data, + train.micro_batch_size, + train.max_seq_length, + longest_seq_ix if iter_num == 1 else None, + ) + + is_accumulating = ( + iter_num % train.gradient_accumulation_iters(devices) != 0 + ) + with fabric.no_backward_sync(model, enabled=is_accumulating): + logits = model(input_ids, lm_head_chunk_size=128) + # shift the targets such that output n predicts token n+1 + logits[-1] = logits[-1][..., :-1, :] + loss = chunked_cross_entropy(logits, targets[..., 1:]) + fabric.backward(loss / train.gradient_accumulation_iters(devices)) + + if not is_accumulating: + optimizer.step() + optimizer.zero_grad() + scheduler.step() + step_count += 1 + + total_lengths += input_ids.numel() + if iter_num % train.log_interval == 0: + loss_item = loss.item() # expensive device-to-host synchronization + t1 = time.perf_counter() + throughput.update( + time=t1 - total_t0, + batches=iter_num, + samples=iter_num * train.micro_batch_size, + lengths=total_lengths, + ) + throughput.compute_and_log(step=iter_num) + fabric.print( + f"iter {iter_num} | step {step_count}: loss {loss_item:.4f}, iter time:" + f" {(t1 - iter_t0) * 1000:.2f} ms{' (optimizer.step)' if not is_accumulating else ''}" + ) + + if not is_accumulating and step_count % eval.interval == 0: + t0 = time.perf_counter() + val_loss = validate( + fabric, model, val_data, tokenizer, eval, train + ) + t1 = time.perf_counter() - t0 + fabric.print( + f"iter {iter_num}: val loss {val_loss.item():.4f}, val time: {t1 * 1000:.2f} ms" + ) + fabric.barrier() + if not is_accumulating and step_count % train.save_interval == 0: + checkpoint_path = io.out_dir / f"iter-{iter_num:06d}-ckpt.pth" + save_adapter_v2_checkpoint(fabric, model, checkpoint_path) + + +# the adapter "kv cache" cannot be initialized under `inference_mode` +@torch.no_grad() +def validate( + fabric: L.Fabric, + model: GPT, + val_data: List[Dict], + tokenizer: Tokenizer, + eval: EvalArgs, + train: TrainArgs, +) -> torch.Tensor: + fabric.print("Validating ...") + model.eval() + losses = torch.zeros(eval.max_iters) + for k in range(eval.max_iters): + input_ids, targets = get_batch( + fabric, val_data, train.micro_batch_size, train.max_seq_length + ) + logits = model(input_ids) + losses[k] = chunked_cross_entropy( + logits[..., :-1, :], targets[..., 1:], chunk_size=0 + ) + val_loss = losses.mean() + + # produce an example: + instruction = "Recommend a movie for me to watch during the weekend and explain the reason." + fabric.print(instruction) + sample = {"instruction": instruction, "input": ""} + prompt = generate_prompt(sample) + encoded = tokenizer.encode(prompt, device=fabric.device) + with fabric.init_tensor(): + # do not set `max_seq_length=max_returned_token` because memory is not a concern here + model.set_kv_cache(batch_size=1) + output = generate( + model, + encoded, + max_returned_tokens=len(encoded) + eval.max_new_tokens, + temperature=0.8, + eos_id=tokenizer.eos_id, + ) + model.clear_kv_cache() + output = tokenizer.decode(output) + fabric.print(output) + + model.train() + return val_loss + + +def get_batch( + fabric: L.Fabric, + data: List[Dict], + micro_batch_size: int, + max_seq_length: Optional[int], + longest_seq_ix: Optional[int] = None, +) -> Tuple[torch.Tensor, torch.Tensor]: + ix = torch.randint(len(data), (micro_batch_size,)) + if longest_seq_ix is not None: + # force the longest sample at the beginning so potential OOMs happen right away + ix[0] = longest_seq_ix + + input_ids = [data[i]["input_ids"].type(torch.int64) for i in ix] + labels = [data[i]["labels"].type(torch.int64) for i in ix] + + # this could be `longest_seq_length` to have a fixed size for all batches + max_len = max(len(s) for s in input_ids) + + def pad_right(x, pad_id): + # pad right based on the longest sequence + n = max_len - len(x) + return torch.cat((x, torch.full((n,), pad_id, dtype=x.dtype))) + + x = torch.stack([pad_right(x, pad_id=0) for x in input_ids]) + y = torch.stack([pad_right(x, pad_id=-1) for x in labels]) + + # Truncate if needed + if max_seq_length: + x = x[:, :max_seq_length] + y = y[:, :max_seq_length] + + if fabric.device.type == "cuda" and x.device.type == "cpu": + x, y = fabric.to_device((x.pin_memory(), y.pin_memory())) + else: + x, y = fabric.to_device((x, y)) + return x, y + + +def get_lr_scheduler(optimizer, warmup_steps: int, max_steps: int): + # linear warmup followed by cosine annealing + scheduler1 = torch.optim.lr_scheduler.LambdaLR( + optimizer, lambda step: step / warmup_steps + ) + scheduler2 = torch.optim.lr_scheduler.CosineAnnealingLR( + optimizer, T_max=(max_steps - warmup_steps) + ) + return torch.optim.lr_scheduler.SequentialLR( + optimizer, [scheduler1, scheduler2], milestones=[warmup_steps] + ) + + +def get_longest_seq_length(data: List[Dict]) -> Tuple[int, int]: + # find out the minimum max_seq_length required during fine-tuning (saves memory!) + lengths = [len(d["input_ids"]) for d in data] + longest_seq_length = max(lengths) + longest_seq_ix = lengths.index(longest_seq_length) + return longest_seq_length, longest_seq_ix + + +def save_adapter_v2_checkpoint( + fabric: L.Fabric, model: torch.nn.Module, file_path: Path +) -> None: + fabric.print(f"Saving adapter v2 weights to {str(file_path)!r}") + fabric.save(file_path, {"model": model}, filter={"model": adapter_filter}) + + +def validate_args(io: IOArgs, train: TrainArgs, eval: EvalArgs) -> None: + issues = [] + unsupported = [(train, ["max_tokens", "max_norm"])] + for args, names in unsupported: + for name in names: + if getattr(args, name) is not None: + issues.append( + f"{__file__} doesn't support the {name!r} argument. This is set in {args}" + ) + required = [ + (io, ["checkpoint_dir", "train_data_dir", "val_data_dir"]), + (train, ["epoch_size", "epochs"]), + (eval, ["max_new_tokens"]), + ] + for args, names in required: + for name in names: + if getattr(args, name) is None: + issues.append( + f"{__file__} requires the {name!r} argument. This is set in {args}" + ) + if issues: + raise ValueError("\n".join(issues)) + + +if __name__ == "__main__": + torch.set_float32_matmul_precision("high") + + CLI(setup) diff --git a/examples/llm_finetuning/finetune/full.py b/examples/llm_finetuning/finetune/full.py new file mode 100644 index 00000000000..02e28a72af3 --- /dev/null +++ b/examples/llm_finetuning/finetune/full.py @@ -0,0 +1,442 @@ +# Copyright Lightning AI. Licensed under the Apache License 2.0, see LICENSE file. +import dataclasses +import math +import os +import sys +import time +from pathlib import Path +from typing import Dict, List, Optional, Tuple, Union + +import lightning as L +import torch +from lightning.fabric.loggers import CSVLogger +from lightning.fabric.strategies import FSDPStrategy +from torchmetrics import RunningMean + +# support running without installing as a package +wd = Path(__file__).parent.parent.resolve() +sys.path.append(str(wd)) + +from generate.base import generate +from lit_gpt.args import EvalArgs, IOArgs, TrainArgs +from lit_gpt.model import GPT, Block, Config +from lit_gpt.tokenizer import Tokenizer +from lit_gpt.utils import ( + CLI, + check_valid_checkpoint_dir, + chunked_cross_entropy, + get_default_supported_precision, + load_checkpoint, + num_parameters, +) + +from scripts.prepare_alpaca import generate_prompt + + +def setup( + precision: Optional[str] = None, + devices: int = 1, + resume: Union[bool, Path] = False, + io: IOArgs = IOArgs( + train_data_dir=Path("data/alpaca"), + val_data_dir=Path("data/alpaca"), + checkpoint_dir=Path("checkpoints/stabilityai/stablelm-base-alpha-3b"), + out_dir=Path("out/full/alpaca"), + ), + train: TrainArgs = TrainArgs( + save_interval=1000, + log_interval=1, + global_batch_size=64, + micro_batch_size=1, + lr_warmup_steps=100, + epochs=5, + epoch_size=50000, + learning_rate=3e-3, + max_seq_length=None, + ), + eval: EvalArgs = EvalArgs(interval=600, max_new_tokens=100, max_iters=100), +) -> None: + print(locals()) + precision = precision or get_default_supported_precision(training=True) + + if devices > 1: + strategy = FSDPStrategy( + auto_wrap_policy={Block}, + activation_checkpointing_policy={Block}, + state_dict_type="full", + limit_all_gathers=True, + cpu_offload=False, + ) + else: + strategy = "auto" + + logger = CSVLogger( + io.out_dir.parent, + io.out_dir.name, + flush_logs_every_n_steps=train.log_interval, + ) + fabric = L.Fabric( + devices=devices, strategy=strategy, precision=precision, loggers=logger + ) + fabric.launch( + main, + devices, + resume, + Config.from_name(name=io.checkpoint_dir.name), + io, + train, + eval, + ) + + +def main( + fabric: L.Fabric, + devices: int, + resume: Union[bool, Path], + config: Config, + io: IOArgs, + train: TrainArgs, + eval: EvalArgs, +) -> None: + validate_args(io, train, eval) + + steps_per_epoch = train.epoch_size // devices // train.batch_size(devices) + lr_max_steps = train.epochs * steps_per_epoch + + check_valid_checkpoint_dir(io.checkpoint_dir) + + fabric.seed_everything( + 1337 + ) # same seed for every process to init model (FSDP) + + if fabric.global_rank == 0: + os.makedirs(io.out_dir, exist_ok=True) + + train_data = torch.load(io.train_data_dir / "train.pt") + val_data = torch.load(io.val_data_dir / "test.pt") + + checkpoint_path = io.checkpoint_dir / "lit_model.pth" + fabric.print( + f"Loading model {str(checkpoint_path)!r} with {config.__dict__}" + ) + with fabric.init_module(empty_init=(devices > 1)): + model = GPT(config) + + fabric.print( + f"Number of trainable parameters: {num_parameters(model, requires_grad=True):,}" + ) + + model = fabric.setup(model) + optimizer = torch.optim.AdamW( + model.parameters(), + lr=train.learning_rate, + weight_decay=train.weight_decay, + betas=(train.beta1, train.beta2), + ) + optimizer = fabric.setup_optimizers(optimizer) + scheduler = get_lr_scheduler( + optimizer, warmup_steps=train.lr_warmup_steps, max_steps=lr_max_steps + ) + state = { + "model": model, + "optimizer": optimizer, + "scheduler": scheduler, + "iter_num": 0, + "step_count": 0, + } + + if resume is True: + resume = max( + io.out_dir.glob("*.pth"), key=(lambda p: int(p.name.split("-")[1])) + ) + if resume: + fabric.print(f"Resuming training from {resume}") + fabric.load(resume, state) + else: + load_checkpoint(fabric, state["model"], checkpoint_path) + + fabric.seed_everything(1337 + fabric.global_rank) + + train_time = time.perf_counter() + fit(fabric, state, train_data, val_data, devices, resume, io, train, eval) + fabric.print(f"Training time: {(time.perf_counter()-train_time):.2f}s") + if fabric.device.type == "cuda": + fabric.print( + f"Memory used: {torch.cuda.max_memory_allocated() / 1e9:.02f} GB" + ) + + # Save the final checkpoint at the end of training + fabric.save( + io.out_dir / "lit_model_finetuned.pth", {"model": state["model"]} + ) + + +def fit( + fabric: L.Fabric, + state: Dict, + train_data: List[Dict], + val_data: List[Dict], + devices: int, + resume: Union[bool, Path], + io: IOArgs, + train: TrainArgs, + eval: EvalArgs, +) -> None: + model = state["model"] + optimizer = state["optimizer"] + scheduler = state["scheduler"] + tokenizer = Tokenizer(io.checkpoint_dir) + longest_seq_length, longest_seq_ix = get_longest_seq_length(train_data) + model.max_seq_length = min( + longest_seq_length, train.max_seq_length or float("inf") + ) + fabric.print( + f"The longest sequence length in the train data is {longest_seq_length}, the model's maximum sequence length is" + f" {model.max_seq_length} and context length is {model.config.block_size}" + ) + + validate( + fabric, + model, + val_data, + tokenizer, + dataclasses.replace(eval, max_iters=2), + train, + ) # sanity check + initial_iter = state["iter_num"] + + # resume data loader state by fast-forwarding through all seen batches + if resume: + resume_t0 = time.perf_counter() + for resume_iter in range(initial_iter): + get_batch(fabric, train_data, None) + if resume_iter % 1000 == 0: + fabric.print( + f"Resuming dataset: {resume_iter} / {initial_iter}" + ) + fabric.barrier() + fabric.print( + f"Resuming data loader finished. Took {time.perf_counter() - resume_t0:.1f} seconds to reach iteration" + f" {initial_iter}." + ) + + running_loss = RunningMean( + window=train.gradient_accumulation_iters(devices), + sync_on_compute=False, + ).to(fabric.device) + fabric.barrier() + + for state["iter_num"] in range( + state["iter_num"] + 1, train.max_iters(devices) + 1 + ): + iter_t0 = time.perf_counter() + + input_ids, targets = get_batch( + fabric, + train_data, + train.micro_batch_size, + train.max_seq_length, + longest_seq_ix if state["iter_num"] == 1 else None, + ) + + is_accumulating = ( + state["iter_num"] % train.gradient_accumulation_iters(devices) != 0 + ) + with fabric.no_backward_sync(model, enabled=is_accumulating): + logits = model(input_ids) + # shift the targets such that output n predicts token n+1 + loss = chunked_cross_entropy(logits[..., :-1, :], targets[..., 1:]) + fabric.backward(loss / train.gradient_accumulation_iters(devices)) + + running_loss.update(loss.detach()) + + if not is_accumulating: + optimizer.step() + optimizer.zero_grad() + scheduler.step() + state["step_count"] += 1 + + if state["iter_num"] % train.log_interval == 0: + loss = ( + running_loss.compute().item() + ) # expensive device-to-host synchronization + t1 = time.perf_counter() + metrics = { + "loss": loss, + "iter": state["iter_num"], + "step": state["step_count"], + "iter_time": t1 - iter_t0, + "tokens": state["iter_num"] + * train.micro_batch_size + * model.config.block_size, + "total_tokens": ( + state["iter_num"] + * train.micro_batch_size + * model.config.block_size + * fabric.world_size + ), + # TODO: log learning rate + } + fabric.print( + f"iter {metrics['iter']} | step {metrics['step']}: loss {metrics['loss']:.4f}, iter time:" + f" {metrics['iter_time'] * 1000:.2f} ms{' (optimizer.step)' if not is_accumulating else ''}" + ) + fabric.log_dict(metrics, step=state["iter_num"]) + + if not is_accumulating and state["step_count"] % eval.interval == 0: + t0 = time.perf_counter() + val_loss = validate( + fabric, model, val_data, tokenizer, eval, train + ) + t1 = time.perf_counter() - t0 + fabric.print( + f"iter {state['iter_num']}: val loss {val_loss.item():.4f}, val time: {t1 * 1000:.2f} ms" + ) + metrics = {"val_loss": val_loss, "val_ppl": math.exp(val_loss)} + fabric.log_dict(metrics, step=state["iter_num"]) + fabric.barrier() + if ( + not is_accumulating + and state["step_count"] % train.save_interval == 0 + ): + checkpoint_path = ( + io.out_dir / f"step-{state['step_count']:06d}.pth" + ) + fabric.print(f"Saving checkpoint to {str(checkpoint_path)!r}") + fabric.save(checkpoint_path, state) + + +# FSDP has issues with `inference_mode` +@torch.no_grad() +def validate( + fabric: L.Fabric, + model: GPT, + val_data: List[Dict], + tokenizer: Tokenizer, + eval: EvalArgs, + train: TrainArgs, +) -> torch.Tensor: + fabric.print("Validating ...") + model.eval() + losses = torch.zeros(eval.max_iters) + for k in range(eval.max_iters): + input_ids, targets = get_batch( + fabric, val_data, train.micro_batch_size, train.max_seq_length + ) + logits = model(input_ids) + losses[k] = chunked_cross_entropy( + logits[..., :-1, :], targets[..., 1:], chunk_size=0 + ) + val_loss = losses.mean() + + # produce an example: + instruction = "Recommend a movie for me to watch during the weekend and explain the reason." + fabric.print(instruction) + sample = {"instruction": instruction, "input": ""} + prompt = generate_prompt(sample) + encoded = tokenizer.encode(prompt, device=fabric.device) + with fabric.init_tensor(): + # do not set `max_seq_length=max_returned_token` because memory is not a concern here + model.set_kv_cache(batch_size=1) + output = generate( + model, + encoded, + max_returned_tokens=len(encoded) + eval.max_new_tokens, + temperature=0.8, + eos_id=tokenizer.eos_id, + ) + model.clear_kv_cache() + output = tokenizer.decode(output) + fabric.print(output) + + model.train() + return val_loss + + +def get_batch( + fabric: L.Fabric, + data: List[Dict], + micro_batch_size: int, + max_seq_length: Optional[int], + longest_seq_ix: Optional[int] = None, +) -> Tuple[torch.Tensor, torch.Tensor]: + ix = torch.randint(len(data), (micro_batch_size,)) + if longest_seq_ix is not None: + # force the longest sample at the beginning so potential OOMs happen right away + ix[0] = longest_seq_ix + + input_ids = [data[i]["input_ids"].type(torch.int64) for i in ix] + labels = [data[i]["labels"].type(torch.int64) for i in ix] + + # this could be `longest_seq_length` to have a fixed size for all batches + max_len = max(len(s) for s in input_ids) + + def pad_right(x, pad_id): + # pad right based on the longest sequence + n = max_len - len(x) + return torch.cat((x, torch.full((n,), pad_id, dtype=x.dtype))) + + x = torch.stack([pad_right(x, pad_id=0) for x in input_ids]) + y = torch.stack([pad_right(x, pad_id=-1) for x in labels]) + + # Truncate if needed + if max_seq_length: + x = x[:, :max_seq_length] + y = y[:, :max_seq_length] + + if fabric.device.type == "cuda" and x.device.type == "cpu": + x, y = fabric.to_device((x.pin_memory(), y.pin_memory())) + else: + x, y = fabric.to_device((x, y)) + return x, y + + +def get_lr_scheduler(optimizer, warmup_steps: int, max_steps: int): + # linear warmup followed by cosine annealing + scheduler1 = torch.optim.lr_scheduler.LambdaLR( + optimizer, lambda step: step / warmup_steps + ) + scheduler2 = torch.optim.lr_scheduler.CosineAnnealingLR( + optimizer, T_max=(max_steps - warmup_steps) + ) + return torch.optim.lr_scheduler.SequentialLR( + optimizer, [scheduler1, scheduler2], milestones=[warmup_steps] + ) + + +def get_longest_seq_length(data: List[Dict]) -> Tuple[int, int]: + # find out the minimum max_seq_length required during fine-tuning (saves memory!) + lengths = [len(d["input_ids"]) for d in data] + longest_seq_length = max(lengths) + longest_seq_ix = lengths.index(longest_seq_length) + return longest_seq_length, longest_seq_ix + + +def validate_args(io: IOArgs, train: TrainArgs, eval: EvalArgs) -> None: + issues = [] + unsupported = [(train, ["max_tokens", "max_norm"])] + for args, names in unsupported: + for name in names: + if getattr(args, name) is not None: + issues.append( + f"{__file__} doesn't support the {name!r} argument. This is set in {args}" + ) + required = [ + (io, ["checkpoint_dir", "train_data_dir", "val_data_dir"]), + (train, ["epoch_size", "epochs"]), + (eval, ["max_new_tokens"]), + ] + for args, names in required: + for name in names: + if getattr(args, name) is None: + issues.append( + f"{__file__} requires the {name!r} argument. This is set in {args}" + ) + if issues: + raise ValueError("\n".join(issues)) + + +if __name__ == "__main__": + torch.set_float32_matmul_precision("high") + + CLI(setup) diff --git a/examples/llm_finetuning/finetune/lora.py b/examples/llm_finetuning/finetune/lora.py new file mode 100644 index 00000000000..39caa06eeb7 --- /dev/null +++ b/examples/llm_finetuning/finetune/lora.py @@ -0,0 +1,483 @@ +# Copyright Lightning AI. Licensed under the Apache License 2.0, see LICENSE file. +import dataclasses +import os +import sys +import time +from pathlib import Path +from typing import Dict, List, Literal, Optional, Tuple + +import lightning as L +import torch +from lightning.fabric.loggers import CSVLogger +from lightning.fabric.plugins import BitsandbytesPrecision +from lightning.fabric.strategies import FSDPStrategy +from lightning.fabric.utilities import ThroughputMonitor + +# support running without installing as a package +wd = Path(__file__).parent.parent.resolve() +sys.path.append(str(wd)) + +from generate.base import generate +from lit_gpt.args import EvalArgs, IOArgs, TrainArgs +from lit_gpt.lora import ( + GPT, + Block, + Config, + lora_filter, + mark_only_lora_as_trainable, +) +from lit_gpt.tokenizer import Tokenizer +from lit_gpt.utils import ( + CLI, + check_valid_checkpoint_dir, + chunked_cross_entropy, + get_default_supported_precision, + load_checkpoint, + num_parameters, +) + +from scripts.prepare_alpaca import generate_prompt + + +def setup( + precision: Optional[str] = None, + quantize: Optional[ + Literal[ + "bnb.nf4", + "bnb.nf4-dq", + "bnb.fp4", + "bnb.fp4-dq", + "bnb.int8-training", + ] + ] = None, + devices: int = 1, + lora_r: int = 8, + lora_alpha: int = 16, + lora_dropout: float = 0.05, + lora_query: bool = True, + lora_key: bool = False, + lora_value: bool = True, + lora_projection: bool = False, + lora_mlp: bool = False, + lora_head: bool = False, + io: IOArgs = IOArgs( + train_data_dir=Path("data/alpaca"), + val_data_dir=Path("data/alpaca"), + checkpoint_dir=Path("checkpoints/stabilityai/stablelm-base-alpha-3b"), + out_dir=Path("out/lora/alpaca"), + ), + train: TrainArgs = TrainArgs( + save_interval=1000, + log_interval=1, + global_batch_size=128, + micro_batch_size=4, + lr_warmup_steps=100, + epochs=5, + epoch_size=50000, + learning_rate=3e-4, + max_seq_length=None, + ), + eval: EvalArgs = EvalArgs(interval=100, max_new_tokens=100, max_iters=100), +) -> None: + print(locals()) + precision = precision or get_default_supported_precision(training=True) + + plugins = None + if quantize is not None and quantize.startswith("bnb."): + if "mixed" in precision: + raise ValueError( + "Quantization and mixed precision is not supported." + ) + dtype = { + "16-true": torch.float16, + "bf16-true": torch.bfloat16, + "32-true": torch.float32, + }[precision] + plugins = BitsandbytesPrecision(quantize[4:], dtype) + precision = None + + if devices > 1: + if quantize: + raise NotImplementedError( + "Quantization is currently not supported for multi-GPU training. Please set devices=1 when using the" + " --quantize flag." + ) + strategy = FSDPStrategy( + auto_wrap_policy={Block}, + activation_checkpointing_policy={Block}, + state_dict_type="full", + limit_all_gathers=True, + cpu_offload=False, + ) + else: + strategy = "auto" + + logger = CSVLogger( + io.out_dir.parent, + io.out_dir.name, + flush_logs_every_n_steps=train.log_interval, + ) + fabric = L.Fabric( + devices=devices, + strategy=strategy, + precision=precision, + loggers=logger, + plugins=plugins, + ) + + if not any( + ( + lora_query, + lora_key, + lora_value, + lora_projection, + lora_mlp, + lora_head, + ) + ): + fabric.print("Warning: all LoRA layers are disabled!") + fabric.launch( + main, + devices, + Config.from_name( + name=io.checkpoint_dir.name, + r=lora_r, + alpha=lora_alpha, + dropout=lora_dropout, + to_query=lora_query, + to_key=lora_key, + to_value=lora_value, + to_projection=lora_projection, + to_mlp=lora_mlp, + to_head=lora_head, + ), + io, + train, + eval, + ) + + +def main( + fabric: L.Fabric, + devices: int, + config: Config, + io: IOArgs, + train: TrainArgs, + eval: EvalArgs, +) -> None: + validate_args(io, train, eval) + + steps_per_epoch = train.epoch_size // devices // train.batch_size(devices) + lr_max_steps = train.epochs * steps_per_epoch + + check_valid_checkpoint_dir(io.checkpoint_dir) + + fabric.seed_everything( + 1337 + ) # same seed for every process to init model (FSDP) + + if fabric.global_rank == 0: + os.makedirs(io.out_dir, exist_ok=True) + + train_data = torch.load(io.train_data_dir / "train.pt") + val_data = torch.load(io.val_data_dir / "test.pt") + + checkpoint_path = io.checkpoint_dir / "lit_model.pth" + fabric.print( + f"Loading model {str(checkpoint_path)!r} with {config.__dict__}" + ) + with fabric.init_module(empty_init=(devices > 1)): + model = GPT(config) + mark_only_lora_as_trainable(model) + + fabric.print( + f"Number of trainable parameters: {num_parameters(model, requires_grad=True):,}" + ) + fabric.print( + f"Number of non trainable parameters: {num_parameters(model, requires_grad=False):,}" + ) + + model = fabric.setup_module(model) + + trainable_params = [p for p in model.parameters() if p.requires_grad] + if isinstance(fabric.strategy.precision, BitsandbytesPrecision): + import bitsandbytes as bnb + + optimizer_cls = bnb.optim.PagedAdamW + else: + optimizer_cls = torch.optim.AdamW + optimizer = optimizer_cls( + trainable_params, + lr=train.learning_rate, + weight_decay=train.weight_decay, + betas=(train.beta1, train.beta2), + ) + optimizer = fabric.setup_optimizers(optimizer) + scheduler = get_lr_scheduler( + optimizer, warmup_steps=train.lr_warmup_steps, max_steps=lr_max_steps + ) + + # strict=False because missing keys due to LoRA weights not contained in state dict + load_checkpoint(fabric, model, checkpoint_path, strict=False) + + fabric.seed_everything(1337 + fabric.global_rank) + + train_time = time.perf_counter() + fit( + fabric, + model, + optimizer, + scheduler, + train_data, + val_data, + devices, + io, + train, + eval, + ) + fabric.print(f"Training time: {(time.perf_counter()-train_time):.2f}s") + if fabric.device.type == "cuda": + fabric.print( + f"Memory used: {torch.cuda.max_memory_allocated() / 1e9:.02f} GB" + ) + + # Save the final LoRA checkpoint at the end of training + save_path = io.out_dir / "lit_model_lora_finetuned.pth" + save_lora_checkpoint(fabric, model, save_path) + + +def fit( + fabric: L.Fabric, + model: GPT, + optimizer: torch.optim.Optimizer, + scheduler: torch.optim.lr_scheduler, + train_data: List[Dict], + val_data: List[Dict], + devices: int, + io: IOArgs, + train: TrainArgs, + eval: EvalArgs, +) -> None: + tokenizer = Tokenizer(io.checkpoint_dir) + longest_seq_length, longest_seq_ix = get_longest_seq_length(train_data) + model.max_seq_length = min( + longest_seq_length, train.max_seq_length or float("inf") + ) + fabric.print( + f"The longest sequence length in the train data is {longest_seq_length}, the model's maximum sequence length is" + f" {model.max_seq_length} and context length is {model.config.block_size}" + ) + + validate( + fabric, + model, + val_data, + tokenizer, + dataclasses.replace(eval, max_iters=2), + train, + ) # sanity check + + throughput = ThroughputMonitor(fabric, window_size=50) + step_count = 0 + total_lengths = 0 + total_t0 = time.perf_counter() + + for iter_num in range(1, train.max_iters(devices) + 1): + iter_t0 = time.perf_counter() + + input_ids, targets = get_batch( + fabric, + train_data, + train.micro_batch_size, + train.max_seq_length, + longest_seq_ix if iter_num == 1 else None, + ) + + is_accumulating = ( + iter_num % train.gradient_accumulation_iters(devices) != 0 + ) + with fabric.no_backward_sync(model, enabled=is_accumulating): + logits = model(input_ids, lm_head_chunk_size=128) + # shift the targets such that output n predicts token n+1 + logits[-1] = logits[-1][..., :-1, :] + loss = chunked_cross_entropy(logits, targets[..., 1:]) + fabric.backward(loss / train.gradient_accumulation_iters(devices)) + + if not is_accumulating: + optimizer.step() + optimizer.zero_grad() + scheduler.step() + step_count += 1 + + total_lengths += input_ids.numel() + if iter_num % train.log_interval == 0: + loss_item = loss.item() # expensive device-to-host synchronization + t1 = time.perf_counter() + throughput.update( + time=t1 - total_t0, + batches=iter_num, + samples=iter_num * train.micro_batch_size, + lengths=total_lengths, + ) + throughput.compute_and_log(step=iter_num) + fabric.print( + f"iter {iter_num} | step {step_count}: loss {loss_item:.4f}, iter time:" + f" {(t1 - iter_t0) * 1000:.2f} ms{' (optimizer.step)' if not is_accumulating else ''}" + ) + + if not is_accumulating and step_count % eval.interval == 0: + t0 = time.perf_counter() + val_loss = validate( + fabric, model, val_data, tokenizer, eval, train + ) + t1 = time.perf_counter() - t0 + fabric.print( + f"iter {iter_num}: val loss {val_loss.item():.4f}, val time: {t1 * 1000:.2f} ms" + ) + fabric.barrier() + if not is_accumulating and step_count % train.save_interval == 0: + checkpoint_path = io.out_dir / f"iter-{iter_num:06d}-ckpt.pth" + save_lora_checkpoint(fabric, model, checkpoint_path) + + +# FSDP has issues with `inference_mode` +@torch.no_grad() +def validate( + fabric: L.Fabric, + model: GPT, + val_data: List[Dict], + tokenizer: Tokenizer, + eval: EvalArgs, + train: TrainArgs, +) -> torch.Tensor: + fabric.print("Validating ...") + model.eval() + losses = torch.zeros(eval.max_iters) + for k in range(eval.max_iters): + input_ids, targets = get_batch( + fabric, val_data, train.micro_batch_size, train.max_seq_length + ) + logits = model(input_ids) + losses[k] = chunked_cross_entropy( + logits[..., :-1, :], targets[..., 1:], chunk_size=0 + ) + val_loss = losses.mean() + + # produce an example: + instruction = "Recommend a movie for me to watch during the weekend and explain the reason." + fabric.print(instruction) + sample = {"instruction": instruction, "input": ""} + prompt = generate_prompt(sample) + encoded = tokenizer.encode(prompt, device=fabric.device) + with fabric.init_tensor(): + # do not set `max_seq_length=max_returned_token` because memory is not a concern here + model.set_kv_cache(batch_size=1) + output = generate( + model, + encoded, + max_returned_tokens=len(encoded) + eval.max_new_tokens, + temperature=0.8, + eos_id=tokenizer.eos_id, + ) + model.clear_kv_cache() + output = tokenizer.decode(output) + fabric.print(output) + + model.train() + return val_loss + + +def get_batch( + fabric: L.Fabric, + data: List[Dict], + micro_batch_size: int, + max_seq_length: Optional[int], + longest_seq_ix: Optional[int] = None, +) -> Tuple[torch.Tensor, torch.Tensor]: + ix = torch.randint(len(data), (micro_batch_size,)) + if longest_seq_ix is not None: + # force the longest sample at the beginning so potential OOMs happen right away + ix[0] = longest_seq_ix + + input_ids = [data[i]["input_ids"].type(torch.int64) for i in ix] + labels = [data[i]["labels"].type(torch.int64) for i in ix] + + # this could be `longest_seq_length` to have a fixed size for all batches + max_len = max(len(s) for s in input_ids) + + def pad_right(x, pad_id): + # pad right based on the longest sequence + n = max_len - len(x) + return torch.cat((x, torch.full((n,), pad_id, dtype=x.dtype))) + + x = torch.stack([pad_right(x, pad_id=0) for x in input_ids]) + y = torch.stack([pad_right(x, pad_id=-1) for x in labels]) + + # Truncate if needed + if max_seq_length: + x = x[:, :max_seq_length] + y = y[:, :max_seq_length] + + if fabric.device.type == "cuda" and x.device.type == "cpu": + x, y = fabric.to_device((x.pin_memory(), y.pin_memory())) + else: + x, y = fabric.to_device((x, y)) + return x, y + + +def get_lr_scheduler(optimizer, warmup_steps: int, max_steps: int): + # linear warmup followed by cosine annealing + scheduler1 = torch.optim.lr_scheduler.LambdaLR( + optimizer, lambda step: step / warmup_steps + ) + scheduler2 = torch.optim.lr_scheduler.CosineAnnealingLR( + optimizer, T_max=(max_steps - warmup_steps) + ) + return torch.optim.lr_scheduler.SequentialLR( + optimizer, [scheduler1, scheduler2], milestones=[warmup_steps] + ) + + +def get_longest_seq_length(data: List[Dict]) -> Tuple[int, int]: + # find out the minimum max_seq_length required during fine-tuning (saves memory!) + lengths = [len(d["input_ids"]) for d in data] + longest_seq_length = max(lengths) + longest_seq_ix = lengths.index(longest_seq_length) + return longest_seq_length, longest_seq_ix + + +def save_lora_checkpoint( + fabric: L.Fabric, model: torch.nn.Module, file_path: Path +) -> None: + fabric.print(f"Saving LoRA weights to {str(file_path)!r}") + fabric.save(file_path, {"model": model}, filter={"model": lora_filter}) + + +def validate_args(io: IOArgs, train: TrainArgs, eval: EvalArgs) -> None: + issues = [] + unsupported = [(train, ["max_tokens", "max_norm"])] + for args, names in unsupported: + for name in names: + if getattr(args, name) is not None: + issues.append( + f"{__file__} doesn't support the {name!r} argument. This is set in {args}" + ) + required = [ + (io, ["checkpoint_dir", "train_data_dir", "val_data_dir"]), + (train, ["epoch_size", "epochs"]), + (eval, ["max_new_tokens"]), + ] + for args, names in required: + for name in names: + if getattr(args, name) is None: + issues.append( + f"{__file__} requires the {name!r} argument. This is set in {args}" + ) + if issues: + raise ValueError("\n".join(issues)) + + +if __name__ == "__main__": + torch.set_float32_matmul_precision("high") + + CLI(setup) diff --git a/examples/llm_finetuning/generate/adapter.py b/examples/llm_finetuning/generate/adapter.py new file mode 100644 index 00000000000..3daa88362b6 --- /dev/null +++ b/examples/llm_finetuning/generate/adapter.py @@ -0,0 +1,159 @@ +# Copyright Lightning AI. Licensed under the Apache License 2.0, see LICENSE file. + +import sys +import time +from pathlib import Path +from typing import Literal, Optional + +import lightning as L +import torch +from lightning.fabric.plugins import BitsandbytesPrecision + +# support running without installing as a package +wd = Path(__file__).parent.parent.resolve() +sys.path.append(str(wd)) + +from generate.base import generate +from lit_gpt import Tokenizer +from lit_gpt.adapter import GPT, Config +from lit_gpt.utils import ( + CLI, + check_valid_checkpoint_dir, + get_default_supported_precision, + lazy_load, +) + +from scripts.prepare_alpaca import generate_prompt + + +def main( + prompt: str = "What food do llamas eat?", + input: str = "", + adapter_path: Path = Path( + "out/adapter/alpaca/lit_model_adapter_finetuned.pth" + ), + checkpoint_dir: Path = Path( + "checkpoints/stabilityai/stablelm-base-alpha-3b" + ), + quantize: Optional[ + Literal["bnb.nf4", "bnb.nf4-dq", "bnb.fp4", "bnb.fp4-dq", "bnb.int8"] + ] = None, + max_new_tokens: int = 100, + top_k: Optional[int] = 200, + temperature: float = 0.8, + precision: Optional[str] = None, +) -> None: + """Generates a response based on a given instruction and an optional input. + This script will only work with checkpoints from the instruction-tuned GPT-Adapter model. + See `finetune/adapter.py`. + + Args: + prompt: The prompt/instruction (Alpaca style). + input: Optional input (Alpaca style). + adapter_path: Path to the checkpoint with trained adapter weights, which are the output of + `finetune/adapter.py`. + checkpoint_dir: The path to the checkpoint folder with pretrained GPT weights. + quantize: Whether to quantize the model and using which method: + - bnb.nf4, bnb.nf4-dq, bnb.fp4, bnb.fp4-dq: 4-bit quantization from bitsandbytes + - bnb.int8: 8-bit quantization from bitsandbytes + for more details, see https://github.com/Lightning-AI/lit-gpt/blob/main/tutorials/quantize.md + max_new_tokens: The number of generation steps to take. + top_k: The number of top most probable tokens to consider in the sampling process. + temperature: A value controlling the randomness of the sampling process. Higher values result in more random + samples. + precision: Indicates the Fabric precision setting to use. + """ + precision = precision or get_default_supported_precision(training=False) + + plugins = None + if quantize is not None and quantize.startswith("bnb."): + if "mixed" in precision: + raise ValueError( + "Quantization and mixed precision is not supported." + ) + dtype = { + "16-true": torch.float16, + "bf16-true": torch.bfloat16, + "32-true": torch.float32, + }[precision] + plugins = BitsandbytesPrecision(quantize[4:], dtype) + precision = None + + fabric = L.Fabric(devices=1, precision=precision, plugins=plugins) + fabric.launch() + + check_valid_checkpoint_dir(checkpoint_dir) + + config = Config.from_json(checkpoint_dir / "lit_config.json") + + checkpoint_path = checkpoint_dir / "lit_model.pth" + + tokenizer = Tokenizer(checkpoint_dir) + sample = {"instruction": prompt, "input": input} + prompt = generate_prompt(sample) + encoded = tokenizer.encode(prompt, device=fabric.device) + prompt_length = encoded.size(0) + max_returned_tokens = prompt_length + max_new_tokens + + fabric.print( + f"Loading model {str(checkpoint_path)!r} with {config.__dict__}", + file=sys.stderr, + ) + t0 = time.perf_counter() + with fabric.init_module(empty_init=True): + model = GPT(config) + fabric.print( + f"Time to instantiate model: {time.perf_counter() - t0:.02f} seconds.", + file=sys.stderr, + ) + with fabric.init_tensor(): + # set the max_seq_length to limit the memory usage to what we need + model.max_seq_length = max_returned_tokens + # enable the kv cache + model.set_kv_cache(batch_size=1) + model.eval() + + t0 = time.perf_counter() + checkpoint = lazy_load(checkpoint_path) + adapter_checkpoint = lazy_load(adapter_path) + checkpoint.update(adapter_checkpoint.get("model", adapter_checkpoint)) + model.load_state_dict(checkpoint) + fabric.print( + f"Time to load the model weights: {time.perf_counter() - t0:.02f} seconds.", + file=sys.stderr, + ) + + model = fabric.setup(model) + + L.seed_everything(1234) + t0 = time.perf_counter() + y = generate( + model, + encoded, + max_returned_tokens, + temperature=temperature, + top_k=top_k, + eos_id=tokenizer.eos_id, + ) + t = time.perf_counter() - t0 + + output = tokenizer.decode(y) + output = output.split("### Response:")[1].strip() + fabric.print(output) + + tokens_generated = y.size(0) - prompt_length + fabric.print( + f"\n\nTime for inference: {t:.02f} sec total, {tokens_generated / t:.02f} tokens/sec", + file=sys.stderr, + ) + if fabric.device.type == "cuda": + fabric.print( + f"Memory used: {torch.cuda.max_memory_allocated() / 1e9:.02f} GB", + file=sys.stderr, + ) + + +if __name__ == "__main__": + torch.set_float32_matmul_precision("high") + + CLI(main) diff --git a/examples/llm_finetuning/generate/adapter_v2.py b/examples/llm_finetuning/generate/adapter_v2.py new file mode 100644 index 00000000000..6f9d76d4c72 --- /dev/null +++ b/examples/llm_finetuning/generate/adapter_v2.py @@ -0,0 +1,159 @@ +# Copyright Lightning AI. Licensed under the Apache License 2.0, see LICENSE file. + +import sys +import time +from pathlib import Path +from typing import Literal, Optional + +import lightning as L +import torch +from lightning.fabric.plugins import BitsandbytesPrecision + +# support running without installing as a package +wd = Path(__file__).parent.parent.resolve() +sys.path.append(str(wd)) + +from generate.base import generate +from lit_gpt import Tokenizer +from lit_gpt.adapter_v2 import GPT, Config +from lit_gpt.utils import ( + CLI, + check_valid_checkpoint_dir, + get_default_supported_precision, + lazy_load, +) + +from scripts.prepare_alpaca import generate_prompt + + +def main( + prompt: str = "What food do llamas eat?", + input: str = "", + adapter_path: Path = Path( + "out/adapter_v2/alpaca/lit_model_adapter_finetuned.pth" + ), + checkpoint_dir: Path = Path( + "checkpoints/stabilityai/stablelm-base-alpha-3b" + ), + quantize: Optional[ + Literal["bnb.nf4", "bnb.nf4-dq", "bnb.fp4", "bnb.fp4-dq", "bnb.int8"] + ] = None, + max_new_tokens: int = 100, + top_k: Optional[int] = 200, + temperature: float = 0.8, + precision: Optional[str] = None, +) -> None: + """Generates a response based on a given instruction and an optional input. + This script will only work with checkpoints from the instruction-tuned GPT-AdapterV2 model. + See `finetune/adapter_v2.py`. + + Args: + prompt: The prompt/instruction (Alpaca style). + input: Optional input (Alpaca style). + adapter_path: Path to the checkpoint with trained adapter weights, which are the output of + `finetune/adapter_v2.py`. + checkpoint_dir: The path to the checkpoint folder with pretrained GPT weights. + quantize: Whether to quantize the model and using which method: + - bnb.nf4, bnb.nf4-dq, bnb.fp4, bnb.fp4-dq: 4-bit quantization from bitsandbytes + - bnb.int8: 8-bit quantization from bitsandbytes + for more details, see https://github.com/Lightning-AI/lit-gpt/blob/main/tutorials/quantize.md + max_new_tokens: The number of generation steps to take. + top_k: The number of top most probable tokens to consider in the sampling process. + temperature: A value controlling the randomness of the sampling process. Higher values result in more random + samples. + precision: Indicates the Fabric precision setting to use. + """ + precision = precision or get_default_supported_precision(training=False) + + plugins = None + if quantize is not None and quantize.startswith("bnb."): + if "mixed" in precision: + raise ValueError( + "Quantization and mixed precision is not supported." + ) + dtype = { + "16-true": torch.float16, + "bf16-true": torch.bfloat16, + "32-true": torch.float32, + }[precision] + plugins = BitsandbytesPrecision(quantize[4:], dtype) + precision = None + + fabric = L.Fabric(devices=1, precision=precision, plugins=plugins) + fabric.launch() + + check_valid_checkpoint_dir(checkpoint_dir) + + config = Config.from_json(checkpoint_dir / "lit_config.json") + + checkpoint_path = checkpoint_dir / "lit_model.pth" + + tokenizer = Tokenizer(checkpoint_dir) + sample = {"instruction": prompt, "input": input} + prompt = generate_prompt(sample) + encoded = tokenizer.encode(prompt, device=fabric.device) + prompt_length = encoded.size(0) + max_returned_tokens = prompt_length + max_new_tokens + + fabric.print( + f"Loading model {str(checkpoint_path)!r} with {config.__dict__}", + file=sys.stderr, + ) + t0 = time.perf_counter() + with fabric.init_module(empty_init=True): + model = GPT(config) + fabric.print( + f"Time to instantiate model: {time.perf_counter() - t0:.02f} seconds.", + file=sys.stderr, + ) + with fabric.init_tensor(): + # set the max_seq_length to limit the memory usage to what we need + model.max_seq_length = max_returned_tokens + # enable the kv cache + model.set_kv_cache(batch_size=1) + model.eval() + + t0 = time.perf_counter() + checkpoint = lazy_load(checkpoint_path) + adapter_checkpoint = lazy_load(adapter_path) + checkpoint.update(adapter_checkpoint.get("model", adapter_checkpoint)) + model.load_state_dict(checkpoint) + fabric.print( + f"Time to load the model weights: {time.perf_counter() - t0:.02f} seconds.", + file=sys.stderr, + ) + + model = fabric.setup(model) + + L.seed_everything(1234) + t0 = time.perf_counter() + y = generate( + model, + encoded, + max_returned_tokens, + temperature=temperature, + top_k=top_k, + eos_id=tokenizer.eos_id, + ) + t = time.perf_counter() - t0 + + output = tokenizer.decode(y) + output = output.split("### Response:")[1].strip() + fabric.print(output) + + tokens_generated = y.size(0) - prompt_length + fabric.print( + f"\n\nTime for inference: {t:.02f} sec total, {tokens_generated / t:.02f} tokens/sec", + file=sys.stderr, + ) + if fabric.device.type == "cuda": + fabric.print( + f"Memory used: {torch.cuda.max_memory_allocated() / 1e9:.02f} GB", + file=sys.stderr, + ) + + +if __name__ == "__main__": + torch.set_float32_matmul_precision("high") + + CLI(main) diff --git a/examples/llm_finetuning/generate/base.py b/examples/llm_finetuning/generate/base.py new file mode 100644 index 00000000000..f8cfa7bd54c --- /dev/null +++ b/examples/llm_finetuning/generate/base.py @@ -0,0 +1,244 @@ +# Copyright Lightning AI. Licensed under the Apache License 2.0, see LICENSE file. + +import sys +import time +from pathlib import Path +from typing import Any, Literal, Optional + +import lightning as L +import torch +import torch._dynamo.config +import torch._inductor.config +from lightning.fabric.plugins import BitsandbytesPrecision + +# support running without installing as a package +wd = Path(__file__).parent.parent.resolve() +sys.path.append(str(wd)) + +from lit_gpt import GPT, Config, Tokenizer +from lit_gpt.utils import ( + CLI, + check_valid_checkpoint_dir, + get_default_supported_precision, + load_checkpoint, +) + + +def multinomial_num_samples_1(probs: torch.Tensor) -> torch.Tensor: + if torch._dynamo.is_compiling(): + # Faster alternative to `torch.multinomial(probs, num_samples=1)` that is also CUDAGraph friendly + distribution = torch.empty_like(probs).exponential_(1) + return torch.argmax(probs / distribution, dim=-1, keepdim=True) + return torch.multinomial(probs, num_samples=1) + + +def sample( + logits: torch.Tensor, temperature: float = 1.0, top_k: Optional[int] = None +) -> torch.Tensor: + logits = logits[0, -1] + # optionally crop the logits to only the top k options + if top_k is not None: + v, i = torch.topk(logits, min(top_k, logits.size(-1))) + # do not use `torch.where` as in nanogpt because it will repeat top-k collisions + logits = torch.full_like(logits, float("-inf")).scatter_(-1, i, v) + # optionally scale the logits and sample from a probability distribution + if temperature > 0.0: + probs = torch.nn.functional.softmax(logits / temperature, dim=-1) + return multinomial_num_samples_1(probs) + return torch.argmax(logits, dim=-1, keepdim=True) + + +def next_token( + model: GPT, input_pos: torch.Tensor, x: torch.Tensor, **kwargs: Any +) -> torch.Tensor: + logits = model(x, input_pos) + next = sample(logits, **kwargs) + return next.to(dtype=x.dtype) + + +@torch.inference_mode() +def generate( + model: GPT, + prompt: torch.Tensor, + max_returned_tokens: int, + *, + temperature: float = 1.0, + top_k: Optional[int] = None, + eos_id: Optional[int] = None, +) -> torch.Tensor: + """Takes a conditioning sequence (prompt) as input and continues to generate as many tokens as requested. + + The implementation of this function is modified from A. Karpathy's nanoGPT. + + Args: + model: The model to use. + prompt: Tensor of shape (T) with indices of the prompt sequence. + max_returned_tokens: The maximum number of tokens to return (given plus generated). + temperature: Scales the predicted logits by 1 / temperature. + top_k: If specified, only sample among the tokens with the k highest probabilities. + eos_id: If specified, stop generating any more token once the token is triggered. + """ + T = prompt.size(0) + assert max_returned_tokens > T + if model.max_seq_length < max_returned_tokens - 1: + # rolling the kv cache based on the `input_pos` value would be necessary. However, doing so would introduce a + # data dependency on the `input_pos` tensor and impact model compilation. Since this setting is uncommon, we do + # not support it to avoid negatively impacting the overall speed + raise NotImplementedError( + f"max_seq_length {model.max_seq_length} needs to be >= {max_returned_tokens - 1}" + ) + + device = prompt.device + tokens = [prompt] + input_pos = torch.tensor([T], device=device) + token = next_token( + model, + torch.arange(0, T, device=device), + prompt.view(1, -1), + temperature=temperature, + top_k=top_k, + ).clone() + tokens.append(token) + for _ in range(2, max_returned_tokens - T + 1): + token = next_token( + model, + input_pos, + token.view(1, -1), + temperature=temperature, + top_k=top_k, + ).clone() + tokens.append(token) + if token == eos_id: + break + input_pos = input_pos.add_(1) + return torch.cat(tokens) + + +@torch.inference_mode() +def main( + prompt: str = "What food do llamas eat?", + *, + num_samples: int = 1, + max_new_tokens: int = 50, + top_k: Optional[int] = 200, + temperature: float = 0.8, + checkpoint_dir: Path = Path( + "checkpoints/stabilityai/stablelm-base-alpha-3b" + ), + quantize: Optional[ + Literal["bnb.nf4", "bnb.nf4-dq", "bnb.fp4", "bnb.fp4-dq", "bnb.int8"] + ] = None, + precision: Optional[str] = None, + compile: bool = False, +) -> None: + """Generates text samples based on a pre-trained model and tokenizer. + + Args: + prompt: The prompt string to use for generating the samples. + num_samples: The number of text samples to generate. + max_new_tokens: The number of generation steps to take. + top_k: The number of top most probable tokens to consider in the sampling process. + temperature: A value controlling the randomness of the sampling process. Higher values result in more random + samples. + checkpoint_dir: The checkpoint directory to load. + quantize: Whether to quantize the model and using which method: + - bnb.nf4, bnb.nf4-dq, bnb.fp4, bnb.fp4-dq: 4-bit quantization from bitsandbytes + - bnb.int8: 8-bit quantization from bitsandbytes + for more details, see https://github.com/Lightning-AI/lit-gpt/blob/main/tutorials/quantize.md + precision: Indicates the Fabric precision setting to use. + compile: Whether to compile the model. + """ + precision = precision or get_default_supported_precision(training=False) + + plugins = None + if quantize is not None and quantize.startswith("bnb."): + if "mixed" in precision: + raise ValueError( + "Quantization and mixed precision is not supported." + ) + dtype = { + "16-true": torch.float16, + "bf16-true": torch.bfloat16, + "32-true": torch.float32, + }[precision] + plugins = BitsandbytesPrecision(quantize[4:], dtype) + precision = None + + fabric = L.Fabric(devices=1, precision=precision, plugins=plugins) + + check_valid_checkpoint_dir(checkpoint_dir) + + config = Config.from_json(checkpoint_dir / "lit_config.json") + + checkpoint_path = checkpoint_dir / "lit_model.pth" + + tokenizer = Tokenizer(checkpoint_dir) + encoded = tokenizer.encode(prompt, device=fabric.device) + prompt_length = encoded.size(0) + max_returned_tokens = prompt_length + max_new_tokens + + fabric.print( + f"Loading model {str(checkpoint_path)!r} with {config.__dict__}", + file=sys.stderr, + ) + t0 = time.perf_counter() + with fabric.init_module(empty_init=True): + model = GPT(config) + fabric.print( + f"Time to instantiate model: {time.perf_counter() - t0:.02f} seconds.", + file=sys.stderr, + ) + with fabric.init_tensor(): + # set the max_seq_length to limit the memory usage to what we need + model.max_seq_length = max_returned_tokens + # enable the kv cache + model.set_kv_cache(batch_size=1) + model.eval() + + if compile: + torch._dynamo.config.automatic_dynamic_shapes = True + torch._inductor.config.triton.unique_kernel_names = True + torch._inductor.config.coordinate_descent_tuning = True + global next_token + next_token = torch.compile(next_token, mode="reduce-overhead") + + model = fabric.setup_module(model) + + t0 = time.perf_counter() + load_checkpoint(fabric, model, checkpoint_path) + fabric.print( + f"Time to load the model weights: {time.perf_counter() - t0:.02f} seconds.", + file=sys.stderr, + ) + + L.seed_everything(1234) + for i in range(num_samples): + t0 = time.perf_counter() + y = generate( + model, + encoded, + max_returned_tokens, + temperature=temperature, + top_k=top_k, + eos_id=tokenizer.eos_id, + ) + t = time.perf_counter() - t0 + for block in model.transformer.h: + block.attn.kv_cache.reset_parameters() + fabric.print(tokenizer.decode(y)) + tokens_generated = y.size(0) - prompt_length + fabric.print( + f"Time for inference {i + 1}: {t:.02f} sec total, {tokens_generated / t:.02f} tokens/sec", + file=sys.stderr, + ) + if fabric.device.type == "cuda": + fabric.print( + f"Memory used: {torch.cuda.max_memory_allocated() / 1e9:.02f} GB", + file=sys.stderr, + ) + + +if __name__ == "__main__": + torch.set_float32_matmul_precision("high") + + CLI(main) diff --git a/examples/llm_finetuning/generate/full.py b/examples/llm_finetuning/generate/full.py new file mode 100644 index 00000000000..cc1da495ef4 --- /dev/null +++ b/examples/llm_finetuning/generate/full.py @@ -0,0 +1,153 @@ +# Copyright Lightning AI. Licensed under the Apache License 2.0, see LICENSE file. + +import sys +import time +from pathlib import Path +from typing import Literal, Optional + +import lightning as L +import torch +from lightning.fabric.plugins import BitsandbytesPrecision + +# support running without installing as a package +wd = Path(__file__).parent.parent.resolve() +sys.path.append(str(wd)) + +from generate.base import generate +from lit_gpt import GPT, Config, Tokenizer +from lit_gpt.utils import ( + CLI, + check_valid_checkpoint_dir, + get_default_supported_precision, + load_checkpoint, +) + +from scripts.prepare_alpaca import generate_prompt + + +def main( + prompt: str = "What food do llamas eat?", + input: str = "", + finetuned_path: Path = Path("out/full/alpaca/lit_model_finetuned.pth"), + checkpoint_dir: Path = Path( + "checkpoints/stabilityai/stablelm-base-alpha-3b" + ), + quantize: Optional[ + Literal["bnb.nf4", "bnb.nf4-dq", "bnb.fp4", "bnb.fp4-dq", "bnb.int8"] + ] = None, + max_new_tokens: int = 100, + top_k: Optional[int] = 200, + temperature: float = 0.8, + precision: Optional[str] = None, +) -> None: + """Generates a response based on a given instruction and an optional input. + This script will only work with checkpoints from the instruction-tuned GPT model. + See `finetune/full.py`. + + Args: + prompt: The prompt/instruction (Alpaca style). + input: Optional input (Alpaca style). + finetuned_path: Path to the checkpoint with trained weights, which are the output of + `finetune/full.py`. + checkpoint_dir: The path to the checkpoint folder with pretrained GPT weights. + quantize: Whether to quantize the model and using which method: + - bnb.nf4, bnb.nf4-dq, bnb.fp4, bnb.fp4-dq: 4-bit quantization from bitsandbytes + - bnb.int8: 8-bit quantization from bitsandbytes + for more details, see https://github.com/Lightning-AI/lit-gpt/blob/main/tutorials/quantize.md + max_new_tokens: The number of generation steps to take. + top_k: The number of top most probable tokens to consider in the sampling process. + temperature: A value controlling the randomness of the sampling process. Higher values result in more random + samples. + precision: Indicates the Fabric precision setting to use. + """ + precision = precision or get_default_supported_precision(training=False) + + plugins = None + if quantize is not None and quantize.startswith("bnb."): + if "mixed" in precision: + raise ValueError( + "Quantization and mixed precision is not supported." + ) + dtype = { + "16-true": torch.float16, + "bf16-true": torch.bfloat16, + "32-true": torch.float32, + }[precision] + plugins = BitsandbytesPrecision(quantize[4:], dtype) + precision = None + + fabric = L.Fabric(devices=1, precision=precision, plugins=plugins) + fabric.launch() + + check_valid_checkpoint_dir(checkpoint_dir) + + config = Config.from_json(checkpoint_dir / "lit_config.json") + + checkpoint_path = finetuned_path + + tokenizer = Tokenizer(checkpoint_dir) + sample = {"instruction": prompt, "input": input} + prompt = generate_prompt(sample) + encoded = tokenizer.encode(prompt, device=fabric.device) + prompt_length = encoded.size(0) + max_returned_tokens = prompt_length + max_new_tokens + + fabric.print( + f"Loading model {str(checkpoint_path)!r} with {config.__dict__}", + file=sys.stderr, + ) + t0 = time.perf_counter() + with fabric.init_module(empty_init=True): + model = GPT(config) + fabric.print( + f"Time to instantiate model: {time.perf_counter() - t0:.02f} seconds.", + file=sys.stderr, + ) + with fabric.init_tensor(): + # set the max_seq_length to limit the memory usage to what we need + model.max_seq_length = max_returned_tokens + # enable the kv cache + model.set_kv_cache(batch_size=1) + model.eval() + + model = fabric.setup(model) + + t0 = time.perf_counter() + load_checkpoint(fabric, model, checkpoint_path) + fabric.print( + f"Time to load the model weights: {time.perf_counter() - t0:.02f} seconds.", + file=sys.stderr, + ) + + L.seed_everything(1234) + t0 = time.perf_counter() + y = generate( + model, + encoded, + max_returned_tokens, + temperature=temperature, + top_k=top_k, + eos_id=tokenizer.eos_id, + ) + t = time.perf_counter() - t0 + + output = tokenizer.decode(y) + output = output.split("### Response:")[1].strip() + fabric.print(output) + + tokens_generated = y.size(0) - prompt_length + fabric.print( + f"\n\nTime for inference: {t:.02f} sec total, {tokens_generated / t:.02f} tokens/sec", + file=sys.stderr, + ) + if fabric.device.type == "cuda": + fabric.print( + f"Memory used: {torch.cuda.max_memory_allocated() / 1e9:.02f} GB", + file=sys.stderr, + ) + + +if __name__ == "__main__": + torch.set_float32_matmul_precision("high") + + CLI(main) diff --git a/examples/llm_finetuning/generate/lora.py b/examples/llm_finetuning/generate/lora.py new file mode 100644 index 00000000000..0b30b701ef2 --- /dev/null +++ b/examples/llm_finetuning/generate/lora.py @@ -0,0 +1,178 @@ +# Copyright Lightning AI. Licensed under the Apache License 2.0, see LICENSE file. + +import sys +import time +from pathlib import Path +from typing import Literal, Optional + +import lightning as L +import torch +from lightning.fabric.plugins import BitsandbytesPrecision + +# support running without installing as a package +wd = Path(__file__).parent.parent.resolve() +sys.path.append(str(wd)) + +from generate.base import generate +from lit_gpt import Tokenizer +from lit_gpt.lora import GPT, Config, merge_lora_weights +from lit_gpt.utils import ( + CLI, + check_valid_checkpoint_dir, + get_default_supported_precision, + lazy_load, +) + +from scripts.prepare_alpaca import generate_prompt + + +def main( + prompt: str = "What food do llamas eat?", + input: str = "", + lora_path: Path = Path("out/lora/alpaca/lit_model_lora_finetuned.pth"), + checkpoint_dir: Path = Path( + "checkpoints/stabilityai/stablelm-base-alpha-3b" + ), + quantize: Optional[ + Literal["bnb.nf4", "bnb.nf4-dq", "bnb.fp4", "bnb.fp4-dq", "bnb.int8"] + ] = None, + max_new_tokens: int = 100, + top_k: Optional[int] = 200, + temperature: float = 0.8, + precision: Optional[str] = None, + lora_r: int = 8, + lora_alpha: int = 16, + lora_dropout: float = 0.05, + lora_query: bool = True, + lora_key: bool = False, + lora_value: bool = True, + lora_projection: bool = False, + lora_mlp: bool = False, + lora_head: bool = False, +) -> None: + """Generates a response based on a given instruction and an optional input. + This script will only work with checkpoints from the instruction-tuned GPT-LoRA model. + See `finetune/lora.py`. + + Args: + prompt: The prompt/instruction (Alpaca style). + input: Optional input (Alpaca style). + lora_path: Path to the checkpoint with trained adapter weights, which are the output of + `finetune/lora.py`. + checkpoint_dir: The path to the checkpoint folder with pretrained GPT weights. + quantize: Whether to quantize the model and using which method: + - bnb.nf4, bnb.nf4-dq, bnb.fp4, bnb.fp4-dq: 4-bit quantization from bitsandbytes + - bnb.int8: 8-bit quantization from bitsandbytes + for more details, see https://github.com/Lightning-AI/lit-gpt/blob/main/tutorials/quantize.md + max_new_tokens: The number of generation steps to take. + top_k: The number of top most probable tokens to consider in the sampling process. + temperature: A value controlling the randomness of the sampling process. Higher values result in more random + samples. + precision: Indicates the Fabric precision setting to use. + """ + precision = precision or get_default_supported_precision(training=False) + + plugins = None + if quantize is not None and quantize.startswith("bnb."): + if "mixed" in precision: + raise ValueError( + "Quantization and mixed precision is not supported." + ) + dtype = { + "16-true": torch.float16, + "bf16-true": torch.bfloat16, + "32-true": torch.float32, + }[precision] + plugins = BitsandbytesPrecision(quantize[4:], dtype) + precision = None + + fabric = L.Fabric(devices=1, precision=precision, plugins=plugins) + fabric.launch() + + check_valid_checkpoint_dir(checkpoint_dir) + + config = Config.from_json( + checkpoint_dir / "lit_config.json", + r=lora_r, + alpha=lora_alpha, + dropout=lora_dropout, + to_query=lora_query, + to_key=lora_key, + to_value=lora_value, + to_projection=lora_projection, + to_mlp=lora_mlp, + to_head=lora_head, + ) + + checkpoint_path = checkpoint_dir / "lit_model.pth" + + tokenizer = Tokenizer(checkpoint_dir) + sample = {"instruction": prompt, "input": input} + prompt = generate_prompt(sample) + encoded = tokenizer.encode(prompt, device=fabric.device) + prompt_length = encoded.size(0) + max_returned_tokens = prompt_length + max_new_tokens + + fabric.print( + f"Loading model {str(checkpoint_path)!r} with {config.__dict__}", + file=sys.stderr, + ) + t0 = time.perf_counter() + with fabric.init_module(empty_init=True): + model = GPT(config) + fabric.print( + f"Time to instantiate model: {time.perf_counter() - t0:.02f} seconds.", + file=sys.stderr, + ) + with fabric.init_tensor(): + # set the max_seq_length to limit the memory usage to what we need + model.max_seq_length = max_returned_tokens + # enable the kv cache + model.set_kv_cache(batch_size=1) + model.eval() + + t0 = time.perf_counter() + checkpoint = lazy_load(checkpoint_path) + lora_checkpoint = lazy_load(lora_path) + checkpoint.update(lora_checkpoint.get("model", lora_checkpoint)) + model.load_state_dict(checkpoint) + fabric.print( + f"Time to load the model weights: {time.perf_counter() - t0:.02f} seconds.", + file=sys.stderr, + ) + + merge_lora_weights(model) + model = fabric.setup(model) + + L.seed_everything(1234) + t0 = time.perf_counter() + y = generate( + model, + encoded, + max_returned_tokens, + temperature=temperature, + top_k=top_k, + eos_id=tokenizer.eos_id, + ) + t = time.perf_counter() - t0 + + output = tokenizer.decode(y) + output = output.split("### Response:")[1].strip() + fabric.print(output) + + tokens_generated = y.size(0) - prompt_length + fabric.print( + f"\n\nTime for inference: {t:.02f} sec total, {tokens_generated / t:.02f} tokens/sec", + file=sys.stderr, + ) + if fabric.device.type == "cuda": + fabric.print( + f"Memory used: {torch.cuda.max_memory_allocated() / 1e9:.02f} GB", + file=sys.stderr, + ) + + +if __name__ == "__main__": + torch.set_float32_matmul_precision("high") + + CLI(main) diff --git a/examples/llm_finetuning/generate/sequentially.py b/examples/llm_finetuning/generate/sequentially.py new file mode 100644 index 00000000000..d2dde4bb843 --- /dev/null +++ b/examples/llm_finetuning/generate/sequentially.py @@ -0,0 +1,301 @@ +# Copyright Lightning AI. Licensed under the Apache License 2.0, see LICENSE file. + +import itertools +import logging +import re +import sys +import time +from collections import OrderedDict +from functools import partial +from pathlib import Path +from typing import Literal, Optional + +import lightning as L +import torch +from lightning.fabric.accelerators import CUDAAccelerator +from lightning.fabric.plugins import BitsandbytesPrecision +from lightning.fabric.utilities.init import _materialize_meta_tensors +from typing_extensions import Type + +# support running without installing as a package +wd = Path(__file__).parent.parent.resolve() +sys.path.append(str(wd)) + +import generate.base as generate_base +from lit_gpt import GPT, Config, Tokenizer +from lit_gpt.model import Block, build_mask_cache +from lit_gpt.utils import ( + CLI, + check_valid_checkpoint_dir, + get_default_supported_precision, +) + + +@torch.inference_mode() +def sequential( + model: GPT, root: torch.device, max_seq_length: int, devices: int +): + if model.config.n_layer % devices: + # TODO: support smarter partitioning schemes + raise NotImplementedError( + f"Only balanced partitioning is implemented: n_layer={model.config.n_layer}, devices {devices}" + ) + layers_per_rank = model.config.n_layer // devices + # dictates where each block should be instantiated + mapping = layer_to_device( + model, chunk_on=Block, chunk_size=layers_per_rank + ) + + # materialize each block on the appropriate device + for path, target_index in mapping.items(): + submodule = model.get_submodule(path) + target_device = torch.device(root.type, target_index) + print(f"Moving {path!r} to {target_device}", file=sys.stderr) + # submodules loaded by the checkpoint will be on CPU (if no quantization). move them + replace_device( + submodule, replace=torch.device("cpu"), by=target_device + ) + # in case the checkpoint was partial, materialize leftover metas + _materialize_meta_tensors(submodule, target_device) + # and build the kv cache + submodule.attn.kv_cache = submodule.attn.build_kv_cache( + 1, max_seq_length, model.cos.size(-1), target_device + ) + # rebuild odd ends + with root: + model.max_seq_length = max_seq_length + # the rope cache which is on meta device + model.cos, model.sin = model.rope_cache() + # the mask cache which cannot be created with `set_kv_cache` because that will set it for all layers + model.mask_cache = build_mask_cache(max_seq_length) + # and everything that is not a block in the root + _materialize_meta_tensors(model, root) + replace_device(model, replace=torch.device("cpu"), by=root) + + if devices > 1: + # install hooks to move layer inputs/output between devices + for layer_num, (path, target_index) in enumerate(mapping.items()): + submodule = model.get_submodule(path) + if layer_num >= layers_per_rank: + # we need to move the block input on the boundaries between devices + # and also on every non-root device because the RoPE and mask cache is shared + # TODO: the second case could be optimized and then we would only need this hook for + # `layer_num in [layers_per_rank * i - 1 for i in range(1, devices + 1)]` + target_device = torch.device(root.type, target_index) + submodule.register_forward_pre_hook( + partial(move_block_input, target_device) + ) + if layer_num == model.config.n_layer - 1: + submodule.register_forward_hook( + partial(move_block_output, root) + ) + + return model + + +def layer_to_device( + module: torch.nn.Module, chunk_on: Type[torch.nn.Module], chunk_size: int +) -> "OrderedDict[str, int]": + """Create a mapping from layer (block) to device.""" + # this assumes that the definition order is the same as the execution order + hits = [ + name + for name, submodule in module.named_modules() + if isinstance(submodule, chunk_on) + ] + return OrderedDict((name, i // chunk_size) for i, name in enumerate(hits)) + + +def move_block_input(device: torch.device, module: torch.nn.Module, ins): + """``forward_pre_hook`` to move a Block's input before forward.""" + # during inference, none of the inputs are None: x, cos, sin, mask, input_pos + return tuple(t.to(device) for t in ins) + + +def move_block_output( + device: torch.device, module: torch.nn.Module, ins, outs +) -> torch.Tensor: + """``forward_hook`` to move a Block's output after forward.""" + return outs.to(device) + + +def replace_device( + module: torch.nn.Module, replace: torch.device, by: torch.device +) -> torch.nn.Module: + for name, submodule in module.named_modules(): + tensors = dict( + itertools.chain( + submodule.named_parameters(recurse=False), + submodule.named_buffers(recurse=False), + ) + ) + if not tensors: + continue + devices = {t.device for t in tensors.values()} + if len(devices) != 1: + # since this is using `submodule.to`, different devices in the same submodule is a problem + path_to_device = { + f"{name}.{p}": t.device for p, t in tensors.items() + } + raise ValueError(f"Found multiple devices: {path_to_device}") + if devices.pop() == replace: + submodule.to(by) + return module + + +@torch.inference_mode() +def main( + prompt: str = "What food do llamas eat?", + *, + num_samples: int = 1, + max_new_tokens: int = 50, + top_k: Optional[int] = 200, + temperature: float = 0.8, + checkpoint_dir: Path = Path( + "checkpoints/mistralai/Mistral-7B-Instruct-v0.1" + ), + quantize: Optional[ + Literal["bnb.nf4", "bnb.nf4-dq", "bnb.fp4", "bnb.fp4-dq"] + ] = None, + precision: Optional[str] = None, + compile: bool = False, +) -> None: + """Generates text samples based on a pre-trained model and tokenizer. + + Args: + prompt: The prompt string to use for generating the samples. + num_samples: The number of text samples to generate. + max_new_tokens: The number of generation steps to take. + top_k: The number of top most probable tokens to consider in the sampling process. + temperature: A value controlling the randomness of the sampling process. Higher values result in more random + samples. + checkpoint_dir: The checkpoint directory to load. + quantize: Whether to quantize the model and using which method: + - bnb.nf4, bnb.nf4-dq, bnb.fp4, bnb.fp4-dq: 4-bit quantization from bitsandbytes + for more details, see https://github.com/Lightning-AI/lit-gpt/blob/main/tutorials/quantize.md + precision: Indicates the Fabric precision setting to use. + compile: Whether to compile the model. + """ + precision = precision or get_default_supported_precision(training=False) + + plugins = None + if quantize is not None: + if compile: + raise NotImplementedError # untested + if "mixed" in precision: + raise ValueError( + "Quantization and mixed precision is not supported." + ) + dtype = { + "16-true": torch.float16, + "bf16-true": torch.bfloat16, + "32-true": torch.float32, + }[precision] + plugins = BitsandbytesPrecision(quantize[4:], dtype) + precision = None + + fabric = L.Fabric( + devices=1, precision=precision, accelerator="cuda", plugins=plugins + ) + + total_devices = CUDAAccelerator.auto_device_count() + print(f"Using {total_devices} devices", file=sys.stderr) + + check_valid_checkpoint_dir(checkpoint_dir) + + config = Config.from_json(checkpoint_dir / "lit_config.json") + + checkpoint_path = checkpoint_dir / "lit_model.pth" + + tokenizer = Tokenizer(checkpoint_dir) + encoded = tokenizer.encode(prompt, device=fabric.device) + prompt_length = encoded.size(0) + max_returned_tokens = prompt_length + max_new_tokens + + print( + f"Loading model {str(checkpoint_path)!r} with {config.__dict__}", + file=sys.stderr, + ) + t0 = time.perf_counter() + # cannot use `init_module` because if bitsandbytes is used, the Linear layers will be replaced + # which means that the weights will get quantized on cuda:0 on checkpoint load. we need to load and then convert + # still, use init_tensor for the precision + with fabric.init_tensor(), torch.device("meta"): + model = GPT(config) + print( + f"Time to instantiate model: {time.perf_counter() - t0:.02f} seconds.", + file=sys.stderr, + ) + + t0 = time.perf_counter() + state_dict = torch.load( + str(checkpoint_path), mmap=True, map_location="cpu" + ) + # TODO: this assumes that the model fits on CPU. Use lazy_load and make the materialization checkpoint aware + model.load_state_dict(state_dict, assign=True) + print( + f"Time to load the model weights: {time.perf_counter() - t0:.02f} seconds.", + file=sys.stderr, + ) + + model = fabric.setup_module(model, move_to_device=False) + + t0 = time.perf_counter() + model = sequential( + model, fabric.device, max_returned_tokens, total_devices + ) + print( + f"Time to sequential-ize the model: {time.perf_counter() - t0:.02f} seconds.", + file=sys.stderr, + ) + + if compile: + # TODO: raises an internal compile AssertionError caused by fabric.strategy.precision.forward_context + raise NotImplementedError + # silence developer warning on nightly builds + # https://github.com/pytorch/pytorch/blob/v2.2.0-rc5/torch/_inductor/ir.py#L4166 + pattern = re.compile(".*DeviceCopy in input program.*") + logging.getLogger("torch._inductor.utils").addFilter( + lambda record: not pattern.search(record.getMessage()) + ) + torch._dynamo.config.automatic_dynamic_shapes = True + torch._inductor.config.triton.unique_kernel_names = True + torch._inductor.config.coordinate_descent_tuning = True + # cannot use cudagraphs because it doesn't support multiple device indices + # https://github.com/pytorch/pytorch/blob/v2.2.0-rc5/torch/_inductor/compile_fx.py#L371-L375 + generate_base.next_token = torch.compile(generate_base.next_token) + + L.seed_everything(1234) + for i in range(num_samples): + t0 = time.perf_counter() + y = generate_base.generate( + model, + encoded, + max_returned_tokens, + temperature=temperature, + top_k=top_k, + eos_id=tokenizer.eos_id, + ) + t = time.perf_counter() - t0 + for block in model.transformer.h: + block.attn.kv_cache.reset_parameters() + print(tokenizer.decode(y)) + tokens_generated = y.size(0) - prompt_length + print( + f"Time for inference {i + 1}: {t:.02f} sec total, {tokens_generated / t:.02f} tokens/sec", + file=sys.stderr, + ) + print( + f"Memory used: {torch.cuda.max_memory_allocated() / 1e9:.02f} GB", + file=sys.stderr, + ) + + +if __name__ == "__main__": + torch.set_float32_matmul_precision("high") + + logging.getLogger( + "lightning.fabric.plugins.precision.bitsandbytes" + ).setLevel(logging.DEBUG) + + CLI(main) diff --git a/examples/llm_finetuning/generate/tp.py b/examples/llm_finetuning/generate/tp.py new file mode 100644 index 00000000000..e8c7e1efc6b --- /dev/null +++ b/examples/llm_finetuning/generate/tp.py @@ -0,0 +1,287 @@ +"""Tensor-parallel implementation adapted from https://github.com/pytorch-labs/gpt-fast/blob/14df27/tp.py""" + +import logging +import sys +import time +from functools import partial +from pathlib import Path +from typing import Literal, Optional, Union + +import lightning as L +import torch +import torch._dynamo.config +import torch._inductor.config +from lightning.fabric.plugins import BitsandbytesPrecision +from lightning.fabric.utilities import rank_zero_only +from torch.distributed._functional_collectives import all_reduce + +# support running without installing as a package +wd = Path(__file__).parent.parent.resolve() +sys.path.append(str(wd)) + +import generate.base as generate_base +from lit_gpt import GPT, Config, Tokenizer +from lit_gpt.model import CausalSelfAttention, GptNeoxMLP, LLaMAMLP, LLaMAMoE +from lit_gpt.utils import ( + CLI, + check_valid_checkpoint_dir, + get_default_supported_precision, +) + + +def tensor_parallel_linear( + fabric: L.Fabric, linear: torch.nn.Linear, style: str +) -> None: + world_size = fabric.world_size + dim, attr = { + "colwise": (0, "out_features"), + "rowwise": (1, "in_features"), + }[style] + size = getattr(linear, attr) + if size % world_size != 0: + raise ValueError( + f"This linear's {attr} value ({size}) is not evenly divisible by the world size ({world_size})" + ) + + shard = torch.tensor_split(linear.weight, world_size, dim=dim)[ + fabric.global_rank + ] + # overwrite `.data` instead of recreating the parameter for quantization (bitsandbytes) support. + # the bitsandbytes linear classes use custom `torch.nn.Parameter` subclasses + linear.weight.data = shard + setattr(linear, attr, shard.size(dim)) + + if linear.bias is not None and dim == 0: + shard = torch.tensor_split(linear.bias, world_size)[fabric.global_rank] + linear.bias = torch.nn.Parameter( + shard, requires_grad=linear.bias.requires_grad + ) + + +def tensor_parallel_mlp( + fabric: L.Fabric, mlp: Union[GptNeoxMLP, LLaMAMLP, LLaMAMoE] +) -> None: + if isinstance(mlp, LLaMAMLP): + tensor_parallel_linear(fabric, mlp.fc_1, "colwise") + tensor_parallel_linear(fabric, mlp.fc_2, "colwise") + tensor_parallel_linear(fabric, mlp.proj, "rowwise") + mlp.register_forward_hook( + partial(all_reduce_output, fabric.world_size) + ) + elif isinstance(mlp, GptNeoxMLP): + tensor_parallel_linear(fabric, mlp.fc, "colwise") + tensor_parallel_linear(fabric, mlp.proj, "rowwise") + mlp.register_forward_hook( + partial(all_reduce_output, fabric.world_size) + ) + elif isinstance(mlp, LLaMAMoE): + # we use expert slicing across ranks, alternatively, we could create a expert parallelism group + # when the number of experts is a multiple of the world size + for expert in mlp.experts: + tensor_parallel_mlp(fabric, expert) + else: + raise NotImplementedError + + +def tensor_parallel_attn(fabric: L.Fabric, attn: CausalSelfAttention) -> None: + tensor_parallel_linear(fabric, attn.attn, "colwise") + tensor_parallel_linear(fabric, attn.proj, "rowwise") + attn.register_forward_hook(partial(all_reduce_output, fabric.world_size)) + + +def all_reduce_output( + world_size: int, module: torch.nn.Module, ins, outs +) -> torch.Tensor: + return all_reduce(outs, "sum", list(range(world_size))) + + +def tensor_parallel(fabric: L.Fabric, model: GPT) -> GPT: + for block in model.transformer.h: + tensor_parallel_mlp(fabric, block.mlp) + tensor_parallel_attn(fabric, block.attn) + + # update the config values to the shard sizes + # this is only relevant for `tensor_parallel_attn`, but it needs to run only once + world_size = fabric.world_size + attrs = ["n_head", "n_embd", "n_query_groups"] + for attr in attrs: + size = getattr(model.config, attr) + if size % world_size != 0: + raise ValueError( + f"This {attr} value ({size}) is not evenly divisible by the world size ({world_size})" + ) + setattr(model.config, attr, size // world_size) + + return model + + +@torch.inference_mode() +def main( + prompt: str = "What food do llamas eat?", + *, + num_samples: int = 1, + max_new_tokens: int = 50, + top_k: Optional[int] = 200, + temperature: float = 0.8, + checkpoint_dir: Path = Path( + "checkpoints/stabilityai/stablelm-base-alpha-3b" + ), + quantize: Optional[ + Literal["bnb.nf4", "bnb.nf4-dq", "bnb.fp4", "bnb.fp4-dq"] + ] = None, + precision: Optional[str] = None, + compile: bool = False, +) -> None: + """Generates text samples based on a pre-trained model and tokenizer. + + Args: + prompt: The prompt string to use for generating the samples. + num_samples: The number of text samples to generate. + max_new_tokens: The number of generation steps to take. + top_k: The number of top most probable tokens to consider in the sampling process. + temperature: A value controlling the randomness of the sampling process. Higher values result in more random + samples. + checkpoint_dir: The checkpoint directory to load. + quantize: Whether to quantize the model and using which method: + - bnb.nf4, bnb.nf4-dq, bnb.fp4, bnb.fp4-dq: 4-bit quantization from bitsandbytes + for more details, see https://github.com/Lightning-AI/lit-gpt/blob/main/tutorials/quantize.md + precision: Indicates the Fabric precision setting to use. + compile: Whether to compile the model. + """ + precision = precision or get_default_supported_precision(training=False) + + plugins = None + if quantize is not None: + if compile: + raise NotImplementedError # untested + if "mixed" in precision: + raise ValueError( + "Quantization and mixed precision is not supported." + ) + dtype = { + "16-true": torch.float16, + "bf16-true": torch.bfloat16, + "32-true": torch.float32, + }[precision] + plugins = BitsandbytesPrecision(quantize[4:], dtype) + precision = None + + # set "ddp" as the strategy for the launching functionality, but there's no data-parallelism + fabric = L.Fabric( + devices="auto", strategy="ddp", precision=precision, plugins=plugins + ) + fabric.launch() + + check_valid_checkpoint_dir(checkpoint_dir) + + config = Config.from_json(checkpoint_dir / "lit_config.json") + + model_file = "lit_model.pth" + checkpoint_path = checkpoint_dir / model_file + + tokenizer = Tokenizer(checkpoint_dir) + encoded = tokenizer.encode(prompt, device=fabric.device) + prompt_length = encoded.size(0) + max_returned_tokens = prompt_length + max_new_tokens + + fabric.print( + f"Loading model {str(checkpoint_path)!r} with {config.__dict__}", + file=sys.stderr, + ) + t0 = time.perf_counter() + # cannot use `init_module` because if bitsandbytes is used, the Linear layers will be replaced + # which means that the weights will get quantized on cuda:0 on checkpoint load. we need to load and then convert + # still, use init_tensor for the precision + with fabric.init_tensor(), torch.device("meta"): + model = GPT(config) + fabric.print( + f"Time to instantiate model: {time.perf_counter() - t0:.02f} seconds.", + file=sys.stderr, + ) + + # sequentially do: load the checkpoint on CPU -> quantize -> apply tp -> move to device + # so that the CPU RAM doesn't OOM with larger models + for rank in range(fabric.world_size): + if fabric.global_rank == rank: + t0 = time.perf_counter() + state_dict = torch.load( + str(checkpoint_path), mmap=True, map_location="cpu" + ) + model.load_state_dict(state_dict, assign=True) + print( + f"[{rank}] Time to load the model weights: {time.perf_counter() - t0:.02f} seconds.", + file=sys.stderr, + ) + + # cannot use `.setup_module` because it will wrap with DDP + model = fabric._precision.convert_module(model) + + t0 = time.perf_counter() + model = tensor_parallel(fabric, model) + print( + f"[{rank}] Time to tensor-parallelize the model: {time.perf_counter() - t0:.02f} seconds.", + file=sys.stderr, + ) + + with fabric.init_tensor(): + # set the max_seq_length to limit the memory usage to what we need + model.max_seq_length = max_returned_tokens + # the rope cache which is on meta device + model.cos, model.sin = model.rope_cache() + # enable the kv cache + model.set_kv_cache(batch_size=1) + model.eval() + + t0 = time.perf_counter() + model = fabric.to_device(model) + print( + f"[{rank}] Time to move the model: {time.perf_counter() - t0:.02f} seconds.", + file=sys.stderr, + ) + fabric.barrier() + + if compile: + torch._dynamo.config.automatic_dynamic_shapes = True + torch._inductor.config.triton.unique_kernel_names = True + torch._inductor.config.coordinate_descent_tuning = True + generate_base.next_token = torch.compile( + generate_base.next_token, mode="reduce-overhead" + ) + + L.seed_everything(1234) + for i in range(num_samples): + t0 = time.perf_counter() + y = generate_base.generate( + model, + encoded, + max_returned_tokens, + temperature=temperature, + top_k=top_k, + eos_id=tokenizer.eos_id, + ) + t = time.perf_counter() - t0 + for block in model.transformer.h: + block.attn.kv_cache.reset_parameters() + fabric.print(tokenizer.decode(y)) + tokens_generated = y.size(0) - prompt_length + fabric.print( + f"Time for inference {i + 1}: {t:.02f} sec total, {tokens_generated / t:.02f} tokens/sec", + file=sys.stderr, + ) + if fabric.device.type == "cuda": + fabric.print( + f"Memory used: {torch.cuda.max_memory_allocated() / 1e9:.02f} GB", + file=sys.stderr, + ) + + +if __name__ == "__main__": + torch.set_float32_matmul_precision("high") + + bnb_logger = logging.getLogger( + "lightning.fabric.plugins.precision.bitsandbytes" + ) + bnb_logger.setLevel(logging.DEBUG) + bnb_logger.debug = rank_zero_only(bnb_logger.debug) + + CLI(main) diff --git a/examples/llm_finetuning/lit_gpt/__init__.py b/examples/llm_finetuning/lit_gpt/__init__.py new file mode 100644 index 00000000000..f3974ec0a1b --- /dev/null +++ b/examples/llm_finetuning/lit_gpt/__init__.py @@ -0,0 +1,29 @@ +# Copyright Lightning AI. Licensed under the Apache License 2.0, see LICENSE file. + +import logging +import re + +from lightning_utilities.core.imports import RequirementCache + +from lit_gpt.model import GPT # isort: skip +from lit_gpt.config import Config # isort: skip +from lit_gpt.tokenizer import Tokenizer + +_LIGHTNING_AVAILABLE = RequirementCache("lightning>=2.2.0.dev0") +if not bool(_LIGHTNING_AVAILABLE): + raise ImportError( + "Lit-GPT requires lightning nightly. Please run:\n" + f" pip uninstall -y lightning; pip install -r requirements.txt\n{str(_LIGHTNING_AVAILABLE)}" + ) + +# Suppress excessive warnings, see https://github.com/pytorch/pytorch/issues/111632 +pattern = re.compile(".*Profiler function .* will be ignored") +logging.getLogger("torch._dynamo.variables.torch").addFilter( + lambda record: not pattern.search(record.getMessage()) +) + +# Avoid printing state-dict profiling output at the WARNING level when saving a checkpoint +logging.getLogger("torch.distributed.fsdp._optim_utils").disabled = True +logging.getLogger("torch.distributed.fsdp._debug_utils").disabled = True + +__all__ = ["GPT", "Config", "Tokenizer"] diff --git a/examples/llm_finetuning/lit_gpt/adapter.py b/examples/llm_finetuning/lit_gpt/adapter.py new file mode 100644 index 00000000000..61744419e4a --- /dev/null +++ b/examples/llm_finetuning/lit_gpt/adapter.py @@ -0,0 +1,206 @@ +# Copyright Lightning AI. Licensed under the Apache License 2.0, see LICENSE file. + +"""Implementation of the paper: + +LLaMA-Adapter: Efficient Fine-tuning of Language Models with Zero-init Attention +https://arxiv.org/abs/2303.16199 + +Port for Lit-GPT +""" + +from dataclasses import dataclass +from typing import Any, Dict, List, Optional, Tuple, Union + +import torch +import torch.nn as nn +from typing_extensions import Self + +from lit_gpt.config import Config as BaseConfig +from lit_gpt.model import GPT as BaseModel +from lit_gpt.model import Block as BaseBlock +from lit_gpt.model import CausalSelfAttention as BaseCausalSelfAttention + + +@dataclass +class Config(BaseConfig): + adapter_prompt_length: int = 10 + adapter_start_layer: int = 2 + + +class GPT(BaseModel): + """The implementation is identical to `lit_gpt.model.GPT` with the exception that + the `Block` saves the layer index and passes it down to the attention layer. + """ + + def __init__(self, config: Config) -> None: + nn.Module.__init__(self) + assert config.padded_vocab_size is not None + self.config = config + + self.lm_head = nn.Linear( + config.n_embd, config.padded_vocab_size, bias=config.lm_head_bias + ) + self.transformer = nn.ModuleDict( + dict( + wte=nn.Embedding(config.padded_vocab_size, config.n_embd), + h=nn.ModuleList( + Block(config, i) for i in range(config.n_layer) + ), + ln_f=config.norm_class(config.n_embd, eps=config.norm_eps), + ) + ) + self.max_seq_length = self.config.block_size + self.mask_cache: Optional[torch.Tensor] = None + + def forward( + self, + idx: torch.Tensor, + input_pos: Optional[torch.Tensor] = None, + lm_head_chunk_size: int = 0, + ) -> Union[torch.Tensor, List[torch.Tensor]]: + T = idx.size(1) + if self.max_seq_length < T: + raise ValueError( + f"Cannot forward sequence of length {T}, max seq length is only {self.max_seq_length}." + ) + + if input_pos is not None: # use the kv cache + cos = self.cos.index_select(0, input_pos) + sin = self.sin.index_select(0, input_pos) + if self.mask_cache is None: + raise TypeError("You need to call `gpt.set_kv_cache()`") + mask = self.mask_cache.index_select(2, input_pos) + else: + cos = self.cos[:T] + sin = self.sin[:T] + mask = None + + x = self.transformer.wte( + idx + ) # token embeddings of shape (b, t, n_embd) + for block in self.transformer.h: + x = block(x, cos, sin, mask, input_pos) + x = self.transformer.ln_f(x) + if lm_head_chunk_size > 0: + # chunk the lm head logits to reduce the peak memory used by autograd + return [ + self.lm_head(x_i) for x_i in x.split(lm_head_chunk_size, dim=1) + ] + return self.lm_head(x) # (b, t, vocab_size) + + @classmethod + def from_name(cls, name: str, **kwargs: Any) -> Self: + return cls(Config.from_name(name, **kwargs)) + + def _init_weights(self, module: nn.Module) -> None: + """Meant to be used with `gpt.apply(gpt._init_weights)`. Unused method left for completeness.""" + super()._init_weights(module) + if isinstance(module, CausalSelfAttention): + module.reset_parameters() + + +class Block(BaseBlock): + """The implementation is identical to `lit_gpt.model.Block` with the exception that + we replace the attention layer where adaption is implemented.""" + + def __init__(self, config: Config, block_idx: int) -> None: + # Skip the parent class __init__ altogether and replace it to avoid useless allocations + nn.Module.__init__(self) + self.norm_1 = config.norm_class(config.n_embd, eps=config.norm_eps) + self.attn = CausalSelfAttention(config, block_idx) + if not config.shared_attention_norm: + self.norm_2 = config.norm_class(config.n_embd, eps=config.norm_eps) + self.mlp = config.mlp_class(config) + + self.config = config + + +class CausalSelfAttention(BaseCausalSelfAttention): + """A modification of `lit_gpt.model.CausalSelfAttention` that adds the attention + over the adaption prompt.""" + + def __init__(self, config: Config, block_idx: int) -> None: + super().__init__(config) + if block_idx >= config.adapter_start_layer: + # adapter embedding layer + self.adapter_wte = nn.Embedding( + config.adapter_prompt_length, config.n_embd + ) + # gate for adaption + self.gating_factor = torch.nn.Parameter( + torch.zeros(1, 1, config.n_head, 1) + ) + # kv cache for inference + self.adapter_kv_cache: Optional[ + Tuple[torch.Tensor, torch.Tensor] + ] = None + self.block_idx = block_idx + + def scaled_dot_product_attention( + self, + q: torch.Tensor, + k: torch.Tensor, + v: torch.Tensor, + mask: Optional[torch.Tensor] = None, + ) -> torch.Tensor: + y = super().scaled_dot_product_attention(q, k, v, mask) + if self.block_idx < self.config.adapter_start_layer: + return y + + aT = self.config.adapter_prompt_length + if self.adapter_kv_cache is not None: + # since this uses the wte weights as the prefix and the kv cache is only used during inference, ak and av + # are the same every call + ak, av = self.adapter_kv_cache + else: + prefix = self.adapter_wte.weight.reshape(1, aT, self.config.n_embd) + aqkv = self.attn(prefix) + q_per_kv = self.config.n_head // self.config.n_query_groups + aqkv = aqkv.view( + 1, + aT, + self.config.n_query_groups, + q_per_kv + 2, + self.config.head_size, + ) + aqkv = aqkv.permute(0, 2, 3, 1, 4) + _, ak, av = aqkv.split((q_per_kv, 1, 1), dim=2) + if self.config.n_query_groups != 1: + # for MHA this is a no-op + ak = ak.repeat_interleave(q_per_kv, dim=2) + av = av.repeat_interleave(q_per_kv, dim=2) + ak = ak.view( + 1, -1, aT, self.config.head_size + ) # (1, nh_ak, aT, hs) + av = av.view( + 1, -1, aT, self.config.head_size + ) # (1, nh_av, aT, hs) + self.adapter_kv_cache = (ak, av) + + T = q.size(2) + amask = torch.ones(T, aT, dtype=torch.bool, device=q.device) + ay = super().scaled_dot_product_attention(q, ak, av, amask) + return y + self.gating_factor * ay + + def reset_parameters(self) -> None: + torch.nn.init.zeros_(self.gating_factor) + + def _load_from_state_dict( + self, state_dict: Dict, prefix: str, *args: Any, **kwargs: Any + ) -> None: + """For compatibility with older checkpoints.""" + if (key := prefix + "gating_factor") in state_dict and state_dict[ + key + ].size(1) == self.config.n_head: + state_dict[key] = state_dict[key].permute(0, 2, 1, 3) + super()._load_from_state_dict(state_dict, prefix, *args, **kwargs) + + +def mark_only_adapter_as_trainable(model: GPT) -> None: + """Sets `requires_grad=False` for all non-adapter weights.""" + for name, param in model.named_parameters(): + param.requires_grad = adapter_filter(name, param) + + +def adapter_filter(key: str, value: Any) -> bool: + return "adapter_wte" in key or "gating_factor" in key diff --git a/examples/llm_finetuning/lit_gpt/adapter_v2.py b/examples/llm_finetuning/lit_gpt/adapter_v2.py new file mode 100644 index 00000000000..5d389471b55 --- /dev/null +++ b/examples/llm_finetuning/lit_gpt/adapter_v2.py @@ -0,0 +1,269 @@ +# Copyright Lightning AI. Licensed under the Apache License 2.0, see LICENSE file. + +"""Implementation of the paper: + +LLaMA-Adapter V2: Parameter-Efficient Visual Instruction Model +https://arxiv.org/abs/2304.15010 + +Port for Lit-GPT +""" + +from dataclasses import dataclass +from typing import Any, Dict, Optional, Tuple, Type + +import torch +import torch.nn as nn +from typing_extensions import Self + +import lit_gpt +from lit_gpt.adapter import GPT as BaseModel +from lit_gpt.adapter import Block as BaseBlock +from lit_gpt.adapter import CausalSelfAttention as BaseCausalSelfAttention +from lit_gpt.adapter import Config as BaseConfig +from lit_gpt.model import KVCache +from lit_gpt.utils import map_old_state_dict_weights + + +@dataclass +class Config(BaseConfig): + @property + def mlp_class(self) -> Type: + return getattr(lit_gpt.adapter_v2, self._mlp_class) + + +def adapter_filter(key: str, value: Any) -> bool: + adapter_substrings = ( + # regular adapter v1 parameters + "adapter_wte", + "gating_factor", + # adapter v2: new bias and scale used in Linear + "adapter_scale", + "adapter_bias", + # adapter v2: Norm parameters are now trainable + "norm_1", + "norm_2", + "ln_f", + ) + return any(s in key for s in adapter_substrings) + + +class AdapterV2Linear(torch.nn.Module): + def __init__(self, in_features: int, out_features: int, **kwargs) -> None: + super().__init__() + self.linear = torch.nn.Linear(in_features, out_features, **kwargs) + self.adapter_bias = torch.nn.Parameter( + torch.zeros(out_features), requires_grad=False + ) + self.adapter_scale = torch.nn.Parameter( + torch.ones(out_features), requires_grad=False + ) + + def forward(self, x: torch.Tensor) -> torch.Tensor: + return self.adapter_scale * (self.linear(x) + self.adapter_bias) + + def reset_parameters(self) -> None: + nn.init.zeros_(self.adapter_bias) + nn.init.ones_(self.adapter_scale) + + +class GPT(BaseModel): + def __init__(self, config: Config) -> None: + # Skip the parent class __init__ altogether and replace it to avoid useless allocations + nn.Module.__init__(self) + assert config.padded_vocab_size is not None + self.config = config + + self.lm_head = AdapterV2Linear( + config.n_embd, config.padded_vocab_size, bias=config.lm_head_bias + ) + self.transformer = nn.ModuleDict( + dict( + wte=nn.Embedding(config.padded_vocab_size, config.n_embd), + h=nn.ModuleList( + Block(config, i) for i in range(config.n_layer) + ), + ln_f=config.norm_class(config.n_embd, eps=config.norm_eps), + ) + ) + self.max_seq_length = self.config.block_size + self.mask_cache: Optional[torch.Tensor] = None + + @classmethod + def from_name(cls, name: str, **kwargs: Any) -> Self: + return cls(Config.from_name(name, **kwargs)) + + def _init_weights(self, module: nn.Module) -> None: + """Meant to be used with `gpt.apply(gpt._init_weights)`. Unused method left for completeness.""" + super()._init_weights(module) + if isinstance(module, AdapterV2Linear): + module.reset_parameters() + + def _load_from_state_dict( + self, state_dict: Dict, prefix: str, *args: Any, **kwargs: Any + ) -> None: + """For compatibility with base checkpoints.""" + mapping = { + "lm_head.weight": "lm_head.linear.weight", + "lm_head.bias": "lm_head.linear.bias", + } + state_dict = map_old_state_dict_weights(state_dict, mapping, prefix) + super()._load_from_state_dict(state_dict, prefix, *args, **kwargs) + + +class Block(BaseBlock): + """The implementation is identical to `lit_gpt.model.Block` with the exception that + we replace the attention layer where adaption is implemented.""" + + def __init__(self, config: Config, block_idx: int) -> None: + # Skip the parent class __init__ altogether and replace it to avoid useless allocations + nn.Module.__init__(self) + self.norm_1 = config.norm_class(config.n_embd, eps=config.norm_eps) + self.attn = CausalSelfAttention(config, block_idx) + if not config.shared_attention_norm: + self.norm_2 = config.norm_class(config.n_embd, eps=config.norm_eps) + self.mlp = config.mlp_class(config) + + self.config = config + + +class CausalSelfAttention(BaseCausalSelfAttention): + """A modification of `lit_gpt.adapter.CausalSelfAttention` that uses the Adapter V2 Linear class""" + + def __init__(self, config: Config, block_idx: int) -> None: + # Skip the parent class __init__ altogether and replace it to avoid useless allocations + nn.Module.__init__(self) + shape = (config.n_head + 2 * config.n_query_groups) * config.head_size + # key, query, value projections for all heads, but in a batch + self.attn = AdapterV2Linear( + in_features=config.n_embd, out_features=shape, bias=config.bias + ) + # output projection + # if `head_size` is explicitly specified in the config, `n_emd` might not be equal to `head_size * n_head` + self.proj = AdapterV2Linear( + config.head_size * config.n_head, config.n_embd, bias=config.bias + ) + # disabled by default + self.kv_cache: Optional[KVCache] = None + + if block_idx >= config.adapter_start_layer: + # adapter embedding layer + self.adapter_wte = nn.Embedding( + config.adapter_prompt_length, config.n_embd + ) + # gate for adaption + self.gating_factor = torch.nn.Parameter( + torch.zeros(1, 1, config.n_head, 1) + ) + # kv cache for inference + self.adapter_kv_cache: Optional[ + Tuple[torch.Tensor, torch.Tensor] + ] = None + self.block_idx = block_idx + + self.config = config + + def _load_from_state_dict( + self, state_dict: Dict, prefix: str, *args: Any, **kwargs: Any + ) -> None: + """For compatibility with base checkpoints.""" + mapping = { + "attn.weight": "attn.linear.weight", + "attn.bias": "attn.linear.bias", + "proj.weight": "proj.linear.weight", + "proj.bias": "proj.linear.bias", + } + state_dict = map_old_state_dict_weights(state_dict, mapping, prefix) + # For compatibility with older checkpoints + if (key := prefix + "gating_factor") in state_dict and state_dict[ + key + ].size(1) == self.config.n_head: + state_dict[key] = state_dict[key].permute(0, 2, 1, 3) + super()._load_from_state_dict(state_dict, prefix, *args, **kwargs) + + +class GptNeoxMLP(lit_gpt.model.GptNeoxMLP): + def __init__(self, config: Config) -> None: + nn.Module.__init__(self) + self.fc = AdapterV2Linear( + config.n_embd, config.intermediate_size, bias=config.bias + ) + self.proj = AdapterV2Linear( + config.intermediate_size, config.n_embd, bias=config.bias + ) + + self.config = config + + def _load_from_state_dict( + self, state_dict: Dict, prefix: str, *args: Any, **kwargs: Any + ) -> None: + """For compatibility with base checkpoints.""" + mapping = { + "fc.weight": "fc.linear.weight", + "fc.bias": "fc.linear.bias", + "proj.weight": "proj.linear.weight", + "proj.bias": "proj.linear.bias", + } + state_dict = map_old_state_dict_weights(state_dict, mapping, prefix) + super()._load_from_state_dict(state_dict, prefix, *args, **kwargs) + + +class LLaMAMLP(lit_gpt.model.LLaMAMLP): + def __init__(self, config: Config) -> None: + nn.Module.__init__(self) + self.fc_1 = AdapterV2Linear( + config.n_embd, config.intermediate_size, bias=config.bias + ) + self.fc_2 = AdapterV2Linear( + config.n_embd, config.intermediate_size, bias=config.bias + ) + self.proj = AdapterV2Linear( + config.intermediate_size, config.n_embd, bias=config.bias + ) + + def _load_from_state_dict( + self, state_dict: Dict, prefix: str, *args: Any, **kwargs: Any + ) -> None: + """For compatibility with base checkpoints.""" + mapping = { + "fc_1.weight": "fc_1.linear.weight", + "fc_1.bias": "fc_1.linear.bias", + "fc_2.weight": "fc_2.linear.weight", + "fc_2.bias": "fc_2.linear.bias", + "proj.weight": "proj.linear.weight", + "proj.bias": "proj.linear.bias", + } + state_dict = map_old_state_dict_weights(state_dict, mapping, prefix) + super()._load_from_state_dict(state_dict, prefix, *args, **kwargs) + + +class GemmaMLP(LLaMAMLP): + def forward(self, x: torch.Tensor) -> torch.Tensor: + x_fc_1 = self.fc_1(x) + x_fc_2 = self.fc_2(x) + x = torch.nn.functional.gelu(x_fc_1) * x_fc_2 + return self.proj(x) + + +class LLaMAMoE(lit_gpt.model.LLaMAMoE): + def __init__(self, config: Config) -> None: + nn.Module.__init__(self) + self.gate = AdapterV2Linear(config.n_embd, config.n_expert, bias=False) + self.experts = nn.ModuleList( + LLaMAMLP(config) for _ in range(config.n_expert) + ) + + self.config = config + + def _load_from_state_dict( + self, state_dict: Dict, prefix: str, *args: Any, **kwargs: Any + ) -> None: + """For compatibility with base checkpoints.""" + mapping = {"gate.weight": "gate.linear.weight"} + state_dict = map_old_state_dict_weights(state_dict, mapping, prefix) + super()._load_from_state_dict(state_dict, prefix, *args, **kwargs) + + +def mark_only_adapter_v2_as_trainable(model: GPT) -> None: + """Sets requires_grad=False for all non-adapter weights""" + for name, param in model.named_parameters(): + param.requires_grad = adapter_filter(name, param) diff --git a/examples/llm_finetuning/lit_gpt/args.py b/examples/llm_finetuning/lit_gpt/args.py new file mode 100644 index 00000000000..264c8f511ee --- /dev/null +++ b/examples/llm_finetuning/lit_gpt/args.py @@ -0,0 +1,85 @@ +from dataclasses import dataclass +from pathlib import Path +from typing import Optional + + +@dataclass +class TrainArgs: + """Training related arguments""" + + save_interval: int = 1000 + """Number of optimizer steps between checkpoints""" + log_interval: int = 1 + """Number of iterations between logging calls""" + global_batch_size: int = 64 + """Number of samples between optimizer steps across data-parallel ranks""" + micro_batch_size: int = 4 + """Number of samples per data-parallel rank""" + lr_warmup_steps: int = 100 + """Number of iterations with learning rate warmup active""" + epochs: Optional[int] = None + """Number of epochs to run""" + epoch_size: Optional[int] = None + """Size of the epoch""" + # TODO: pretrain/tinyllama is the only script using `max_tokens` explicitly. replace it with epoch_size*epochs? + max_tokens: Optional[int] = None + """Total number of tokens to train on""" + max_seq_length: Optional[int] = None + """Limits the length of samples. Off by default""" + + # Optimization args + learning_rate: float = 1e-3 + weight_decay: float = 0.02 + beta1: float = 0.9 + beta2: float = 0.95 + max_norm: Optional[float] = None + min_lr: float = 6e-5 + + def max_iters(self, devices: int) -> int: + """Number of iterations""" + max_iters = ( + self.epochs * self.epoch_size // devices // self.micro_batch_size + ) + assert max_iters > 0 + return max_iters + + def gradient_accumulation_iters(self, devices: int) -> int: + """Number of iterations between gradient synchronizations""" + gradient_accumulation_iters = ( + self.batch_size(devices) // self.micro_batch_size + ) + assert gradient_accumulation_iters > 0 + return gradient_accumulation_iters + + def batch_size(self, devices: int) -> int: + """Number of samples between optimizer steps per data-parallel rank""" + batch_size = self.global_batch_size // devices + assert batch_size > 0 + return batch_size + + +@dataclass +class EvalArgs: + """Evaluation related arguments""" + + interval: int = 600 + """Number of optimizer steps between evaluation calls""" + max_new_tokens: Optional[int] = None + """Number of tokens to generate""" + max_iters: int = 100 + """Number of iterations""" + + +@dataclass +class IOArgs: + """Inputs and outputs related arguments""" + + # Optional because pretrain/tinyllama hardcodes the path + train_data_dir: Optional[Path] = Path("data/alpaca") + """Where to read training data from""" + val_data_dir: Optional[Path] = None + """Where to read validation data from""" + checkpoint_dir: Optional[Path] = None + """Where to read weights and tokenizer data from""" + out_dir: Path = Path("out/adapter/alpaca") + """Where to save artifacts""" diff --git a/examples/llm_finetuning/lit_gpt/config.py b/examples/llm_finetuning/lit_gpt/config.py new file mode 100644 index 00000000000..dab1523ba53 --- /dev/null +++ b/examples/llm_finetuning/lit_gpt/config.py @@ -0,0 +1,1487 @@ +# Copyright Lightning AI. Licensed under the Apache License 2.0, see LICENSE file. + +import json +from copy import deepcopy +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Literal, Optional, Type, Union + +import torch +from typing_extensions import Self + +import lit_gpt.model +from lit_gpt.utils import find_multiple + + +@dataclass +class Config: + name: str = "" + hf_config: dict = field(default_factory=dict) + scale_embeddings: bool = False + block_size: int = 4096 + vocab_size: int = 50254 + padding_multiple: int = 512 + padded_vocab_size: Optional[int] = None + n_layer: int = 16 + n_head: int = 32 + head_size: Optional[int] = None + n_embd: int = 4096 + rotary_percentage: float = 0.25 + parallel_residual: bool = True + bias: bool = True + lm_head_bias: bool = False + # to use multi-head attention (MHA), set this to `n_head` (default) + # to use multi-query attention (MQA), set this to 1 + # to use grouped-query attention (GQA), set this to a value in between + # Example with `n_head=4` + # ┌───┐┌───┐┌───┐┌───┐ ┌───┐ ┌───┐ ┌───┐ + # │ v ││ v ││ v ││ v │ │ v │ │ v │ │ v │ + # └───┘└───┘└───┘└───┘ └───┘ └───┘ └───┘ + # │ │ │ │ │ │ │ + # ┌───┐┌───┐┌───┐┌───┐ ┌───┐ ┌───┐ ┌───┐ + # │ k ││ k ││ k ││ k │ │ k │ │ k │ │ k │ + # └───┘└───┘└───┘└───┘ └───┘ └───┘ └───┘ + # │ │ │ │ ┌──┴──┐ ┌──┴──┐ ┌────┬──┴─┬────┐ + # ┌───┐┌───┐┌───┐┌───┐ ┌───┐┌───┐┌───┐┌───┐ ┌───┐┌───┐┌───┐┌───┐ + # │ q ││ q ││ q ││ q │ │ q ││ q ││ q ││ q │ │ q ││ q ││ q ││ q │ + # └───┘└───┘└───┘└───┘ └───┘└───┘└───┘└───┘ └───┘└───┘└───┘└───┘ + # ◀──────────────────▶ ◀──────────────────▶ ◀──────────────────▶ + # MHA GQA MQA + # n_query_groups=4 n_query_groups=2 n_query_groups=1 + # + # credit https://arxiv.org/pdf/2305.13245.pdf + n_query_groups: Optional[int] = None + shared_attention_norm: bool = False + _norm_class: Literal["LayerNorm", "RMSNorm"] = "LayerNorm" + norm_eps: float = 1e-5 + _mlp_class: Literal[ + "GptNeoxMLP", "LLaMAMLP", "GemmaMLP", "LLaMAMoE" + ] = "GptNeoxMLP" + gelu_approximate: str = "none" + intermediate_size: Optional[int] = None + rope_condense_ratio: int = 1 + rope_base: int = 10000 + n_expert: int = 0 + n_expert_per_token: int = 0 + + def __post_init__(self): + if not self.name: + self.name = self.hf_config.get("name", self.name) + + if self.head_size is None: + assert self.n_embd % self.n_head == 0 + self.head_size = self.n_embd // self.n_head + + # vocab size should be a power of 2 to be optimal on hardware. compute the closest value + if self.padded_vocab_size is None: + self.padded_vocab_size = find_multiple( + self.vocab_size, self.padding_multiple + ) + else: + # vocab size shouldn't be larger than padded vocab size + self.vocab_size = min(self.vocab_size, self.padded_vocab_size) + + # compute the number of query groups + if self.n_query_groups is not None: + assert self.n_head % self.n_query_groups == 0 + else: + self.n_query_groups = self.n_head + + # compute the intermediate size for MLP if not set + if self.intermediate_size is None: + if self._mlp_class == "LLaMAMLP": + raise ValueError( + "The config needs to set the `intermediate_size`" + ) + self.intermediate_size = 4 * self.n_embd + + self.rope_n_elem = int(self.rotary_percentage * self.head_size) + + @classmethod + def from_name(cls, name: str, **kwargs: Any) -> Self: + if name not in name_to_config: + # search through all `config['hf_config']['name']` + try: + conf_dict = next( + config + for config in configs + if name == config["hf_config"]["name"] + ) + except StopIteration: + raise ValueError(f"{name!r} is not a supported config name") + else: + conf_dict = name_to_config[name] + + conf_dict = conf_dict.copy() + if "condense_ratio" in kwargs: # legacy name + kwargs["rope_condense_ratio"] = kwargs.pop("condense_ratio") + conf_dict.update(kwargs) + return cls(**conf_dict) + + @classmethod + def from_json(cls, path: Union[str, Path], **kwargs: Any) -> Self: + with open(path, encoding="utf-8") as fp: + json_kwargs = json.load(fp) + if "condense_ratio" in json_kwargs: # legacy name + json_kwargs["rope_condense_ratio"] = json_kwargs.pop( + "condense_ratio" + ) + if "condense_ratio" in kwargs: # legacy name + kwargs["rope_condense_ratio"] = kwargs.pop("condense_ratio") + if "org" in json_kwargs: # legacy name + json_kwargs["hf_config"] = { + "name": json_kwargs["name"], + "org": json_kwargs.pop("org"), + } + if "org" in kwargs: # legacy name + kwargs["hf_config"] = { + "name": kwargs.get("name", json_kwargs["name"]), + "org": kwargs.pop("org"), + } + json_kwargs.update(kwargs) + return cls(**json_kwargs) + + @classmethod + def from_checkpoint(cls, path: Path, **kwargs: Any) -> Self: + """Automatically load `lit_config.json` and if it doesn't exist - a matching config from `lit_gpt/config.py`.""" + if (config_path := path / "lit_config.json").is_file(): + return cls.from_json(config_path, **kwargs) + if (model_name := path.name) in name_to_config: + return cls.from_name(model_name, **kwargs) + raise FileNotFoundError( + f"For {str(path)!r} neither 'lit_config.json' nor matching config exists." + ) + + @property + def mlp_class(self) -> Type: + # `self._mlp_class` cannot be the type to keep the config json serializable + return getattr(lit_gpt.model, self._mlp_class) + + @property + def norm_class(self) -> Type: + # `self._norm_class` cannot be the type to keep the config json serializable + if self._norm_class == "RMSNorm": + from functools import partial + + from lit_gpt.rmsnorm import RMSNorm + + return partial(RMSNorm, add_unit_offset="Gemma" in self.name) + return getattr(torch.nn, self._norm_class) + + +######################## +# Stability AI StableLM +######################## +configs = [ + # https://huggingface.co/stabilityai/stablelm-base-alpha-3b/blob/main/config.json + dict( + name="stablelm-base-alpha-3b", + hf_config=dict(org="stabilityai", name="stablelm-base-alpha-3b"), + ), + # https://huggingface.co/stabilityai/stablelm-base-alpha-7b/blob/main/config.json + dict( + name="stablelm-base-alpha-7b", + hf_config=dict(org="stabilityai", name="stablelm-base-alpha-7b"), + n_head=48, + n_embd=6144, + padding_multiple=256, + ), + # https://huggingface.co/stabilityai/stablelm-tuned-alpha-3b/blob/main/config.json + dict( + name="stablelm-tuned-alpha-3b", + hf_config=dict(org="stabilityai", name="stablelm-tuned-alpha-3b"), + n_head=32, + ), + # https://huggingface.co/stabilityai/stablelm-tuned-alpha-7b/blob/main/config.json + dict( + name="stablelm-tuned-alpha-7b", + hf_config=dict(org="stabilityai", name="stablelm-tuned-alpha-7b"), + n_head=48, + n_embd=6144, + padding_multiple=256, + ), + # https://huggingface.co/stabilityai/stablelm-zephyr-3b/blob/main/config.json + dict( + name="stablelm-zephyr-3b", + hf_config=dict(org="stabilityai", name="stablelm-zephyr-3b"), + padded_vocab_size=50304, + n_layer=32, + n_head=32, + n_embd=2560, + parallel_residual=False, + bias=False, + _mlp_class="LLaMAMLP", + intermediate_size=6912, + ), +] + +#################### +# EleutherAI Pythia +#################### +pythia = [ + # https://huggingface.co/EleutherAI/pythia-14m/blob/main/config.json + dict( + name="pythia-14m", + hf_config=dict(org="EleutherAI", name="pythia-14m"), + block_size=512, + n_layer=6, + n_embd=128, + n_head=4, + padding_multiple=128, + ), + # https://huggingface.co/EleutherAI/pythia-31m/blob/main/config.json + dict( + name="pythia-31m", + hf_config=dict(org="EleutherAI", name="pythia-31m"), + block_size=1024, + n_layer=6, + n_embd=256, + n_head=8, + padding_multiple=128, + ), + # https://huggingface.co/EleutherAI/pythia-70m/blob/main/config.json + dict( + name="pythia-70m", + hf_config=dict(org="EleutherAI", name="pythia-70m"), + block_size=2048, + n_layer=6, + n_embd=512, + n_head=8, + padding_multiple=128, + ), + # https://huggingface.co/EleutherAI/pythia-160m/blob/main/config.json + dict( + name="pythia-160m", + hf_config=dict(org="EleutherAI", name="pythia-160m"), + block_size=2048, + n_layer=12, + n_embd=768, + n_head=12, + padding_multiple=128, + ), + # https://huggingface.co/EleutherAI/pythia-410m/blob/main/config.json + dict( + name="pythia-410m", + hf_config=dict(org="EleutherAI", name="pythia-410m"), + block_size=2048, + n_layer=24, + n_embd=1024, + n_head=16, + padding_multiple=128, + ), + # https://huggingface.co/EleutherAI/pythia-1b/blob/main/config.json + dict( + name="pythia-1b", + hf_config=dict(org="EleutherAI", name="pythia-1b"), + block_size=2048, + n_embd=2048, + n_head=8, + padding_multiple=128, + ), + # https://huggingface.co/EleutherAI/pythia-1.4b/blob/main/config.json + dict( + name="pythia-1.4b", + hf_config=dict(org="EleutherAI", name="pythia-1.4b"), + block_size=2048, + n_layer=24, + n_embd=2048, + n_head=16, + padding_multiple=128, + ), + # https://huggingface.co/EleutherAI/pythia-2.8b/blob/main/config.json + dict( + name="pythia-2.8b", + hf_config=dict(org="EleutherAI", name="pythia-2.8b"), + block_size=2048, + n_layer=32, + n_embd=2560, + padding_multiple=128, + ), + # https://huggingface.co/EleutherAI/pythia-6.9b/blob/main/config.json + dict( + name="pythia-6.9b", + hf_config=dict(org="EleutherAI", name="pythia-6.9b"), + block_size=2048, + n_layer=32, + padding_multiple=256, + ), + # https://huggingface.co/EleutherAI/pythia-12b/blob/main/config.json + dict( + name="pythia-12b", + hf_config=dict(org="EleutherAI", name="pythia-12b"), + block_size=2048, + n_layer=36, + n_embd=5120, + n_head=40, + ), +] +configs.extend(pythia) +for c in pythia: + # "pythia-14m" and "pythia-31m" don't have deduped version + if c["name"] in ("pythia-14m", "pythia-31m"): + continue + copy = deepcopy(c) + copy["name"] = f"{c['name']}-deduped" + copy["hf_config"]["name"] = f"{c['hf_config']['name']}-deduped" + configs.append(copy) + + +################### +# databricks Dolly +################### +dolly = [ + # https://huggingface.co/databricks/dolly-v2-3b/blob/main/config.json + dict( + name="dolly-v2-3b", + hf_config=dict(org="databricks", name="dolly-v2-3b"), + block_size=2048, + n_layer=32, + n_embd=2560, + padded_vocab_size=50280, + ), + # https://huggingface.co/databricks/dolly-v2-7b/blob/main/config.json + dict( + name="dolly-v2-7b", + hf_config=dict(org="databricks", name="dolly-v2-7b"), + block_size=2048, + n_layer=32, + padded_vocab_size=50280, + ), + # https://huggingface.co/databricks/dolly-v2-12b/blob/main/config.json + dict( + name="dolly-v2-12b", + hf_config=dict(org="databricks", name="dolly-v2-12b"), + block_size=2048, + n_layer=36, + n_embd=5120, + n_head=40, + padded_vocab_size=50280, + ), +] +configs.extend(dolly) + + +#################################### +# togethercomputer RedPajama INCITE +#################################### +redpajama_incite = [ + # https://huggingface.co/togethercomputer/RedPajama-INCITE-Base-3B-v1/blob/main/config.json + dict( + name="RedPajama-INCITE-{}-3B-v1", + hf_config=dict( + org="togethercomputer", name="RedPajama-INCITE-{}-3B-v1" + ), + block_size=2048, + n_layer=32, + n_embd=2560, + padding_multiple=256, + rotary_percentage=1.0, + parallel_residual=False, + ), + # https://huggingface.co/togethercomputer/RedPajama-INCITE-7B-Base/blob/main/config.json + dict( + name="RedPajama-INCITE-7B-{}", + hf_config=dict(org="togethercomputer", name="RedPajama-INCITE-7B-{}"), + block_size=2048, + n_layer=32, + padding_multiple=256, + rotary_percentage=1.0, + parallel_residual=False, + ), + # this redirects to the checkpoint above. kept for those who had the old weights already downloaded + dict( + name="RedPajama-INCITE-{}-7B-v0.1", + hf_config=dict( + org="togethercomputer", name="RedPajama-INCITE-{}-7B-v0.1" + ), + block_size=2048, + n_layer=32, + padding_multiple=256, + rotary_percentage=1.0, + parallel_residual=False, + ), +] +for c in redpajama_incite: + for kind in ("Base", "Chat", "Instruct"): + copy = deepcopy(c) + copy["name"] = c["name"].format(kind) + copy["hf_config"]["name"] = c["hf_config"]["name"].format(kind) + configs.append(copy) + + +################# +# TII UAE Falcon +################# +falcon = [ + # https://huggingface.co/tiiuae/falcon-7b/blob/main/config.json + dict( + name="falcon-7b{}", + hf_config=dict(org="tiiuae", name="falcon-7b{}"), + block_size=2048, + vocab_size=65024, + padded_vocab_size=65024, + n_layer=32, + n_head=71, + n_embd=4544, + rotary_percentage=1.0, + n_query_groups=1, + bias=False, + # this is not in the config, but in the original model implementation, only for this config + shared_attention_norm=True, + ), + # https://huggingface.co/tiiuae/falcon-40b/blob/main/config.json + dict( + name="falcon-40b{}", + hf_config=dict(org="tiiuae", name="falcon-40b{}"), + block_size=2048, + vocab_size=65024, + padded_vocab_size=65024, + n_layer=60, + n_head=128, + n_embd=8192, + rotary_percentage=1.0, + n_query_groups=8, + bias=False, + ), +] +for c in falcon: + for kind in ("", "-instruct"): + copy = deepcopy(c) + copy["name"] = c["name"].format(kind) + copy["hf_config"]["name"] = c["hf_config"]["name"].format(kind) + configs.append(copy) + +# https://huggingface.co/tiiuae/falcon-180b/blob/main/config.json +falcon180b = dict( + name="falcon-180B{}", + hf_config=dict(org="tiiuae", name="falcon-180B{}"), + block_size=2048, + vocab_size=65024, + padded_vocab_size=65024, + n_layer=80, + n_head=232, + n_embd=14848, + rotary_percentage=1.0, + n_query_groups=8, + bias=False, +) + +for kind in ("", "-chat"): + copy = deepcopy(falcon180b) + copy["name"] = falcon180b["name"].format(kind) + copy["hf_config"]["name"] = falcon180b["hf_config"]["name"].format(kind) + configs.append(copy) + + +############################# +# OpenLM Research Open LLaMA +############################# +open_LLaMA = [ + # https://huggingface.co/openlm-research/open_llama_3b/blob/main/config.json + dict( + name="open_llama_3b", + hf_config=dict(org="openlm-research", name="open_llama_3b"), + block_size=2048, + vocab_size=32000, + padding_multiple=64, + n_layer=26, + n_embd=3200, + rotary_percentage=1.0, + parallel_residual=False, + bias=False, + _norm_class="RMSNorm", + norm_eps=1e-6, + _mlp_class="LLaMAMLP", + intermediate_size=8640, + ), + # https://huggingface.co/openlm-research/open_llama_7b/blob/main/config.json + dict( + name="open_llama_7b", + hf_config=dict(org="openlm-research", name="open_llama_7b"), + block_size=2048, + vocab_size=32000, + padding_multiple=64, + n_layer=32, + rotary_percentage=1.0, + parallel_residual=False, + bias=False, + _norm_class="RMSNorm", + norm_eps=1e-6, + _mlp_class="LLaMAMLP", + intermediate_size=11008, + ), + # https://huggingface.co/openlm-research/open_llama_13b/blob/main/config.json + dict( + name="open_llama_13b", + hf_config=dict(org="openlm-research", name="open_llama_13b"), + block_size=2048, + vocab_size=32000, + padding_multiple=64, + n_layer=40, + n_head=40, + n_embd=5120, + rotary_percentage=1.0, + parallel_residual=False, + bias=False, + _norm_class="RMSNorm", + norm_eps=1e-6, + _mlp_class="LLaMAMLP", + intermediate_size=13824, + ), +] +configs.extend(open_LLaMA) + + +############### +# LMSYS Vicuna +############### +vicuna = [ + # https://huggingface.co/lmsys/vicuna-7b-v1.3/blob/main/config.json + dict( + name="vicuna-7b-v1.3", + hf_config=dict(org="lmsys", name="vicuna-7b-v1.3"), + block_size=2048, + vocab_size=32000, + padding_multiple=64, + n_layer=32, + rotary_percentage=1.0, + parallel_residual=False, + bias=False, + _norm_class="RMSNorm", + norm_eps=1e-6, + _mlp_class="LLaMAMLP", + intermediate_size=11008, + ), + # https://huggingface.co/lmsys/vicuna-13b-v1.3/blob/main/config.json + dict( + name="vicuna-13b-v1.3", + hf_config=dict(org="lmsys", name="vicuna-13b-v1.3"), + block_size=2048, + vocab_size=32000, + padding_multiple=64, + n_layer=40, + n_head=40, + n_embd=5120, + rotary_percentage=1.0, + parallel_residual=False, + bias=False, + _norm_class="RMSNorm", + norm_eps=1e-6, + _mlp_class="LLaMAMLP", + intermediate_size=13824, + ), + # https://huggingface.co/lmsys/vicuna-33b-v1.3/blob/main/config.json + dict( + name="vicuna-33b-v1.3", + hf_config=dict(org="lmsys", name="vicuna-33b-v1.3"), + block_size=2048, + vocab_size=32000, + padding_multiple=64, + n_layer=60, + n_head=52, + n_embd=6656, + rotary_percentage=1.0, + parallel_residual=False, + bias=False, + _norm_class="RMSNorm", + norm_eps=1e-6, + _mlp_class="LLaMAMLP", + intermediate_size=17920, + ), + # https://huggingface.co/lmsys/vicuna-7b-v1.5/blob/main/config.json + dict( + name="vicuna-7b-v1.5", + hf_config=dict(org="lmsys", name="vicuna-7b-v1.5"), + vocab_size=32000, + padding_multiple=64, + n_layer=32, + rotary_percentage=1.0, + parallel_residual=False, + bias=False, + _norm_class="RMSNorm", + _mlp_class="LLaMAMLP", + intermediate_size=11008, + ), + # https://huggingface.co/lmsys/vicuna-7b-v1.5-16k/blob/main/config.json + dict( + name="vicuna-7b-v1.5-16k", + hf_config=dict(org="lmsys", name="vicuna-7b-v1.5-16k"), + block_size=16384, + vocab_size=32000, + padding_multiple=64, + n_layer=32, + rotary_percentage=1.0, + parallel_residual=False, + bias=False, + _norm_class="RMSNorm", + _mlp_class="LLaMAMLP", + intermediate_size=11008, + rope_condense_ratio=4, + ), + # https://huggingface.co/lmsys/vicuna-13b-v1.5/blob/main/config.json + dict( + name="vicuna-13b-v1.5", + hf_config=dict(org="lmsys", name="vicuna-13b-v1.5"), + vocab_size=32000, + padding_multiple=64, + n_layer=40, + n_head=40, + n_embd=5120, + rotary_percentage=1.0, + parallel_residual=False, + bias=False, + _norm_class="RMSNorm", + _mlp_class="LLaMAMLP", + intermediate_size=13824, + ), + # https://huggingface.co/lmsys/vicuna-13b-v1.5-16k/blob/main/config.json + dict( + name="vicuna-13b-v1.5-16k", + hf_config=dict(org="lmsys", name="vicuna-13b-v1.5-16k"), + block_size=16384, + vocab_size=32000, + padding_multiple=64, + n_layer=40, + n_head=40, + n_embd=5120, + rotary_percentage=1.0, + parallel_residual=False, + bias=False, + _norm_class="RMSNorm", + _mlp_class="LLaMAMLP", + intermediate_size=13824, + rope_condense_ratio=4, + ), +] +configs.extend(vicuna) + + +################# +# LMSYS LongChat +################# +long_chat = [ + # https://huggingface.co/lmsys/longchat-7b-16k/blob/main/config.json + dict( + name="longchat-7b-16k", + hf_config=dict(org="lmsys", name="longchat-7b-16k"), + block_size=16384, + vocab_size=32000, + padding_multiple=64, + n_layer=32, + rotary_percentage=1.0, + parallel_residual=False, + bias=False, + _norm_class="RMSNorm", + norm_eps=1e-6, + _mlp_class="LLaMAMLP", + intermediate_size=11008, + rope_condense_ratio=8, + ), + # https://huggingface.co/lmsys/longchat-13b-16k/blob/main/config.json + dict( + name="longchat-13b-16k", + hf_config=dict(org="lmsys", name="longchat-13b-16k"), + block_size=16384, + vocab_size=32000, + padding_multiple=64, + n_layer=40, + n_head=40, + n_embd=5120, + rotary_percentage=1.0, + parallel_residual=False, + bias=False, + _norm_class="RMSNorm", + norm_eps=1e-6, + _mlp_class="LLaMAMLP", + intermediate_size=13824, + rope_condense_ratio=8, + ), +] +configs.extend(long_chat) + + +###################### +# NousResearch Hermes +###################### +nous_research = [ + # https://huggingface.co/NousResearch/Nous-Hermes-llama-2-7b/blob/main/config.json + dict( + name="Nous-Hermes-llama-2-7b", + hf_config=dict(org="NousResearch", name="Nous-Hermes-llama-2-7b"), + padded_vocab_size=32000, + n_layer=32, + rotary_percentage=1.0, + parallel_residual=False, + bias=False, + _norm_class="RMSNorm", + norm_eps=1e-05, + _mlp_class="LLaMAMLP", + intermediate_size=11008, + ), + # https://huggingface.co/NousResearch/Nous-Hermes-13B/blob/main/config.json + dict( + name="Nous-Hermes-13b", + hf_config=dict(org="NousResearch", name="Nous-Hermes-13b"), + block_size=2048, + vocab_size=32000, + padded_vocab_size=32001, + n_layer=40, + n_head=40, + n_embd=5120, + rotary_percentage=1.0, + parallel_residual=False, + bias=False, + _norm_class="RMSNorm", + norm_eps=1e-6, + _mlp_class="LLaMAMLP", + intermediate_size=13824, + ), + # https://huggingface.co/NousResearch/Nous-Hermes-Llama2-13b + dict( + name="Nous-Hermes-Llama2-13b", + hf_config=dict(org="NousResearch", name="Nous-Hermes-Llama2-13b"), + vocab_size=32000, + padded_vocab_size=32032, + n_layer=40, + n_head=40, + n_embd=5120, + rotary_percentage=1.0, + parallel_residual=False, + bias=False, + _norm_class="RMSNorm", + norm_eps=1e-05, + _mlp_class="LLaMAMLP", + intermediate_size=13824, + ), +] +configs.extend(nous_research) + + +############### +# Meta LLaMA 2 +############### +llama_2 = [ + # https://huggingface.co/meta-llama/Llama-2-7b-hf/blob/main/config.json + dict( + name="Llama-2-7b{}-hf", + hf_config=dict(org="meta-llama", name="Llama-2-7b{}-hf"), + vocab_size=32000, + padding_multiple=64, + n_layer=32, + rotary_percentage=1.0, + parallel_residual=False, + bias=False, + _norm_class="RMSNorm", + _mlp_class="LLaMAMLP", + intermediate_size=11008, + ), + # https://huggingface.co/meta-llama/Llama-2-13b-hf/blob/main/config.json + dict( + name="Llama-2-13b{}-hf", + hf_config=dict(org="meta-llama", name="Llama-2-13b{}-hf"), + vocab_size=32000, + padding_multiple=64, + n_layer=40, + n_head=40, + n_embd=5120, + rotary_percentage=1.0, + parallel_residual=False, + bias=False, + _norm_class="RMSNorm", + _mlp_class="LLaMAMLP", + intermediate_size=13824, + ), + # https://huggingface.co/meta-llama/Llama-2-70b-hf/blob/main/config.json + dict( + name="Llama-2-70b{}-hf", + hf_config=dict(org="meta-llama", name="Llama-2-70b{}-hf"), + vocab_size=32000, + padding_multiple=64, + n_layer=80, + n_head=64, + n_embd=8192, + n_query_groups=8, + rotary_percentage=1.0, + parallel_residual=False, + bias=False, + _norm_class="RMSNorm", + _mlp_class="LLaMAMLP", + intermediate_size=28672, + ), +] +for c in llama_2: + for kind in ("", "-chat"): + copy = deepcopy(c) + copy["name"] = c["name"].format(kind) + copy["hf_config"]["name"] = c["hf_config"]["name"].format(kind) + configs.append(copy) + + +############### +# Google Gemma +############### +gemma = [ + # https://huggingface.co/google/gemma-2b/blob/main/config.json + dict( + name="Gemma-2b", + hf_config=dict(org="google", name="gemma-2b"), + scale_embeddings=True, + vocab_size=256000, + padding_multiple=64, + n_embd=2048, + n_layer=18, + n_head=8, + n_query_groups=1, + rotary_percentage=1.0, + parallel_residual=False, + bias=False, + _norm_class="RMSNorm", + _mlp_class="GemmaMLP", + intermediate_size=16384, + ), + # https://huggingface.co/google/gemma-7b/blob/main/config.json + dict( + name="Gemma-7b", + hf_config=dict(org="google", name="gemma-7b"), + scale_embeddings=True, + vocab_size=256000, + padding_multiple=64, + n_embd=3072, + n_layer=28, + n_head=16, + head_size=256, + rotary_percentage=1.0, + parallel_residual=False, + bias=False, + _norm_class="RMSNorm", + _mlp_class="GemmaMLP", + intermediate_size=24576, + ), +] +configs.extend(gemma) +for c in gemma: + copy = deepcopy(c) + copy["name"] = f"{c['name']}-it" + copy["hf_config"]["name"] = f"{c['hf_config']['name']}-it" + configs.append(copy) + + +########################## +# Stability AI FreeWilly2 +########################## +freewilly_2 = [ + # https://huggingface.co/stabilityai/FreeWilly2/blob/main/config.json + dict( + name="FreeWilly2", + hf_config=dict(org="stabilityai", name="FreeWilly2"), + vocab_size=32000, + padding_multiple=64, + n_layer=80, + n_head=64, + n_embd=8192, + n_query_groups=8, + rotary_percentage=1.0, + parallel_residual=False, + bias=False, + _norm_class="RMSNorm", + _mlp_class="LLaMAMLP", + intermediate_size=28672, + ) +] +configs.extend(freewilly_2) + + +################## +# Meta Code Llama +################## +code_llama = [ + # https://huggingface.co/codellama/CodeLlama-7b-hf/blob/main/config.json + dict( + name="CodeLlama-7b-hf", + hf_config=dict(org="codellama", name="CodeLlama-7b-hf"), + block_size=16384, + vocab_size=32016, + padding_multiple=16, + n_layer=32, + rotary_percentage=1.0, + parallel_residual=False, + bias=False, + _norm_class="RMSNorm", + norm_eps=1e-05, + _mlp_class="LLaMAMLP", + intermediate_size=11008, + rope_base=1000000, + ), + # https://huggingface.co/codellama/CodeLlama-13b-hf/blob/main/config.json + dict( + name="CodeLlama-13b-hf", + hf_config=dict(org="codellama", name="CodeLlama-13b-hf"), + block_size=16384, + vocab_size=32016, + padding_multiple=16, + n_layer=40, + n_head=40, + n_embd=5120, + rotary_percentage=1.0, + parallel_residual=False, + bias=False, + _norm_class="RMSNorm", + norm_eps=1e-05, + _mlp_class="LLaMAMLP", + intermediate_size=13824, + rope_base=1000000, + ), + # https://huggingface.co/codellama/CodeLlama-34b-hf/blob/main/config.json + dict( + name="CodeLlama-34b-hf", + hf_config=dict(org="codellama", name="CodeLlama-34b-hf"), + block_size=16384, + vocab_size=32000, + padded_vocab_size=32000, + n_layer=48, + n_head=64, + n_embd=8192, + n_query_groups=8, + rotary_percentage=1.0, + parallel_residual=False, + bias=False, + _norm_class="RMSNorm", + norm_eps=1e-05, + _mlp_class="LLaMAMLP", + intermediate_size=22016, + rope_base=1000000, + ), + # https://huggingface.co/codellama/CodeLlama-70b-hf/blob/main/config.json + dict( + name="CodeLlama-70b-hf", + hf_config=dict(org="codellama", name="CodeLlama-70b-hf"), + block_size=16384, + vocab_size=32016, + padding_multiple=16, + n_layer=80, + n_head=64, + n_embd=8192, + n_query_groups=8, + rotary_percentage=1.0, + parallel_residual=False, + bias=False, + _norm_class="RMSNorm", + norm_eps=1e-05, + _mlp_class="LLaMAMLP", + intermediate_size=28672, + rope_base=1000000, + ), + # https://huggingface.co/codellama/CodeLlama-7b-Python-hf/blob/main/config.json + dict( + name="CodeLlama-7b-Python-hf", + hf_config=dict(org="codellama", name="CodeLlama-7b-Python-hf"), + block_size=16384, + vocab_size=32000, + padded_vocab_size=32000, + n_layer=32, + rotary_percentage=1.0, + parallel_residual=False, + bias=False, + _norm_class="RMSNorm", + norm_eps=1e-05, + _mlp_class="LLaMAMLP", + intermediate_size=11008, + rope_base=1000000, + ), + # https://huggingface.co/codellama/CodeLlama-13b-Python-hf/blob/main/config.json + dict( + name="CodeLlama-13b-Python-hf", + hf_config=dict(org="codellama", name="CodeLlama-13b-Python-hf"), + block_size=16384, + vocab_size=32000, + padded_vocab_size=32000, + n_layer=40, + n_head=40, + n_embd=5120, + rotary_percentage=1.0, + parallel_residual=False, + bias=False, + _norm_class="RMSNorm", + norm_eps=1e-05, + _mlp_class="LLaMAMLP", + intermediate_size=13824, + rope_base=1000000, + ), + # https://huggingface.co/codellama/CodeLlama-34b-Python-hf/blob/main/config.json + dict( + name="CodeLlama-34b-Python-hf", + hf_config=dict(org="codellama", name="CodeLlama-34b-Python-hf"), + block_size=16384, + vocab_size=32000, + padded_vocab_size=32000, + n_layer=48, + n_head=64, + n_embd=8192, + n_query_groups=8, + rotary_percentage=1.0, + parallel_residual=False, + bias=False, + _norm_class="RMSNorm", + norm_eps=1e-05, + _mlp_class="LLaMAMLP", + intermediate_size=22016, + rope_base=1000000, + ), + # https://huggingface.co/codellama/CodeLlama-70b-Python-hf/blob/main/config.json + dict( + name="CodeLlama-70b-Python-hf", + hf_config=dict(org="codellama", name="CodeLlama-70b-Python-hf"), + block_size=16384, + vocab_size=32016, + padding_multiple=16, + n_layer=80, + n_head=64, + n_embd=8192, + n_query_groups=8, + rotary_percentage=1.0, + parallel_residual=False, + bias=False, + _norm_class="RMSNorm", + norm_eps=1e-05, + _mlp_class="LLaMAMLP", + intermediate_size=28672, + rope_base=1000000, + ), + # https://huggingface.co/codellama/CodeLlama-7b-Instruct-hf/blob/main/config.json + dict( + name="CodeLlama-7b-Instruct-hf", + hf_config=dict(org="codellama", name="CodeLlama-7b-Instruct-hf"), + block_size=16384, + vocab_size=32016, + padding_multiple=16, + n_layer=32, + rotary_percentage=1.0, + parallel_residual=False, + bias=False, + _norm_class="RMSNorm", + norm_eps=1e-05, + _mlp_class="LLaMAMLP", + intermediate_size=11008, + rope_base=1000000, + ), + # https://huggingface.co/codellama/CodeLlama-13b-Instruct-hf/blob/main/config.json + dict( + name="CodeLlama-13b-Instruct-hf", + hf_config=dict(org="codellama", name="CodeLlama-13b-Instruct-hf"), + block_size=2048, + vocab_size=32016, + padding_multiple=16, + n_layer=40, + n_head=40, + n_embd=5120, + rotary_percentage=1.0, + parallel_residual=False, + bias=False, + _norm_class="RMSNorm", + norm_eps=1e-05, + _mlp_class="LLaMAMLP", + intermediate_size=13824, + rope_base=1000000, + ), + # https://huggingface.co/codellama/CodeLlama-34b-Instruct-hf/blob/main/config.json + dict( + name="CodeLlama-34b-Instruct-hf", + hf_config=dict(org="codellama", name="CodeLlama-34b-Instruct-hf"), + block_size=16384, + vocab_size=32000, + padded_vocab_size=32000, + n_layer=48, + n_head=64, + n_embd=8192, + n_query_groups=8, + rotary_percentage=1.0, + parallel_residual=False, + bias=False, + _norm_class="RMSNorm", + norm_eps=1e-05, + _mlp_class="LLaMAMLP", + intermediate_size=22016, + rope_base=1000000, + ), + # https://huggingface.co/codellama/CodeLlama-70b-Instruct-hf/blob/main/config.json + dict( + name="CodeLlama-70b-Instruct-hf", + hf_config=dict(org="codellama", name="CodeLlama-70b-Instruct-hf"), + block_size=16384, + vocab_size=32016, + padding_multiple=16, + n_layer=80, + n_head=64, + n_embd=8192, + n_query_groups=8, + rotary_percentage=1.0, + parallel_residual=False, + bias=False, + _norm_class="RMSNorm", + norm_eps=1e-05, + _mlp_class="LLaMAMLP", + intermediate_size=28672, + rope_base=1000000, + ), +] +configs.extend(code_llama) + + +######################## +# garage-bAInd Platypus +######################## +platypus = [ + # https://huggingface.co/garage-bAInd/Platypus-30B/blob/main/config.json + dict( + name="Platypus-30B", + hf_config=dict(org="garage-bAInd", name="Platypus-30B"), + block_size=2048, + padded_vocab_size=32000, + n_layer=60, + n_head=52, + n_embd=6656, + rotary_percentage=1.0, + parallel_residual=False, + bias=False, + _norm_class="RMSNorm", + norm_eps=1e-06, + _mlp_class="LLaMAMLP", + intermediate_size=17920, + ), + # https://huggingface.co/garage-bAInd/Platypus2-7B/blob/main/config.json + dict( + name="Platypus2-7B", + hf_config=dict(org="garage-bAInd", name="Platypus2-7B"), + padded_vocab_size=32000, + n_layer=32, + rotary_percentage=1.0, + parallel_residual=False, + bias=False, + _norm_class="RMSNorm", + norm_eps=1e-05, + _mlp_class="LLaMAMLP", + intermediate_size=11008, + ), + # https://huggingface.co/garage-bAInd/Platypus2-13B/blob/main/config.json + dict( + name="Platypus2-13B", + hf_config=dict(org="garage-bAInd", name="Platypus2-13B"), + padded_vocab_size=32000, + n_layer=40, + n_head=40, + n_embd=5120, + rotary_percentage=1.0, + parallel_residual=False, + bias=False, + _norm_class="RMSNorm", + norm_eps=1e-05, + _mlp_class="LLaMAMLP", + intermediate_size=13824, + ), + # https://huggingface.co/garage-bAInd/Platypus2-70B/blob/main/config.json + dict( + name="Platypus2-70B", + hf_config=dict(org="garage-bAInd", name="Platypus2-70B"), + padded_vocab_size=32000, + n_layer=80, + n_head=64, + n_embd=8192, + rotary_percentage=1.0, + parallel_residual=False, + bias=False, + _norm_class="RMSNorm", + _mlp_class="LLaMAMLP", + intermediate_size=28672, + ), + # https://huggingface.co/garage-bAInd/Camel-Platypus2-13B/blob/main/config.json + dict( + name="Camel-Platypus2-13B", + hf_config=dict(org="garage-bAInd", name="Camel-Platypus2-13B"), + padded_vocab_size=32000, + n_layer=40, + n_head=40, + n_embd=5120, + rotary_percentage=1.0, + parallel_residual=False, + bias=False, + _norm_class="RMSNorm", + _mlp_class="LLaMAMLP", + intermediate_size=13824, + ), + # https://huggingface.co/garage-bAInd/Camel-Platypus2-70B/blob/main/config.json + dict( + name="Camel-Platypus2-70B", + hf_config=dict(org="garage-bAInd", name="Camel-Platypus2-70B"), + padded_vocab_size=32000, + n_layer=80, + n_head=64, + n_embd=8192, + n_query_groups=8, + rotary_percentage=1.0, + parallel_residual=False, + bias=False, + _norm_class="RMSNorm", + _mlp_class="LLaMAMLP", + intermediate_size=28672, + ), + # https://huggingface.co/garage-bAInd/Stable-Platypus2-13B/blob/main/config.json + dict( + name="Stable-Platypus2-13B", + hf_config=dict(org="garage-bAInd", name="Stable-Platypus2-13B"), + padded_vocab_size=32000, + n_layer=40, + n_head=40, + n_embd=5120, + rotary_percentage=1.0, + parallel_residual=False, + bias=False, + _norm_class="RMSNorm", + _mlp_class="LLaMAMLP", + intermediate_size=13824, + ), + # https://huggingface.co/garage-bAInd/Platypus2-70B-instruct/blob/main/config.json + dict( + name="Platypus2-70B-instruct", + hf_config=dict(org="garage-bAInd", name="Platypus2-70B-instruct"), + padded_vocab_size=32000, + n_layer=80, + n_head=64, + n_embd=8192, + n_query_groups=8, + rotary_percentage=1.0, + parallel_residual=False, + bias=False, + _norm_class="RMSNorm", + _mlp_class="LLaMAMLP", + intermediate_size=28672, + ), +] +configs.extend(platypus) + + +########################## +# Stability AI StableCode +########################## +stablecode = [ + # https://huggingface.co/stabilityai/stablecode-completion-alpha-3b/blob/main/config.json + dict( + name="stablecode-completion-alpha-3b", + hf_config=dict( + org="stabilityai", name="stablecode-completion-alpha-3b" + ), + block_size=16384, + vocab_size=49152, + n_layer=32, + n_embd=2560, + ), + # https://huggingface.co/stabilityai/stablecode-completion-alpha-3b-4k/blob/main/config.json + dict( + name="stablecode-completion-alpha-3b-4k", + hf_config=dict( + org="stabilityai", name="stablecode-completion-alpha-3b-4k" + ), + vocab_size=49152, + n_layer=32, + n_embd=2560, + ), + # https://huggingface.co/stabilityai/stablecode-instruct-alpha-3b/blob/main/config.json + dict( + name="stablecode-instruct-alpha-3b", + hf_config=dict(org="stabilityai", name="stablecode-instruct-alpha-3b"), + vocab_size=49152, + n_layer=32, + n_embd=2560, + ), +] +configs.extend(stablecode) + + +################################## +# togethercomputer LLaMA-2-7B-32K +################################## +together_llama2_32k = [ + # https://huggingface.co/togethercomputer/LLaMA-2-7B-32K/blob/main/config.json + dict( + name="LLaMA-2-7B-32K", + hf_config=dict(org="togethercomputer", name="LLaMA-2-7B-32K"), + vocab_size=32000, + padding_multiple=64, + n_layer=32, + rotary_percentage=1.0, + parallel_residual=False, + bias=False, + _norm_class="RMSNorm", + _mlp_class="LLaMAMLP", + intermediate_size=11008, + rope_condense_ratio=8, + ) +] +configs.extend(together_llama2_32k) + + +################ +# Microsoft Phi +################ +phi = [ + # https://huggingface.co/microsoft/phi-1_5/blob/main/config.json + dict( + name="phi-1_5", + hf_config=dict(org="microsoft", name="phi-1_5"), + vocab_size=50257, + padded_vocab_size=51200, + block_size=2048, + n_embd=2048, + n_layer=24, + rotary_percentage=0.5, # 32 / (n_embd / n_head) = 32 / 64 + shared_attention_norm=True, + lm_head_bias=True, + gelu_approximate="tanh", + ), + # https://huggingface.co/microsoft/phi-2/blob/main/config.json + dict( + name="phi-2", + hf_config=dict(org="microsoft", name="phi-2"), + vocab_size=50257, + padded_vocab_size=51200, + block_size=2048, + n_embd=2560, + n_layer=32, + rotary_percentage=0.4, # 32 / (n_embd / n_head) = 32 / 80 + shared_attention_norm=True, + lm_head_bias=True, + gelu_approximate="tanh", + ), +] +configs.extend(phi) + + +############# +# Mistral AI +############# +mistral = [ + # https://huggingface.co/mistralai/Mistral-7B-v0.1/blob/main/config.json + dict( + name="Mistral-7B-{}v0.1", + hf_config=dict(org="mistralai", name="Mistral-7B-{}v0.1"), + padded_vocab_size=32000, + block_size=4096, # should be 32768 but sliding window attention is not implemented + n_layer=32, + n_query_groups=8, + rotary_percentage=1.0, + parallel_residual=False, + bias=False, + _norm_class="RMSNorm", + norm_eps=1e-05, + _mlp_class="LLaMAMLP", + intermediate_size=14336, + ), + # https://huggingface.co/mistralai/Mixtral-8x7B-v0.1/blob/main/config.json + dict( + name="Mixtral-8x7B-{}v0.1", + hf_config=dict(org="mistralai", name="Mixtral-8x7B-{}v0.1"), + padded_vocab_size=32000, + block_size=32768, + n_layer=32, + n_query_groups=8, + rotary_percentage=1.0, + parallel_residual=False, + bias=False, + _norm_class="RMSNorm", + norm_eps=1e-05, + _mlp_class="LLaMAMoE", + intermediate_size=14336, + rope_base=1000000, + n_expert=8, + n_expert_per_token=2, + ), +] +for c in mistral: + for kind in ("", "Instruct-"): + copy = deepcopy(c) + copy["name"] = c["name"].format(kind) + copy["hf_config"]["name"] = c["hf_config"]["name"].format(kind) + configs.append(copy) +configs.append( + # https://huggingface.co/mistralai/Mistral-7B-Instruct-v0.2/blob/main/config.json + dict( + name="Mistral-7B-Instruct-v0.2", + hf_config=dict(org="mistralai", name="Mistral-7B-Instruct-v0.2"), + padded_vocab_size=32000, + block_size=32768, + n_layer=32, + n_query_groups=8, + rotary_percentage=1.0, + parallel_residual=False, + bias=False, + _norm_class="RMSNorm", + norm_eps=1e-05, + _mlp_class="LLaMAMLP", + intermediate_size=14336, + ) +) + + +############ +# TinyLlama +############ +tiny_llama = [ + dict( + name="tiny-llama-1.1b{}", + hf_config=dict(org="TinyLlama", name="TinyLlama-1.1B{}"), + block_size=2048, + vocab_size=32000, + padding_multiple=64, + n_layer=22, + n_head=32, + n_embd=2048, + rotary_percentage=1.0, + parallel_residual=False, + bias=False, + _norm_class="RMSNorm", # original TinyLlama uses FusedRMSNorm + norm_eps=1e-5, + _mlp_class="LLaMAMLP", + intermediate_size=5632, + n_query_groups=4, + ) +] +for c in tiny_llama: + for kind, hf_postfix in ( + ("", "-intermediate-step-1431k-3T"), + ("-chat", "-Chat-v1.0"), + ): + copy = deepcopy(c) + copy["name"] = c["name"].format(kind) + copy["hf_config"]["name"] = c["hf_config"]["name"].format(hf_postfix) + configs.append(copy) + + +########################## +# Trelis Function Calling +########################## +llama_2_function_calling = [ + # https://huggingface.co/Trelis/Llama-2-7b-chat-hf-function-calling-v2/blob/main/config.json + dict( + name="Llama-2-7b-chat-hf-function-calling-v2", + hf_config=dict( + org="Trelis", name="Llama-2-7b-chat-hf-function-calling-v2" + ), + padding_multiple=64, + n_layer=32, + rotary_percentage=1.0, + parallel_residual=False, + bias=False, + _norm_class="RMSNorm", + _mlp_class="LLaMAMLP", + intermediate_size=11008, + norm_eps=1e-6, + block_size=4096, + vocab_size=32000, + n_head=32, + n_embd=4096, + rope_base=10000, + ) +] + +configs.extend(llama_2_function_calling) + +name_to_config = {config["name"]: config for config in configs} diff --git a/examples/llm_finetuning/lit_gpt/lora.py b/examples/llm_finetuning/lit_gpt/lora.py new file mode 100644 index 00000000000..84d42543e73 --- /dev/null +++ b/examples/llm_finetuning/lit_gpt/lora.py @@ -0,0 +1,816 @@ +# Copyright Lightning AI. Licensed under the Apache License 2.0, see LICENSE file. + +# Derived from https://github.com/microsoft/LoRA +# ------------------------------------------------------------------------------------------ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +# ------------------------------------------------------------------------------------------ + +r""" + Low Ranking Adaptation for LLMs scheme. + + ┌───────────────────┐ + ┆ h ┆ + └───────────────────┘ + ▲ + | + + + / \ + ┌─────────────────┐ ╭───────────────╮ Matrix initialization: + ┆ ┆ \ B / B = 0 + ┆ pretrained ┆ \ r*d / A = N(0, sigma^2) + ┆ weights ┆ ╰─────────╯ + ┆ ┆ | r | r - rank + ┆ W e R^(d*d) ┆ | ◀─────▶ | + ┆ ┆ ╭─────────╮ + └─────────────────┘ / A \ + ▲ / d*r \ + \ ╰───────────────╯ + \ ▲ + \ / + \ / + ┌───────────────────┐ + ┆ x ┆ + └───────────────────┘ + +With LoRA (Low Ranking Adaptation: https://arxiv.org/abs/2106.09685) instead of learning weights of size d*d, +we can freeze the pretrained weights and instead learn two matrices of size d*r and r*d (they will store weight updates +for the pretrained weights): the number of parameters in this case will be reduced drastically (depending on the rank of +course) yet after multiplication of matrices d*r and r*d we will get a matrix d*d which we can sum with frozen +pretrained weights and thus fine-tune the model. + +The goal of this approach is to move weight updates into a separate matrix which is decomposed with +two matrices of a lower rank. +""" + +import math +from dataclasses import dataclass +from typing import Any, Dict, List, Optional, Tuple, Type, Union + +import torch +import torch.nn as nn +from torch.nn import functional as F +from typing_extensions import Self + +import lit_gpt +from lit_gpt.config import Config as BaseConfig +from lit_gpt.model import GPT as BaseModel +from lit_gpt.model import Block as BaseBlock +from lit_gpt.model import CausalSelfAttention as BaseCausalSelfAttention +from lit_gpt.model import KVCache +from lit_gpt.utils import map_old_state_dict_weights + + +class LoRALayer(nn.Module): + def __init__(self, r: int, lora_alpha: int, lora_dropout: float): + """Store LoRA specific attributes in a class. + + Args: + r: rank of the weight update matrices. To make sense of using LoRA the rank should be smaller than the rank of + the weights of the model. The rank can be as low as 1: https://arxiv.org/pdf/2106.09685.pdf (section 7.2) + lora_alpha: alpha is needed for scaling updates as alpha/r + "This scaling helps to reduce the need to retune hyperparameters when we vary r" + https://arxiv.org/pdf/2106.09685.pdf (section 4.1) + lora_dropout: dropout that is applied on the input in the LoRA branch (before multiplying by matrix A) + """ + super().__init__() + assert r >= 0 + self.r = r + self.lora_alpha = lora_alpha + # Optional dropout + if lora_dropout > 0.0: + self.lora_dropout = nn.Dropout(p=lora_dropout) + else: + self.lora_dropout = lambda x: x + # Mark the weight as unmerged + self.merged = False + + +class LoRALinear(LoRALayer): + # LoRA implemented in a dense layer + def __init__( + self, + # ↓ this part is for pretrained weights + in_features: int, + out_features: int, + # ↓ the remaining part is for LoRA + r: int = 0, + lora_alpha: int = 1, + lora_dropout: float = 0.0, + **kwargs: Any, + ): + """LoRA wrapper around linear class. + + This class has three weight matrices: + 1. Pretrained weights are stored as `self.linear.weight` + 2. LoRA A matrix as `self.lora_A` + 3. LoRA B matrix as `self.lora_B` + Only LoRA's A and B matrices are updated, pretrained weights stay frozen. + + Args: + in_features: number of input features of the pretrained weights + out_features: number of output features of the pretrained weights + r: rank of the weight update matrices. To make sense of using LoRA the rank should be smaller than the rank of + the weights of the model. The rank can be as low as 1: https://arxiv.org/pdf/2106.09685.pdf (section 7.2) + lora_alpha: alpha is needed for scaling updates as alpha/r + "This scaling helps to reduce the need to retune hyperparameters when we vary r" + https://arxiv.org/pdf/2106.09685.pdf (section 4.1) + lora_dropout: dropout that is applied on the input in the LoRA branch (before multiplying by matrix A) + """ + super().__init__(r=r, lora_alpha=lora_alpha, lora_dropout=lora_dropout) + self.linear = torch.nn.Linear(in_features, out_features, **kwargs) + + # Actual trainable parameters + if r > 0: + self.lora_A = nn.Parameter(torch.zeros((r, in_features))) + self.lora_B = nn.Parameter(torch.zeros((out_features, r))) + self.scaling = self.lora_alpha / self.r + self.reset_parameters() + + def reset_parameters(self) -> None: + """Reset all the weights, even including pretrained ones.""" + if hasattr(self, "lora_A"): + # initialize A the same way as the default for nn.Linear and B to zero + # Wondering why 'a' is equal to math.sqrt(5)?: https://github.com/pytorch/pytorch/issues/15314 + nn.init.kaiming_uniform_(self.lora_A, a=math.sqrt(5)) + nn.init.zeros_(self.lora_B) + + def get_lora_AB(self) -> torch.Tensor: + """Return merged lora_A and lora_B matrices with the same shape as the pretrained weights.""" + return (self.lora_B @ self.lora_A) * self.scaling + + def merge(self) -> None: + """Merges the LoRA weights into the full-rank weights (W = W + delta_W).""" + if self.r > 0 and not self.merged: + pretrained_dtype = self.linear.weight.data.dtype + lora_data = self.get_lora_AB() + # if the pretrained weights and LoRA weights are of the same dtype - simply sum them + if pretrained_dtype == lora_data.dtype: + self.linear.weight.data += lora_data + # if only the pretrained are in quantized form - dequantize, sum with LoRA and quantize the result + elif pretrained_dtype == torch.uint8: + import bitsandbytes as bnb + + weight = self.linear.weight + # dequantize the pretrained weights + weight_data = bnb.functional.dequantize_4bit( + weight.data, weight.quant_state + ).to(lora_data.dtype) + # add pretrained and LoRA weights + weight_data += lora_data + # assign updated weights and quantize by moving to CUDA device + self.linear.weight = bnb.nn.Params4bit( + weight_data, requires_grad=False, **weight.__dict__ + ) + self.linear.weight.cuda(weight.device) + else: + raise NotImplementedError( + f"Cannot merge the pretrained weights of type {pretrained_dtype}" + f" and LoRA weights of type {lora_data.dtype}" + ) + + self.merged = True + + def forward(self, x: torch.Tensor) -> torch.Tensor: + # if weights are merged or rank is less or equal to zero (LoRA is disabled) - it's only a regular nn.Linear forward pass; + # otherwise in addition do the forward pass with LoRA weights and add it's output to the output from pretrained weights + pretrained = self.linear(x) + if self.r == 0 or self.merged: + return pretrained + lora = ( + self.lora_dropout(x) + @ self.lora_A.transpose(0, 1) + @ self.lora_B.transpose(0, 1) + ) * self.scaling + return pretrained + lora + + +class LoRAQKVLinear(LoRALinear): + # LoRA implemented in a dense layer + def __init__( + self, + # ↓ this part is for pretrained weights + in_features: int, + out_features: int, + # ↓ the remaining part is for LoRA + n_head: int, + n_query_groups: int, + r: int = 0, + lora_alpha: int = 1, + lora_dropout: float = 0.0, + enable_lora: Union[bool, Tuple[bool, bool, bool]] = False, + **kwargs: Any, + ): + """LoRA wrapper around linear class that is used for calculation of q, k and v matrices. + + This class has three weight matrices: + 1. Pretrained weights are stored as `self.linear.weight` + 2. LoRA A matrix as `self.lora_A` + 3. LoRA B matrix as `self.lora_B` + Only LoRA's A and B matrices are updated, pretrained weights stay frozen. + + Args: + in_features: number of input features of the pretrained weights + out_features: number of output features of the pretrained weights + n_head: number of attention heads + n_query_groups: number of query groups (see diagram in `lit_gpt/config.py`) + r: rank of the weight update matrices. To make sense of using LoRA the rank should be smaller than the rank of + the weights of the model. The rank can be as low as 1: https://arxiv.org/pdf/2106.09685.pdf (section 7.2) + lora_alpha: alpha is needed for scaling updates as alpha/r + "This scaling helps to reduce the need to retune hyperparameters when we vary r" + https://arxiv.org/pdf/2106.09685.pdf (section 4.1) + lora_dropout: dropout that is applied on the input in the LoRA branch (before multiplying by matrix A) + enable_lora: MergeLinear class is for attention mechanism where qkv are calculated with a single weight matrix. If we + don't want to apply LoRA we can set it as False. For example if we want to apply LoRA only to `query` + and `value` but keep `key` without weight updates we should pass `[True, False, True]` + """ + super(LoRALinear, self).__init__( + r=r, lora_alpha=lora_alpha, lora_dropout=lora_dropout + ) + self.linear = torch.nn.Linear(in_features, out_features, **kwargs) + self.n_head = n_head + self.n_query_groups = n_query_groups + if isinstance(enable_lora, bool): + enable_lora = [enable_lora] * 3 + assert len(enable_lora) == 3 + self.enable_lora = enable_lora + + # Actual trainable parameters + # To better understand initialization let's imagine that we have such parameters: + # ⚬ in_features: 128 (embeddings_size) + # ⚬ out_features: 384 (3 * embedding_size) + # ⚬ r: 2 + # ⚬ enable_lora: [True, False, True] + if r > 0 and any(enable_lora): + self.lora_A = nn.Parameter( + torch.zeros((r * sum(enable_lora), in_features)) + ) # (4, 128) + enable_q, enable_k, enable_v = enable_lora + self.kv_embd_size = self.linear.in_features // ( + n_head // n_query_groups + ) + # qkv_shapes will be used to split a tensor with weights correctly + qkv_shapes = ( + self.linear.in_features * enable_q, + self.kv_embd_size * enable_k, + self.kv_embd_size * enable_v, + ) + self.qkv_shapes = [s for s in qkv_shapes if s] + self.lora_B = nn.Parameter( + torch.zeros(sum(self.qkv_shapes), r) + ) # (256, 2)) + # Notes about shapes above + # - self.lora_A has shape (4, 128): 4 because rank is 2 and LoRA is applied only to two matrices; + # 128 is the input size of the x (embedding size). (4, 128) and not (128, 4) because later on in + # F.linear function weights are automatically transposed. In addition conv1d requires channels to + # be before seq length + # - self.lora_B has shape (256, 2): 256 because LoRA is applied only to two matrices, so the output is + # 128*2; 2 tells to have two channels per group for group convolution + + # Scaling: + # This balances the pretrained model`s knowledge and the new task-specific adaptation + # https://lightning.ai/pages/community/tutorial/lora-llm/ + # So, set alpha to 1.0 to fully add LoRA. If the LoRA seems to have too much effect (i.e., overfitted), set + # alpha to lower value. If the LoRA seems to have too little effect, set alpha to higher than 1.0. You can + # tune these values to your needs. This value can be even slightly greater than 1.0! + # https://github.com/cloneofsimo/lora + self.scaling = self.lora_alpha / self.r + + # Compute the indices + # Indices are needed to properly pad weight updates with zeros in `zero_pad` method. + q_per_kv = self.n_head // self.n_query_groups + total_qkv = q_per_kv + 2 + head_size = out_features // (self.n_query_groups * total_qkv) + ind = range(out_features) + self.lora_ind = [] + if enable_q: + q_ind = [ + x + for x in ind + if (x // head_size) % total_qkv < total_qkv - 2 + ] + self.lora_ind.extend(q_ind) + if enable_k: + k_ind = [ + x + for x in ind + if (x // head_size) % total_qkv == total_qkv - 2 + ] + self.lora_ind.extend(k_ind) + if enable_v: + v_ind = [ + x + for x in ind + if (x // head_size) % total_qkv == total_qkv - 1 + ] + self.lora_ind.extend(v_ind) + self.reset_parameters() + + def zero_pad(self, x: torch.Tensor) -> torch.Tensor: + """Properly pad weight updates with zeros. + + If, based on `self.enable_lora`, we want to fine-tune queries and values, but not keys, + then the weights update should be: + + [[ΔW,ΔW,ΔW, ..., 0,0,0, ..., ΔW,ΔW,ΔW,], + [....................................], + [ΔW,ΔW,ΔW, ..., 0,0,0, ..., ΔW,ΔW,ΔW,]] + ↑ ↑ ↑ + ________________________________________ + | query | key | value | + ---------------------------------------- + For Llama2's GQA support, Q, K, and V weights are interleaved, so that weights for grouped + queries are adjacent to their associated key and value weights. + For example, suppose we have n_head = 12 with 3 query groups. + Then along the embedding dimension the interleaved weights would look like + + [Q, Q, Q, Q, K, V, Q, Q, Q, Q, K, V, Q, Q, Q, Q, K, V], + + where each Q, K, and V has size head_size. + + In this case, the previously-described weight update applies separately to each + individual block, so the update will take the form + + [[ΔW,ΔW,ΔW, ..., 0,0,0, ..., ΔW,ΔW,ΔW, ΔW,ΔW,ΔW, ..., 0,0,0, ..., ΔW,ΔW,ΔW, ...], + [.............................................................................], + [ΔW,ΔW,ΔW, ..., 0,0,0, ..., ΔW,ΔW,ΔW, ΔW,ΔW,ΔW, ..., 0,0,0, ..., ΔW,ΔW,ΔW, ...]] + ↑ ↑ ↑ ↑ ↑ ↑ + ________________________________________________________________________________ + | q block 1 | k block 1 | v block 1 | q block 2 | k block 2 | v block 2 | ... + -------------------------------------------------------------------------------- + Note that in the above diagram, the size of each q block will equal q_per_kv + times the size of each k and v block. + + Args: + x: tensor with weights update that will be padded with zeros if necessary + + Returns: + A tensor with weight updates and zeros for deselected q, k or v + """ + # we need to do zero padding only if LoRA is disabled for one of QKV matrices + if all(self.enable_lora): + return x + + # Let's image that: + # ⚬ input x has shape (64, 64, 256): (batch_size, sequence_length, embeddings_size) + # ⚬ embeddings_size: 128 + # ⚬ self.linear.out_features: 384 (3 * embeddings_size) + # ⚬ enable_lora: [True, False, True] + # Then x has embeddings_size of 256 (2 * 128 as enable_lora only for query and value, not keys) and expected + # embeddings_size is 384 (self.linear.out_features), so that means that we need to pad from 256 to 384 with zeros, but + # only for key updates (this is where self.lora_ind comes in handy) + # Note: double transpose (in the beginning and in the end) is basically a guard for two-dimensional tensors + # for example when we want to merge/unmerge LoRA weights and pretrained weights + x = x.transpose(0, 1) + result = x.new_zeros( + (*x.shape[:-1], self.linear.out_features) + ) # (64, 64, 384) + result = result.view(-1, self.linear.out_features) # (4096, 384) + result = result.index_copy( + 1, + torch.tensor(self.lora_ind, device=result.device), + x.reshape(-1, sum(self.qkv_shapes)), + ) # (4096, 256) + return result.view( + (*x.shape[:-1], self.linear.out_features) + ).transpose( + 0, 1 + ) # (64, 64, 384) + + def conv1d( + self, input: torch.Tensor, weight: torch.Tensor + ) -> torch.Tensor: + """An extension of the `torch.nn.functional.conv1d` function with a logic specific to grouped queries. + + If the number of heads is equal to the number of query groups - grouped queries are disabled + (see scheme in `lit_gpt/config.py:Config`). In this case the combined QKV matrix consists of equally sized + query, key and value parts, which means we can utilize `groups` argument from `conv1d`: with this argument the + input and weight matrices will be splitted in equally sized parts and applied separately (like having multiple + conv layers side by side). + + Otherwise QKV matrix consists of unequally sized parts and thus we have to split input and weight matrices manually, + apply each part of the weight matrix to the corresponding input's part and concatenate the result. + + Args: + input: input matrix of shape (B, C, T) + weight: weight matrix of shape (C_output, rank, 1). + "C_output" is defined as a sum of embedding sizes for each enabled LoRA layer (see init method of the class). + + Returns: + A tensor with a shape (B, C_output, T) + + """ + if self.n_head == self.n_query_groups: + return F.conv1d( + input, weight, groups=sum(self.enable_lora) + ) # (B, C_output, T) + + # Notation: + # ⚬ N: number of enabled LoRA layers (self.enable_lora) + # ⚬ C_output': embeddings size for each LoRA layer (not equal in size) + # ⚬ r: rank of all LoRA layers (equal in size) + + input_splitted = input.chunk( + sum(self.enable_lora), dim=1 + ) # N * (B, C // N, T) + weight_splitted = weight.split( + self.qkv_shapes + ) # N * (C_output', r, 1) + return torch.cat( + [F.conv1d(a, b) for a, b in zip(input_splitted, weight_splitted)], + dim=1, # (B, C_output', T) + ) # (B, C_output, T) + + def get_lora_AB(self) -> torch.Tensor: + """Return merged lora_A and lora_B matrices with the same shape as the pretrained weights.""" + # Let's assume that: + # ⚬ self.linear.weight.data: (384, 128) or (3 * embedding_size, embedding_size) + # ⚬ self.lora_A.data: (4, 128) + # ⚬ self.lora_B.data: (256, 2) + lora = self.conv1d( + self.lora_A.data.unsqueeze(0), # (4, 128) -> (1, 4, 128) + self.lora_B.data.unsqueeze(-1), # (256, 2) -> (256, 2, 1) + ).squeeze( + 0 + ) # (1, 4, 128) @ (256, 2, 1) -> (1, 256, 128) -> (256, 128) + return self.zero_pad( + lora * self.scaling + ) # (256, 128) after zero_pad (384, 128) + + def merge(self) -> None: + """Merges the LoRA weights into the full-rank weights (W = W + delta_W).""" + if self.r > 0 and any(self.enable_lora) and not self.merged: + super().merge() + + def forward(self, x: torch.Tensor) -> torch.Tensor: + """Do the forward pass. + + If LoRA's weights are merged with pretrained ones then it's a simple matrix multiplication. + If not, then multiply pretrained weights with input, apply LoRA on input and do summation. + + Args: + x: input tensor of shape (batch_size, context_length, embedding_size) + + Returns: + Output tensor of shape (batch_size, context_length, 3 * embedding_size) + """ + + # Let's assume that: + # ⚬ x: (64, 64, 128) or (batch_size, context_length, embedding_size) + # ⚬ self.linear.weight: (384, 128) or (3 * embedding_size, embedding_size) + # ⚬ self.lora_A.data: (4, 128) + # ⚬ self.lora_B.data: (256, 2) + + # if weights are merged or LoRA is disabled (r <= 0 or all `enable_lora` are False) - it's only a regular nn.Linear forward pass; + # otherwise in addition do the forward pass with LoRA weights and add it's output to the output from pretrained weights + pretrained = self.linear(x) + if self.r == 0 or not any(self.enable_lora) or self.merged: + return pretrained + after_A = F.linear( + self.lora_dropout(x), self.lora_A + ) # (64, 64, 128) @ (4, 128) -> (64, 64, 4) + # For F.conv1d: + # ⚬ input: input tensor of shape (mini-batch, in_channels, iW) + # ⚬ weight: filters of shape (out_channels, in_channels/groups, kW) + after_B = self.conv1d( + after_A.transpose(-2, -1), # (64, 64, 4) -> (64, 4, 64) + self.lora_B.unsqueeze(-1), # (256, 2) -> (256, 2, 1) + ).transpose( + -2, -1 + ) # (64, 4, 64) @ (256, 2, 1) -> (64, 256, 64) -> (64, 64, 256) + lora = ( + self.zero_pad(after_B) * self.scaling + ) # (64, 64, 256) after zero_pad (64, 64, 384) + return pretrained + lora + + +def mark_only_lora_as_trainable(model: nn.Module, bias: str = "none") -> None: + """Freeze all modules except LoRA's and depending on 'bias' value unfreezes bias weights. + + Args: + model: model with LoRA layers + bias: + ``"none"``: all bias weights will be frozen, + ``"lora_only"``: only bias weight for LoRA layers will be unfrozen, + ``"all"``: all bias weights will be unfrozen. + + Raises: + NotImplementedError: if `bias` not in ["none", "lora_only", "all"] + """ + # freeze all layers except LoRA's + for n, p in model.named_parameters(): + if "lora_" not in n: + p.requires_grad = False + + # depending on the `bias` value unfreeze bias weights + if bias == "none": + return + if bias == "all": + for n, p in model.named_parameters(): + if "bias" in n: + p.requires_grad = True + elif bias == "lora_only": + for m in model.modules(): + if ( + isinstance(m, LoRALayer) + and hasattr(m, "bias") + and m.bias is not None + ): + m.bias.requires_grad = True + else: + raise NotImplementedError + + +def lora_filter(key: str, value: Any) -> bool: + return "lora_" in key + + +@dataclass +class Config(BaseConfig): + """ + Args: + r: rank of the weight update matrices. To make sense of using LoRA the rank should be smaller than the rank of + the weights of the model. The rank can be as low as 1: https://arxiv.org/pdf/2106.09685.pdf (section 7.2) + alpha: alpha is needed for scaling updates as alpha/r + "This scaling helps to reduce the need to retune hyperparameters when we vary r" + https://arxiv.org/pdf/2106.09685.pdf (section 4.1) + dropout: dropout that is applied on the input in the LoRA branch (before multiplying by matrix A) + to_*: either apply LoRA to the specified weights or not + """ + + r: int = 0 + alpha: int = 1 + dropout: float = 0.0 + to_query: bool = False + to_key: bool = False + to_value: bool = False + to_projection: bool = False + to_mlp: bool = False + to_head: bool = False + + @property + def mlp_class(self) -> Type: + return getattr(lit_gpt.lora, self._mlp_class) + + +class GPT(BaseModel): + def __init__(self, config: Config) -> None: + nn.Module.__init__(self) + assert config.padded_vocab_size is not None + self.config = config + + self.lm_head = LoRALinear( + config.n_embd, + config.padded_vocab_size, + bias=config.lm_head_bias, + r=(config.r if config.to_head else 0), + lora_alpha=config.alpha, + lora_dropout=config.dropout, + ) + self.transformer = nn.ModuleDict( + dict( + wte=nn.Embedding(config.padded_vocab_size, config.n_embd), + h=nn.ModuleList(Block(config) for _ in range(config.n_layer)), + ln_f=config.norm_class(config.n_embd, eps=config.norm_eps), + ) + ) + self.max_seq_length = self.config.block_size + self.mask_cache: Optional[torch.Tensor] = None + + def forward( + self, + idx: torch.Tensor, + input_pos: Optional[torch.Tensor] = None, + lm_head_chunk_size: int = 0, + ) -> Union[torch.Tensor, List[torch.Tensor]]: + T = idx.size(1) + if self.max_seq_length < T: + raise ValueError( + f"Cannot forward sequence of length {T}, max seq length is only {self.max_seq_length}." + ) + + if input_pos is not None: # use the kv cache + cos = self.cos.index_select(0, input_pos) + sin = self.sin.index_select(0, input_pos) + if self.mask_cache is None: + raise TypeError("You need to call `gpt.set_kv_cache()`") + mask = self.mask_cache.index_select(2, input_pos) + else: + cos = self.cos[:T] + sin = self.sin[:T] + mask = None + + x = self.transformer.wte( + idx + ) # token embeddings of shape (b, t, n_embd) + for block in self.transformer.h: + x = block(x, cos, sin, mask, input_pos) + x = self.transformer.ln_f(x) + if lm_head_chunk_size > 0: + # chunk the lm head logits to reduce the peak memory used by autograd + return [ + self.lm_head(x_i) for x_i in x.split(lm_head_chunk_size, dim=1) + ] + return self.lm_head(x) # (B, T, vocab_size) + + @classmethod + def from_name(cls, name: str, **kwargs: Any) -> Self: + return cls(Config.from_name(name, **kwargs)) + + def _init_weights(self, module: nn.Module) -> None: + """Meant to be used with `gpt.apply(gpt._init_weights)`. Unused method left for completeness.""" + super()._init_weights(module) + if isinstance(module, LoRALinear): + module.reset_parameters() + + def _load_from_state_dict( + self, state_dict: Dict, prefix: str, *args: Any, **kwargs: Any + ) -> None: + """For compatibility with base checkpoints.""" + mapping = { + "lm_head.weight": "lm_head.linear.weight", + "lm_head.bias": "lm_head.linear.bias", + } + state_dict = map_old_state_dict_weights(state_dict, mapping, prefix) + super()._load_from_state_dict(state_dict, prefix, *args, **kwargs) + + +class Block(BaseBlock): + def __init__(self, config: Config) -> None: + nn.Module.__init__(self) + self.norm_1 = config.norm_class(config.n_embd, eps=config.norm_eps) + self.attn = CausalSelfAttention(config) + if not config.shared_attention_norm: + self.norm_2 = config.norm_class(config.n_embd, eps=config.norm_eps) + self.mlp = config.mlp_class(config) + + self.config = config + + +class CausalSelfAttention(BaseCausalSelfAttention): + def __init__(self, config: Config) -> None: + # Skip the parent class __init__ altogether and replace it to avoid + # useless allocations + nn.Module.__init__(self) + shape = (config.n_head + 2 * config.n_query_groups) * config.head_size + # key, query, value projections for all heads, but in a batch + self.attn = LoRAQKVLinear( + in_features=config.n_embd, + out_features=shape, + r=config.r, + lora_alpha=config.alpha, + lora_dropout=config.dropout, + enable_lora=(config.to_query, config.to_key, config.to_value), + bias=config.bias, + # for MQA/GQA support + n_head=config.n_head, + n_query_groups=config.n_query_groups, + ) + # output projection + # if `head_size` is explicitly specified in the config, `n_emd` might not be equal to `head_size * n_head` + self.proj = LoRALinear( + config.head_size * config.n_head, + config.n_embd, + bias=config.bias, + r=(config.r if config.to_projection else 0), + lora_alpha=config.alpha, + lora_dropout=config.dropout, + ) + # disabled by default + self.kv_cache: Optional[KVCache] = None + + self.config = config + + def _load_from_state_dict( + self, state_dict: Dict, prefix: str, *args: Any, **kwargs: Any + ) -> None: + """For compatibility with base checkpoints.""" + mapping = { + "attn.weight": "attn.linear.weight", + "attn.bias": "attn.linear.bias", + "proj.weight": "proj.linear.weight", + "proj.bias": "proj.linear.bias", + } + state_dict = map_old_state_dict_weights(state_dict, mapping, prefix) + super()._load_from_state_dict(state_dict, prefix, *args, **kwargs) + + +class GptNeoxMLP(lit_gpt.model.GptNeoxMLP): + def __init__(self, config: Config) -> None: + nn.Module.__init__(self) + self.fc = LoRALinear( + config.n_embd, + config.intermediate_size, + bias=config.bias, + r=(config.r if config.to_mlp else 0), + lora_alpha=config.alpha, + lora_dropout=config.dropout, + ) + self.proj = LoRALinear( + config.intermediate_size, + config.n_embd, + bias=config.bias, + r=(config.r if config.to_mlp else 0), + lora_alpha=config.alpha, + lora_dropout=config.dropout, + ) + + self.config = config + + def _load_from_state_dict( + self, state_dict: Dict, prefix: str, *args: Any, **kwargs: Any + ) -> None: + """For compatibility with base checkpoints.""" + mapping = { + "fc.weight": "fc.linear.weight", + "fc.bias": "fc.linear.bias", + "proj.weight": "proj.linear.weight", + "proj.bias": "proj.linear.bias", + } + state_dict = map_old_state_dict_weights(state_dict, mapping, prefix) + super()._load_from_state_dict(state_dict, prefix, *args, **kwargs) + + +class LLaMAMLP(lit_gpt.model.LLaMAMLP): + def __init__(self, config: Config) -> None: + nn.Module.__init__(self) + self.fc_1 = LoRALinear( + config.n_embd, + config.intermediate_size, + bias=config.bias, + r=(config.r if config.to_mlp else 0), + lora_alpha=config.alpha, + lora_dropout=config.dropout, + ) + self.fc_2 = LoRALinear( + config.n_embd, + config.intermediate_size, + bias=config.bias, + r=(config.r if config.to_mlp else 0), + lora_alpha=config.alpha, + lora_dropout=config.dropout, + ) + self.proj = LoRALinear( + config.intermediate_size, + config.n_embd, + bias=config.bias, + r=(config.r if config.to_mlp else 0), + lora_alpha=config.alpha, + lora_dropout=config.dropout, + ) + + def _load_from_state_dict( + self, state_dict: Dict, prefix: str, *args: Any, **kwargs: Any + ) -> None: + """For compatibility with base checkpoints.""" + mapping = { + "fc_1.weight": "fc_1.linear.weight", + "fc_1.bias": "fc_1.linear.bias", + "fc_2.weight": "fc_2.linear.weight", + "fc_2.bias": "fc_2.linear.bias", + "proj.weight": "proj.linear.weight", + "proj.bias": "proj.linear.bias", + } + state_dict = map_old_state_dict_weights(state_dict, mapping, prefix) + super()._load_from_state_dict(state_dict, prefix, *args, **kwargs) + + +class GemmaMLP(LLaMAMLP): + def forward(self, x: torch.Tensor) -> torch.Tensor: + x_fc_1 = self.fc_1(x) + x_fc_2 = self.fc_2(x) + x = torch.nn.functional.gelu(x_fc_1) * x_fc_2 + return self.proj(x) + + +class LLaMAMoE(lit_gpt.model.LLaMAMoE): + def __init__(self, config: Config) -> None: + nn.Module.__init__(self) + self.gate = LoRALinear( + config.n_embd, + config.n_expert, + bias=False, + r=(config.r if config.to_mlp else 0), + lora_alpha=config.alpha, + lora_dropout=config.dropout, + ) + self.experts = nn.ModuleList( + LLaMAMLP(config) for _ in range(config.n_expert) + ) + + self.config = config + + def _load_from_state_dict( + self, state_dict: Dict, prefix: str, *args: Any, **kwargs: Any + ) -> None: + """For compatibility with base checkpoints.""" + mapping = {"gate.weight": "gate.linear.weight"} + state_dict = map_old_state_dict_weights(state_dict, mapping, prefix) + super()._load_from_state_dict(state_dict, prefix, *args, **kwargs) + + +def merge_lora_weights(model: GPT) -> None: + """Merge LoRA weights into the full-rank weights to speed up inference.""" + for module in model.modules(): + if isinstance(module, LoRALinear): + module.merge() diff --git a/examples/llm_finetuning/lit_gpt/model.py b/examples/llm_finetuning/lit_gpt/model.py new file mode 100644 index 00000000000..1ff378fd419 --- /dev/null +++ b/examples/llm_finetuning/lit_gpt/model.py @@ -0,0 +1,501 @@ +# Copyright Lightning AI. Licensed under the Apache License 2.0, see LICENSE file. + +"""Full definition of a decoder-only transformer-based language model, all of it in this single file. + +Based on the nanoGPT implementation: https://github.com/karpathy/nanoGPT and +https://github.com/EleutherAI/gpt-neox/tree/main/megatron/model. +""" + +import math +from typing import Any, Optional, Tuple + +import torch +import torch.nn as nn +from typing_extensions import Self + +from lit_gpt.config import Config + + +class GPT(nn.Module): + def __init__(self, config: Config) -> None: + super().__init__() + assert config.padded_vocab_size is not None + self.config = config + + self.lm_head = nn.Linear( + config.n_embd, config.padded_vocab_size, bias=config.lm_head_bias + ) + self.transformer = nn.ModuleDict( + dict( + wte=nn.Embedding(config.padded_vocab_size, config.n_embd), + h=nn.ModuleList(Block(config) for _ in range(config.n_layer)), + ln_f=config.norm_class(config.n_embd, eps=config.norm_eps), + ) + ) + self.max_seq_length = self.config.block_size + self.mask_cache: Optional[torch.Tensor] = None + + @property + def max_seq_length(self) -> int: + return self._max_seq_length + + @max_seq_length.setter + def max_seq_length(self, value: int) -> None: + """ + When doing inference, the sequences used might be shorter than the model's context length. + This allows setting a smaller number to avoid allocating unused memory + """ + if value > self.config.block_size: + raise ValueError( + f"Cannot attend to {value}, block size is only {self.config.block_size}" + ) + self._max_seq_length = value + if not hasattr(self, "cos"): + # first call + cos, sin = self.rope_cache() + self.register_buffer("cos", cos, persistent=False) + self.register_buffer("sin", sin, persistent=False) + # override + elif value != self.cos.size(0): + self.cos, self.sin = self.rope_cache(device=self.cos.device) + # the mask and kv cache size will get updated on `set_kv_cache`. we cannot update it here because we don't know + # if the kv cache is expected + + def reset_parameters(self) -> None: + # Trigger resetting the rope-cache + self.cos, self.sin = self.rope_cache() + + def _init_weights(self, module: nn.Module) -> None: + """Meant to be used with `gpt.apply(gpt._init_weights)`.""" + if isinstance(module, nn.Linear): + torch.nn.init.normal_(module.weight, mean=0.0, std=0.02) + if module.bias is not None: + torch.nn.init.zeros_(module.bias) + elif isinstance(module, nn.Embedding): + torch.nn.init.normal_(module.weight, mean=0.0, std=0.02) + + def forward( + self, idx: torch.Tensor, input_pos: Optional[torch.Tensor] = None + ) -> torch.Tensor: + T = idx.size(1) + if self.max_seq_length < T: + raise ValueError( + f"Cannot forward sequence of length {T}, max seq length is only {self.max_seq_length}." + ) + + if input_pos is not None: # use the kv cache + cos = self.cos.index_select(0, input_pos) + sin = self.sin.index_select(0, input_pos) + if self.mask_cache is None: + raise TypeError("You need to call `gpt.set_kv_cache()`") + mask = self.mask_cache.index_select(2, input_pos) + else: + cos = self.cos[:T] + sin = self.sin[:T] + mask = None + + x = self.transformer.wte( + idx + ) # token embeddings of shape (b, t, n_embd) + if self.config.scale_embeddings: + x = x * (self.config.n_embd**0.5) + + for block in self.transformer.h: + x = block(x, cos, sin, mask, input_pos) + x = self.transformer.ln_f(x) + return self.lm_head(x) # (b, t, vocab_size) + + @classmethod + def from_name(cls, name: str, **kwargs: Any) -> Self: + return cls(Config.from_name(name, **kwargs)) + + def rope_cache( + self, device: Optional[torch.device] = None + ) -> Tuple[torch.Tensor, torch.Tensor]: + return build_rope_cache( + seq_len=self.max_seq_length, + n_elem=self.config.rope_n_elem, + device=device, + condense_ratio=self.config.rope_condense_ratio, + base=self.config.rope_base, + ) + + def set_kv_cache( + self, + batch_size: int, + rope_cache_length: Optional[int] = None, + device: Optional[torch.device] = None, + dtype: Optional[torch.dtype] = None, + ) -> None: + if rope_cache_length is None: + rope_cache_length = self.cos.size(-1) + max_seq_length = self.max_seq_length + + # initialize the kv cache for all blocks + for block in self.transformer.h: + block.attn.kv_cache = block.attn.build_kv_cache( + batch_size, max_seq_length, rope_cache_length, device, dtype + ) + + if ( + self.mask_cache is None + or self.mask_cache.size(3) != max_seq_length + ): + # passing `attn_mask` to SDPA disables the flash implementation. since we only need the mask + # for the kv-cache support (only during inference), we only create it in that situation + self.mask_cache = build_mask_cache(max_seq_length, device) + + def clear_kv_cache(self) -> None: + self.mask_cache = None + for block in self.transformer.h: + block.attn.kv_cache = None + + +class Block(nn.Module): + def __init__(self, config: Config) -> None: + super().__init__() + self.norm_1 = config.norm_class(config.n_embd, eps=config.norm_eps) + self.attn = CausalSelfAttention(config) + self.norm_2 = ( + None + if config.shared_attention_norm + else config.norm_class(config.n_embd, eps=config.norm_eps) + ) + self.mlp = config.mlp_class(config) + + self.config = config + + def forward( + self, + x: torch.Tensor, + cos: torch.Tensor, + sin: torch.Tensor, + mask: Optional[torch.Tensor] = None, + input_pos: Optional[torch.Tensor] = None, + ) -> torch.Tensor: + n_1 = self.norm_1(x) + h = self.attn(n_1, cos, sin, mask, input_pos) + if self.config.parallel_residual: + n_2 = n_1 if self.config.shared_attention_norm else self.norm_2(x) + x = self.mlp(n_2) + h + x + else: + if self.config.shared_attention_norm: + raise NotImplementedError( + "No checkpoint amongst the ones we support uses this configuration" + " (non-parallel residual and shared attention norm)." + ) + x = h + x + x = self.mlp(self.norm_2(x)) + x + return x + + +class CausalSelfAttention(nn.Module): + def __init__(self, config: Config) -> None: + super().__init__() + shape = (config.n_head + 2 * config.n_query_groups) * config.head_size + # key, query, value projections for all heads, but in a batch + self.attn = nn.Linear(config.n_embd, shape, bias=config.bias) + # output projection + # if `head_size` is explicitly specified in the config, `n_emd` might not be equal to `head_size * n_head` + self.proj = nn.Linear( + config.head_size * config.n_head, config.n_embd, bias=config.bias + ) + # disabled by default + self.kv_cache: Optional[KVCache] = None + + self.config = config + + def forward( + self, + x: torch.Tensor, + cos: torch.Tensor, + sin: torch.Tensor, + mask: Optional[torch.Tensor] = None, + input_pos: Optional[torch.Tensor] = None, + ) -> torch.Tensor: + ( + B, + T, + C, + ) = ( + x.size() + ) # batch size, sequence length, embedding dimensionality (n_embd) + + qkv = self.attn(x) + + # assemble into a number of query groups to support MHA, MQA and GQA together (see `config.n_query_groups`) + q_per_kv = self.config.n_head // self.config.n_query_groups + total_qkv = ( + q_per_kv + 2 + ) # each group has 1+ queries, 1 key, and 1 value + qkv = qkv.view( + B, T, self.config.n_query_groups, total_qkv, self.config.head_size + ) + qkv = qkv.permute( + 0, 2, 3, 1, 4 + ) # (B, n_query_groups, total_qkv, T, hs) + + # split batched computation into three + q, k, v = qkv.split((q_per_kv, 1, 1), dim=2) + + # maybe repeat k and v if for the non multi-head attention cases + # training: flash attention requires it + # inference: multi-query would require a full kv cache so avoid it to limit its memory usage + if self.config.n_query_groups != self.config.n_head and ( + input_pos is None or self.config.n_query_groups != 1 + ): + k = k.expand( + B, + self.config.n_query_groups, + q_per_kv, + T, + self.config.head_size, + ) + v = v.expand( + B, + self.config.n_query_groups, + q_per_kv, + T, + self.config.head_size, + ) + + q = q.reshape(B, -1, T, self.config.head_size) # (B, nh_q, T, hs) + k = k.reshape(B, -1, T, self.config.head_size) # (B, nh_k, T, hs) + v = v.reshape(B, -1, T, self.config.head_size) # (B, nh_v, T, hs) + + q_roped = apply_rope(q[..., : self.config.rope_n_elem], cos, sin) + k_roped = apply_rope(k[..., : self.config.rope_n_elem], cos, sin) + q = torch.cat((q_roped, q[..., self.config.rope_n_elem :]), dim=-1) + k = torch.cat((k_roped, k[..., self.config.rope_n_elem :]), dim=-1) + + if input_pos is not None: + if not isinstance(self.kv_cache, KVCache): + raise TypeError("You need to call `gpt.set_kv_cache()`") + k, v = self.kv_cache(input_pos, k, v) + + y = self.scaled_dot_product_attention(q, k, v, mask) + + y = y.reshape( + B, T, self.config.head_size * self.config.n_head + ) # re-assemble all head outputs side by side + + # output projection + return self.proj(y) + + def scaled_dot_product_attention( + self, + q: torch.Tensor, + k: torch.Tensor, + v: torch.Tensor, + mask: Optional[torch.Tensor] = None, + ) -> torch.Tensor: + scale = 1.0 / math.sqrt(self.config.head_size) + y = torch.nn.functional.scaled_dot_product_attention( + q, + k, + v, + attn_mask=mask, + dropout_p=0.0, + scale=scale, + is_causal=mask is None, + ) + return y.transpose(1, 2) + + def build_kv_cache( + self, + batch_size: int, + max_seq_length: int, + rope_cache_length: Optional[int] = None, + device: Optional[torch.device] = None, + dtype: Optional[torch.dtype] = None, + ) -> "KVCache": + heads = 1 if self.config.n_query_groups == 1 else self.config.n_head + v_shape = (batch_size, heads, max_seq_length, self.config.head_size) + if rope_cache_length is None: + if self.config.rotary_percentage != 1.0: + raise TypeError( + "Please pass the `rope_cache_length=gpt.cos.size(-1)` value" + ) + k_shape = v_shape + else: + k_shape = ( + batch_size, + heads, + max_seq_length, + rope_cache_length + + self.config.head_size + - self.config.rope_n_elem, + ) + return KVCache(k_shape, v_shape, device=device, dtype=dtype) + + +class GptNeoxMLP(nn.Module): + def __init__(self, config: Config) -> None: + super().__init__() + self.fc = nn.Linear( + config.n_embd, config.intermediate_size, bias=config.bias + ) + self.proj = nn.Linear( + config.intermediate_size, config.n_embd, bias=config.bias + ) + + self.config = config + + def forward(self, x: torch.Tensor) -> torch.Tensor: + x = self.fc(x) + x = torch.nn.functional.gelu( + x, approximate=self.config.gelu_approximate + ) + return self.proj(x) + + +class LLaMAMLP(nn.Module): + def __init__(self, config: Config) -> None: + super().__init__() + self.fc_1 = nn.Linear( + config.n_embd, config.intermediate_size, bias=config.bias + ) + self.fc_2 = nn.Linear( + config.n_embd, config.intermediate_size, bias=config.bias + ) + self.proj = nn.Linear( + config.intermediate_size, config.n_embd, bias=config.bias + ) + + def forward(self, x: torch.Tensor) -> torch.Tensor: + x_fc_1 = self.fc_1(x) + x_fc_2 = self.fc_2(x) + x = torch.nn.functional.silu(x_fc_1) * x_fc_2 + return self.proj(x) + + +class GemmaMLP(LLaMAMLP): + def forward(self, x: torch.Tensor) -> torch.Tensor: + x_fc_1 = self.fc_1(x) + x_fc_2 = self.fc_2(x) + x = torch.nn.functional.gelu(x_fc_1) * x_fc_2 + return self.proj(x) + + +class LLaMAMoE(nn.Module): + def __init__(self, config: Config) -> None: + super().__init__() + self.gate = nn.Linear(config.n_embd, config.n_expert, bias=False) + self.experts = nn.ModuleList( + LLaMAMLP(config) for _ in range(config.n_expert) + ) + + self.config = config + + def forward(self, x: torch.Tensor) -> torch.Tensor: + """ + Derived from: https://github.com/mistralai/mistral-src/blob/b46d6/moe_one_file_ref.py#L203-L219 + See also figure 1 in https://arxiv.org/abs/2211.15841 + """ + ( + B, + T, + C, + ) = ( + x.size() + ) # batch size, sequence length, embedding dimensionality (n_embd) + x = x.view(-1, C) # (B*T, C) + router = self.gate(x) # (B*T, n_expert) + probs, indices = torch.topk( + router, self.config.n_expert_per_token + ) # (B*T, n_expert_per_token) + probs = probs.softmax(dim=1, dtype=torch.float).to(dtype=x.dtype) + masks = indices.unsqueeze(-1) == torch.arange( + self.config.n_expert, device=x.device + ) + masks = masks.permute(2, 0, 1) # (n_expert, B*T, n_expert_per_token) + y = torch.zeros_like(x) # (B*T, C) + for mask, expert in zip(masks, self.experts): + token_idx, expert_idx = torch.where(mask) + y[token_idx] += probs[token_idx, expert_idx, None] * expert( + x[token_idx] + ) + return y.view(B, T, C) + + +def build_rope_cache( + seq_len: int, + n_elem: int, + device: Optional[torch.device] = None, + base: int = 10000, + condense_ratio: int = 1, +) -> Tuple[torch.Tensor, torch.Tensor]: + """Enhanced Transformer with Rotary Position Embedding. + + Derived from: https://github.com/labmlai/annotated_deep_learning_paper_implementations/blob/master/labml_nn/ + transformers/rope/__init__.py. MIT License: + https://github.com/labmlai/annotated_deep_learning_paper_implementations/blob/master/license. + """ + # $\Theta = {\theta_i = 10000^{\frac{2(i-1)}{d}}, i \in [1, 2, ..., \frac{d}{2}]}$ + theta = 1.0 / ( + base ** (torch.arange(0, n_elem, 2, device=device).float() / n_elem) + ) + + # Create position indexes `[0, 1, ..., seq_len - 1]` + seq_idx = torch.arange(seq_len, device=device) / condense_ratio + + # Calculate the product of position index and $\theta_i$ + idx_theta = torch.outer(seq_idx, theta).repeat(1, 2) + + return torch.cos(idx_theta), torch.sin(idx_theta) + + +def apply_rope( + x: torch.Tensor, cos: torch.Tensor, sin: torch.Tensor +) -> torch.Tensor: + head_size = x.size(-1) + x1 = x[..., : head_size // 2] # (B, nh, T, hs/2) + x2 = x[..., head_size // 2 :] # (B, nh, T, hs/2) + rotated = torch.cat((-x2, x1), dim=-1) # (B, nh, T, hs) + roped = (x * cos) + (rotated * sin) + return roped.to(dtype=x.dtype) + + +class KVCache(nn.Module): + def __init__( + self, + k_shape: Tuple[int, int, int, int], + v_shape: Tuple[int, int, int, int], + device: Optional[torch.device] = None, + dtype: Optional[torch.dtype] = None, + ) -> None: + super().__init__() + self.register_buffer( + "k", + torch.zeros(k_shape, device=device, dtype=dtype), + persistent=False, + ) + self.register_buffer( + "v", + torch.zeros(v_shape, device=device, dtype=dtype), + persistent=False, + ) + + def forward( + self, input_pos: torch.Tensor, k: torch.Tensor, v: torch.Tensor + ) -> Tuple[torch.Tensor, torch.Tensor]: + # move the buffer to the activation dtype for when AMP is used + self.k = self.k.to(k.dtype) + self.v = self.v.to(v.dtype) + # update the cache + k = self.k.index_copy_(2, input_pos, k) + v = self.v.index_copy_(2, input_pos, v) + return k, v + + def reset_parameters(self) -> None: + torch.nn.init.zeros_(self.k) + torch.nn.init.zeros_(self.v) + + +def build_mask_cache( + max_seq_length: int, device: Optional[torch.device] = None +) -> torch.Tensor: + ones = torch.ones( + (max_seq_length, max_seq_length), device=device, dtype=torch.bool + ) + return torch.tril(ones).unsqueeze(0).unsqueeze(0) diff --git a/examples/llm_finetuning/lit_gpt/packed_dataset.py b/examples/llm_finetuning/lit_gpt/packed_dataset.py new file mode 100644 index 00000000000..a183d4c2423 --- /dev/null +++ b/examples/llm_finetuning/lit_gpt/packed_dataset.py @@ -0,0 +1,274 @@ +# Copyright Lightning AI. Licensed under the Apache License 2.0, see LICENSE file. + +# Very loosely inspired by indexed_dataset in Fairseq, Megatron +# https://github.com/NVIDIA/Megatron-LM/blob/main/megatron/data/indexed_dataset.py + + +import os +import random +import struct + +import numpy as np +import torch +from torch.utils.data import IterableDataset, get_worker_info + +dtypes = { + 1: np.uint8, + 2: np.int8, + 3: np.int16, + 4: np.int32, + 5: np.int64, + 6: np.float32, + 7: np.float64, + 8: np.uint16, +} + + +def code(dtype): + for k in dtypes: + if dtypes[k] == dtype: + return k + raise ValueError(dtype) + + +HDR_MAGIC = b"LITPKDS" +HDR_SIZE = 24 # bytes + + +class PackedDataset(IterableDataset): + def __init__( + self, + filenames, + n_chunks, + block_size, + seed=12345, + shuffle=True, + wrap=False, + num_processes=1, + process_rank=0, + ): + self._filenames = filenames + self._n_chunks = n_chunks + self._block_size = block_size + self._seed = seed + self._shuffle = shuffle + self._wrap = wrap + self._num_processes = num_processes + self._process_rank = process_rank + + def __iter__(self): + worker_info = get_worker_info() + num_workers = worker_info.num_workers if worker_info is not None else 1 + worker_id = worker_info.id if worker_info is not None else 0 + num_shards = num_workers * self._num_processes + shard_id = self._process_rank * num_workers + worker_id + + max_num_files = len(self._filenames) // num_shards * num_shards + filenames = self._filenames[shard_id:max_num_files:num_shards] + + return PackedDatasetIterator( + filenames=filenames, + n_chunks=self._n_chunks, + block_size=self._block_size, + seed=self._seed, + shuffle=self._shuffle, + wrap=self._wrap, + ) + + +class PackedDatasetBuilder(object): + def __init__( + self, + outdir, + prefix, + chunk_size, + sep_token, + dtype="auto", + vocab_size=None, + ): + if dtype == "auto": + if vocab_size is None: + raise ValueError("vocab_size cannot be None when dtype='auto'") + if vocab_size is not None and vocab_size < 65500: + self._dtype = np.uint16 + else: + self._dtype = np.int32 + else: + self._dtype = dtype + self._counter = 0 + self._chunk_size = chunk_size + self._outdir = outdir + self._prefix = prefix + self._sep_token = sep_token + self._arr = np.zeros(self._chunk_size, dtype=self._dtype) + self._arr.fill(self._sep_token) + self._idx = 0 + self._version = 1 + self._filenames = [] + + def _write_chunk(self): + filename = f"{self._prefix}_{self._counter:010d}.bin" + filename = os.path.join(self._outdir, filename) + + with open(filename, "wb") as f: + f.write(HDR_MAGIC) + f.write(struct.pack(" self._chunk_size: + part_len = self._chunk_size - self._idx + self._arr[self._idx : self._idx + part_len] = arr[:part_len] + self._write_chunk() + arr = arr[part_len:] + + arr_len = arr.shape[0] + self._arr[self._idx : self._idx + arr_len] = arr + self._idx += arr_len + + def write_reminder(self): + self._write_chunk() + + +class PackedDatasetIterator: + def __init__(self, filenames, n_chunks, block_size, seed, shuffle, wrap): + self._seed = seed + self._shuffle = shuffle + self._rng = np.random.default_rng(seed) if shuffle else None + self._block_idxs = None + + self._wrap = wrap + + # TODO: instead of filenames, we could have a single text stream + # (or text file) with the sequence of all files to be + # fetched/loaded. + self._filenames = filenames + self._file_idx = 0 + + self._n_chunks = n_chunks + + self._dtype = None + self._block_size = block_size + self._n_blocks = None + + self._mmaps = [] + self._buffers = [] + + self._block_idxs = [] + self._curr_idx = 0 + + self._load_n_chunks() + + def _read_header(self, path): + with open(path, "rb") as f: + magic = f.read(len(HDR_MAGIC)) + assert magic == HDR_MAGIC, "File doesn't match expected format." + version = struct.unpack(" len(self._filenames[self._file_idx :]): + if not self._wrap: + raise StopIteration + self._file_idx = 0 + + for i in range(self._n_chunks): + filename = self._filenames[self._file_idx + i] + if self._dtype is None: + self._dtype, self._chunk_size = self._read_header(filename) + self._n_blocks = self._chunk_size // self._block_size + # TODO: check header matches with previous files + mmap = np.memmap(filename, mode="r", order="C", offset=HDR_SIZE) + self._mmaps.append(mmap) + self._buffers.append(memoryview(mmap)) + + self._file_idx += self._n_chunks + n_all_blocks = self._n_chunks * self._n_blocks + + self._block_idxs = ( + self._rng.permutation(n_all_blocks) + if self._shuffle + else range(n_all_blocks) + ) + + self._curr_idx = 0 + + def __del__(self): + self._close_mmaps() + del self._mmaps + del self._buffers + + def __iter__(self): + return self + + def __next__(self): + if self._curr_idx >= len(self._block_idxs): + self._load_n_chunks() + # TODO: trigger fetching next next n_chunks if remote + block_idx = self._block_idxs[self._curr_idx] + chunk_id = block_idx // self._n_blocks + buffer = self._buffers[chunk_id] + elem_id = (block_idx % self._n_blocks) * self._block_size + offset = np.dtype(self._dtype).itemsize * elem_id + arr = np.frombuffer( + buffer, dtype=self._dtype, count=self._block_size, offset=offset + ) + self._curr_idx += 1 + return torch.from_numpy(arr.astype(np.int64)) + + +class CombinedDataset(IterableDataset): + def __init__(self, datasets, seed, weights=None): + self._seed = seed + self._datasets = datasets + self._weights = weights + n_datasets = len(datasets) + if weights is None: + self._weights = [1 / n_datasets] * n_datasets + else: + self._weights = [w / sum(weights) for w in weights] + + def __iter__(self): + return CombinedDatasetIterator( + self._datasets, self._seed, self._weights + ) + + +class CombinedDatasetIterator: + def __init__(self, datasets, seed, weights): + self._datasets = [iter(el) for el in datasets] + self._weights = weights + self._rng = random.Random(seed) + + def __next__(self): + (dataset,) = self._rng.choices( + self._datasets, weights=self._weights, k=1 + ) + return next(dataset) diff --git a/examples/llm_finetuning/lit_gpt/rmsnorm.py b/examples/llm_finetuning/lit_gpt/rmsnorm.py new file mode 100644 index 00000000000..108288128f7 --- /dev/null +++ b/examples/llm_finetuning/lit_gpt/rmsnorm.py @@ -0,0 +1,40 @@ +# Copyright Lightning AI. Licensed under the Apache License 2.0, see LICENSE file. + +import torch + + +class RMSNorm(torch.nn.Module): + """Root Mean Square Layer Normalization. + + Derived from https://github.com/bzhangGo/rmsnorm/blob/master/rmsnorm_torch.py. BSD 3-Clause License: + https://github.com/bzhangGo/rmsnorm/blob/master/LICENSE. + """ + + def __init__( + self, + size: int, + dim: int = -1, + eps: float = 1e-6, + add_unit_offset: bool = False, + ) -> None: + super().__init__() + self.weight = torch.nn.Parameter(torch.ones(size)) + self.eps = eps + self.dim = dim + self.add_unit_offset = add_unit_offset + + def forward(self, x: torch.Tensor) -> torch.Tensor: + dtype = x.dtype + x = x.float() + # NOTE: the original RMSNorm paper implementation is not equivalent + norm_x = torch.mean(x * x, dim=self.dim, keepdim=True) + x_normed = x * torch.rsqrt(norm_x + self.eps) + x_normed = x_normed.to(dtype=dtype) + if self.add_unit_offset: + # Gemma model requires a unit offset + # https://github.com/google/gemma_pytorch/blob/main/gemma/model.py#L176 + return x_normed * (1 + self.weight) + return x_normed * self.weight + + def reset_parameters(self) -> None: + torch.nn.init.ones_(self.weight) diff --git a/examples/llm_finetuning/lit_gpt/tokenizer.py b/examples/llm_finetuning/lit_gpt/tokenizer.py new file mode 100644 index 00000000000..f2832ce61c2 --- /dev/null +++ b/examples/llm_finetuning/lit_gpt/tokenizer.py @@ -0,0 +1,136 @@ +# Copyright Lightning AI. Licensed under the Apache License 2.0, see LICENSE file. + +import json +from pathlib import Path +from typing import Optional, Union + +import torch + + +class Tokenizer: + def __init__(self, checkpoint_dir: Union[Path, str]) -> None: + checkpoint_dir = Path(checkpoint_dir) + if not checkpoint_dir.exists(): + raise NotADirectoryError( + f"The checkpoint directory does not exist: {str(checkpoint_dir)}" + ) + + self.use_bos = self.check_if_bos_token_used(checkpoint_dir) + self.bos_id = None + self.eos_id = None + + # some checkpoints have both files, `.model` takes precedence + if (vocabulary_path := checkpoint_dir / "tokenizer.model").is_file(): + from sentencepiece import SentencePieceProcessor + + self.processor = SentencePieceProcessor( + model_file=str(vocabulary_path) + ) + self.backend = "sentencepiece" + self.bos_id = self.processor.bos_id() + self.eos_id = self.processor.eos_id() + + elif (vocabulary_path := checkpoint_dir / "tokenizer.json").is_file(): + from tokenizers import Tokenizer as HFTokenizer + + self.processor = HFTokenizer.from_file(str(vocabulary_path)) + self.backend = "huggingface" + + if ( + special_tokens_path := checkpoint_dir / "tokenizer_config.json" + ).is_file(): + with open(special_tokens_path) as fp: + config = json.load(fp) + bos_token = config.get("bos_token") + self.bos_id = ( + self.token_to_id(bos_token) + if bos_token is not None + else None + ) + eos_token = config.get("eos_token") + self.eos_id = ( + self.token_to_id(eos_token) + if eos_token is not None + else None + ) + if ( + special_tokens_path := checkpoint_dir + / "generation_config.json" + ).is_file(): + with open(special_tokens_path) as fp: + config = json.load(fp) + if self.bos_id is None: + self.bos_id = config.get("bos_token_id") + if self.eos_id is None: + self.eos_id = config.get("eos_token_id") + else: + raise NotImplementedError + + @property + def vocab_size(self) -> int: + if self.backend == "huggingface": + return self.processor.get_vocab_size(with_added_tokens=False) + if self.backend == "sentencepiece": + return self.processor.vocab_size() + raise RuntimeError + + def token_to_id(self, token: str) -> int: + if self.backend == "huggingface": + id_ = self.processor.token_to_id(token) + elif self.backend == "sentencepiece": + id_ = self.processor.piece_to_id(token) + else: + raise RuntimeError + if id_ is None: + raise ValueError(f"token {token!r} not found in the collection.") + return id_ + + def check_if_bos_token_used(self, checkpoint_dir: Path) -> bool: + if not ( + tokenizer_config_path := checkpoint_dir / "tokenizer_config.json" + ).is_file(): + return False + with open(tokenizer_config_path) as fp: + config = json.load(fp) + if any( + config.get(check, False) + for check in ("add_bos_token", "add_prefix_space") + ): + return True + # for examples that also use the Llama tokenizer, but do not have or set add_bos_token to True. + # ex: https://huggingface.co/stabilityai/StableBeluga2/blob/main/tokenizer_config.json#L2 + return ( + config.get("add_bos_token") is None + and config.get("tokenizer_class") == "LlamaTokenizer" + ) + + def encode( + self, + string: str, + device: Optional[torch.device] = None, + bos: Optional[bool] = None, + eos: bool = False, + max_length: int = -1, + ) -> torch.Tensor: + if self.backend == "huggingface": + tokens = self.processor.encode(string).ids + elif self.backend == "sentencepiece": + tokens = self.processor.encode(string) + else: + raise RuntimeError + if bos or (bos is None and self.use_bos): + bos_id = self.bos_id + if bos_id is None: + raise NotImplementedError( + "This tokenizer does not have a defined a bos token" + ) + tokens = [bos_id] + tokens + if eos: + tokens = tokens + [self.eos_id] + if max_length > 0: + tokens = tokens[:max_length] + return torch.tensor(tokens, dtype=torch.int, device=device) + + def decode(self, tensor: torch.Tensor) -> str: + tokens = [tensor.item()] if tensor.ndim == 0 else tensor.tolist() + return self.processor.decode(tokens) diff --git a/examples/llm_finetuning/lit_gpt/utils.py b/examples/llm_finetuning/lit_gpt/utils.py new file mode 100644 index 00000000000..ba4706ff473 --- /dev/null +++ b/examples/llm_finetuning/lit_gpt/utils.py @@ -0,0 +1,477 @@ +# Copyright Lightning AI. Licensed under the Apache License 2.0, see LICENSE file. + +"""Utility functions for training and inference.""" + +import math +import pickle +import sys +from io import BytesIO +from pathlib import Path +from typing import ( + TYPE_CHECKING, + Any, + Dict, + Iterable, + List, + Mapping, + Optional, + TypeVar, + Union, +) + +import lightning as L +import torch +import torch.nn as nn +import torch.utils._device +from lightning.fabric.strategies import FSDPStrategy +from lightning.fabric.utilities.load import _lazy_load as lazy_load +from torch.serialization import normalize_storage_type +from typing_extensions import Self + +if TYPE_CHECKING: + from lit_gpt import GPT + + +def find_multiple(n: int, k: int) -> int: + assert k > 0 + if n % k == 0: + return n + return n + k - (n % k) + + +def num_parameters( + module: nn.Module, requires_grad: Optional[bool] = None +) -> int: + total = 0 + for p in module.parameters(): + if requires_grad is None or p.requires_grad == requires_grad: + if hasattr(p, "quant_state"): + # bitsandbytes 4bit layer support + total += math.prod(p.quant_state[1]) + else: + total += p.numel() + return total + + +def check_valid_checkpoint_dir(checkpoint_dir: Path) -> None: + files = { + "lit_model.pth": (checkpoint_dir / "lit_model.pth").is_file(), + "lit_config.json": (checkpoint_dir / "lit_config.json").is_file(), + "tokenizer.json OR tokenizer.model": ( + checkpoint_dir / "tokenizer.json" + ).is_file() + or (checkpoint_dir / "tokenizer.model").is_file(), + "tokenizer_config.json": ( + checkpoint_dir / "tokenizer_config.json" + ).is_file(), + } + if checkpoint_dir.is_dir(): + if all(files.values()): + # we're good + return + problem = f" is missing the files: {[f for f, exists in files.items() if not exists]!r}" + else: + problem = " is not a checkpoint directory" + + # list locally available checkpoints + available = list(Path("checkpoints").glob("*/*")) + if available: + options = "\n --checkpoint_dir ".join( + [""] + [repr(str(p.resolve())) for p in available] + ) + extra = f"\nYou have downloaded locally:{options}\n" + else: + extra = "" + + error_message = ( + f"--checkpoint_dir {str(checkpoint_dir.absolute())!r}{problem}." + "\nFind download instructions at https://github.com/Lightning-AI/lit-gpt/blob/main/tutorials\n" + f"{extra}\nSee all download options by running:\n python scripts/download.py" + ) + print(error_message, file=sys.stderr) + raise SystemExit(1) + + +class SavingProxyForStorage: + def __init__(self, obj, saver, protocol_version=5): + self.protocol_version = protocol_version + self.saver = saver + if not ( + isinstance(obj, torch.storage.TypedStorage) + or torch.is_storage(obj) + ): + raise TypeError(f"expected storage, not {type(obj)}") + + # this logic is taken from PyTorch 2.0+ torch/serialization.py + if isinstance(obj, torch.storage.TypedStorage): + # PT upstream wants to deprecate this eventually... + storage = obj._untyped_storage + storage_type_str = obj._pickle_storage_type() + storage_type = getattr(torch, storage_type_str) + storage_numel = obj._size() + else: + storage = obj + storage_type = normalize_storage_type(type(obj)) + storage_numel = storage.nbytes() + + storage_key = saver._write_storage_and_return_key(storage) + location = torch.serialization.location_tag(storage) + + self.storage_info = ( + "storage", + storage_type, + storage_key, + location, + storage_numel, + ) + + def __reduce_ex__(self, protocol_version): + assert False, "this should be handled with out of band" + + +class SavingProxyForTensor: + def __init__(self, tensor, saver, protocol_version=5): + self.protocol_version = protocol_version + self.reduce_ret_fn, reduce_args = tensor.__reduce_ex__( + protocol_version + ) + if reduce_args[0] == torch._utils._rebuild_tensor_v2: + # for Tensors with Python attributes + (a0, a1, (storage, *a2_other), *other_reduce_args) = reduce_args + assert isinstance( + storage, torch.storage.TypedStorage + ), "Please check for updates" + storage_proxy = SavingProxyForStorage( + storage, saver, protocol_version=protocol_version + ) + self.reduce_args = ( + a0, + a1, + (storage_proxy, *a2_other), + *other_reduce_args, + ) + else: + (storage, *other_reduce_args) = reduce_args + assert isinstance( + storage, torch.storage.TypedStorage + ), "Please check for updates" + storage_proxy = SavingProxyForStorage( + storage, saver, protocol_version=protocol_version + ) + self.reduce_args = (storage_proxy, *other_reduce_args) + + def __reduce_ex__(self, protocol_version): + if protocol_version != self.protocol_version: + raise RuntimeError( + f"Unexpected protocol version: expected {self.protocol_version}, got {protocol_version}" + ) + return self.reduce_ret_fn, self.reduce_args + + +class IncrementalPyTorchPickler(pickle.Pickler): + def __init__(self, saver, *args, **kwargs): + super().__init__(*args, **kwargs) + self.storage_dtypes = {} + self.saver = saver + self.id_map = {} + + # this logic is taken from PyTorch 2.0+ torch/serialization.py + def persistent_id(self, obj): + # FIXME: the docs say that persistent_id should only return a string + # but torch store returns tuples. This works only in the binary protocol + # see + # https://docs.python.org/2/library/pickle.html#pickling-and-unpickling-external-objects + # https://github.com/python/cpython/blob/master/Lib/pickle.py#L527-L537 + if isinstance(obj, SavingProxyForStorage): + return obj.storage_info + + if isinstance(obj, torch.storage.TypedStorage) or torch.is_storage( + obj + ): + if isinstance(obj, torch.storage.TypedStorage): + # TODO: Once we decide to break serialization FC, this case + # can be deleted + storage = obj._untyped_storage + storage_dtype = obj.dtype + storage_type_str = obj._pickle_storage_type() + storage_type = getattr(torch, storage_type_str) + storage_numel = obj._size() + + else: + storage = obj + storage_dtype = torch.uint8 + storage_type = normalize_storage_type(type(obj)) + storage_numel = storage.nbytes() + + # If storage is allocated, ensure that any other saved storages + # pointing to the same data all have the same dtype. If storage is + # not allocated, don't perform this check + if storage.data_ptr() != 0: + if storage.data_ptr() in self.storage_dtypes: + if ( + storage_dtype + != self.storage_dtypes[storage.data_ptr()] + ): + raise RuntimeError( + "Cannot save multiple tensors or storages that view the same data as different types" + ) + else: + self.storage_dtypes[storage.data_ptr()] = storage_dtype + + storage_key = self.id_map.get(storage._cdata) + if storage_key is None: + storage_key = self.saver._write_storage_and_return_key(storage) + self.id_map[storage._cdata] = storage_key + location = torch.serialization.location_tag(storage) + + return ( + "storage", + storage_type, + storage_key, + location, + storage_numel, + ) + + return None + + +class incremental_save: + def __init__(self, name): + self.name = name + self.zipfile = torch._C.PyTorchFileWriter(str(name)) + self.has_saved = False + self.next_key = 0 + + def __enter__(self): + return self + + def store_early(self, tensor): + if isinstance(tensor, torch.Tensor): + return SavingProxyForTensor(tensor, self) + raise TypeError(f"can only store tensors early, not {type(tensor)}") + + def save(self, obj): + if self.has_saved: + raise RuntimeError("have already saved") + # Write the pickle data for `obj` + data_buf = BytesIO() + pickler = IncrementalPyTorchPickler(self, data_buf, protocol=5) + pickler.dump(obj) + data_value = data_buf.getvalue() + self.zipfile.write_record("data.pkl", data_value, len(data_value)) + self.has_saved = True + + def _write_storage_and_return_key(self, storage): + if self.has_saved: + raise RuntimeError("have already saved") + key = self.next_key + self.next_key += 1 + name = f"data/{key}" + if storage.device.type != "cpu": + storage = storage.cpu() + num_bytes = storage.nbytes() + self.zipfile.write_record(name, storage.data_ptr(), num_bytes) + return key + + def __exit__(self, type, value, traceback): + self.zipfile.write_end_of_file() + + +T = TypeVar("T") + + +def chunked_cross_entropy( + logits: Union[torch.Tensor, List[torch.Tensor]], + targets: torch.Tensor, + chunk_size: int = 128, + ignore_index: int = -1, +) -> torch.Tensor: + # with large max_sequence_lengths, the beginning of `backward` allocates a large memory chunk which can dominate + # the memory usage in fine-tuning settings with low number of parameters. + # as a workaround hack, the cross entropy computation is chunked to force it to deallocate on the go, reducing + # the memory spike's magnitude + + # lm_head was chunked (we are fine-tuning) + if isinstance(logits, list): + # don't want to chunk cross entropy + if chunk_size == 0: + logits = torch.cat(logits, dim=1) + logits = logits.reshape(-1, logits.size(-1)) + targets = targets.reshape(-1) + return torch.nn.functional.cross_entropy( + logits, targets, ignore_index=ignore_index + ) + + # chunk cross entropy + logit_chunks = [ + logit_chunk.reshape(-1, logit_chunk.size(-1)) + for logit_chunk in logits + ] + target_chunks = [ + target_chunk.reshape(-1) + for target_chunk in targets.split(logits[0].size(1), dim=1) + ] + loss_chunks = [ + torch.nn.functional.cross_entropy( + logit_chunk, + target_chunk, + ignore_index=ignore_index, + reduction="none", + ) + for logit_chunk, target_chunk in zip(logit_chunks, target_chunks) + ] + non_masked_elems = (targets != ignore_index).sum() + return torch.cat(loss_chunks).sum() / max(1, non_masked_elems) + + # no chunking at all + logits = logits.reshape(-1, logits.size(-1)) + targets = targets.reshape(-1) + if chunk_size == 0: + return torch.nn.functional.cross_entropy( + logits, targets, ignore_index=ignore_index + ) + + # lm_head wasn't chunked, chunk cross entropy + logit_chunks = logits.split(chunk_size) + target_chunks = targets.split(chunk_size) + loss_chunks = [ + torch.nn.functional.cross_entropy( + logit_chunk, + target_chunk, + ignore_index=ignore_index, + reduction="none", + ) + for logit_chunk, target_chunk in zip(logit_chunks, target_chunks) + ] + non_masked_elems = (targets != ignore_index).sum() + return torch.cat(loss_chunks).sum() / max(1, non_masked_elems) + + +def map_old_state_dict_weights( + state_dict: Dict, mapping: Mapping, prefix: str +) -> Dict: + for checkpoint_name, attribute_name in mapping.items(): + full_checkpoint_name = prefix + checkpoint_name + if full_checkpoint_name in state_dict: + full_attribute_name = prefix + attribute_name + state_dict[full_attribute_name] = state_dict.pop( + full_checkpoint_name + ) + return state_dict + + +def get_default_supported_precision(training: bool) -> str: + """Return default precision that is supported by the hardware: either `bf16` or `16`. + + Args: + training: `-mixed` or `-true` version of the precision to use + + Returns: + default precision that is suitable for the task and is supported by the hardware + """ + from lightning.fabric.accelerators import MPSAccelerator + + if MPSAccelerator.is_available() or ( + torch.cuda.is_available() and not torch.cuda.is_bf16_supported() + ): + return "16-mixed" if training else "16-true" + return "bf16-mixed" if training else "bf16-true" + + +def load_checkpoint( + fabric: L.Fabric, + model: nn.Module, + checkpoint_path: Path, + strict: bool = True, +) -> None: + if isinstance(fabric.strategy, FSDPStrategy): + fabric.load_raw(checkpoint_path, model, strict=strict) + else: + state_dict = lazy_load(checkpoint_path) + state_dict = state_dict.get("model", state_dict) + model.load_state_dict(state_dict, strict=strict) + + +def flops_per_param( + max_seq_length: int, n_layer: int, n_embd: int, n_params: int +) -> int: + flops_per_token = ( + 2 * n_params + ) # each parameter is used for a MAC (2 FLOPS) per network operation + # this assumes that all samples have a fixed length equal to the block size + # which is most likely false during finetuning + flops_per_seq = flops_per_token * max_seq_length + attn_flops_per_seq = n_layer * 2 * 2 * (n_embd * (max_seq_length**2)) + return flops_per_seq + attn_flops_per_seq + + +def estimate_flops(model: "GPT", training: bool) -> int: + """Measures estimated FLOPs for MFU. + + Refs: + * https://ar5iv.labs.arxiv.org/html/2205.05198#A1 + * https://ar5iv.labs.arxiv.org/html/2204.02311#A2 + """ + # using all parameters for this is a naive over estimation because not all model parameters actually contribute to + # this FLOP computation (e.g. embedding, norm). For this reason, the result will be higher by a fixed percentage + # (~10%) compared to the measured FLOPs, making those lower but more realistic. + # For a proper estimate, this needs a more fine-grained calculation as in Appendix A of the paper. + n_trainable_params = num_parameters(model, requires_grad=True) + trainable_flops = flops_per_param( + model.max_seq_length, + model.config.n_layer, + model.config.n_embd, + n_trainable_params, + ) + # forward + backward + gradients (assumes no gradient accumulation) + ops_per_step = 3 if training else 1 + n_frozen_params = num_parameters(model, requires_grad=False) + frozen_flops = flops_per_param( + model.max_seq_length, + model.config.n_layer, + model.config.n_embd, + n_frozen_params, + ) + # forward + backward + frozen_ops_per_step = 2 if training else 1 + return ops_per_step * trainable_flops + frozen_ops_per_step * frozen_flops + + +class CycleIterator: + """An iterator that cycles through an iterable indefinitely. + + Example: + >>> iterator = CycleIterator([1, 2, 3]) + >>> [next(iterator) for _ in range(5)] + [1, 2, 3, 1, 2] + + Note: + Unlike ``itertools.cycle``, this iterator does not cache the values of the iterable. + """ + + def __init__(self, iterable: Iterable) -> None: + self.iterable = iterable + self.epoch = 0 + self._iterator = None + + def __next__(self) -> Any: + if self._iterator is None: + self._iterator = iter(self.iterable) + try: + return next(self._iterator) + except StopIteration: + self._iterator = iter(self.iterable) + self.epoch += 1 + return next(self._iterator) + + def __iter__(self) -> Self: + return self + + +def CLI(*args: Any, **kwargs: Any) -> Any: + from jsonargparse import CLI, set_docstring_parse_options + + set_docstring_parse_options(attribute_docstrings=True) + + kwargs.setdefault("as_positional", False) + return CLI(*args, **kwargs) diff --git a/examples/llm_finetuning/materializers/__init__.py b/examples/llm_finetuning/materializers/__init__.py new file mode 100644 index 00000000000..757bd8418a5 --- /dev/null +++ b/examples/llm_finetuning/materializers/__init__.py @@ -0,0 +1,16 @@ +# Apache Software License 2.0 +# +# Copyright (c) ZenML GmbH 2024. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# diff --git a/examples/llm_finetuning/materializers/directory_materializer.py b/examples/llm_finetuning/materializers/directory_materializer.py new file mode 100644 index 00000000000..4adc7b4a10a --- /dev/null +++ b/examples/llm_finetuning/materializers/directory_materializer.py @@ -0,0 +1,71 @@ +# Apache Software License 2.0 +# +# Copyright (c) ZenML GmbH 2024. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import os +from pathlib import Path +from tempfile import mkdtemp +from typing import Any, ClassVar, Tuple, Type + +from zenml.enums import ArtifactType +from zenml.io import fileio +from zenml.materializers.base_materializer import BaseMaterializer + + +class DirectoryMaterializer(BaseMaterializer): + """Materializer to store local directories in the artifact store.""" + + ASSOCIATED_TYPES: ClassVar[Tuple[Type[Any], ...]] = (Path,) + ASSOCIATED_ARTIFACT_TYPE: ClassVar[ArtifactType] = ArtifactType.DATA + + def load(self, data_type: Type[Any]) -> Any: + """Copy the artifact files to a local temp directory. + + Args: + data_type: Unused. + + Returns: + Path to the local directory that contains the artifact files. + """ + directory = mkdtemp(prefix="zenml-artifact") + self._copy_directory(src=self.uri, dst=directory) + return Path(directory) + + def save(self, data: Any) -> None: + """Store the directory in the artifact store. + + Args: + data: Path to a local directory to store. + """ + assert isinstance(data, Path) + self._copy_directory(src=str(data), dst=self.uri) + + @staticmethod + def _copy_directory(src: str, dst: str) -> None: + """Recursively copy a directory. + + Args: + src: The directory to copy. + dst: Where to copy the directory to. + """ + for src_dir, _, files in fileio.walk(src): + dst_dir = os.path.join(dst, os.path.relpath(src_dir, src)) + fileio.makedirs(dst_dir) + + for file in files: + src_file = os.path.join(src_dir, file) + dst_file = os.path.join(dst_dir, file) + fileio.copy(src_file, dst_file) diff --git a/examples/llm_finetuning/pipelines/__init__.py b/examples/llm_finetuning/pipelines/__init__.py new file mode 100644 index 00000000000..2d7c5390a7d --- /dev/null +++ b/examples/llm_finetuning/pipelines/__init__.py @@ -0,0 +1,21 @@ +# Apache Software License 2.0 +# +# Copyright (c) ZenML GmbH 2024. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +from pipelines.evaluate import llm_lora_evaluation +from pipelines.feature_engineering import llm_lora_feature_engineering +from pipelines.finetuning import llm_lora_finetuning +from pipelines.merge import llm_lora_merging diff --git a/examples/llm_finetuning/pipelines/evaluate.py b/examples/llm_finetuning/pipelines/evaluate.py new file mode 100644 index 00000000000..41feb5bfa72 --- /dev/null +++ b/examples/llm_finetuning/pipelines/evaluate.py @@ -0,0 +1,33 @@ +# Apache Software License 2.0 +# +# Copyright (c) ZenML GmbH 2024. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +from steps import evaluate + +from zenml import pipeline +from zenml.config import DockerSettings + + +@pipeline( + settings={ + "docker": DockerSettings( + apt_packages=["git"], requirements="requirements.txt" + ) + } +) +def llm_lora_evaluation() -> None: + """Pipeline to evaluate a LoRA fine-tuned LLM.""" + evaluate() diff --git a/examples/llm_finetuning/pipelines/feature_engineering.py b/examples/llm_finetuning/pipelines/feature_engineering.py new file mode 100644 index 00000000000..6630bd1fb86 --- /dev/null +++ b/examples/llm_finetuning/pipelines/feature_engineering.py @@ -0,0 +1,33 @@ +# Apache Software License 2.0 +# +# Copyright (c) ZenML GmbH 2024. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +from steps import feature_engineering + +from zenml import pipeline +from zenml.config import DockerSettings + + +@pipeline( + settings={ + "docker": DockerSettings( + apt_packages=["git"], requirements="requirements.txt" + ) + } +) +def llm_lora_feature_engineering() -> None: + """Feature engineering pipeline.""" + feature_engineering() diff --git a/examples/llm_finetuning/pipelines/finetuning.py b/examples/llm_finetuning/pipelines/finetuning.py new file mode 100644 index 00000000000..faa7d185fda --- /dev/null +++ b/examples/llm_finetuning/pipelines/finetuning.py @@ -0,0 +1,44 @@ +# Apache Software License 2.0 +# +# Copyright (c) ZenML GmbH 2024. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +from typing import Optional + +from steps import finetune + +from zenml import get_pipeline_context, pipeline +from zenml.config import DockerSettings + + +@pipeline( + settings={ + "docker": DockerSettings( + apt_packages=["git"], requirements="requirements.txt" + ) + } +) +def llm_lora_finetuning( + dataset_artifact_name: Optional[str] = None, + dataset_artifact_version: Optional[str] = None, +) -> None: + """Pipeline to finetune LLMs using LoRA.""" + dataset_directory = None + if dataset_artifact_name: + dataset_directory = get_pipeline_context().model.get_artifact( + name=dataset_artifact_name, version=dataset_artifact_version + ) + + finetune(dataset_directory=dataset_directory) diff --git a/examples/llm_finetuning/pipelines/merge.py b/examples/llm_finetuning/pipelines/merge.py new file mode 100644 index 00000000000..20c1c1f36f1 --- /dev/null +++ b/examples/llm_finetuning/pipelines/merge.py @@ -0,0 +1,33 @@ +# Apache Software License 2.0 +# +# Copyright (c) ZenML GmbH 2024. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +from steps import merge + +from zenml import pipeline +from zenml.config import DockerSettings + + +@pipeline( + settings={ + "docker": DockerSettings( + apt_packages=["git"], requirements="requirements.txt" + ) + } +) +def llm_lora_merging() -> None: + """Pipeline to merge LLMs with adapters.""" + merge() diff --git a/examples/llm_finetuning/requirements.txt b/examples/llm_finetuning/requirements.txt new file mode 100644 index 00000000000..ad19fe96de8 --- /dev/null +++ b/examples/llm_finetuning/requirements.txt @@ -0,0 +1,17 @@ +zenml +torch>=2.2.0 +lightning @ git+https://github.com/Lightning-AI/lightning@ed367ca675861cdf40dbad2e4d66f7eee2ec50af +jsonargparse[signatures] # CLI +bitsandbytes==0.41.0 # quantization +scipy # required by bitsandbytes +sentencepiece # llama-based models +tokenizers # pythia, falcon, redpajama +datasets # eval +requests # scripts/prepare_* +zstandard # scripts/prepare_redpajama.py, scripts/prepare_starcoder.py +pandas # scripts/prepare_csv.py, scripts/prepare_starcoder.py +pyarrow # scripts/prepare_starcoder.py +# eval +git+https://github.com/EleutherAI/lm-evaluation-harness.git@115206dc89dad67b8beaa90051fb52db77f0a529 +# scripts/prepare_slimpajama.py, scripts/prepare_starcoder.py, pretrain/tinyllama.py +lightning[data] @ git+https://github.com/Lightning-AI/lightning@ed367ca675861cdf40dbad2e4d66f7eee2ec50af diff --git a/examples/llm_finetuning/run.py b/examples/llm_finetuning/run.py new file mode 100644 index 00000000000..5bfd379ba1d --- /dev/null +++ b/examples/llm_finetuning/run.py @@ -0,0 +1,132 @@ +# Apache Software License 2.0 +# +# Copyright (c) ZenML GmbH 2024. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import os +from typing import Optional + +import click +from pipelines import ( + llm_lora_evaluation, + llm_lora_feature_engineering, + llm_lora_finetuning, + llm_lora_merging, +) + +from zenml.logger import get_logger + +logger = get_logger(__name__) + + +@click.command( + help=""" +ZenML LLM Finetuning project CLI v0.1.0. + +Run the ZenML LLM Finetuning project LLM LoRA finetuning pipelines. + +Examples: + + \b + # Run the feature feature engineering pipeline + python run.py --feature-pipeline + + \b + # Run the finetuning pipeline + python run.py --finetuning-pipeline + + \b + # Run the merging pipeline + python run.py --merging-pipeline + + \b + # Run the evaluation pipeline + python run.py --eval-pipeline +""" +) +@click.option( + "--config", + type=str, + default=None, + help="Path to the YAML config file.", +) +@click.option( + "--feature-pipeline", + is_flag=True, + default=False, + help="Whether to run the pipeline that creates the dataset.", +) +@click.option( + "--finetuning-pipeline", + is_flag=True, + default=False, + help="Whether to run the pipeline that finetunes the model.", +) +@click.option( + "--merging-pipeline", + is_flag=True, + default=False, + help="Whether to run the pipeline that merges the model and adapter.", +) +@click.option( + "--eval-pipeline", + is_flag=True, + default=False, + help="Whether to run the pipeline that evaluates the model.", +) +@click.option( + "--no-cache", + is_flag=True, + default=False, + help="Disable caching for the pipeline run.", +) +def main( + config: Optional[str] = None, + feature_pipeline: bool = False, + finetuning_pipeline: bool = False, + merging_pipeline: bool = False, + eval_pipeline: bool = False, + no_cache: bool = False, +): + """Main entry point for the pipeline execution. + + Args: + no_cache: If `True` cache will be disabled. + """ + config_folder = os.path.join( + os.path.dirname(os.path.realpath(__file__)), + "configs", + ) + pipeline_args = {"enable_cache": not no_cache} + if not config: + raise RuntimeError("Config file is required to run a pipeline.") + + pipeline_args["config_path"] = os.path.join(config_folder, config) + + if feature_pipeline: + llm_lora_feature_engineering.with_options(**pipeline_args)() + + if finetuning_pipeline: + llm_lora_finetuning.with_options(**pipeline_args)() + + if merging_pipeline: + llm_lora_merging.with_options(**pipeline_args)() + + if eval_pipeline: + llm_lora_evaluation.with_options(**pipeline_args)() + + +if __name__ == "__main__": + main() diff --git a/examples/llm_finetuning/scripts/convert_hf_checkpoint.py b/examples/llm_finetuning/scripts/convert_hf_checkpoint.py new file mode 100644 index 00000000000..14d0ff6fb73 --- /dev/null +++ b/examples/llm_finetuning/scripts/convert_hf_checkpoint.py @@ -0,0 +1,377 @@ +# Copyright Lightning AI. Licensed under the Apache License 2.0, see LICENSE file. + +import gc +import json +import sys +from collections import defaultdict +from dataclasses import asdict +from functools import partial +from pathlib import Path +from typing import Dict, List, Optional, Tuple, Union + +import torch +from lightning.fabric.utilities.load import ( + _NotYetLoadedTensor as NotYetLoadedTensor, +) + +# support running without installing as a package +wd = Path(__file__).parent.parent.resolve() +sys.path.append(str(wd)) + +from lit_gpt import Config +from lit_gpt.utils import incremental_save, lazy_load + + +def copy_weights_gpt_neox( + state_dict: Dict[str, torch.Tensor], + hf_weights: Dict[str, Union[torch.Tensor, NotYetLoadedTensor]], + saver: Optional[incremental_save] = None, + dtype: Optional[torch.dtype] = None, +) -> None: + weight_map = { + "gpt_neox.embed_in.weight": "transformer.wte.weight", + "gpt_neox.layers.{}.input_layernorm.bias": "transformer.h.{}.norm_1.bias", + "gpt_neox.layers.{}.input_layernorm.weight": "transformer.h.{}.norm_1.weight", + "gpt_neox.layers.{}.attention.query_key_value.bias": "transformer.h.{}.attn.attn.bias", + "gpt_neox.layers.{}.attention.query_key_value.weight": "transformer.h.{}.attn.attn.weight", + "gpt_neox.layers.{}.attention.dense.bias": "transformer.h.{}.attn.proj.bias", + "gpt_neox.layers.{}.attention.dense.weight": "transformer.h.{}.attn.proj.weight", + "gpt_neox.layers.{}.attention.rotary_emb.inv_freq": None, + "gpt_neox.layers.{}.attention.bias": None, + "gpt_neox.layers.{}.attention.masked_bias": None, + "gpt_neox.layers.{}.post_attention_layernorm.bias": "transformer.h.{}.norm_2.bias", + "gpt_neox.layers.{}.post_attention_layernorm.weight": "transformer.h.{}.norm_2.weight", + "gpt_neox.layers.{}.mlp.dense_h_to_4h.bias": "transformer.h.{}.mlp.fc.bias", + "gpt_neox.layers.{}.mlp.dense_h_to_4h.weight": "transformer.h.{}.mlp.fc.weight", + "gpt_neox.layers.{}.mlp.dense_4h_to_h.bias": "transformer.h.{}.mlp.proj.bias", + "gpt_neox.layers.{}.mlp.dense_4h_to_h.weight": "transformer.h.{}.mlp.proj.weight", + "gpt_neox.final_layer_norm.bias": "transformer.ln_f.bias", + "gpt_neox.final_layer_norm.weight": "transformer.ln_f.weight", + "embed_out.weight": "lm_head.weight", + } + + for name, param in hf_weights.items(): + if "gpt_neox.layers" in name: + from_name, number = layer_template(name, 2) + to_name = weight_map[from_name] + if to_name is None: + continue + to_name = to_name.format(number) + else: + to_name = weight_map[name] + param = load_param(param, name, dtype) + if saver is not None: + param = saver.store_early(param) + state_dict[to_name] = param + + +def copy_weights_falcon( + model_name: str, + state_dict: Dict[str, torch.Tensor], + hf_weights: Dict[str, Union[torch.Tensor, NotYetLoadedTensor]], + saver: Optional[incremental_save] = None, + dtype: Optional[torch.dtype] = None, +) -> None: + weight_map = { + "transformer.word_embeddings.weight": "transformer.wte.weight", + "transformer.h.{}.self_attention.query_key_value.weight": "transformer.h.{}.attn.attn.weight", + "transformer.h.{}.self_attention.dense.weight": "transformer.h.{}.attn.proj.weight", + "transformer.h.{}.mlp.dense_h_to_4h.weight": "transformer.h.{}.mlp.fc.weight", + "transformer.h.{}.mlp.dense_4h_to_h.weight": "transformer.h.{}.mlp.proj.weight", + "transformer.ln_f.bias": "transformer.ln_f.bias", + "transformer.ln_f.weight": "transformer.ln_f.weight", + "lm_head.weight": "lm_head.weight", + } + # the original model definition is different for each size + if "7b" in model_name: + weight_map.update( + { + "transformer.h.{}.input_layernorm.bias": "transformer.h.{}.norm_1.bias", + "transformer.h.{}.input_layernorm.weight": "transformer.h.{}.norm_1.weight", + } + ) + elif "40b" in model_name or "180B" in model_name: + weight_map.update( + { + "transformer.h.{}.ln_attn.bias": "transformer.h.{}.norm_1.bias", + "transformer.h.{}.ln_attn.weight": "transformer.h.{}.norm_1.weight", + "transformer.h.{}.ln_mlp.bias": "transformer.h.{}.norm_2.bias", + "transformer.h.{}.ln_mlp.weight": "transformer.h.{}.norm_2.weight", + } + ) + else: + raise NotImplementedError + + for name, param in hf_weights.items(): + if "transformer.h" in name: + from_name, number = layer_template(name, 2) + to_name = weight_map[from_name].format(number) + else: + to_name = weight_map[name] + param = load_param(param, name, dtype) + if saver is not None: + param = saver.store_early(param) + state_dict[to_name] = param + + +def copy_weights_hf_llama( + config: Config, + qkv_weights: Dict[int, List[Optional[NotYetLoadedTensor]]], + state_dict: Dict[str, torch.Tensor], + hf_weights: Dict[str, Union[torch.Tensor, NotYetLoadedTensor]], + saver: Optional[incremental_save] = None, + dtype: Optional[torch.dtype] = None, +) -> None: + weight_map = { + "model.embed_tokens.weight": "transformer.wte.weight", + "model.layers.{}.input_layernorm.weight": "transformer.h.{l}.norm_1.weight", + "model.layers.{}.input_layernorm.bias": "transformer.h.{l}.norm_1.bias", + "model.layers.{}.self_attn.q_proj.weight": None, + "model.layers.{}.self_attn.k_proj.weight": None, + "model.layers.{}.self_attn.v_proj.weight": None, + "model.layers.{}.self_attn.o_proj.weight": "transformer.h.{l}.attn.proj.weight", + "model.layers.{}.self_attn.rotary_emb.inv_freq": None, + "model.layers.{}.post_attention_layernorm.weight": "transformer.h.{l}.norm_2.weight", + "model.layers.{}.post_attention_layernorm.bias": "transformer.h.{l}.norm_2.bias", + "model.norm.weight": "transformer.ln_f.weight", + "model.norm.bias": "transformer.ln_f.bias", + "lm_head.weight": "lm_head.weight", + } + if config._mlp_class == "LLaMAMoE": + weight_map.update( + { + "model.layers.{}.block_sparse_moe.gate.weight": "transformer.h.{l}.mlp.gate.weight", + "model.layers.{}.block_sparse_moe.experts.{}.w1.weight": "transformer.h.{l}.mlp.experts.{e}.fc_1.weight", + "model.layers.{}.block_sparse_moe.experts.{}.w3.weight": "transformer.h.{l}.mlp.experts.{e}.fc_2.weight", + "model.layers.{}.block_sparse_moe.experts.{}.w2.weight": "transformer.h.{l}.mlp.experts.{e}.proj.weight", + } + ) + elif config._mlp_class in ("LLaMAMLP", "GemmaMLP"): + weight_map.update( + { + "model.layers.{}.mlp.gate_proj.weight": "transformer.h.{l}.mlp.fc_1.weight", + "model.layers.{}.mlp.up_proj.weight": "transformer.h.{l}.mlp.fc_2.weight", + "model.layers.{}.mlp.down_proj.weight": "transformer.h.{l}.mlp.proj.weight", + } + ) + else: + raise NotImplementedError + + for name, param in hf_weights.items(): + if "model.layers" in name: + from_name, l = layer_template(name, 2) + e = None + if "block_sparse_moe.experts" in name: + from_name, e = layer_template(from_name, 5) + qkv = qkv_weights.setdefault(l, [None, None, None]) + if "q_proj" in name: + qkv[0] = param + elif "k_proj" in name: + qkv[1] = param + elif "v_proj" in name: + qkv[2] = param + to_name = weight_map[from_name] + if to_name is None: + continue + to_name = to_name.format(l=l, e=e) + else: + to_name = weight_map[name] + param = load_param(param, name, dtype) + if saver is not None: + param = saver.store_early(param) + state_dict[to_name] = param + + if "lm_head.weight" not in state_dict: + state_dict["lm_head.weight"] = state_dict["transformer.wte.weight"] + + # convert separate q, k, v matrices into an interleaved qkv + for i, (q, k, v) in list(qkv_weights.items()): + if q is None or k is None or v is None: + # split across different .bin files + continue + q = load_param(q, f"layer {i} q", dtype) + k = load_param(k, f"layer {i} k", dtype) + v = load_param(v, f"layer {i} v", dtype) + q_per_kv = config.n_head // config.n_query_groups + qs = torch.split(q, config.head_size * q_per_kv) + ks = torch.split(k, config.head_size) + vs = torch.split(v, config.head_size) + cycled = [t for group in zip(qs, ks, vs) for t in group] + qkv = torch.cat(cycled) + state_dict[f"transformer.h.{i}.attn.attn.weight"] = qkv + del qkv_weights[i] + + +def copy_weights_phi( + config: Config, + qkv_weights: dict, + state_dict: Dict[str, torch.Tensor], + hf_weights: Dict[str, Union[torch.Tensor, NotYetLoadedTensor]], + saver: Optional[incremental_save] = None, + dtype: Optional[torch.dtype] = None, +) -> None: + if any( + layer_name.startswith(("layers.", "transformer.")) + for layer_name in hf_weights + ): + raise ValueError( + "You are using an outdated Phi checkpoint. Please reload it as described in 'tutorials/download_phi.md'" + ) + + weight_map = { + "model.embed_tokens.weight": "transformer.wte.weight", + "model.layers.{}.input_layernorm.weight": "transformer.h.{}.norm_1.weight", + "model.layers.{}.input_layernorm.bias": "transformer.h.{}.norm_1.bias", + "model.layers.{}.self_attn.q_proj.weight": None, + "model.layers.{}.self_attn.q_proj.bias": None, + "model.layers.{}.self_attn.k_proj.weight": None, + "model.layers.{}.self_attn.k_proj.bias": None, + "model.layers.{}.self_attn.v_proj.weight": None, + "model.layers.{}.self_attn.v_proj.bias": None, + "model.layers.{}.self_attn.dense.weight": "transformer.h.{}.attn.proj.weight", + "model.layers.{}.self_attn.dense.bias": "transformer.h.{}.attn.proj.bias", + "model.layers.{}.mlp.fc1.weight": "transformer.h.{}.mlp.fc.weight", + "model.layers.{}.mlp.fc1.bias": "transformer.h.{}.mlp.fc.bias", + "model.layers.{}.mlp.fc2.weight": "transformer.h.{}.mlp.proj.weight", + "model.layers.{}.mlp.fc2.bias": "transformer.h.{}.mlp.proj.bias", + "model.final_layernorm.weight": "transformer.ln_f.weight", + "model.final_layernorm.bias": "transformer.ln_f.bias", + "lm_head.weight": "lm_head.weight", + "lm_head.bias": "lm_head.bias", + } + + for name, param in hf_weights.items(): + if name.startswith("model.layers."): + from_name, l = layer_template(name, 2) + qkv = qkv_weights.setdefault(l, defaultdict(dict)) + if any(w in from_name for w in ("q_proj", "k_proj", "v_proj")): + weight_name, weight_type = from_name.split(".")[-2:] + qkv[weight_type][weight_name] = param + to_name = weight_map[from_name] + if to_name is None: + continue + to_name = to_name.format(l) + else: + to_name = weight_map[name] + param = load_param(param, name, dtype) + if saver is not None: + param = saver.store_early(param) + state_dict[to_name] = param + + for i in list(qkv_weights): + for weight_type in list(qkv_weights[i]): + qkv = qkv_weights[i][weight_type] + if len(qkv) != 3: + # split across different .bin files + continue + q = load_param(qkv["q_proj"], f"layer {i} q {weight_type}", dtype) + k = load_param(qkv["k_proj"], f"layer {i} k {weight_type}", dtype) + v = load_param(qkv["v_proj"], f"layer {i} v {weight_type}", dtype) + q_per_kv = config.n_head // config.n_query_groups + qs = torch.split(q, config.head_size * q_per_kv) + ks = torch.split(k, config.head_size) + vs = torch.split(v, config.head_size) + cycled = [t for group in zip(qs, ks, vs) for t in group] + qkv = torch.cat(cycled) + state_dict[f"transformer.h.{i}.attn.attn.{weight_type}"] = qkv + del qkv_weights[i][weight_type] + + +def layer_template(layer_name: str, idx: int) -> Tuple[str, int]: + split = layer_name.split(".") + number = int(split[idx]) + split[idx] = "{}" + from_name = ".".join(split) + return from_name, number + + +def load_param( + param: Union[torch.Tensor, NotYetLoadedTensor], + name: str, + dtype: Optional[torch.dtype], +) -> torch.Tensor: + if hasattr(param, "_load_tensor"): + # support tensors loaded via `lazy_load()` + print(f"Loading {name!r} into RAM") + param = param._load_tensor() + if ( + dtype is not None + and type(dtype) is not NotYetLoadedTensor + and dtype != param.dtype + ): + print(f"Converting {name!r} from {param.dtype} to {dtype}") + param = param.to(dtype) + return param + + +@torch.inference_mode() +def convert_hf_checkpoint( + *, + checkpoint_dir: Path = Path( + "checkpoints/stabilityai/stablelm-base-alpha-3b" + ), + model_name: Optional[str] = None, + dtype: Optional[str] = None, +) -> None: + if model_name is None: + model_name = checkpoint_dir.name + if dtype is not None: + dtype = getattr(torch, dtype) + + config = Config.from_name(model_name) + config_dict = asdict(config) + print(f"Model config {config_dict}") + with open(checkpoint_dir / "lit_config.json", "w") as json_config: + json.dump(config_dict, json_config) + + if "falcon" in model_name: + copy_fn = partial(copy_weights_falcon, model_name) + elif config._mlp_class in ("LLaMAMLP", "GemmaMLP", "LLaMAMoE"): + # holder to reconstitute the split q, k, v + qkv_weights = {} + copy_fn = partial(copy_weights_hf_llama, config, qkv_weights) + elif "phi" in model_name: + # holder to reconstitute the split q, k, v + qkv_weights = {} + copy_fn = partial(copy_weights_phi, config, qkv_weights) + else: + copy_fn = copy_weights_gpt_neox + + # initialize a new empty state dict to hold our new weights + sd = {} + + # Load the json file containing weight mapping + pytorch_bin_map_json_path = checkpoint_dir / "pytorch_model.bin.index.json" + if ( + pytorch_bin_map_json_path.is_file() + ): # not all checkpoints have this file + with open(pytorch_bin_map_json_path) as json_map: + bin_index = json.load(json_map) + bin_files = { + checkpoint_dir / bin for bin in bin_index["weight_map"].values() + } + else: + bin_files = set(checkpoint_dir.glob("*.bin")) + # some checkpoints serialize the training arguments + bin_files = {f for f in bin_files if f.name != "training_args.bin"} + if not bin_files: + raise ValueError( + f"Expected {str(checkpoint_dir)!r} to contain .bin files" + ) + + with incremental_save(checkpoint_dir / "lit_model.pth") as saver: + # for checkpoints that split the QKV across several files, we need to keep all the bin files + # open, so we use `ExitStack` to close them all together at the end + for bin_file in sorted(bin_files): + print("Processing", bin_file) + hf_weights = lazy_load(bin_file) + copy_fn(sd, hf_weights, saver=saver, dtype=dtype) + gc.collect() + print("Saving converted checkpoint") + saver.save(sd) + + +if __name__ == "__main__": + from jsonargparse import CLI + + CLI(convert_hf_checkpoint) diff --git a/examples/llm_finetuning/scripts/convert_lit_checkpoint.py b/examples/llm_finetuning/scripts/convert_lit_checkpoint.py new file mode 100644 index 00000000000..1239e7d255d --- /dev/null +++ b/examples/llm_finetuning/scripts/convert_lit_checkpoint.py @@ -0,0 +1,284 @@ +# Copyright Lightning AI. Licensed under the Apache License 2.0, see LICENSE file. + +import gc +import sys +from functools import partial +from pathlib import Path +from typing import Dict, Optional, Tuple, Union + +import torch +from lightning.fabric.utilities.load import ( + _NotYetLoadedTensor as NotYetLoadedTensor, +) + +# support running without installing as a package +wd = Path(__file__).parent.parent.resolve() +sys.path.append(str(wd)) + +from lit_gpt import Config +from lit_gpt.utils import CLI, incremental_save, lazy_load + +from scripts.convert_hf_checkpoint import layer_template, load_param + + +def copy_weights_falcon( + model_name: str, + state_dict: Dict[str, torch.Tensor], + lit_weights: Dict[str, Union[torch.Tensor, NotYetLoadedTensor]], + saver: Optional[incremental_save] = None, +) -> None: + weight_map = { + "transformer.wte.weight": "transformer.word_embeddings.weight", + "transformer.h.{}.attn.attn.weight": "transformer.h.{}.self_attention.query_key_value.weight", + "transformer.h.{}.attn.proj.weight": "transformer.h.{}.self_attention.dense.weight", + "transformer.h.{}.mlp.fc.weight": "transformer.h.{}.mlp.dense_h_to_4h.weight", + "transformer.h.{}.mlp.proj.weight": "transformer.h.{}.mlp.dense_4h_to_h.weight", + "transformer.ln_f.bias": "transformer.ln_f.bias", + "transformer.ln_f.weight": "transformer.ln_f.weight", + "lm_head.weight": "lm_head.weight", + } + # the original model definition is different for each size + if "7b" in model_name: + weight_map.update( + { + "transformer.h.{}.norm_1.bias": "transformer.h.{}.input_layernorm.bias", + "transformer.h.{}.norm_1.weight": "transformer.h.{}.input_layernorm.weight", + } + ) + elif "40b" in model_name or "180B" in model_name: + weight_map.update( + { + "transformer.h.{}.norm_1.bias": "transformer.h.{}.ln_attn.bias", + "transformer.h.{}.norm_1.weight": "transformer.h.{}.ln_attn.weight", + "transformer.h.{}.norm_2.bias": "transformer.h.{}.ln_mlp.bias", + "transformer.h.{}.norm_2.weight": "transformer.h.{}.ln_mlp.weight", + } + ) + else: + raise NotImplementedError + + for name, param in lit_weights.items(): + if "transformer.h" in name: + from_name, number = layer_template(name, 2) + to_name = weight_map[from_name].format(number) + else: + to_name = weight_map[name] + param = load_param(param, name, None) + if saver is not None: + param = saver.store_early(param) + state_dict[to_name] = param + + +def copy_weights_gpt_neox( + state_dict: Dict[str, torch.Tensor], + lit_weights: Dict[str, Union[torch.Tensor, NotYetLoadedTensor]], + saver: Optional[incremental_save] = None, +) -> None: + weight_map = { + "transformer.wte.weight": "gpt_neox.embed_in.weight", + "transformer.h.{}.norm_1.bias": "gpt_neox.layers.{}.input_layernorm.bias", + "transformer.h.{}.norm_1.weight": "gpt_neox.layers.{}.input_layernorm.weight", + "transformer.h.{}.attn.attn.bias": "gpt_neox.layers.{}.attention.query_key_value.bias", + "transformer.h.{}.attn.attn.weight": "gpt_neox.layers.{}.attention.query_key_value.weight", + "transformer.h.{}.attn.proj.bias": "gpt_neox.layers.{}.attention.dense.bias", + "transformer.h.{}.attn.proj.weight": "gpt_neox.layers.{}.attention.dense.weight", + "transformer.h.{}.norm_2.bias": "gpt_neox.layers.{}.post_attention_layernorm.bias", + "transformer.h.{}.norm_2.weight": "gpt_neox.layers.{}.post_attention_layernorm.weight", + "transformer.h.{}.mlp.fc.bias": "gpt_neox.layers.{}.mlp.dense_h_to_4h.bias", + "transformer.h.{}.mlp.fc.weight": "gpt_neox.layers.{}.mlp.dense_h_to_4h.weight", + "transformer.h.{}.mlp.proj.bias": "gpt_neox.layers.{}.mlp.dense_4h_to_h.bias", + "transformer.h.{}.mlp.proj.weight": "gpt_neox.layers.{}.mlp.dense_4h_to_h.weight", + "transformer.ln_f.bias": "gpt_neox.final_layer_norm.bias", + "transformer.ln_f.weight": "gpt_neox.final_layer_norm.weight", + "lm_head.weight": "embed_out.weight", + } + + for name, param in lit_weights.items(): + if "transformer.h" in name: + from_name, number = layer_template(name, 2) + to_name = weight_map[from_name].format(number) + else: + to_name = weight_map[name] + param = load_param(param, name, None) + if saver is not None: + param = saver.store_early(param) + state_dict[to_name] = param + + +def copy_weights_llama( + config: Config, + state_dict: Dict[str, torch.Tensor], + lit_weights: Dict[str, Union[torch.Tensor, NotYetLoadedTensor]], + untie_weights: bool = False, + saver: Optional[incremental_save] = None, +) -> None: + weight_map = { + "transformer.wte.weight": "model.embed_tokens.weight", + "transformer.h.{}.norm_1.weight": "model.layers.{l}.input_layernorm.weight", + "transformer.h.{}.norm_1.bias": "model.layers.{l}.input_layernorm.bias", + "transformer.h.{}.attn.proj.weight": "model.layers.{l}.self_attn.o_proj.weight", + "transformer.h.{}.norm_2.weight": "model.layers.{l}.post_attention_layernorm.weight", + "transformer.h.{}.norm_2.bias": "model.layers.{l}.post_attention_layernorm.bias", + "transformer.ln_f.weight": "model.norm.weight", + "transformer.ln_f.bias": "model.norm.bias", + "lm_head.weight": "lm_head.weight", + } + if config._mlp_class == "LLaMAMoE": + weight_map.update( + { + "transformer.h.{}.mlp.gate.weight": "model.layers.{l}.block_sparse_moe.gate.weight", + "transformer.h.{}.mlp.experts.{}.fc_1.weight": "model.layers.{l}.block_sparse_moe.experts.{e}.w1.weight", + "transformer.h.{}.mlp.experts.{}.fc_2.weight": "model.layers.{l}.block_sparse_moe.experts.{e}.w3.weight", + "transformer.h.{}.mlp.experts.{}.proj.weight": "model.layers.{l}.block_sparse_moe.experts.{e}.w2.weight", + } + ) + elif config._mlp_class in ("LLaMAMLP", "GemmaMLP"): + weight_map.update( + { + "transformer.h.{}.mlp.fc_1.weight": "model.layers.{l}.mlp.gate_proj.weight", + "transformer.h.{}.mlp.fc_2.weight": "model.layers.{l}.mlp.up_proj.weight", + "transformer.h.{}.mlp.proj.weight": "model.layers.{l}.mlp.down_proj.weight", + } + ) + else: + raise NotImplementedError + + for name, param in lit_weights.items(): + if name == "lm_head.weight" and untie_weights: + continue + if name.endswith(".attn.attn.weight"): + from_name, l = layer_template(name, 2) + q = "model.layers.{}.self_attn.q_proj.weight".format(l) + k = "model.layers.{}.self_attn.k_proj.weight".format(l) + v = "model.layers.{}.self_attn.v_proj.weight".format(l) + qkv = load_param(param, name, None) + qp, kp, vp = qkv_split(qkv, config) + for to_name, param in zip((q, k, v), (qp, kp, vp)): + if saver is not None: + param = saver.store_early(param) + state_dict[to_name] = param + else: + if "transformer.h" in name: + from_name, l = layer_template(name, 2) + e = None + if "mlp.experts" in name: + from_name, e = layer_template(from_name, 5) + to_name = weight_map[from_name] + to_name = to_name.format(l=l, e=e) + else: + to_name = weight_map[name] + param = load_param(param, name, None) + if saver is not None: + param = saver.store_early(param) + state_dict[to_name] = param + + +def copy_weights_phi( + config: Config, + state_dict: Dict[str, torch.Tensor], + lit_weights: Dict[str, Union[torch.Tensor, NotYetLoadedTensor]], + saver: Optional[incremental_save] = None, +) -> None: + weight_map = { + "transformer.wte.weight": "model.embed_tokens.weight", + "transformer.h.{}.norm_1.weight": "model.layers.{}.input_layernorm.weight", + "transformer.h.{}.norm_1.bias": "model.layers.{}.input_layernorm.bias", + "transformer.h.{}.attn.proj.weight": "model.layers.{}.self_attn.dense.weight", + "transformer.h.{}.attn.proj.bias": "model.layers.{}.self_attn.dense.bias", + "transformer.h.{}.mlp.fc.weight": "model.layers.{}.mlp.fc1.weight", + "transformer.h.{}.mlp.fc.bias": "model.layers.{}.mlp.fc1.bias", + "transformer.h.{}.mlp.proj.weight": "model.layers.{}.mlp.fc2.weight", + "transformer.h.{}.mlp.proj.bias": "model.layers.{}.mlp.fc2.bias", + "transformer.ln_f.weight": "model.final_layernorm.weight", + "transformer.ln_f.bias": "model.final_layernorm.bias", + "lm_head.weight": "lm_head.weight", + "lm_head.bias": "lm_head.bias", + } + + for name, param in lit_weights.items(): + if name.endswith((".attn.attn.weight", ".attn.attn.bias")): + from_name, l = layer_template(name, 2) + weight_type = name.split(".")[-1] # weight or bias + q = f"model.layers.{l}.self_attn.q_proj.{weight_type}" + k = f"model.layers.{l}.self_attn.k_proj.{weight_type}" + v = f"model.layers.{l}.self_attn.v_proj.{weight_type}" + qkv = load_param(param, name, None) + qp, kp, vp = qkv_split(qkv, config) + for to_name, param in zip((q, k, v), (qp, kp, vp)): + if saver is not None: + param = saver.store_early(param) + state_dict[to_name] = param + else: + if "transformer.h" in name: + from_name, l = layer_template(name, 2) + to_name = weight_map[from_name] + to_name = to_name.format(l) + else: + to_name = weight_map[name] + param = load_param(param, name, None) + if saver is not None: + param = saver.store_early(param) + state_dict[to_name] = param + + +def qkv_split( + param: Union[torch.Tensor, NotYetLoadedTensor], config: Config +) -> Tuple[torch.Tensor, torch.Tensor, torch.Tensor]: + q_per_kv = config.n_head // config.n_query_groups + qs = [] + ks = [] + vs = [] + for chunk in torch.chunk(param, config.n_query_groups): + split = torch.split( + chunk, + [config.head_size * q_per_kv, config.head_size, config.head_size], + ) + qs.append(split[0]) + ks.append(split[1]) + vs.append(split[2]) + q = torch.cat(qs) + k = torch.cat(ks) + v = torch.cat(vs) + return q, k, v + + +def check_conversion_supported(lit_weights: Dict[str, torch.Tensor]) -> None: + if any("lora" in wn for wn in lit_weights): + raise ValueError( + "Checkpoints with LoRA weights cannot be converted. Call `scripts/merge_lora.py` first." + ) + if any("adapter" in wn or "gating_factor" in wn for wn in lit_weights): + raise NotImplementedError("Converting adapter models is supported.") + + +@torch.inference_mode() +def convert_lit_checkpoint( + checkpoint_path: Path, output_path: Path, config_path: Path +) -> None: + config = Config.from_json(config_path) + + if "falcon" in config.name: + copy_fn = partial(copy_weights_falcon, config.name) + elif config._mlp_class in ("LLaMAMLP", "GemmaMLP", "LLaMAMoE"): + untie_weights = "Gemma" in config.name + copy_fn = partial( + copy_weights_llama, config, untie_weights=untie_weights + ) + elif "phi" in config.name: + copy_fn = partial(copy_weights_phi, config) + else: + copy_fn = copy_weights_gpt_neox + + # initialize a new empty state dict to hold our new weights + sd = {} + with incremental_save(output_path) as saver: + lit_weights = lazy_load(checkpoint_path) + lit_weights = lit_weights.get("model", lit_weights) + check_conversion_supported(lit_weights) + copy_fn(sd, lit_weights, saver=saver) + gc.collect() + saver.save(sd) + + +if __name__ == "__main__": + CLI(convert_lit_checkpoint) diff --git a/examples/llm_finetuning/scripts/convert_pretrained_checkpoint.py b/examples/llm_finetuning/scripts/convert_pretrained_checkpoint.py new file mode 100644 index 00000000000..a6c3093374a --- /dev/null +++ b/examples/llm_finetuning/scripts/convert_pretrained_checkpoint.py @@ -0,0 +1,88 @@ +# Copyright Lightning AI. Licensed under the Apache License 2.0, see LICENSE file. + +import json +import shutil +import sys +from dataclasses import asdict +from pathlib import Path + +import torch + +# support running without installing as a package +wd = Path(__file__).parent.parent.resolve() +sys.path.append(str(wd)) + +from lit_gpt import Config +from lit_gpt.utils import CLI, incremental_save + + +@torch.inference_mode() +def convert_checkpoint( + checkpoint_file: Path, + tokenizer_dir: Path, + config_name: str, + output_dir: Path, +) -> None: + """Convert a checkpoint after pretraining. + + The pretrained checkpoint contains optimizer states and several other metadata that are not needed after training + is finished. This script will export the state-dict of the model and place it in the chosen output folder together + with the tokenizer and model config, which then can be loaded by other scripts for inference, evaluation, etc. + + Args: + checkpoint_file: Path to a checkpoint file scripts produced by the scripts in ``lit_gpt/pretrain/``. + tokenizer_dir: A path to the folder that holds the tokenizer configuration files that were used to train + the model. All files with a name starting with 'tokenizer' will be copied to the output folder. + config_name: The name of the model loaded with the ``lit_gpt.Config``. The configuration will be saved as a + JSON file to the output folder. + output_dir: The output folder where model state-dict file, the tokenizer config file, and the model config + file will be saved. + """ + + if output_dir.is_dir() and output_dir.glob("*"): + raise FileExistsError( + f"The output folder exists and is not empty: {str(output_dir)}." + " Please delete it first or choose a different name." + ) + if not tokenizer_dir.is_dir(): + raise FileNotFoundError( + f"The tokenizer_dir must be a directory: {str(output_dir)}." + ) + + output_dir.mkdir(parents=True) + output_checkpoint_file = output_dir / "lit_model.pth" + output_config_file = output_dir / "lit_config.json" + + # Save the config to output folder + config = Config.from_name(config_name) + with open(output_config_file, "w") as json_config: + json.dump(asdict(config), json_config) + + # Export the tokenizer configuration to output folder + for tokenizer_file in tokenizer_dir.glob("tokenizer*"): + shutil.copyfile(tokenizer_file, output_dir / tokenizer_file.name) + + # Copy config for tokenization if found + if (tokenizer_dir / "generation_config.json").is_file(): + shutil.copyfile( + tokenizer_dir / "generation_config.json", + output_dir / "generation_config.json", + ) + + # Extract the model state dict and save to output folder + with incremental_save(output_checkpoint_file) as saver: + print("Processing", checkpoint_file) + full_checkpoint = torch.load(str(checkpoint_file), mmap=True) + loaded_state_dict = full_checkpoint["model"] + converted_state_dict = {} + for param_name, param in loaded_state_dict.items(): + saver.store_early(param) + # remove prefix for compiled model (if any) + param_name = param_name.replace("_orig_mod.", "") + converted_state_dict[param_name] = param + print(f"Saving converted checkpoint to {str(output_checkpoint_file)}.") + saver.save(converted_state_dict) + + +if __name__ == "__main__": + CLI(convert_checkpoint) diff --git a/examples/llm_finetuning/scripts/download.py b/examples/llm_finetuning/scripts/download.py new file mode 100644 index 00000000000..e5a7459d2be --- /dev/null +++ b/examples/llm_finetuning/scripts/download.py @@ -0,0 +1,106 @@ +# Copyright Lightning AI. Licensed under the Apache License 2.0, see LICENSE file. + +import os +import sys +from pathlib import Path +from typing import Optional + +import torch +from lightning_utilities.core.imports import RequirementCache + +# support running without installing as a package +wd = Path(__file__).parent.parent.resolve() +sys.path.append(str(wd)) + +from lit_gpt.utils import CLI + +_SAFETENSORS_AVAILABLE = RequirementCache("safetensors") +_HF_TRANSFER_AVAILABLE = RequirementCache("hf_transfer") + + +def download_from_hub( + repo_id: Optional[str] = None, + access_token: Optional[str] = os.getenv("HF_TOKEN"), + from_safetensors: bool = False, + tokenizer_only: bool = False, + checkpoint_dir: Path = Path("checkpoints"), +) -> None: + if repo_id is None: + from lit_gpt.config import configs + + options = [ + f"{config['hf_config']['org']}/{config['hf_config']['name']}" + for config in configs + ] + print("Please specify --repo_id . Available values:") + print("\n".join(options)) + return + + from huggingface_hub import snapshot_download + + if ( + "meta-llama" in repo_id or "falcon-180" in repo_id + ) and not access_token: + raise ValueError( + f"{repo_id} requires authentication, please set the `HF_TOKEN=your_token` environment" + " variable or pass --access_token=your_token. You can find your token by visiting" + " https://huggingface.co/settings/tokens" + ) + + download_files = ["tokenizer*", "generation_config.json"] + if not tokenizer_only: + if from_safetensors: + if not _SAFETENSORS_AVAILABLE: + raise ModuleNotFoundError(str(_SAFETENSORS_AVAILABLE)) + download_files.append("*.safetensors") + else: + # covers `.bin` files and `.bin.index.json` + download_files.append("*.bin*") + elif from_safetensors: + raise ValueError( + "`--from_safetensors=True` won't have an effect with `--tokenizer_only=True`" + ) + + import huggingface_hub._snapshot_download as download + import huggingface_hub.constants as constants + + previous = constants.HF_HUB_ENABLE_HF_TRANSFER + if _HF_TRANSFER_AVAILABLE and not previous: + print("Setting HF_HUB_ENABLE_HF_TRANSFER=1") + constants.HF_HUB_ENABLE_HF_TRANSFER = True + download.HF_HUB_ENABLE_HF_TRANSFER = True + + directory = checkpoint_dir / repo_id + snapshot_download( + repo_id, + local_dir=directory, + local_dir_use_symlinks=False, + resume_download=True, + allow_patterns=download_files, + token=access_token, + ) + + constants.HF_HUB_ENABLE_HF_TRANSFER = previous + download.HF_HUB_ENABLE_HF_TRANSFER = previous + + # convert safetensors to PyTorch binaries + if from_safetensors: + from safetensors import SafetensorError + from safetensors.torch import load_file as safetensors_load + + print("Converting .safetensor files to PyTorch binaries (.bin)") + for safetensor_path in directory.glob("*.safetensors"): + bin_path = safetensor_path.with_suffix(".bin") + try: + result = safetensors_load(safetensor_path) + except SafetensorError as e: + raise RuntimeError( + f"{safetensor_path} is likely corrupted. Please try to re-download it." + ) from e + print(f"{safetensor_path} --> {bin_path}") + torch.save(result, bin_path) + os.remove(safetensor_path) + + +if __name__ == "__main__": + CLI(download_from_hub) diff --git a/examples/llm_finetuning/scripts/merge_lora.py b/examples/llm_finetuning/scripts/merge_lora.py new file mode 100644 index 00000000000..89818a999fa --- /dev/null +++ b/examples/llm_finetuning/scripts/merge_lora.py @@ -0,0 +1,94 @@ +# Copyright Lightning AI. Licensed under the Apache License 2.0, see LICENSE file. + +"""This script merges the LoRA weights with the base model""" + +import sys +from pathlib import Path +from typing import Optional + +import lightning as L +import torch + +# support running without installing as a package +wd = Path(__file__).parent.parent.resolve() +sys.path.append(str(wd)) + +from lit_gpt.lora import GPT, Config, lora_filter, merge_lora_weights +from lit_gpt.utils import ( + CLI, + check_valid_checkpoint_dir, + get_default_supported_precision, + lazy_load, +) + + +def merge_lora( + lora_path: Path = Path("out/lora/alpaca/lit_model_lora_finetuned.pth"), + checkpoint_dir: Path = Path( + "checkpoints/stabilityai/stablelm-base-alpha-3b" + ), + out_dir: Path = Path("out/lora/checkpoint"), + precision: Optional[str] = None, + lora_r: int = 8, + lora_alpha: int = 16, + lora_dropout: float = 0.05, + lora_query: bool = True, + lora_key: bool = False, + lora_value: bool = True, + lora_projection: bool = False, + lora_mlp: bool = False, + lora_head: bool = False, +) -> None: + """Generates a response based on a given instruction and an optional input. + This script will only work with checkpoints from the instruction-tuned GPT-LoRA model. + See `finetune/lora.py`. + + Args: + lora_path: Path to the checkpoint with trained adapter weights, which are the output of + `finetune/lora.py`. + checkpoint_dir: The path to the checkpoint folder with pretrained GPT weights. + out_dir: The path to the merged model that is created by this script. + precision: Indicates the Fabric precision setting to use. + """ + check_valid_checkpoint_dir(checkpoint_dir) + out_dir.mkdir(parents=True, exist_ok=True) + + precision = precision or get_default_supported_precision(training=False) + fabric = L.Fabric(devices=1, precision=precision) + + config = Config.from_json( + checkpoint_dir / "lit_config.json", + r=lora_r, + alpha=lora_alpha, + dropout=lora_dropout, + to_query=lora_query, + to_key=lora_key, + to_value=lora_value, + to_projection=lora_projection, + to_mlp=lora_mlp, + to_head=lora_head, + ) + + with fabric.init_module(empty_init=True): + model = GPT(config) + checkpoint_path = checkpoint_dir / "lit_model.pth" + checkpoint = lazy_load(checkpoint_path) + lora_checkpoint = lazy_load(lora_path) + checkpoint.update(lora_checkpoint.get("model", lora_checkpoint)) + model.load_state_dict(checkpoint) + + merge_lora_weights(model) + + save_path = out_dir / "lit_model.pth" + fabric.print(f"Saving weights to {str(save_path)!r}") + # remove lora parameters and the lora linear substring + state_dict = { + k.replace("linear.", ""): v + for k, v in model.state_dict().items() + if not lora_filter(k, v) + } + torch.save(state_dict, save_path) + + +if __name__ == "__main__": + CLI(merge_lora) diff --git a/examples/llm_finetuning/scripts/prepare_alpaca.py b/examples/llm_finetuning/scripts/prepare_alpaca.py new file mode 100644 index 00000000000..cde6fca1b67 --- /dev/null +++ b/examples/llm_finetuning/scripts/prepare_alpaca.py @@ -0,0 +1,169 @@ +# Copyright Lightning AI. Licensed under the Apache License 2.0, see LICENSE file. + +"""Implementation derived from https://github.com/tloen/alpaca-lora""" + +import json +import sys +from pathlib import Path +from typing import Optional + +import torch +from lightning_utilities.core.imports import RequirementCache +from torch.utils.data import random_split +from tqdm import tqdm + +# support running without installing as a package +wd = Path(__file__).parent.parent.resolve() +sys.path.append(str(wd)) + +from lit_gpt.tokenizer import Tokenizer +from lit_gpt.utils import CLI + + +def prepare( + destination_path: Path = Path("data/alpaca"), + checkpoint_dir: Path = Path( + "checkpoints/stabilityai/stablelm-base-alpha-3b" + ), + test_split_fraction: float = 0.03865, # to get exactly 2000 test samples, + seed: int = 42, + mask_inputs: bool = False, # as in alpaca-lora + data_file_name: str = "alpaca_data_cleaned_archive.json", + data_file_url: str = "https://raw.githubusercontent.com/tloen/alpaca-lora/main/alpaca_data_cleaned_archive.json", + ignore_index: int = -1, + max_seq_length: Optional[int] = None, +) -> None: + """Prepare the Alpaca dataset for instruction tuning. + + The output is a training and test dataset saved as `train.pt` and `test.pt`, + which stores the preprocessed and tokenized prompts and labels. + """ + if max_seq_length is None: + with open( + checkpoint_dir / "lit_config.json", "r", encoding="utf-8" + ) as file: + config = json.load(file) + max_seq_length = config["block_size"] + + destination_path.mkdir(parents=True, exist_ok=True) + data_file_path = destination_path / data_file_name + print("Loading data file...") + download_if_missing(data_file_path, data_file_url) + with open(data_file_path, "r", encoding="utf-8") as file: + data = json.load(file) + + print("Loading tokenizer...") + tokenizer = Tokenizer(checkpoint_dir) + + # Partition the dataset into train and test + train_set, test_set = random_split( + data, + [1.0 - test_split_fraction, test_split_fraction], + generator=torch.Generator().manual_seed(seed), + ) + train_set, test_set = list(train_set), list(test_set) + + print(f"train has {len(train_set):,} samples") + print(f"test has {len(test_set):,} samples") + + print("Processing train split ...") + train_set = [ + prepare_sample( + example=sample, + tokenizer=tokenizer, + max_length=max_seq_length, + mask_inputs=mask_inputs, + ignore_index=ignore_index, + ) + for sample in tqdm(train_set) + ] + torch.save(train_set, destination_path / "train.pt") + + print("Processing test split ...") + test_set = [ + prepare_sample( + example=sample, + tokenizer=tokenizer, + max_length=max_seq_length, + mask_inputs=mask_inputs, + ignore_index=ignore_index, + ) + for sample in tqdm(test_set) + ] + torch.save(test_set, destination_path / "test.pt") + + +def download_if_missing(file_path: Path, file_url: str) -> None: + """Downloads the raw json data file and saves it in the given destination.""" + if file_path.exists() and file_path.stat().st_size > 0: + return + requests_available = RequirementCache("requests") + if not requests_available: + raise ModuleNotFoundError(str(requests_available)) + import requests + + with open(file_path, "w", encoding="utf-8") as f: + f.write(requests.get(file_url).text) + + +def prepare_sample( + example: dict, + tokenizer: Tokenizer, + max_length: int, + mask_inputs: bool, + ignore_index: int, +) -> dict: + """Processes a single sample. + + Each sample in the dataset consists of: + - instruction: A string describing the task + - input: A string holding a special input value for the instruction. + This only applies to some samples, and in others this is empty. + - output: The response string + + This function processes this data to produce a prompt text and a label for + supervised training. The prompt text is formed as a single message including both + the instruction and the input. The label/target is the same message but with the + response attached. + + Finally, both the prompt and the label get tokenized. If desired, all tokens + in the label that correspond to the original input prompt get masked out (default). + """ + full_prompt = generate_prompt(example) + full_prompt_and_response = full_prompt + example["output"] + encoded_full_prompt = tokenizer.encode(full_prompt, max_length=max_length) + encoded_full_prompt_and_response = tokenizer.encode( + full_prompt_and_response, eos=True, max_length=max_length + ) + + # The labels are the full prompt with response, but with the prompt masked out + labels = encoded_full_prompt_and_response.clone() + if mask_inputs: + labels[: len(encoded_full_prompt)] = ignore_index + + return { + **example, + "input_ids": encoded_full_prompt_and_response, + "labels": labels, + } + + +def generate_prompt(example: dict) -> str: + """Generates a standardized message to prompt the model with an instruction, optional input and a + 'response' field.""" + + if example["input"]: + return ( + "Below is an instruction that describes a task, paired with an input that provides further context. " + "Write a response that appropriately completes the request.\n\n" + f"### Instruction:\n{example['instruction']}\n\n### Input:\n{example['input']}\n\n### Response:" + ) + return ( + "Below is an instruction that describes a task. " + "Write a response that appropriately completes the request.\n\n" + f"### Instruction:\n{example['instruction']}\n\n### Response:" + ) + + +if __name__ == "__main__": + CLI(prepare) diff --git a/examples/llm_finetuning/scripts/prepare_csv.py b/examples/llm_finetuning/scripts/prepare_csv.py new file mode 100644 index 00000000000..bbd27074d52 --- /dev/null +++ b/examples/llm_finetuning/scripts/prepare_csv.py @@ -0,0 +1,157 @@ +# Copyright Lightning AI. Licensed under the Apache License 2.0, see LICENSE file. + +import json +import logging +import sys +from pathlib import Path +from typing import Optional, Tuple + +import torch +from torch.utils.data import random_split +from tqdm import tqdm + +# support running without installing as a package +wd = Path(__file__).parent.parent.resolve() +logger = logging.getLogger(__name__) +sys.path.append(str(wd)) + +from lit_gpt.tokenizer import Tokenizer +from lit_gpt.utils import CLI + + +def prepare( + csv_path: Path, + destination_path: Path = Path("data/csv"), + checkpoint_dir: Path = Path( + "checkpoints/stabilityai/stablelm-base-alpha-3b" + ), + test_split_fraction: float = 0.1, + seed: int = 42, + mask_inputs: bool = False, + ignore_index: int = -1, + max_seq_length: Optional[int] = None, + columns: Tuple[str, ...] = ("instruction", "input", "output"), +) -> None: + """Prepare a CSV dataset for instruction tuning. + + The output is a training and test dataset saved as `train.pt` and `test.pt`, + which stores the preprocessed and tokenized prompts and labels. + """ + if max_seq_length is None: + with open(checkpoint_dir / "lit_config.json", "r") as file: + config = json.load(file) + max_seq_length = config["block_size"] + + destination_path.mkdir(parents=True, exist_ok=True) + logger.info("Loading data file ...") + import pandas as pd + + df = pd.read_csv(csv_path, dtype=str).fillna("") + if not (df.columns.values == columns).all(): + raise ValueError( + f"CSV columns must be {columns}, found {df.columns.values}" + ) + data = json.loads(df.to_json(orient="records", indent=4)) + + print("Loading tokenizer...") + tokenizer = Tokenizer(checkpoint_dir) + + # Partition the dataset into train and test + train_set, test_set = random_split( + data, + [1.0 - test_split_fraction, test_split_fraction], + generator=torch.Generator().manual_seed(seed), + ) + train_set, test_set = list(train_set), list(test_set) + + print(f"train has {len(train_set):,} samples") + print(f"test has {len(test_set):,} samples") + + print("Processing train split ...") + train_set = [ + prepare_sample( + example=sample, + tokenizer=tokenizer, + max_length=max_seq_length, + mask_inputs=mask_inputs, + ignore_index=ignore_index, + ) + for sample in tqdm(train_set) + ] + torch.save(train_set, destination_path / "train.pt") + + print("Processing test split ...") + test_set = [ + prepare_sample( + example=sample, + tokenizer=tokenizer, + max_length=max_seq_length, + mask_inputs=mask_inputs, + ignore_index=ignore_index, + ) + for sample in tqdm(test_set) + ] + torch.save(test_set, destination_path / "test.pt") + + +def prepare_sample( + example: dict, + tokenizer: Tokenizer, + max_length: int, + mask_inputs: bool, + ignore_index: int, +) -> dict: + """Processes a single sample. + + Each sample in the dataset consists of: + - instruction: A string describing the task + - input: A string holding a special input value for the instruction. + This only applies to some samples, and in others this is empty. + - output: The response string + + This function processes this data to produce a prompt text and a label for + supervised training. The prompt text is formed as a single message including both + the instruction and the input. The label/target is the same message but with the + response attached. + + Finally, both the prompt and the label get tokenized. If desired, all tokens + in the label that correspond to the original input prompt get masked out (default). + """ + full_prompt = generate_prompt(example) + full_prompt_and_response = full_prompt + example["output"] + encoded_full_prompt = tokenizer.encode(full_prompt, max_length=max_length) + encoded_full_prompt_and_response = tokenizer.encode( + full_prompt_and_response, eos=True, max_length=max_length + ) + + # The labels are the full prompt with response, but with the prompt masked out + labels = encoded_full_prompt_and_response.clone() + if mask_inputs: + labels[: len(encoded_full_prompt)] = ignore_index + + return { + **example, + "input_ids": encoded_full_prompt_and_response, + "labels": labels, + } + + +def generate_prompt(example: dict) -> str: + """Generates a standardized message to prompt the model with an instruction, optional input and a + 'response' field.""" + + if example["input"]: + return ( + "Below is an instruction that describes a task, paired with an input that provides further context. " + "Write a response that appropriately completes the request.\n\n" + f"### Instruction:\n{example['instruction']}\n\n### Input:\n{example['input']}\n\n### Response:" + ) + return ( + "Below is an instruction that describes a task. " + "Write a response that appropriately completes the request.\n\n" + f"### Instruction:\n{example['instruction']}\n\n### Response:" + ) + + +if __name__ == "__main__": + CLI(prepare) diff --git a/examples/llm_finetuning/scripts/prepare_dolly.py b/examples/llm_finetuning/scripts/prepare_dolly.py new file mode 100644 index 00000000000..8bb434398fa --- /dev/null +++ b/examples/llm_finetuning/scripts/prepare_dolly.py @@ -0,0 +1,163 @@ +# Copyright Lightning AI. Licensed under the Apache License 2.0, see LICENSE file. + +"""Implementation derived from https://github.com/tloen/alpaca-lora""" + +import json +import sys +from pathlib import Path +from typing import Optional + +import torch +from torch.utils.data import random_split +from tqdm import tqdm + +# support running without installing as a package +wd = Path(__file__).parent.parent.resolve() +sys.path.append(str(wd)) + +from lit_gpt.tokenizer import Tokenizer +from lit_gpt.utils import CLI + +from scripts.prepare_alpaca import download_if_missing + + +def prepare( + destination_path: Path = Path("data/dolly"), + checkpoint_dir: Path = Path( + "checkpoints/stabilityai/stablelm-base-alpha-3b" + ), + test_split_fraction: float = 0.1, + seed: int = 42, + mask_inputs: bool = False, + data_file_name: str = "dolly_data_cleaned.json", + data_file_url: str = "https://huggingface.co/datasets/databricks/databricks-dolly-15k/resolve/main/databricks-dolly-15k.jsonl", + ignore_index: int = -1, + max_seq_length: Optional[int] = None, +) -> None: + """Prepare the Dolly 15k dataset for instruction tuning. + + The output is a training and test dataset saved as `train.pt` and `test.pt`, + which stores the preprocessed and tokenized prompts and labels. + """ + + if max_seq_length is None: + with open( + checkpoint_dir / "lit_config.json", "r", encoding="utf-8" + ) as file: + config = json.load(file) + max_seq_length = config["block_size"] + + destination_path.mkdir(parents=True, exist_ok=True) + data_file_path = destination_path / data_file_name + print("Loading data file...") + download_if_missing(data_file_path, data_file_url) + + with open(data_file_path, "r", encoding="utf-8") as file: + data = file.readlines() + data = [json.loads(line) for line in data] + for item in data: + item["input"] = item.pop("context") + item["output"] = item.pop("response") + + print("Loading tokenizer...") + tokenizer = Tokenizer(checkpoint_dir) + + # Partition the dataset into train and test + train_set, test_set = random_split( + data, + [1.0 - test_split_fraction, test_split_fraction], + generator=torch.Generator().manual_seed(seed), + ) + train_set, test_set = list(train_set), list(test_set) + + print(f"train has {len(train_set):,} samples") + print(f"test has {len(test_set):,} samples") + + print("Processing train split ...") + train_set = [ + prepare_sample( + example=sample, + tokenizer=tokenizer, + max_length=max_seq_length, + mask_inputs=mask_inputs, + ignore_index=ignore_index, + ) + for sample in tqdm(train_set) + ] + torch.save(train_set, destination_path / "train.pt") + + print("Processing test split ...") + test_set = [ + prepare_sample( + example=sample, + tokenizer=tokenizer, + max_length=max_seq_length, + mask_inputs=mask_inputs, + ignore_index=ignore_index, + ) + for sample in tqdm(test_set) + ] + torch.save(test_set, destination_path / "test.pt") + + +def prepare_sample( + example: dict, + tokenizer: Tokenizer, + max_length: int, + mask_inputs: bool, + ignore_index: int, +) -> dict: + """Processes a single sample. + + Each sample in the dataset consists of: + - instruction: A string describing the task + - input: A string holding a special input value for the instruction. + This only applies to some samples, and in others this is empty. + - output: The response string + + This function processes this data to produce a prompt text and a label for + supervised training. The prompt text is formed as a single message including both + the instruction and the input. The label/target is the same message but with the + response attached. + + Finally, both the prompt and the label get tokenized. If desired, all tokens + in the label that correspond to the original input prompt get masked out (default). + """ + full_prompt = generate_prompt(example) + full_prompt_and_response = full_prompt + example["output"] + encoded_full_prompt = tokenizer.encode(full_prompt, max_length=max_length) + encoded_full_prompt_and_response = tokenizer.encode( + full_prompt_and_response, eos=True, max_length=max_length + ) + + # The labels are the full prompt with response, but with the prompt masked out + labels = encoded_full_prompt_and_response.clone() + if mask_inputs: + labels[: len(encoded_full_prompt)] = ignore_index + + return { + **example, + "input_ids": encoded_full_prompt_and_response, + "labels": labels, + } + + +def generate_prompt(example: dict) -> str: + """Generates a standardized message to prompt the model with an instruction, optional input and a + 'response' field.""" + + if example["input"]: + return ( + "Below is an instruction that describes a task, paired with an input that provides further context. " + "Write a response that appropriately completes the request.\n\n" + f"### Instruction:\n{example['instruction']}\n\n### Input:\n{example['input']}\n\n### Response:" + ) + return ( + "Below is an instruction that describes a task. " + "Write a response that appropriately completes the request.\n\n" + f"### Instruction:\n{example['instruction']}\n\n### Response:" + ) + + +if __name__ == "__main__": + CLI(prepare) diff --git a/examples/llm_finetuning/scripts/prepare_flan.py b/examples/llm_finetuning/scripts/prepare_flan.py new file mode 100644 index 00000000000..a34b547213b --- /dev/null +++ b/examples/llm_finetuning/scripts/prepare_flan.py @@ -0,0 +1,249 @@ +# Copyright Lightning AI. Licensed under the Apache License 2.0, see LICENSE file. + +"""Implementation derived from https://github.com/tloen/alpaca-lora""" +import json +import sys +from pathlib import Path +from typing import Optional + +import torch +from tqdm import tqdm + +# support running without installing as a package +wd = Path(__file__).parent.parent.resolve() +sys.path.append(str(wd)) + +from lit_gpt.tokenizer import Tokenizer +from lit_gpt.utils import CLI + +from scripts.prepare_alpaca import download_if_missing + + +def load_jsonl(filename): + data = [] + with open(filename, "r", encoding="utf-8") as f: + for line in f: + data.append(json.loads(line)) + return data + + +def prepare( + destination_path: Path = Path("data/flan"), + checkpoint_dir: Path = Path( + "checkpoints/stabilityai/stablelm-base-alpha-3b" + ), + mask_inputs: bool = False, # as in alpaca-lora + subsets: Optional[str] = None, + ignore_index: int = -1, + max_seq_length: Optional[int] = None, +) -> None: + """Prepare the FLAN-collection datasets for instruction tuning. + + The output is a training and test dataset saved as `train.pt` and `test.pt`, + which stores the preprocessed and tokenized prompts and labels. + + Since the original test set does not have responses, the validation set + is used as the test set. + """ + + supported_subsets = { + "aeslc_10templates", + "ag_news_subset_10templates", + "anli_r1_10templates", + "anli_r2_10templates", + "anli_r3_10templates", + "arc_challenge_10templates", + "arc_easy_10templates", + "bool_q_10templates", + "cb_10templates", + "cnn_dailymail_10templates", + "cola_10templates", + "common_gen_10templates", + "copa_10templates", + "coqa_10templates", + "cosmos_qa_10templates", + "dart_10templates", + "definite_pronoun_resolution_10templates", + "drop_10templates", + "e2e_nlg_10templates", + "fix_punct_10templates", + "gigaword_10templates", + "glue_mrpc_10templates", + "glue_qqp_10templates", + "hellaswag_10templates", + "imdb_reviews_10templates", + "math_dataset_10templates", + "mnli_matched_10templates", + "mnli_mismatched_10templates", + "multi_news_10templates", + "multirc_10templates", + "natural_questions_10templates", + "openbookqa_10templates", + "opinion_abstracts_idebate_10templates", + "opinion_abstracts_rotten_tomatoes_10templates", + "para_crawl_enes_10templates", + "paws_wiki_10templates", + "piqa_10templates", + "qnli_10templates", + "quac_10templates", + "record_10templates", + "rte_10templates", + "samsum_10templates", + "sentiment140_10templates", + "snli_10templates", + "squad_v1_10templates", + "squad_v2_10templates", + "sst2_10templates", + "story_cloze_10templates", + "stsb_10templates", + "trec_10templates", + "trivia_qa_10templates", + "true_case_10templates", + "web_nlg_en_10templates", + "wic_10templates", + "wiki_lingua_english_en_10templates", + "wmt14_enfr_10templates", + "wmt16_translate_csen_10templates", + "wmt16_translate_deen_10templates", + "wmt16_translate_fien_10templates", + "wmt16_translate_roen_10templates", + "wmt16_translate_ruen_10templates", + "wmt16_translate_tren_10templates", + "wnli_10templates", + "word_segment_10templates", + "wsc_10templates", + "yelp_polarity_reviews_10templates", + } + + if subsets is not None: + subsets = subsets.split(",") + for sub in subsets: + if sub not in supported_subsets: + raise ValueError(f"{sub} not in {supported_subsets}") + else: + subsets = list(supported_subsets) + + if max_seq_length is None: + with open( + checkpoint_dir / "lit_config.json", "r", encoding="utf-8" + ) as file: + config = json.load(file) + max_seq_length = config["block_size"] + + destination_path.mkdir(parents=True, exist_ok=True) + print("Loading data file...") + + base_url = "https://huggingface.co/datasets/Muennighoff/flan/resolve/main/" + + train_set, test_set = [], [] + for sub in subsets: + train_sub = sub + "_train" + data_file_name = train_sub + ".jsonl" + data_file_path = destination_path / data_file_name + data_file_url = base_url + "train/" + data_file_name + + print(f"Loading training data file {sub}...") + download_if_missing(data_file_path, data_file_url) + sub_train_set = load_jsonl(data_file_path) + train_set.extend(sub_train_set) + + test_sub = sub + "_test" + data_file_name = test_sub + ".jsonl" + data_file_path = destination_path / data_file_name + data_file_url = base_url + "test/" + data_file_name + + print(f"Loading test data file {sub}...") + download_if_missing(data_file_path, data_file_url) + sub_test_set = load_jsonl(data_file_path) + test_set.extend(sub_test_set) + + print("Loading tokenizer...") + tokenizer = Tokenizer(checkpoint_dir) + + train_set, test_set = list(train_set), list(test_set) + + print(f"train has {len(train_set):,} samples") + print(f"test has {len(test_set):,} samples") + + print("Processing train split ...") + train_set = [ + prepare_sample( + example=sample, + tokenizer=tokenizer, + max_length=max_seq_length, + mask_inputs=mask_inputs, + ignore_index=ignore_index, + ) + for sample in tqdm(train_set) + ] + torch.save(train_set, destination_path / "train.pt") + + print("Processing test split ...") + test_set = [ + prepare_sample( + example=sample, + tokenizer=tokenizer, + max_length=max_seq_length, + mask_inputs=mask_inputs, + ignore_index=ignore_index, + ) + for sample in tqdm(test_set) + ] + torch.save(test_set, destination_path / "test.pt") + + +def prepare_sample( + example: dict, + tokenizer: Tokenizer, + max_length: int, + mask_inputs: bool, + ignore_index: int, +): + """Processes a single sample. + + Each sample in the dataset consists of: + - instruction: A string describing the task + - input: A string holding a special input value for the instruction. + This only applies to some samples, and in others this is empty. + - output: The response string + + This function processes this data to produce a prompt text and a label for + supervised training. The prompt text is formed as a single message including both + the instruction and the input. The label/target is the same message but with the + response attached. + + Finally, both the prompt and the label get tokenized. If desired, all tokens + in the label that correspond to the original input prompt get masked out (default). + """ + full_prompt = generate_prompt(example) + full_prompt_and_response = full_prompt + example["targets"] + encoded_full_prompt = tokenizer.encode(full_prompt, max_length=max_length) + encoded_full_prompt_and_response = tokenizer.encode( + full_prompt_and_response, eos=True, max_length=max_length + ) + + # The labels are the full prompt with response, but with the prompt masked out + labels = encoded_full_prompt_and_response.clone() + if mask_inputs: + labels[: len(encoded_full_prompt)] = ignore_index + + return { + **example, + "input_ids": encoded_full_prompt_and_response, + "labels": labels, + } + + +def generate_prompt(example): + """Generates a standardized message to prompt the model with an instruction, optional input and a + 'response' field.""" + + return ( + "Below is an instruction that describes a task. " + "Write a response that appropriately completes the request.\n\n" + f"### Instruction:\n{example['inputs']}\n\n### Response:" + ) + + +if __name__ == "__main__": + CLI(prepare) diff --git a/examples/llm_finetuning/scripts/prepare_lima.py b/examples/llm_finetuning/scripts/prepare_lima.py new file mode 100644 index 00000000000..e27928ce9e2 --- /dev/null +++ b/examples/llm_finetuning/scripts/prepare_lima.py @@ -0,0 +1,198 @@ +# Copyright Lightning AI. Licensed under the Apache License 2.0, see LICENSE file. + +"""Implementation derived from https://github.com/tloen/alpaca-lora""" + +import json +import os +import sys +from pathlib import Path +from typing import List, Optional + +import torch +from torch.utils.data import random_split +from tqdm import tqdm + +# support running without installing as a package +wd = Path(__file__).parent.parent.resolve() +sys.path.append(str(wd)) + +from lit_gpt.tokenizer import Tokenizer +from lit_gpt.utils import CLI + + +def prepare( + destination_path: Path = Path("data/lima"), + test_split_fraction: float = 0.1, + checkpoint_dir: Path = Path( + "checkpoints/stabilityai/stablelm-base-alpha-3b" + ), + mask_inputs: bool = False, # as in alpaca-lora + seed: int = 42, + include_multiturn_conversations: bool = False, + data_repo_id: str = "GAIR/lima", + ignore_index: int = -1, + access_token: Optional[str] = os.getenv("HF_TOKEN"), + max_seq_length: Optional[int] = None, +) -> None: + """Prepare the LIMA dataset for instruction tuning. + + The output is a training and test dataset saved as `train.pt` and `test.pt`, + which stores the preprocessed and tokenized prompts and labels. + """ + + if access_token is None: + raise ValueError( + "LIMA requires authentication, please set the `HF_TOKEN=your_token` environment" + " variable or pass --access_token=your_token. You can find your token by visiting" + " https://huggingface.co/settings/tokens" + ) + + if max_seq_length is None: + with open( + checkpoint_dir / "lit_config.json", "r", encoding="utf-8" + ) as file: + config = json.load(file) + max_seq_length = config["block_size"] + + destination_path.mkdir(parents=True, exist_ok=True) + print("Loading data file...") + + from datasets import load_dataset + + dataset = load_dataset(data_repo_id, token=access_token) + train_data = format_dataset( + dataset["train"], include_multiturn_conversations + ) + + # test set is present but doesn't have any solutions, so we cannot use it here + # but have to create our own + # for consistency with prepare_alpaca.py and prepare_dolly.py + # test_set = format_dataset(dataset["test"], include_multiturn_conversations) + + print("Loading tokenizer...") + tokenizer = Tokenizer(checkpoint_dir) + + # Partition the dataset into train and test + train_set, test_set = random_split( + train_data, + [1.0 - test_split_fraction, test_split_fraction], + generator=torch.Generator().manual_seed(seed), + ) + train_set, test_set = list(train_set), list(test_set) + + print(f"train has {len(train_set):,} samples") + print(f"test has {len(test_set):,} samples") + + print("Processing train split ...") + train_set = [ + prepare_sample( + example=sample, + tokenizer=tokenizer, + max_length=max_seq_length, + mask_inputs=mask_inputs, + ignore_index=ignore_index, + ) + for sample in tqdm(train_set) + ] + torch.save(train_set, destination_path / "train.pt") + + print("Processing test split ...") + test_set = [ + prepare_sample( + example=sample, + tokenizer=tokenizer, + max_length=max_seq_length, + mask_inputs=mask_inputs, + ignore_index=ignore_index, + ) + for sample in tqdm(test_set) + ] + torch.save(test_set, destination_path / "test.pt") + + +def format_dataset( + dataset_partition: dict, include_multi_turn_conversations: bool +) -> List[dict]: + formatted_ds = [] + + for entry in dataset_partition: + convo = entry["conversations"] + if include_multi_turn_conversations: + for i in range(0, len(convo) - 1, 2): + formatted_ds.append( + { + "instruction": convo[i], + "input": "", + "output": convo[i + 1], + } + ) + + else: + formatted_ds.append( + {"instruction": convo[0], "input": "", "output": convo[1]} + ) + + return formatted_ds + + +def prepare_sample( + example: dict, + tokenizer: Tokenizer, + max_length: int, + mask_inputs: bool, + ignore_index: int, +) -> dict: + """Processes a single sample. + + Each sample in the dataset consists of: + - instruction: A string describing the task + - input: A string holding a special input value for the instruction. + This only applies to some samples, and in others this is empty. + - output: The response string + + This function processes this data to produce a prompt text and a label for + supervised training. The prompt text is formed as a single message including both + the instruction and the input. The label/target is the same message but with the + response attached. + + Finally, both the prompt and the label get tokenized. If desired, all tokens + in the label that correspond to the original input prompt get masked out (default). + """ + full_prompt = generate_prompt(example) + full_prompt_and_response = full_prompt + example["output"] + encoded_full_prompt = tokenizer.encode(full_prompt, max_length=max_length) + encoded_full_prompt_and_response = tokenizer.encode( + full_prompt_and_response, eos=True, max_length=max_length + ) + + # The labels are the full prompt with response, but with the prompt masked out + labels = encoded_full_prompt_and_response.clone() + if mask_inputs: + labels[: len(encoded_full_prompt)] = ignore_index + + return { + **example, + "input_ids": encoded_full_prompt_and_response, + "labels": labels, + } + + +def generate_prompt(example: dict) -> str: + """Generates a standardized message to prompt the model with an instruction, optional input and a + 'response' field.""" + + if example["input"]: + return ( + "Below is an instruction that describes a task, paired with an input that provides further context. " + "Write a response that appropriately completes the request.\n\n" + f"### Instruction:\n{example['instruction']}\n\n### Input:\n{example['input']}\n\n### Response:" + ) + return ( + "Below is an instruction that describes a task. " + "Write a response that appropriately completes the request.\n\n" + f"### Instruction:\n{example['instruction']}\n\n### Response:" + ) + + +if __name__ == "__main__": + CLI(prepare) diff --git a/examples/llm_finetuning/scripts/prepare_longform.py b/examples/llm_finetuning/scripts/prepare_longform.py new file mode 100644 index 00000000000..6327bad8654 --- /dev/null +++ b/examples/llm_finetuning/scripts/prepare_longform.py @@ -0,0 +1,153 @@ +# Copyright Lightning AI. Licensed under the Apache License 2.0, see LICENSE file. + +"""Implementation derived from https://github.com/tloen/alpaca-lora""" + +import json +import sys +from pathlib import Path +from typing import Optional + +import torch +from tqdm import tqdm + +# support running without installing as a package +wd = Path(__file__).parent.parent.resolve() +sys.path.append(str(wd)) + +from lit_gpt.tokenizer import Tokenizer +from lit_gpt.utils import CLI + +from scripts.prepare_alpaca import download_if_missing + + +def prepare( + destination_path: Path = Path("data/longform"), + checkpoint_dir: Path = Path( + "checkpoints/stabilityai/stablelm-base-alpha-3b" + ), + mask_inputs: bool = False, # as in alpaca-lora + ignore_index: int = -1, + max_seq_length: Optional[int] = None, +) -> None: + """Prepare the Alpaca dataset for instruction tuning. + + The output is a training and test dataset saved as `train.pt` and `test.pt`, + which stores the preprocessed and tokenized prompts and labels. + """ + if max_seq_length is None: + with open( + checkpoint_dir / "lit_config.json", "r", encoding="utf-8" + ) as file: + config = json.load(file) + max_seq_length = config["block_size"] + + destination_path.mkdir(parents=True, exist_ok=True) + + train_file_name = "train.json" + # val_file_name = "val.json" + test_file_name = "test.json" + + train_file_url = "https://raw.githubusercontent.com/akoksal/LongForm/main/dataset/train.json" + # val_file_url = "https://raw.githubusercontent.com/akoksal/LongForm/main/dataset/val.json" + test_file_url = "https://raw.githubusercontent.com/akoksal/LongForm/main/dataset/test.json" + + train_file_path = destination_path / train_file_name + print("Loading train data file...") + download_if_missing(train_file_path, train_file_url) + with open(train_file_path, "r", encoding="utf-8") as file: + train_data = json.load(file) + + test_file_path = destination_path / test_file_name + print("Loading test data file...") + download_if_missing(test_file_path, test_file_url) + with open(test_file_path, "r", encoding="utf-8") as file: + test_data = json.load(file) + + print("Loading tokenizer...") + tokenizer = Tokenizer(checkpoint_dir) + + print(f"train has {len(train_data):,} samples") + print(f"test has {len(test_data):,} samples") + + print("Processing train set ...") + train_data = [ + prepare_sample( + example=sample, + tokenizer=tokenizer, + max_length=max_seq_length, + mask_inputs=mask_inputs, + ignore_index=ignore_index, + ) + for sample in tqdm(train_data) + ] + torch.save(train_data, destination_path / "train.pt") + + print("Processing test set ...") + test_data = [ + prepare_sample( + example=sample, + tokenizer=tokenizer, + max_length=max_seq_length, + mask_inputs=mask_inputs, + ignore_index=ignore_index, + ) + for sample in tqdm(test_data) + ] + torch.save(test_data, destination_path / "test.pt") + + +def prepare_sample( + example: dict, + tokenizer: Tokenizer, + max_length: int, + mask_inputs: bool, + ignore_index: int, +) -> dict: + """Processes a single sample. + + Each sample in the dataset consists of: + - instruction: A string describing the task + - input: A string holding a special input value for the instruction. + This only applies to some samples, and in others this is empty. + - output: The response string + + This function processes this data to produce a prompt text and a label for + supervised training. The prompt text is formed as a single message including both + the instruction and the input. The label/target is the same message but with the + response attached. + + Finally, both the prompt and the label get tokenized. If desired, all tokens + in the label that correspond to the original input prompt get masked out (default). + """ + full_prompt = generate_prompt(example) + full_prompt_and_response = full_prompt + example["output"] + encoded_full_prompt = tokenizer.encode(full_prompt, max_length=max_length) + encoded_full_prompt_and_response = tokenizer.encode( + full_prompt_and_response, eos=True, max_length=max_length + ) + + # The labels are the full prompt with response, but with the prompt masked out + labels = encoded_full_prompt_and_response.clone() + if mask_inputs: + labels[: len(encoded_full_prompt)] = ignore_index + + return { + **example, + "input_ids": encoded_full_prompt_and_response, + "labels": labels, + } + + +def generate_prompt(example: dict) -> str: + """Generates a standardized message to prompt the model with an instruction and a + 'response' field.""" + + return ( + "Below is an instruction that describes a task, paired with an input that provides further context. " + "Write a response that appropriately completes the request.\n\n" + f"### Instruction:\n{example['input']}\n\n### Response:" + ) + + +if __name__ == "__main__": + CLI(prepare) diff --git a/examples/llm_finetuning/scripts/prepare_openwebtext.py b/examples/llm_finetuning/scripts/prepare_openwebtext.py new file mode 100644 index 00000000000..fbb4a8d9d96 --- /dev/null +++ b/examples/llm_finetuning/scripts/prepare_openwebtext.py @@ -0,0 +1,100 @@ +# Copyright Lightning AI. Licensed under the Apache License 2.0, see LICENSE file. + +# saves the openwebtext dataset to a binary file for training. following was helpful: +# https://github.com/HazyResearch/flash-attention/blob/main/training/src/datamodules/language_modeling_hf.py +import os +import sys +from pathlib import Path +from typing import Union + +import numpy as np +from tqdm import tqdm + +# support running without installing as a package +wd = Path(__file__).parent.parent.resolve() +sys.path.append(str(wd)) + +from lit_gpt import Tokenizer +from lit_gpt.utils import CLI + + +def prepare( + destination_path: Path = Path("data/openwebtext"), + checkpoint_dir: Path = Path( + "checkpoints/stabilityai/stablelm-base-alpha-3b" + ), + seed: int = 42, + test_size: Union[float, int, None] = 0.0005, +) -> None: + from datasets import load_dataset # huggingface datasets + + destination_path.mkdir(parents=True, exist_ok=True) + + tokenizer = Tokenizer(checkpoint_dir) + + # number of workers in .map() call + # good number to use is ~order number of cpu cores // 2 + num_proc = os.cpu_count() // 2 + + # number of workers in load_dataset() call + # best number might be different from num_proc above as it also depends on HW speed. + # it is better than 1 usually though + num_proc_load_dataset = num_proc + + # takes 54GB in huggingface .cache dir, about 8M documents (8,013,769) + dataset = load_dataset("openwebtext", num_proc=num_proc_load_dataset) + + # owt by default only contains the 'train' split, so create a test split + split_dataset = dataset["train"].train_test_split( + test_size=test_size, seed=seed, shuffle=True + ) + split_dataset["val"] = split_dataset.pop( + "test" + ) # rename the test split to val + + def process(example): + ids = tokenizer.encode(example["text"]).tolist() + ids.append(tokenizer.eos_id) + + # ids = enc.encode_ordinary(example['text']) # encode_ordinary ignores any special tokens + # ids.append(enc.eot_token) # add the end of text token, e.g. 50256 for gpt2 bpe + # note: I think eot should be prepended not appended... hmm. it's called "eot" though... + return {"ids": ids, "len": len(ids)} + + # tokenize the dataset + tokenized = split_dataset.map( + process, + remove_columns=["text"], + desc="tokenizing the splits", + num_proc=num_proc, + ) + + # concatenate all the ids in each dataset into one large file we can use for training + for split, dset in tokenized.items(): + arr_len = np.sum(dset["len"], dtype=np.uint64) + filename = destination_path / f"{split}.bin" + dtype = ( + np.uint16 + ) # (can do since enc.max_token_value == 50256 is < 2**16) + arr = np.memmap( + str(filename), dtype=dtype, mode="w+", shape=(arr_len,) + ) + total_batches = 1024 + + idx = 0 + for batch_idx in tqdm( + range(total_batches), desc=f"writing {filename}" + ): + # Batch together samples for faster write + batch = dset.shard( + num_shards=total_batches, index=batch_idx, contiguous=True + ).with_format("numpy") + arr_batch = np.concatenate(batch["ids"]) + # Write into mmap + arr[idx : idx + len(arr_batch)] = arr_batch + idx += len(arr_batch) + arr.flush() + + +if __name__ == "__main__": + CLI(prepare) diff --git a/examples/llm_finetuning/scripts/prepare_redpajama.py b/examples/llm_finetuning/scripts/prepare_redpajama.py new file mode 100644 index 00000000000..02044307797 --- /dev/null +++ b/examples/llm_finetuning/scripts/prepare_redpajama.py @@ -0,0 +1,185 @@ +# Copyright Lightning AI. Licensed under the Apache License 2.0, see LICENSE file. + +import glob +import json +import os +import sys +from pathlib import Path + +import numpy as np +from tqdm import tqdm + +# support running without installing as a package +wd = Path(__file__).parent.parent.resolve() +sys.path.append(str(wd)) + +import lit_gpt.packed_dataset as packed_dataset +from lit_gpt import Config, Tokenizer +from lit_gpt.utils import CLI + +filenames_sample = [ + "arxiv_sample.jsonl", + "book_sample.jsonl", + "c4_sample.jsonl", + "cc_2019-30_sample.jsonl", + "cc_2020-05_sample.jsonl", + "cc_2021-04_sample.jsonl", + "cc_2022-05_sample.jsonl", + "cc_2023-06_sample.jsonl", + "github_sample.jsonl", + "stackexchange_sample.jsonl", + "wikipedia_sample.jsonl", +] + +filename_sets = { + "arxiv": "arxiv/arxiv*", + "book": "book/book*", + "c4": "c4/c4-train*", + "common_crawl": "common_crawl/*", + "github": "github/filtered*", + "stackexchange": "stackexchange/stackexchange*", + "wikipedia": "wikipedia/wiki*", +} + + +def prepare_sample( + source_path: Path, + checkpoint_dir: Path, + destination_path: Path, + chunk_size: int, + match: str = "", +) -> None: + """Prepare the "Red Pajama" dataset using the original tokenizer.""" + destination_path.mkdir(parents=True, exist_ok=True) + + tokenizer = Tokenizer(checkpoint_dir) + + for name in filenames_sample: + if match and match not in name: + continue + + filepath = source_path / name + + if not filepath.is_file(): + raise RuntimeError( + f"Input file not found at {filepath}. \nMake sure you download the data, e.g. wget -i" + " https://data.together.xyz/redpajama-data-1T/v1.0.0/urls.txt or through" + " \nhttps://huggingface.co/datasets/togethercomputer/RedPajama-Data-1T" + " \nhttps://huggingface.co/datasets/togethercomputer/RedPajama-Data-1T-Sample \n" + ) + + prefix, _ = os.path.splitext(name) + + builder = packed_dataset.PackedDatasetBuilder( + outdir=destination_path, + prefix=prefix, + chunk_size=chunk_size, + sep_token=tokenizer.eos_id, + dtype="auto", + vocab_size=tokenizer.vocab_size, + ) + + print(f"Processing {name}") + + with open(filepath, encoding="utf-8") as f: + for row in tqdm(f): + text = json.loads(row)["text"] + text_ids = tokenizer.encode(text) + builder.add_array(np.array(text_ids, dtype=builder.dtype)) + + builder.write_reminder() + + +def prepare_full( + source_path: Path, + checkpoint_dir: Path, + destination_path: Path, + chunk_size: int, + match: str = "", +) -> None: + """Prepare the "Red Pajama" dataset using the original tokenizer.""" + import zstandard as zstd + + destination_path.mkdir(parents=True, exist_ok=True) + + tokenizer = Tokenizer(checkpoint_dir) + + for set_name, pattern in filename_sets.items(): + if match and match not in set_name: + continue + + is_cc = set_name == "common_crawl" + + filenames = glob.glob( + os.path.join(source_path, pattern), recursive=True + ) + + if not filenames: + raise RuntimeError( + f"No files matching {pattern} found at {source_path}. \nMake sure you download the data, e.g. wget -i" + " https://data.together.xyz/redpajama-data-1T/v1.0.0/urls.txt or through" + " \nhttps://huggingface.co/datasets/togethercomputer/RedPajama-Data-1T" + " \nhttps://huggingface.co/datasets/togethercomputer/RedPajama-Data-1T-Sample \n" + ) + + builder = packed_dataset.PackedDatasetBuilder( + outdir=destination_path, + prefix=set_name, + chunk_size=chunk_size, + sep_token=tokenizer.eos_id, + dtype="auto", + vocab_size=tokenizer.vocab_size, + ) + + for name in filenames: + filepath = source_path / name + + print(f"Processing {name}") + + if is_cc: + with zstd.open( + open(filepath, "rb"), "rt", encoding="utf-8" + ) as f: + for row in tqdm(f): + text = json.loads(row)["text"] + text_ids = tokenizer.encode(text) + builder.add_array( + np.array(text_ids, dtype=builder.dtype) + ) + else: + with open(filepath, encoding="utf-8") as f: + for row in tqdm(f): + text = json.loads(row)["text"] + text_ids = tokenizer.encode(text) + builder.add_array( + np.array(text_ids, dtype=builder.dtype) + ) + + builder.write_reminder() + + +def prepare( + source_path: Path = Path("data/RedPajama-Data-1T-Sample"), + checkpoint_dir: Path = Path( + "checkpoints/stabilityai/stablelm-base-alpha-3b" + ), + destination_path: Path = Path("data/redpajama_sample"), + sample: bool = True, + match: str = "", +) -> None: + """Prepare the "Red Pajama" dataset. We assume tokenizer has been trained.""" + config = Config.from_checkpoint(checkpoint_dir) + + prepare_fn = prepare_sample if sample else prepare_full + prepare_fn( + source_path=source_path, + checkpoint_dir=checkpoint_dir, + destination_path=destination_path, + chunk_size=(config.block_size + 1) + * 1024, # block size + 1 for causal, 1024 blocks + match=match, + ) + + +if __name__ == "__main__": + CLI(prepare) diff --git a/examples/llm_finetuning/scripts/prepare_slimpajama.py b/examples/llm_finetuning/scripts/prepare_slimpajama.py new file mode 100644 index 00000000000..0a80191f299 --- /dev/null +++ b/examples/llm_finetuning/scripts/prepare_slimpajama.py @@ -0,0 +1,68 @@ +# Copyright Lightning AI. Licensed under the Apache License 2.0, see LICENSE file. + +import json +import os +import sys +import time +from pathlib import Path + +import zstandard as zstd +from lightning.data.streaming import DataChunkRecipe, DataProcessor + +# support running without installing as a package +wd = Path(__file__).parent.parent.resolve() +sys.path.append(str(wd)) + +from lit_gpt import Tokenizer +from lit_gpt.utils import CLI + + +class SlimPajamaDataRecipe(DataChunkRecipe): + def __init__(self, tokenizer: Tokenizer, chunk_size: int): + super().__init__(chunk_size) + self.tokenizer = tokenizer + + def prepare_structure(self, input_dir): + files = Path(input_dir).rglob("*.zst") + return [str(file) for file in files] + + def prepare_item(self, filepath): + with zstd.open(open(filepath, "rb"), "rt", encoding="utf-8") as f: + for row in f: + text = json.loads(row)["text"] + if ( + json.loads(row)["meta"]["redpajama_set_name"] + == "RedPajamaGithub" + ): + continue # exclude the GitHub data since it overlaps with starcoder + text_ids = self.tokenizer.encode(text, bos=False, eos=True) + yield text_ids + + +def prepare( + input_dir: Path = Path("data/SlimPajama-627B/train"), + output_dir: Path = Path("data/slimpajama/train"), + tokenizer_path: Path = Path("checkpoints/Llama-2-7b-hf/"), + chunk_size: int = (2049 * 16384), + fast_dev_run: bool = False, +) -> None: + tokenizer = Tokenizer(tokenizer_path) + data_recipe = SlimPajamaDataRecipe( + tokenizer=tokenizer, chunk_size=chunk_size + ) + data_processor = DataProcessor( + input_dir=str(input_dir), + output_dir=str(output_dir), + fast_dev_run=fast_dev_run, + num_workers=os.cpu_count(), + num_downloaders=1, + ) + + start_time = time.time() + data_processor.run(data_recipe) + elapsed_time = time.time() - start_time + print(f"Time taken: {elapsed_time:.2f} seconds") + + +if __name__ == "__main__": + CLI(prepare) diff --git a/examples/llm_finetuning/scripts/prepare_starcoder.py b/examples/llm_finetuning/scripts/prepare_starcoder.py new file mode 100644 index 00000000000..1f67c93e1fe --- /dev/null +++ b/examples/llm_finetuning/scripts/prepare_starcoder.py @@ -0,0 +1,78 @@ +# Copyright Lightning AI. Licensed under the Apache License 2.0, see LICENSE file. + +import os +import sys +import time +import traceback +from pathlib import Path + +import pyarrow.parquet as pq +from lightning.data.streaming import DataChunkRecipe, DataProcessor + +# support running without installing as a package +wd = Path(__file__).parent.parent.resolve() +sys.path.append(str(wd)) + +from lit_gpt import Tokenizer +from lit_gpt.utils import CLI + + +class StarcoderDataRecipe(DataChunkRecipe): + def __init__(self, tokenizer: Tokenizer, chunk_size: int): + super().__init__(chunk_size) + self.tokenizer = tokenizer + + def prepare_structure(self, input_dir): + files = Path(input_dir).rglob("*.parquet") + return [str(file) for file in files] + + def prepare_item(self, item_metadata): + filepath = item_metadata + start = time.time() + + try: + parquet_file = pq.ParquetFile(filepath) + # reduce RAM usage + for batch in parquet_file.iter_batches( + batch_size=8192, columns=["content"] + ): + for text in batch.to_pandas()["content"]: + yield self.tokenizer.encode(text, bos=False, eos=True) + + except Exception: + print(traceback.format_exc()) + print(f"Error reading {filepath}") + return + + parquet_file.close() + end = time.time() + print(f"Took {end - start:.2f} seconds total", filepath) + + +def prepare( + input_dir: Path = Path("data/starcoderdata"), + output_dir: Path = Path("data/starcoder"), + tokenizer_path: Path = Path("checkpoints/Llama-2-7b-hf/"), + chunk_size: int = (2049 * 8192), + fast_dev_run: bool = False, +) -> None: + tokenizer = Tokenizer(tokenizer_path) + data_recipe = StarcoderDataRecipe( + tokenizer=tokenizer, chunk_size=chunk_size + ) + data_processor = DataProcessor( + input_dir=str(input_dir), + output_dir=str(output_dir), + fast_dev_run=fast_dev_run, + num_workers=os.cpu_count(), + num_downloaders=1, + ) + + start_time = time.time() + data_processor.run(data_recipe) + elapsed_time = time.time() - start_time + print(f"Time taken: {elapsed_time:.2f} seconds") + + +if __name__ == "__main__": + CLI(prepare) diff --git a/examples/llm_finetuning/steps/__init__.py b/examples/llm_finetuning/steps/__init__.py new file mode 100644 index 00000000000..c9630597e75 --- /dev/null +++ b/examples/llm_finetuning/steps/__init__.py @@ -0,0 +1,21 @@ +# Apache Software License 2.0 +# +# Copyright (c) ZenML GmbH 2024. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +from steps.evaluate import evaluate +from steps.feature_engineering import feature_engineering +from steps.finetune import finetune +from steps.merge import merge diff --git a/examples/llm_finetuning/steps/evaluate.py b/examples/llm_finetuning/steps/evaluate.py new file mode 100644 index 00000000000..f9570dee734 --- /dev/null +++ b/examples/llm_finetuning/steps/evaluate.py @@ -0,0 +1,143 @@ +# Apache Software License 2.0 +# +# Copyright (c) ZenML GmbH 2024. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import json +import shutil +from pathlib import Path +from typing import Any, Dict, List, Literal, Optional + +import torch +from evaluate.lm_eval_harness import run_eval_harness +from huggingface_hub import snapshot_download +from pydantic import BaseModel +from scripts.download import download_from_hub +from scripts.merge_lora import merge_lora +from typing_extensions import Annotated + +from steps.params import LoraParameters +from steps.utils import ( + convert_to_lit_checkpoint_if_necessary, + get_huggingface_access_token, +) +from zenml import step +from zenml.logger import get_logger + +logger = get_logger(__file__) + + +class EvaluationParameters(BaseModel): + """Parameters for the evaluation step. + + If `adapter_repo` is set, it will be merged with the model. Otherwise + the model itself will be evaluated. + """ + + model_repo: str + from_safetensors: bool = False + adapter_repo: Optional[str] = None + + precision: Optional[str] = None + quantize: Optional[ + Literal[ + "bnb.nf4", + "bnb.nf4-dq", + "bnb.fp4", + "bnb.fp4-dq", + "bnb.int8-training", + ] + ] = None + + lora: LoraParameters = LoraParameters() + + eval_tasks: List[str] = [ + "arc_challenge", + "piqa", + "hellaswag", + "hendrycksTest-*", + ] + num_fewshot: int = 0 + limit: Optional[int] = None + bootstrap_iters: int = 100000 + no_cache: bool = True + + +@step +def evaluate( + config: EvaluationParameters, +) -> Annotated[Dict[str, Any], "evaluation_results"]: + """Evaluate model. + + Args: + config: Configuration for this step. + """ + torch.set_float32_matmul_precision("high") + + access_token = get_huggingface_access_token() + + checkpoint_root_dir = Path("checkpoints") + checkpoint_dir = checkpoint_root_dir / config.model_repo + + if checkpoint_dir.exists(): + logger.info( + "Checkpoint directory already exists, skipping download..." + ) + else: + download_from_hub( + repo_id=config.model_repo, + from_safetensors=config.from_safetensors, + checkpoint_dir=checkpoint_root_dir, + access_token=access_token, + ) + + convert_to_lit_checkpoint_if_necessary(checkpoint_dir=checkpoint_dir) + + if config.adapter_repo: + adapter_dir = Path("adapters") / config.adapter_repo + merged_dir = Path("output/merged") + + snapshot_download( + config.adapter_repo, + local_dir=adapter_dir, + local_dir_use_symlinks=False, + resume_download=True, + token=access_token, + ) + + lora_path = adapter_dir / "lit_model_lora_finetuned.pth" + merge_lora( + lora_path=lora_path, + checkpoint_dir=checkpoint_dir, + out_dir=merged_dir, + precision=config.precision, + **config.lora.dict(), + ) + + for path in Path(checkpoint_dir).glob("*.json"): + destination = Path(merged_dir) / path.name + shutil.copy(src=path, dst=destination) + + checkpoint_dir = merged_dir + + output_path = Path("output.json") + run_eval_harness( + checkpoint_dir=checkpoint_dir, + save_filepath=output_path, + **config.dict(exclude={"model_repo", "adapter_repo", "lora"}), + ) + + with open(output_path, "r") as f: + return json.load(f) diff --git a/examples/llm_finetuning/steps/feature_engineering.py b/examples/llm_finetuning/steps/feature_engineering.py new file mode 100644 index 00000000000..c47eb8a28e3 --- /dev/null +++ b/examples/llm_finetuning/steps/feature_engineering.py @@ -0,0 +1,89 @@ +# Apache Software License 2.0 +# +# Copyright (c) ZenML GmbH 2024. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import importlib +import json +from dataclasses import asdict +from pathlib import Path +from typing import Any, Dict + +from lit_gpt import Config +from materializers.directory_materializer import DirectoryMaterializer +from pydantic import BaseModel +from scripts.download import download_from_hub +from typing_extensions import Annotated + +from steps.utils import get_huggingface_access_token +from zenml import log_artifact_metadata, step + + +class FeatureEngineeringParameters(BaseModel): + """Parameters for the feature engineering step.""" + + model_repo: str + dataset_name: str + + prepare_kwargs: Dict[str, Any] = {} + + +@step(output_materializers=DirectoryMaterializer) +def feature_engineering( + config: FeatureEngineeringParameters, +) -> Annotated[Path, "dataset"]: + """Prepare the dataset. + + Args: + config: Configuration for this step. + """ + access_token = get_huggingface_access_token() + + checkpoint_root_dir = Path("checkpoints") + download_from_hub( + repo_id=config.model_repo, + tokenizer_only=True, + checkpoint_dir=checkpoint_root_dir, + access_token=access_token, + ) + + checkpoint_dir = checkpoint_root_dir / config.model_repo + + model_name = checkpoint_dir.name + lit_config = Config.from_name(model_name) + lit_config_dict = asdict(lit_config) + with open(checkpoint_dir / "lit_config.json", "w") as json_config: + json.dump(lit_config_dict, json_config) + + log_artifact_metadata( + metadata={ + "model_name": model_name, + "model_config": lit_config_dict, + "dataset_name": config.dataset_name, + } + ) + destination_dir = Path("data") / config.dataset_name + + helper_module = importlib.import_module( + f"scripts.prepare_{config.dataset_name}" + ) + prepare_function = getattr(helper_module, "prepare") + + prepare_function( + checkpoint_dir=checkpoint_dir, + destination_path=destination_dir, + **config.prepare_kwargs, + ) + return destination_dir diff --git a/examples/llm_finetuning/steps/finetune.py b/examples/llm_finetuning/steps/finetune.py new file mode 100644 index 00000000000..fa3a9305e8b --- /dev/null +++ b/examples/llm_finetuning/steps/finetune.py @@ -0,0 +1,249 @@ +# Apache Software License 2.0 +# +# Copyright (c) ZenML GmbH 2024. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import shutil +from pathlib import Path +from typing import Literal, Optional + +import torch +from finetune.lora import setup +from huggingface_hub import upload_folder +from lit_gpt.args import EvalArgs, IOArgs, TrainArgs +from materializers.directory_materializer import DirectoryMaterializer +from pydantic import BaseModel +from scripts.convert_lit_checkpoint import convert_lit_checkpoint +from scripts.download import download_from_hub +from scripts.merge_lora import merge_lora +from scripts.prepare_alpaca import prepare +from typing_extensions import Annotated + +from steps.params import LoraParameters +from steps.utils import ( + convert_to_lit_checkpoint_if_necessary, + get_huggingface_access_token, +) +from zenml import get_step_context, log_model_metadata, step +from zenml.logger import get_logger +from zenml.materializers import BuiltInMaterializer + +logger = get_logger(__file__) + + +class DataParameters(BaseModel): + """Data preprocessing parameters.""" + + seed: int = 42 + test_split_fraction: float = 0.03865 + mask_inputs: bool = False + ignore_index: int = -1 + max_seq_length: Optional[int] = None + + +class TrainingParameters(BaseModel): + """Training parameters.""" + + save_interval: int = 1000 + log_interval: int = 1 + global_batch_size: int = 64 + micro_batch_size: int = 4 + lr_warmup_steps: int = 100 + epochs: Optional[int] = None + epoch_size: Optional[int] = None + max_tokens: Optional[int] = None + max_seq_length: Optional[int] = None + + learning_rate: float = 1e-3 + weight_decay: float = 0.02 + beta1: float = 0.9 + beta2: float = 0.95 + max_norm: Optional[float] = None + min_lr: float = 6e-5 + + +class EvalParameters(BaseModel): + """Mid-training evaluation parameters.""" + + interval: int = 100 + max_new_tokens: int = 100 + max_iters: int = 100 + + +class FinetuningParameters(BaseModel): + """Parameters for the finetuning step.""" + + base_model_repo: str + from_safetensors: bool = False + + adapter_output_repo: Optional[str] = None + merged_output_repo: Optional[str] = None + convert_to_hf_checkpoint: bool = False + + precision: Optional[str] = None + quantize: Optional[ + Literal[ + "bnb.nf4", + "bnb.nf4-dq", + "bnb.fp4", + "bnb.fp4-dq", + "bnb.int8-training", + ] + ] = None + + data: DataParameters = DataParameters() + training: TrainingParameters = TrainingParameters() + eval: EvalParameters = EvalParameters() + lora: LoraParameters = LoraParameters() + + +@step(output_materializers=[DirectoryMaterializer, BuiltInMaterializer]) +def finetune( + config: FinetuningParameters, dataset_directory: Optional[Path] = None +) -> Annotated[Optional[Path], "adapter"]: + """Finetune model using LoRA. + + Args: + config: Configuration for this step. + """ + torch.set_float32_matmul_precision("high") + + access_token = get_huggingface_access_token() + + checkpoint_root_dir = Path("checkpoints") + checkpoint_dir = checkpoint_root_dir / config.base_model_repo + + if checkpoint_dir.exists(): + logger.info( + "Checkpoint directory already exists, skipping download..." + ) + else: + download_from_hub( + repo_id=config.base_model_repo, + from_safetensors=config.from_safetensors, + checkpoint_dir=checkpoint_root_dir, + access_token=access_token, + ) + + convert_to_lit_checkpoint_if_necessary(checkpoint_dir=checkpoint_dir) + + if dataset_directory: + try: + dataset_name = ( + get_step_context() + .inputs["dataset_directory"] + .run_metadata["dataset_name"] + .value + ) + except KeyError: + dataset_name = "unknown_dataset" + else: + dataset_directory = Path("data/alpaca") + dataset_name = dataset_directory.name + prepare( + destination_path=dataset_directory, + checkpoint_dir=checkpoint_dir, + test_split_fraction=config.data.test_split_fraction, + seed=config.data.seed, + mask_inputs=config.data.mask_inputs, + ignore_index=config.data.ignore_index, + max_seq_length=config.data.max_seq_length, + ) + + model_name = checkpoint_dir.name + + log_model_metadata( + metadata={"model_name": model_name, "dataset_name": dataset_name} + ) + adapter_output_dir = Path("output/lora") / dataset_name / model_name + + io_args = IOArgs( + train_data_dir=dataset_directory, + val_data_dir=dataset_directory, + checkpoint_dir=checkpoint_dir, + out_dir=adapter_output_dir, + ) + train_args = TrainArgs(**config.training.dict()) + eval_args = EvalArgs(**config.eval.dict()) + setup( + devices=1, + io=io_args, + train=train_args, + eval=eval_args, + precision=config.precision, + quantize=config.quantize, + **config.lora.dict(), + ) + + if config.merged_output_repo: + lora_path = adapter_output_dir / "lit_model_lora_finetuned.pth" + + merge_output_dir = ( + Path("output/lora_merged") / dataset_name / model_name + ) + merge_lora( + lora_path=lora_path, + checkpoint_dir=checkpoint_dir, + out_dir=merge_output_dir, + precision=config.precision, + **config.lora.dict(), + ) + + for path in Path(checkpoint_dir).glob("*.json"): + destination = Path(merge_output_dir) / path.name + shutil.copy(src=path, dst=destination) + + if config.convert_to_hf_checkpoint: + upload_dir = ( + Path("output/lora_merged_hf") / dataset_name / model_name + ) + upload_dir.mkdir(parents=True, exist_ok=True) + convert_lit_checkpoint( + checkpoint_path=config.merged_output_repo / "lit_model.pth", + config_path=config.merged_output_repo / "lit_config.json", + output_path=upload_dir / "pytorch_model", + ) + else: + upload_dir = merge_output_dir + + commit = upload_folder( + repo_id=config.merged_output_repo, + folder_path=upload_dir, + token=access_token, + ) + log_model_metadata( + metadata={ + "merged_model_huggingface_commit_hash": commit.oid, + "merged_model_huggingface_commit_url": commit.commit_url, + } + ) + + if config.adapter_output_repo: + commit = upload_folder( + repo_id=config.adapter_output_repo, + folder_path=adapter_output_dir, + token=access_token, + ) + log_model_metadata( + metadata={ + "adapter_huggingface_commit_hash": commit.oid, + "adapter_huggingface_commit_url": commit.commit_url, + } + ) + return None + else: + # If the adapter should not be uploaded to the HF Hub, we store it + # in the artifact store + return adapter_output_dir diff --git a/examples/llm_finetuning/steps/merge.py b/examples/llm_finetuning/steps/merge.py new file mode 100644 index 00000000000..bc8fa90f716 --- /dev/null +++ b/examples/llm_finetuning/steps/merge.py @@ -0,0 +1,124 @@ +# Apache Software License 2.0 +# +# Copyright (c) ZenML GmbH 2024. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import shutil +from pathlib import Path +from typing import Optional + +from huggingface_hub import snapshot_download, upload_folder +from pydantic import BaseModel +from scripts.convert_lit_checkpoint import convert_lit_checkpoint +from scripts.download import download_from_hub +from scripts.merge_lora import merge_lora + +from steps.params import LoraParameters +from steps.utils import ( + convert_to_lit_checkpoint_if_necessary, + get_huggingface_access_token, +) +from zenml import log_model_metadata, step +from zenml.logger import get_logger + +logger = get_logger(__file__) + + +class MergeParameters(BaseModel): + """Parameters for the merging step.""" + + base_model_repo: str + from_safetensors: bool = False + + adapter_repo: str + output_repo: str + convert_to_hf_checkpoint: bool = False + + precision: Optional[str] = None + lora: LoraParameters = LoraParameters() + + +@step +def merge(config: MergeParameters) -> None: + """Merge base model and LoRA adapter. + + Args: + config: Configuration for this step. + """ + access_token = get_huggingface_access_token() + + checkpoint_root_dir = Path("checkpoints") + base_model_dir = checkpoint_root_dir / config.base_model_repo + adapter_dir = Path("adapters") / config.adapter_repo + + if base_model_dir.exists(): + logger.info( + "Checkpoint directory already exists, skipping download..." + ) + else: + download_from_hub( + repo_id=config.base_model_repo, + from_safetensors=config.from_safetensors, + checkpoint_dir=checkpoint_root_dir, + access_token=access_token, + ) + + convert_to_lit_checkpoint_if_necessary(checkpoint_dir=base_model_dir) + + snapshot_download( + config.adapter_repo, + local_dir=adapter_dir, + local_dir_use_symlinks=False, + resume_download=True, + token=access_token, + ) + + lora_path = adapter_dir / "lit_model_lora_finetuned.pth" + merged_dir = Path("output/merged") + + merge_lora( + lora_path=lora_path, + checkpoint_dir=base_model_dir, + out_dir=merged_dir, + precision=config.precision, + **config.lora.dict(), + ) + + for path in Path(base_model_dir).glob("*.json"): + destination = Path(merged_dir) / path.name + shutil.copy(src=path, dst=destination) + + if config.convert_to_hf_checkpoint: + model_name = base_model_dir.name + + output_dir = Path("output/lora_merged_hf") / model_name + output_dir.mkdir(parents=True, exist_ok=True) + convert_lit_checkpoint( + checkpoint_path=merged_dir / "lit_model.pth", + config_path=merged_dir / "lit_config.json", + output_path=output_dir / "pytorch_model", + ) + else: + output_dir = merged_dir + + commit = upload_folder( + repo_id=config.output_repo, folder_path=output_dir, token=access_token + ) + log_model_metadata( + metadata={ + "merged_model_huggingface_commit_hash": commit.oid, + "merged_model_huggingface_commit_url": commit.commit_url, + } + ) diff --git a/examples/llm_finetuning/steps/params.py b/examples/llm_finetuning/steps/params.py new file mode 100644 index 00000000000..52e450de206 --- /dev/null +++ b/examples/llm_finetuning/steps/params.py @@ -0,0 +1,32 @@ +# Apache Software License 2.0 +# +# Copyright (c) ZenML GmbH 2024. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +from pydantic import BaseModel + + +class LoraParameters(BaseModel): + """Lora specific parameters.""" + + lora_r: int = 8 + lora_alpha: int = 16 + lora_dropout: float = 0.05 + lora_query: bool = True + lora_key: bool = False + lora_value: bool = True + lora_projection: bool = False + lora_mlp: bool = False + lora_head: bool = False diff --git a/examples/llm_finetuning/steps/utils.py b/examples/llm_finetuning/steps/utils.py new file mode 100644 index 00000000000..c81238fef5c --- /dev/null +++ b/examples/llm_finetuning/steps/utils.py @@ -0,0 +1,54 @@ +# Apache Software License 2.0 +# +# Copyright (c) ZenML GmbH 2024. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import os +from pathlib import Path +from typing import Optional + +from scripts.convert_hf_checkpoint import convert_hf_checkpoint + +from zenml.client import Client + + +def get_huggingface_access_token() -> Optional[str]: + """Get access token for huggingface. + + Returns: + The access token if one was found. + """ + try: + return ( + Client() + .get_secret("huggingface_credentials") + .secret_values["token"] + ) + except KeyError: + return os.getenv("HF_TOKEN") + + +def convert_to_lit_checkpoint_if_necessary(checkpoint_dir: Path) -> None: + """Convert an HF checkpoint to a lit checkpoint if necessary. + + Args: + checkpoint_dir: The directory of the HF checkpoint. + """ + lit_model_path = checkpoint_dir / "lit_model.pth" + + if lit_model_path.is_file(): + return + + convert_hf_checkpoint(checkpoint_dir=checkpoint_dir) diff --git a/pyproject.toml b/pyproject.toml index 3ef62fc0d45..9697557dc20 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -304,6 +304,12 @@ exclude = [ "venv", '__init__.py', 'src/zenml/cli/version.py', + # LitGPT files from the LLM Finetuning example + 'examples/llm_finetuning/evaluate', + 'examples/llm_finetuning/finetune', + 'examples/llm_finetuning/generate', + 'examples/llm_finetuning/lit_gpt', + 'examples/llm_finetuning/scripts', ] src = ["src", "test"] diff --git a/src/zenml/cli/base.py b/src/zenml/cli/base.py index 9ae9d77abfd..e8a8b1655bd 100644 --- a/src/zenml/cli/base.py +++ b/src/zenml/cli/base.py @@ -83,6 +83,10 @@ def copier_github_url(self) -> str: github_url="zenml-io/template-nlp", github_tag="2024.01.12", # Make sure it is aligned with .github/workflows/update-templates-to-examples.yml ), + llm_finetuning=ZenMLProjectTemplateLocation( + github_url="zenml-io/template-llm-finetuning", + github_tag="2024.03.18", # Make sure it is aligned with .github/workflows/update-templates-to-examples.yml + ), ) @@ -98,9 +102,9 @@ def copier_github_url(self) -> str: type=str, required=False, help="Name or URL of the ZenML project template to use to initialize the " - "repository, Can be a string like `e2e_batch`, `nlp`, `starter` etc. or a " - "copier URL like gh:owner/repo_name. If not specified, no template is " - "used.", + "repository, Can be a string like `e2e_batch`, `nlp`, `llm_finetuning`, " + "`starter` etc. or a copier URL like gh:owner/repo_name. If not specified, " + "no template is used.", ) @click.option( "--template-tag", From f100a57df97a139cb3d7ac21978b4e65c2f1f14d Mon Sep 17 00:00:00 2001 From: Andrei Vishniakov <31008759+avishniakov@users.noreply.github.com> Date: Mon, 18 Mar 2024 12:59:40 +0100 Subject: [PATCH 16/45] Rate limiting for login API (#2484) * set rate limits for login api * Auto-update of Starter template * Auto-update of NLP template * properly handle exceptions * configurable rates * Auto-update of E2E template * relax rate limit for tests * relax rate limit for tests * relax rate limit for tests * relax dev server rate limits * reduce limiter on successful requests * relax limit in tests again * toggle for rate limiting * darglint * update docs * Update docs/book/deploying-zenml/zenml-self-hosted/deploy-with-docker.md Co-authored-by: Alex Strick van Linschoten * Auto-update of Starter template * use own rate limiter * lint and docstrings * check limits before processing request * review suggestions * Update src/zenml/zen_server/utils.py Co-authored-by: Stefan Nica * review suggestions * fix 3.8/3.9 --------- Co-authored-by: GitHub Actions Co-authored-by: Alex Strick van Linschoten Co-authored-by: Stefan Nica --- docker/zenml-dev.Dockerfile | 3 +- docker/zenml-server-dev.Dockerfile | 4 +- .../zenml-self-hosted/deploy-with-docker.md | 3 + src/zenml/config/server_config.py | 8 + src/zenml/constants.py | 2 + .../zen_server/routers/auth_endpoints.py | 5 + src/zenml/zen_server/utils.py | 168 +++++++++++++++++- .../functional/zen_server/test_zen_server.py | 19 ++ 8 files changed, 208 insertions(+), 4 deletions(-) diff --git a/docker/zenml-dev.Dockerfile b/docker/zenml-dev.Dockerfile index bc91b4ab112..91d8cd43e5c 100644 --- a/docker/zenml-dev.Dockerfile +++ b/docker/zenml-dev.Dockerfile @@ -8,8 +8,7 @@ ENV PYTHONFAULTHANDLER=1 \ PIP_DISABLE_PIP_VERSION_CHECK=1 \ ZENML_DEBUG=1 \ ZENML_LOGGING_VERBOSITY=INFO \ - ZENML_CONTAINER=1 - + ZENML_CONTAINER=1 WORKDIR /zenml diff --git a/docker/zenml-server-dev.Dockerfile b/docker/zenml-server-dev.Dockerfile index b7c5efa9559..26272853857 100644 --- a/docker/zenml-server-dev.Dockerfile +++ b/docker/zenml-server-dev.Dockerfile @@ -8,7 +8,9 @@ ENV PYTHONFAULTHANDLER=1 \ PIP_DISABLE_PIP_VERSION_CHECK=1 \ ZENML_DEBUG=1 \ ZENML_LOGGING_VERBOSITY=INFO \ - ZENML_CONTAINER=1 + ZENML_CONTAINER=1 \ + ZENML_SERVER_RATE_LIMIT_ENABLED=1 \ + ZENML_SERVER_LOGIN_RATE_LIMIT_MINUTE=100 ARG USERNAME=zenml ARG USER_UID=1000 diff --git a/docs/book/deploying-zenml/zenml-self-hosted/deploy-with-docker.md b/docs/book/deploying-zenml/zenml-self-hosted/deploy-with-docker.md index 07148bc2eb7..d9103dfecf2 100644 --- a/docs/book/deploying-zenml/zenml-self-hosted/deploy-with-docker.md +++ b/docs/book/deploying-zenml/zenml-self-hosted/deploy-with-docker.md @@ -44,6 +44,9 @@ The following environment variables can be passed to the container: * **ZENML\_STORE\_SSL\_VERIFY\_SERVER\_CERT**: This boolean variable controls whether the SSL certificate in use by the MySQL server is verified. Only valid when `ZENML_STORE_URL` points to a MySQL database that uses SSL-secured connections. Defaults to `False`. * **ZENML\_LOGGING\_VERBOSITY**: Use this variable to control the verbosity of logs inside the container. It can be set to one of the following values: `NOTSET`, `ERROR`, `WARN`, `INFO` (default), `DEBUG` or `CRITICAL`. * **ZENML\_STORE\_BACKUP\_STRATEGY**: This variable controls the database backup strategy used by the ZenML server. See the [Database backup and recovery](#database-backup-and-recovery) section for more details about this feature and other related environment variables. Defaults to `in-memory`. +* **ZENML\_SERVER\_RATE\_LIMIT\_ENABLED**: This variable controls the rate limiting for ZenML API (currently only for the `LOGIN` endpoint). It is disabled by default, so set it to `1` only if you need to enable rate limiting. To determine unique users a `X_FORWARDED_FOR` header or `request.client.host` is used, so before enabling this make sure that your network configuration is associating proper information with your clients in order to avoid disruptions for legitimate requests. +* **ZENML\_SERVER\_LOGIN\_RATE\_LIMIT\_MINUTE**: If rate limiting is enabled, this variable controls how many requests will be allowed to query the login endpoint in a one minute interval. Set it to a desired integer value; defaults to `5`. +* **ZENML\_SERVER\_LOGIN\_RATE\_LIMIT\_DAY**: If rate limiting is enabled, this variable controls how many requests will be allowed to query the login endpoint in an interval of day interval. Set it to a desired integer value; defaults to `1000`. If none of the `ZENML_STORE_*` variables are set, the container will default to creating and using an SQLite database file stored at `/zenml/.zenconfig/local_stores/default_zen_store/zenml.db` inside the container. The `/zenml/.zenconfig/local_stores` base path where the default SQLite database is located can optionally be overridden by setting the `ZENML_LOCAL_STORES_PATH` environment variable to point to a different path (e.g. a persistent volume or directory that is mounted from the host). diff --git a/src/zenml/config/server_config.py b/src/zenml/config/server_config.py index eee3844d3af..4883ada6e45 100644 --- a/src/zenml/config/server_config.py +++ b/src/zenml/config/server_config.py @@ -26,6 +26,8 @@ DEFAULT_ZENML_JWT_TOKEN_LEEWAY, DEFAULT_ZENML_SERVER_DEVICE_AUTH_POLLING, DEFAULT_ZENML_SERVER_DEVICE_AUTH_TIMEOUT, + DEFAULT_ZENML_SERVER_LOGIN_RATE_LIMIT_DAY, + DEFAULT_ZENML_SERVER_LOGIN_RATE_LIMIT_MINUTE, DEFAULT_ZENML_SERVER_MAX_DEVICE_AUTH_ATTEMPTS, DEFAULT_ZENML_SERVER_PIPELINE_RUN_AUTH_WINDOW, ENV_ZENML_SERVER_PREFIX, @@ -119,6 +121,8 @@ class ServerConfiguration(BaseModel): pipeline_run_auth_window: The default time window in minutes for which a pipeline run action is allowed to authenticate with the ZenML server. + login_rate_limit_minute: The number of login attempts allowed per minute. + login_rate_limit_day: The number of login attempts allowed per day. """ deployment_type: ServerDeploymentType = ServerDeploymentType.OTHER @@ -157,6 +161,10 @@ class ServerConfiguration(BaseModel): DEFAULT_ZENML_SERVER_PIPELINE_RUN_AUTH_WINDOW ) + rate_limit_enabled: bool = False + login_rate_limit_minute: int = DEFAULT_ZENML_SERVER_LOGIN_RATE_LIMIT_MINUTE + login_rate_limit_day: int = DEFAULT_ZENML_SERVER_LOGIN_RATE_LIMIT_DAY + _deployment_id: Optional[UUID] = None @root_validator(pre=True) diff --git a/src/zenml/constants.py b/src/zenml/constants.py index 4abf73cd3d7..ab282a0e9d7 100644 --- a/src/zenml/constants.py +++ b/src/zenml/constants.py @@ -178,6 +178,8 @@ def handle_int_env_var(var: str, default: int = 0) -> int: DEFAULT_HTTP_TIMEOUT = 30 ZENML_API_KEY_PREFIX = "ZENKEY_" DEFAULT_ZENML_SERVER_PIPELINE_RUN_AUTH_WINDOW = 60 * 48 # 48 hours +DEFAULT_ZENML_SERVER_LOGIN_RATE_LIMIT_MINUTE = 5 +DEFAULT_ZENML_SERVER_LOGIN_RATE_LIMIT_DAY = 1000 # API Endpoint paths: ACTIVATE = "/activate" diff --git a/src/zenml/zen_server/routers/auth_endpoints.py b/src/zenml/zen_server/routers/auth_endpoints.py index f6b2d289ecb..c3296cdd550 100644 --- a/src/zenml/zen_server/routers/auth_endpoints.py +++ b/src/zenml/zen_server/routers/auth_endpoints.py @@ -70,6 +70,7 @@ from zenml.zen_server.utils import ( get_ip_location, handle_exceptions, + rate_limit_requests, server_config, zen_store, ) @@ -255,6 +256,10 @@ def generate_access_token( LOGIN, response_model=Union[OAuthTokenResponse, OAuthRedirectResponse], ) +@rate_limit_requests( + day_limit=server_config().login_rate_limit_day, + minute_limit=server_config().login_rate_limit_minute, +) @handle_exceptions def token( request: Request, diff --git a/src/zenml/zen_server/utils.py b/src/zenml/zen_server/utils.py index bc6be68e24b..6dff76b6f47 100644 --- a/src/zenml/zen_server/utils.py +++ b/src/zenml/zen_server/utils.py @@ -15,11 +15,25 @@ import inspect import os +import time +from collections import defaultdict from functools import wraps -from typing import Any, Callable, Optional, Tuple, Type, TypeVar, cast +from typing import ( + TYPE_CHECKING, + Any, + Callable, + Dict, + List, + Optional, + Tuple, + Type, + TypeVar, + cast, +) from urllib.parse import urlparse from pydantic import BaseModel, ValidationError +from starlette.requests import Request from zenml.config.global_config import GlobalConfiguration from zenml.config.server_config import ServerConfiguration @@ -41,6 +55,10 @@ from zenml.zen_server.rbac.rbac_interface import RBACInterface from zenml.zen_stores.sql_zen_store import SqlZenStore +if TYPE_CHECKING: + pass + + logger = get_logger(__name__) _zen_store: Optional["SqlZenStore"] = None @@ -318,6 +336,154 @@ def decorated(*args: Any, **kwargs: Any) -> Any: return cast(F, decorated) +class RequestLimiter: + """Simple in-memory rate limiter.""" + + def __init__( + self, + day_limit: Optional[int] = None, + minute_limit: Optional[int] = None, + ): + """Initializes the limiter. + + Args: + day_limit: The number of requests allowed per day. + minute_limit: The number of requests allowed per minute. + + Raises: + ValueError: If both day_limit and minute_limit are None. + """ + self.limiting_enabled = server_config().rate_limit_enabled + if not self.limiting_enabled: + return + if day_limit is None and minute_limit is None: + raise ValueError("Pass either day or minuter limits, or both.") + self.day_limit = day_limit + self.minute_limit = minute_limit + self.limiter: Dict[str, List[float]] = defaultdict(list) + + def hit_limiter(self, request: Request) -> None: + """Increase the number of hits in the limiter. + + Args: + request: Request object. + + Raises: + HTTPException: If the request limit is exceeded. + """ + if not self.limiting_enabled: + return + from fastapi import HTTPException + + requester = self._get_ipaddr(request) + now = time.time() + minute_ago = now - 60 + day_ago = now - 60 * 60 * 24 + self.limiter[requester].append(now) + + from bisect import bisect_left + + # remove failures older than a day + older_index = bisect_left(self.limiter[requester], day_ago) + self.limiter[requester] = self.limiter[requester][older_index:] + + if self.day_limit and len(self.limiter[requester]) > self.day_limit: + raise HTTPException( + status_code=429, detail="Daily request limit exceeded." + ) + minute_requests = len( + [ + limiter_hit + for limiter_hit in self.limiter[requester][::-1] + if limiter_hit >= minute_ago + ] + ) + if self.minute_limit and minute_requests > self.minute_limit: + raise HTTPException( + status_code=429, detail="Minute request limit exceeded." + ) + + def reset_limiter(self, request: Request) -> None: + """Resets the limiter on successful request. + + Args: + request: Request object. + """ + if self.limiting_enabled: + requester = self._get_ipaddr(request) + if requester in self.limiter: + del self.limiter[requester] + + def _get_ipaddr(self, request: Request) -> str: + """Returns the IP address for the current request. + + Based on the X-Forwarded-For headers or client information. + + Args: + request: The request object. + + Returns: + The ip address for the current request (or 127.0.0.1 if none found). + """ + if "X_FORWARDED_FOR" in request.headers: + return request.headers["X_FORWARDED_FOR"] + else: + if not request.client or not request.client.host: + return "127.0.0.1" + + return request.client.host + + +def rate_limit_requests( + day_limit: Optional[int] = None, + minute_limit: Optional[int] = None, +) -> Callable[..., Any]: + """Decorator to handle exceptions in the API. + + Args: + day_limit: Number of requests allowed per day. + minute_limit: Number of requests allowed per minute. + + Returns: + Decorated function. + """ + limiter = RequestLimiter(day_limit=day_limit, minute_limit=minute_limit) + + def decorator(func: F) -> F: + request_arg, request_kwarg = None, None + parameters = inspect.signature(func).parameters + for arg_num, arg_name in enumerate(parameters): + if parameters[arg_name].annotation == Request: + request_arg = arg_num + request_kwarg = arg_name + break + if request_arg is None or request_kwarg is None: + raise ValueError( + "Rate limiting APIs must have argument of `Request` type." + ) + + @wraps(func) + def decorated( + *args: Any, + **kwargs: Any, + ) -> Any: + if request_kwarg in kwargs: + request = kwargs[request_kwarg] + else: + request = args[request_arg] + limiter.hit_limiter(request) + + ret = func(*args, **kwargs) + + # if request was successful - reset limiter + limiter.reset_limiter(request) + return ret + + return cast(F, decorated) + + return decorator + + # Code from https://github.com/tiangolo/fastapi/issues/1474#issuecomment-1160633178 # to send 422 response when receiving invalid query parameters def make_dependable(cls: Type[BaseModel]) -> Callable[..., Any]: diff --git a/tests/integration/functional/zen_server/test_zen_server.py b/tests/integration/functional/zen_server/test_zen_server.py index 93290aa22d8..322635c8e8b 100644 --- a/tests/integration/functional/zen_server/test_zen_server.py +++ b/tests/integration/functional/zen_server/test_zen_server.py @@ -17,8 +17,13 @@ import pytest import requests +from zenml.client import Client +from zenml.constants import DEFAULT_USERNAME +from zenml.enums import StoreType from zenml.utils.networking_utils import scan_for_available_port from zenml.zen_server.deploy import ServerDeployer, ServerDeploymentConfig +from zenml.zen_server.utils import server_config +from zenml.zen_stores.rest_zen_store import RestZenStore SERVER_START_STOP_TIMEOUT = 60 @@ -73,3 +78,17 @@ def test_server_up_down(clean_client, mocker): print(line) raise assert deployer.list_servers() == [] + + +def test_rate_limit_is_not_impacted_by_successful_requests(): + zen_store = Client().zen_store + if zen_store.type == StoreType.SQL: + pytest.skip("SQL ZenStore does not support rate limiting.") + + assert Client().active_user.name == DEFAULT_USERNAME + zen_store: RestZenStore = zen_store + + repeat = server_config().login_rate_limit_minute * 2 + for _ in range(repeat): + zen_store.clear_session() + zen_store.session From e3ac3af701ebe7801ecfa820a8f237295bcd4b7e Mon Sep 17 00:00:00 2001 From: Alexej Penner Date: Mon, 18 Mar 2024 14:19:29 +0100 Subject: [PATCH 17/45] Fixed docstring --- src/zenml/zen_server/routers/webhook_endpoints.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/zenml/zen_server/routers/webhook_endpoints.py b/src/zenml/zen_server/routers/webhook_endpoints.py index 2e945b8d2b4..6073cce6dc3 100644 --- a/src/zenml/zen_server/routers/webhook_endpoints.py +++ b/src/zenml/zen_server/routers/webhook_endpoints.py @@ -69,6 +69,9 @@ def webhook( background_tasks: Background task handler raw_body: The raw request body + Returns: + Static dict stating that event is received. + Raises: AuthorizationException: If the Event Source does not exist. KeyError: If no appropriate Plugin found in the plugin registry From 1d996f0f43f312f67a3f9635349b7e6c27c2a06b Mon Sep 17 00:00:00 2001 From: Alexej Penner Date: Mon, 18 Mar 2024 14:35:55 +0100 Subject: [PATCH 18/45] Reformatted --- .../plugins/bitbucket_webhook_event_source_flavor.py | 6 +++--- src/zenml/zen_server/routers/webhook_endpoints.py | 1 + 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/zenml/integrations/bitbucket/plugins/bitbucket_webhook_event_source_flavor.py b/src/zenml/integrations/bitbucket/plugins/bitbucket_webhook_event_source_flavor.py index 2d0836058d7..a389b6677ee 100644 --- a/src/zenml/integrations/bitbucket/plugins/bitbucket_webhook_event_source_flavor.py +++ b/src/zenml/integrations/bitbucket/plugins/bitbucket_webhook_event_source_flavor.py @@ -30,9 +30,9 @@ class BitbucketWebhookEventSourceFlavor(BaseWebhookEventSourceFlavor): """Enables users to configure Bitbucket event sources.""" FLAVOR: ClassVar[str] = BITBUCKET_EVENT_FLAVOR - PLUGIN_CLASS: ClassVar[ - Type[BitbucketWebhookEventSourceHandler] - ] = BitbucketWebhookEventSourceHandler + PLUGIN_CLASS: ClassVar[Type[BitbucketWebhookEventSourceHandler]] = ( + BitbucketWebhookEventSourceHandler + ) # EventPlugin specific EVENT_SOURCE_CONFIG_CLASS: ClassVar[ diff --git a/src/zenml/zen_server/routers/webhook_endpoints.py b/src/zenml/zen_server/routers/webhook_endpoints.py index 6073cce6dc3..9cb167a5d34 100644 --- a/src/zenml/zen_server/routers/webhook_endpoints.py +++ b/src/zenml/zen_server/routers/webhook_endpoints.py @@ -12,6 +12,7 @@ # or implied. See the License for the specific language governing # permissions and limitations under the License. """Endpoint definitions for webhooks.""" + from typing import Dict from uuid import UUID From 48799e9c1a725d73daa6b1e252a150bfe3b11fcf Mon Sep 17 00:00:00 2001 From: Christian Versloot Date: Mon, 18 Mar 2024 17:16:38 +0100 Subject: [PATCH 19/45] Try/catch for Docker client (#2513) * update TOC (#2406) * Add try/except to DockerClient from env * Move generation of Docker client from environment to separate utility function --------- Co-authored-by: Alex Strick van Linschoten --- .../base_container_registry.py | 4 ++- .../image_builders/local_image_builder.py | 4 +-- .../local_docker/local_docker_orchestrator.py | 5 ++-- .../docker_service_connector.py | 5 +++- .../services/container/container_service.py | 5 +++- src/zenml/utils/docker_utils.py | 30 +++++++++++++++---- 6 files changed, 39 insertions(+), 14 deletions(-) diff --git a/src/zenml/container_registries/base_container_registry.py b/src/zenml/container_registries/base_container_registry.py index 4617b7db588..d8f641cf4b4 100644 --- a/src/zenml/container_registries/base_container_registry.py +++ b/src/zenml/container_registries/base_container_registry.py @@ -142,7 +142,9 @@ def docker_client(self) -> "DockerClient": ) self._docker_client = client else: - self._docker_client = DockerClient.from_env() + self._docker_client = ( + docker_utils._try_get_docker_client_from_env() + ) credentials = self.credentials if credentials: diff --git a/src/zenml/image_builders/local_image_builder.py b/src/zenml/image_builders/local_image_builder.py index 16a1fd29c1f..5a918e934f9 100644 --- a/src/zenml/image_builders/local_image_builder.py +++ b/src/zenml/image_builders/local_image_builder.py @@ -17,8 +17,6 @@ import tempfile from typing import TYPE_CHECKING, Any, Dict, Optional, Type, cast -from docker.client import DockerClient - from zenml.image_builders import ( BaseImageBuilder, BaseImageBuilderConfig, @@ -106,7 +104,7 @@ def build( # authenticated to access additional registries docker_client = container_registry.docker_client else: - docker_client = DockerClient.from_env() + docker_client = docker_utils._try_get_docker_client_from_env() with tempfile.TemporaryFile(mode="w+b") as f: build_context.write_archive(f) diff --git a/src/zenml/orchestrators/local_docker/local_docker_orchestrator.py b/src/zenml/orchestrators/local_docker/local_docker_orchestrator.py index c5cd80bbc43..cd5d254d442 100644 --- a/src/zenml/orchestrators/local_docker/local_docker_orchestrator.py +++ b/src/zenml/orchestrators/local_docker/local_docker_orchestrator.py @@ -38,7 +38,7 @@ ContainerizedOrchestrator, ) from zenml.stack import Stack, StackValidator -from zenml.utils import string_utils +from zenml.utils import docker_utils, string_utils if TYPE_CHECKING: from zenml.models import PipelineDeploymentResponse @@ -117,9 +117,8 @@ def prepare_or_run_pipeline( "and the pipeline will be run immediately." ) - from docker.client import DockerClient + docker_client = docker_utils._try_get_docker_client_from_env() - docker_client = DockerClient.from_env() entrypoint = StepEntrypointConfiguration.get_entrypoint_command() # Add the local stores path as a volume mount diff --git a/src/zenml/service_connectors/docker_service_connector.py b/src/zenml/service_connectors/docker_service_connector.py index 70f6c92f26d..13f9d632035 100644 --- a/src/zenml/service_connectors/docker_service_connector.py +++ b/src/zenml/service_connectors/docker_service_connector.py @@ -37,6 +37,7 @@ AuthenticationConfig, ServiceConnector, ) +from zenml.utils import docker_utils from zenml.utils.enum_utils import StrEnum logger = get_logger(__name__) @@ -258,7 +259,9 @@ def _connect_to_resource( An authenticated python-docker client object. """ assert self.resource_id is not None - docker_client = DockerClient.from_env() + + docker_client = docker_utils._try_get_docker_client_from_env() + self._authorize_client(docker_client, self.resource_id) return docker_client diff --git a/src/zenml/services/container/container_service.py b/src/zenml/services/container/container_service.py index 5c8dcb3b8cb..28089b1bdf9 100644 --- a/src/zenml/services/container/container_service.py +++ b/src/zenml/services/container/container_service.py @@ -33,6 +33,7 @@ ) from zenml.services.service import BaseService, ServiceConfig from zenml.services.service_status import ServiceState, ServiceStatus +from zenml.utils import docker_utils from zenml.utils.io_utils import ( create_dir_recursive_if_not_exists, get_global_config_directory, @@ -177,7 +178,9 @@ def docker_client(self) -> DockerClient: The docker client. """ if self._docker_client is None: - self._docker_client = DockerClient.from_env() + self._docker_client = ( + docker_utils._try_get_docker_client_from_env() + ) return self._docker_client @property diff --git a/src/zenml/utils/docker_utils.py b/src/zenml/utils/docker_utils.py index 225a1ab5e09..4b8097542dc 100644 --- a/src/zenml/utils/docker_utils.py +++ b/src/zenml/utils/docker_utils.py @@ -29,6 +29,7 @@ ) from docker.client import DockerClient +from docker.errors import DockerException from docker.utils import build as docker_build_utils from zenml.io import fileio @@ -227,7 +228,8 @@ def build_image( logger.info("Building the image might take a while...") - docker_client = DockerClient.from_env() + docker_client = _try_get_docker_client_from_env() + # We use the client api directly here, so we can stream the logs output_stream = docker_client.images.client.api.build( fileobj=build_context, @@ -258,7 +260,7 @@ def push_image( RuntimeError: If fetching the repository digest of the image failed. """ logger.info("Pushing Docker image `%s`.", image_name) - docker_client = docker_client or DockerClient.from_env() + docker_client = _try_get_docker_client_from_env() output_stream = docker_client.images.push(image_name, stream=True) aux_info = _process_stream(output_stream) logger.info("Finished pushing Docker image.") @@ -283,7 +285,7 @@ def tag_image(image_name: str, target: str) -> None: image_name: The name of the image to tag. target: The full target name including a tag. """ - docker_client = DockerClient.from_env() + docker_client = _try_get_docker_client_from_env() image = docker_client.images.get(image_name) image.tag(target) @@ -298,7 +300,8 @@ def get_image_digest(image_name: str) -> Optional[str]: Returns the repo digest for the given image if there exists exactly one. If there are zero or multiple repo digests, returns `None`. """ - docker_client = DockerClient.from_env() + docker_client = _try_get_docker_client_from_env() + image = docker_client.images.get(image_name) repo_digests = image.attrs["RepoDigests"] if len(repo_digests) == 1: @@ -321,7 +324,7 @@ def is_local_image(image_name: str) -> bool: Returns: `True` if the image was pulled from a registry, `False` otherwise. """ - docker_client = DockerClient.from_env() + docker_client = _try_get_docker_client_from_env() images = docker_client.images.list(name=image_name) if images: # An image with this name is available locally -> now check whether it @@ -333,6 +336,23 @@ def is_local_image(image_name: str) -> bool: return False +def _try_get_docker_client_from_env() -> DockerClient: + """Tries to create a Docker client from the environment. + + Raises: + RuntimeError: If creating a Docker client from the environment failed. + + Returns: + A Docker client created from the environment. + """ + try: + return DockerClient.from_env() + except DockerException as e: + raise RuntimeError( + "Could not create a Docker client from the environment. Is your Docker daemon running?" + ) from e + + def _process_stream(stream: Iterable[bytes]) -> List[Dict[str, Any]]: """Processes the output stream of a docker command call. From 026bd42d53e94ad966cfad1bb16681782c4854ba Mon Sep 17 00:00:00 2001 From: Michael Schuster Date: Tue, 19 Mar 2024 02:42:46 +0100 Subject: [PATCH 20/45] Fix config file in starter guide (#2534) --- docs/book/user-guide/starter-guide/create-an-ml-pipeline.md | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/docs/book/user-guide/starter-guide/create-an-ml-pipeline.md b/docs/book/user-guide/starter-guide/create-an-ml-pipeline.md index eaa21d2599c..2e5910b0257 100644 --- a/docs/book/user-guide/starter-guide/create-an-ml-pipeline.md +++ b/docs/book/user-guide/starter-guide/create-an-ml-pipeline.md @@ -260,10 +260,8 @@ check them into git history. A simple version of such a YAML file could be: ```yaml -steps: - svc_trainer: - parameters: - gamma: 0.01 +parameters: + gamma: 0.01 ``` Please note that this would take precedence over any parameters passed in the code. From 332eab751f2c52702a38ba3d33dfcec13c3df46b Mon Sep 17 00:00:00 2001 From: Jayesh Sharma Date: Tue, 19 Mar 2024 13:05:58 +0530 Subject: [PATCH 21/45] Log URL for pipelines and model versions when running a pipeline (#2506) * add model url function and use config's base url * attach model version URL with model creation log * output url of pipeline in dashboard before deploying it * only display model URL for cloud * log model url for new and existing models Co-authored-by: Andrei Vishniakov <31008759+avishniakov@users.noreply.github.com> * don't add workspace in cloud url * small embellishment * attempt to fix circular import * Auto-update of Starter template * format files * fix function arg type * fix loading of config * change where model URL is logged * linting * improve logging message and log non cloud case only once * fix docstring * make cloud check more robust * move cloud functions to new cloud utils * fix linter and docstring --------- Co-authored-by: Andrei Vishniakov <31008759+avishniakov@users.noreply.github.com> Co-authored-by: GitHub Actions --- src/zenml/model/model.py | 12 ++++++--- src/zenml/new/pipelines/pipeline.py | 8 +++--- src/zenml/new/pipelines/run_utils.py | 12 ++++++++- src/zenml/utils/cloud_utils.py | 40 ++++++++++++++++++++++++++++ src/zenml/utils/dashboard_utils.py | 36 +++++++++++++++++++++++++ 5 files changed, 100 insertions(+), 8 deletions(-) create mode 100644 src/zenml/utils/cloud_utils.py diff --git a/src/zenml/model/model.py b/src/zenml/model/model.py index 260d0d67c08..b75dfe7861c 100644 --- a/src/zenml/model/model.py +++ b/src/zenml/model/model.py @@ -503,9 +503,13 @@ def _root_validator(cls, values: Dict[str, Any]) -> Dict[str, Any]: values["suppress_class_validation_warnings"] = True return values - def _validate_config_in_runtime(self) -> None: - """Validate that config doesn't conflict with runtime environment.""" - self._get_or_create_model_version() + def _validate_config_in_runtime(self) -> "ModelVersionResponse": + """Validate that config doesn't conflict with runtime environment. + + Returns: + The model version based on configuration. + """ + return self._get_or_create_model_version() def _get_or_create_model(self) -> "ModelResponse": """This method should get or create a model from Model Control Plane. @@ -722,7 +726,9 @@ def _get_or_create_model_version( retries_made += 1 self.version = model_version.name self.was_created_in_this_run = True + logger.info(f"New model version `{self.version}` was created.") + self._id = model_version.id self._model_id = model_version.model.id self._number = model_version.number diff --git a/src/zenml/new/pipelines/pipeline.py b/src/zenml/new/pipelines/pipeline.py index 1a5ee998896..5b0358f93d8 100644 --- a/src/zenml/new/pipelines/pipeline.py +++ b/src/zenml/new/pipelines/pipeline.py @@ -743,10 +743,6 @@ def _run( run_id=run.id if run else None, ) - deploy_pipeline( - deployment=deployment_model, stack=stack, placeholder_run=run - ) - if run: run_url = dashboard_utils.get_run_url(run) if run_url: @@ -758,6 +754,10 @@ def _run( "`zenml up`." ) + deploy_pipeline( + deployment=deployment_model, stack=stack, placeholder_run=run + ) + return run @staticmethod diff --git a/src/zenml/new/pipelines/run_utils.py b/src/zenml/new/pipelines/run_utils.py index e98caef2792..2b3d750ba6a 100644 --- a/src/zenml/new/pipelines/run_utils.py +++ b/src/zenml/new/pipelines/run_utils.py @@ -28,6 +28,7 @@ from zenml.new.pipelines.model_utils import NewModelRequest from zenml.orchestrators.utils import get_run_name from zenml.stack import Stack +from zenml.utils import cloud_utils if TYPE_CHECKING: from zenml.config.source import Source @@ -232,6 +233,7 @@ def _validate_new_version_requests( new_versions_requested: A dict of new model version request objects. """ + is_cloud_model = True for key, data in new_versions_requested.items(): model_name, model_version = key if len(data.requesters) > 1: @@ -241,4 +243,12 @@ def _validate_new_version_requests( "that `Model` requesting new version is configured only in one " "place of the pipeline." ) - data.model._validate_config_in_runtime() + model_version_response = data.model._validate_config_in_runtime() + is_cloud_model &= cloud_utils.is_cloud_model_version( + model_version_response + ) + if not is_cloud_model: + logger.info( + "Models can be viewed in the dashboard using ZenML Cloud. Sign up " + "for a free trial at https://www.zenml.io/cloud/" + ) diff --git a/src/zenml/utils/cloud_utils.py b/src/zenml/utils/cloud_utils.py new file mode 100644 index 00000000000..cad6b9dcb98 --- /dev/null +++ b/src/zenml/utils/cloud_utils.py @@ -0,0 +1,40 @@ +# Copyright (c) ZenML GmbH 2024. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at: +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express +# or implied. See the License for the specific language governing +# permissions and limitations under the License. +"""Utilities for ZenML Cloud.""" + +from zenml.logger import get_logger +from zenml.models.v2.core.model_version import ModelVersionResponse +from zenml.utils.dashboard_utils import get_model_version_url + +logger = get_logger(__name__) + + +def is_cloud_model_version(model_version: ModelVersionResponse) -> bool: + """Check if a model version is from a ZenML Cloud server. + + Args: + model_version: The model version to check. + + Returns: + True if the model version is from a ZenML Cloud server, else False. + """ + model_version_url = get_model_version_url(model_version.id) + if model_version_url: + logger.info( + f"Dashboard URL for Model Version with name {model_version.name} " + f": {model_version_url}" + ) + return True + else: + return False diff --git a/src/zenml/utils/dashboard_utils.py b/src/zenml/utils/dashboard_utils.py index 172dfc5805b..73d9e63c522 100644 --- a/src/zenml/utils/dashboard_utils.py +++ b/src/zenml/utils/dashboard_utils.py @@ -14,6 +14,7 @@ """Utility class to help with interacting with the dashboard.""" from typing import Optional +from uuid import UUID from zenml import constants from zenml.client import Client @@ -34,6 +35,17 @@ def get_base_url() -> Optional[str]: client = Client() if client.zen_store.type == StoreType.REST: + # if the server config has a base URL use that + server_model = client.zen_store.get_store_info() + if server_model.base_url: + url = server_model.base_url + # if the base url has cloud.zenml.io in it, then it is a cloud + # deployment and there isn't a workspace in the URL + if "cloud.zenml.io" in url: + return url + return ( + url + f"{constants.WORKSPACES}/{client.active_workspace.name}" + ) url = ( client.zen_store.url + f"{constants.WORKSPACES}/{client.active_workspace.name}" @@ -94,6 +106,30 @@ def get_run_url(run: PipelineRunResponse) -> Optional[str]: return None +def get_model_version_url(model_version_id: UUID) -> Optional[str]: + """Function to get the dashboard URL of a given model version. + + Args: + model_version_id: the id of the model version. + + Returns: + the URL to the model version if the dashboard is available, else None. + """ + client = Client() + server_model = client.zen_store.get_store_info() + # if organization_id exists as key in server_config.metadata + # only then output a URL. + if server_model.metadata.get("organization_id"): + base_url = get_base_url() + if base_url: + # TODO MODEL_VERSIONS resolves to /model_versions but on the + # cloud, the URL is /model-versions. This should be fixed? + return ( + f"{base_url}{constants.MODEL_VERSIONS}/{str(model_version_id)}" + ) + return None + + def show_dashboard(url: str) -> None: """Show the ZenML dashboard at the given URL. From 416b39047389d52becb570f51656c4f72b6c711a Mon Sep 17 00:00:00 2001 From: Michael Schuster Date: Tue, 19 Mar 2024 09:22:29 +0100 Subject: [PATCH 22/45] Add security exclude (#2541) --- scripts/check-security.sh | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/check-security.sh b/scripts/check-security.sh index de3fbef2060..8894b8d1eb2 100755 --- a/scripts/check-security.sh +++ b/scripts/check-security.sh @@ -8,4 +8,6 @@ SRC=${1:-"src/zenml tests examples"} export ZENML_DEBUG=1 export ZENML_ANALYTICS_OPT_IN=false -bandit -r $SRC -ll +bandit -r $SRC -ll \ + --exclude examples/llm_finetuning/scripts/prepare_alpaca.py + From 60d362d8bd46c57096b6d81bdd2cdaf06906fed9 Mon Sep 17 00:00:00 2001 From: Alexej Penner Date: Tue, 19 Mar 2024 10:02:15 +0100 Subject: [PATCH 23/45] Dealt with linting issues --- .../bitbucket_webhook_event_source.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/zenml/integrations/bitbucket/plugins/event_sources/bitbucket_webhook_event_source.py b/src/zenml/integrations/bitbucket/plugins/event_sources/bitbucket_webhook_event_source.py index c6e4feec81a..c9a6c247958 100644 --- a/src/zenml/integrations/bitbucket/plugins/event_sources/bitbucket_webhook_event_source.py +++ b/src/zenml/integrations/bitbucket/plugins/event_sources/bitbucket_webhook_event_source.py @@ -69,7 +69,7 @@ class Commit(BaseModel): hash: str message: str - links: dict + links: Dict[str, Any] author: User @@ -79,14 +79,14 @@ class Repository(BaseModel): uuid: str name: str full_name: str - links: dict + links: Dict[str, Any] class PushChange(BaseModel): """Bitbucket Push Change.""" - new: Optional[dict] - old: Optional[dict] + new: Optional[Dict[str, Any]] + old: Optional[Dict[str, Any]] commits: List[Commit] @@ -96,7 +96,7 @@ class Push(BaseModel): changes: List[PushChange] -class BitbucketEvent(BaseModel): +class BitbucketEvent(BaseEvent): """Bitbucket Event.""" actor: User @@ -116,7 +116,9 @@ def branch(self) -> Optional[str]: The branch name. """ if self.push.changes[0].new: - return self.push.changes[0].new.get("name", None) + branch = self.push.changes[0].new.get("name", None) + if self.push.changes[0].new.get("name", None): + return str(branch) return None @property From 03419988a6ecf3643284a53abb917b4df2580f8b Mon Sep 17 00:00:00 2001 From: Alex Strick van Linschoten Date: Tue, 19 Mar 2024 10:39:14 +0100 Subject: [PATCH 24/45] Update error message around notebook use (#2536) * update error message around notebook use * formatting --- src/zenml/utils/source_utils.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/zenml/utils/source_utils.py b/src/zenml/utils/source_utils.py index ea1fcfe6d0d..ba9e9e3e91f 100644 --- a/src/zenml/utils/source_utils.py +++ b/src/zenml/utils/source_utils.py @@ -231,7 +231,9 @@ def get_source_root() -> str: raise RuntimeError( "Unable to determine source root because the main module does not " "have an associated file. This could be because you're running in " - "an interactive Python environment." + "an interactive Python environment. If you are trying to run from " + "within a Jupyter notebook, please run `zenml init` from the root " + "where your notebook is located and restart your notebook server. " ) path = Path(main_module.__file__).resolve().parent From e18c688a5d159c3eff18b081559d06ec996a8a29 Mon Sep 17 00:00:00 2001 From: Andrei Vishniakov <31008759+avishniakov@users.noreply.github.com> Date: Tue, 19 Mar 2024 13:10:17 +0100 Subject: [PATCH 25/45] Cap `fsspec` for Huggingface integration (#2542) * cap max fsspec version * cap max fsspec version for hf only * cap max fsspec version for hf only --- src/zenml/integrations/huggingface/__init__.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/zenml/integrations/huggingface/__init__.py b/src/zenml/integrations/huggingface/__init__.py index c1a92f48e41..5f11ebc1cb3 100644 --- a/src/zenml/integrations/huggingface/__init__.py +++ b/src/zenml/integrations/huggingface/__init__.py @@ -30,6 +30,11 @@ class HuggingfaceIntegration(Integration): "transformers<=4.31", "datasets", "huggingface_hub>0.19.0", + # temporary fix for CI issue similar to: + # - https://github.com/huggingface/datasets/issues/6737 + # - https://github.com/huggingface/datasets/issues/6697 + # TODO try relaxing it back going forward + "fsspec<=2023.12.0", ] @classmethod From eabcdde2aa7da5cf56fa27041478e74d69389c64 Mon Sep 17 00:00:00 2001 From: Alex Strick van Linschoten Date: Tue, 19 Mar 2024 15:17:12 +0100 Subject: [PATCH 26/45] Fix integration materializers' URLs in docs (#2538) * fix urls for materializers * fix integration overview link --------- Co-authored-by: Safoine El Khabich <34200873+safoinme@users.noreply.github.com> --- .../data-management/handle-custom-data-types.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/book/user-guide/advanced-guide/data-management/handle-custom-data-types.md b/docs/book/user-guide/advanced-guide/data-management/handle-custom-data-types.md index 360fb2349f9..4c4dac0ebdf 100644 --- a/docs/book/user-guide/advanced-guide/data-management/handle-custom-data-types.md +++ b/docs/book/user-guide/advanced-guide/data-management/handle-custom-data-types.md @@ -20,9 +20,9 @@ ZenML also provides a built-in [CloudpickleMaterializer](https://sdkdocs.zenml.i ## Integration Materializers -In addition to the built-in materializers, ZenML also provides several integration-specific materializers that can be activated by installing the respective [integration](../../component-guide/integration-overview.md): +In addition to the built-in materializers, ZenML also provides several integration-specific materializers that can be activated by installing the respective [integration](../../../stacks-and-components/component-guide/integration-overview.md): -
IntegrationMaterializerHandled Data TypesStorage Format
bentomlBentoMaterializerbentoml.Bento.bento
deepchecksDeepchecksResultMateriailzerdeepchecks.CheckResult, deepchecks.SuiteResult.json
evidentlyEvidentlyProfileMaterializerevidently.Profile.json
great_expectationsGreatExpectationsMaterializergreat_expectations.ExpectationSuite, great_expectations.CheckpointResult.json
huggingfaceHFDatasetMaterializerdatasets.Dataset, datasets.DatasetDictDirectory
huggingfaceHFPTModelMaterializertransformers.PreTrainedModelDirectory
huggingfaceHFTFModelMaterializertransformers.TFPreTrainedModelDirectory
huggingfaceHFTokenizerMaterializertransformers.PreTrainedTokenizerBaseDirectory
lightgbmLightGBMBoosterMaterializerlgbm.Booster.txt
lightgbmLightGBMDatasetMaterializerlgbm.Dataset.binary
neural_prophetNeuralProphetMaterializerNeuralProphet.pt
pillowPillowImageMaterializerPillow.Image.PNG
polarsPolarsMaterializerpl.DataFrame, pl.Series.parquet
pycaretPyCaretMaterializerAny sklearn, xgboost, lightgbm or catboost model.pkl
pytorchPyTorchDataLoaderMaterializertorch.Dataset, torch.DataLoader.pt
pytorchPyTorchModuleMaterializertorch.Module.pt
scipySparseMaterializerscipy.spmatrix.npz
sparkSparkDataFrameMaterializerpyspark.DataFrame.parquet
sparkSparkModelMaterializerpyspark.Transformerpyspark.Estimator
tensorflowKerasMaterializertf.keras.ModelDirectory
tensorflowTensorflowDatasetMaterializertf.DatasetDirectory
whylogsWhylogsMaterializerwhylogs.DatasetProfileView.pb
xgboostXgboostBoosterMaterializerxgb.Booster.json
xgboostXgboostDMatrixMaterializerxgb.DMatrix.binary
+
IntegrationMaterializerHandled Data TypesStorage Format
bentomlBentoMaterializerbentoml.Bento.bento
deepchecksDeepchecksResultMateriailzerdeepchecks.CheckResult, deepchecks.SuiteResult.json
evidentlyEvidentlyProfileMaterializerevidently.Profile.json
great_expectationsGreatExpectationsMaterializergreat_expectations.ExpectationSuite, great_expectations.CheckpointResult.json
huggingfaceHFDatasetMaterializerdatasets.Dataset, datasets.DatasetDictDirectory
huggingfaceHFPTModelMaterializertransformers.PreTrainedModelDirectory
huggingfaceHFTFModelMaterializertransformers.TFPreTrainedModelDirectory
huggingfaceHFTokenizerMaterializertransformers.PreTrainedTokenizerBaseDirectory
lightgbmLightGBMBoosterMaterializerlgbm.Booster.txt
lightgbmLightGBMDatasetMaterializerlgbm.Dataset.binary
neural_prophetNeuralProphetMaterializerNeuralProphet.pt
pillowPillowImageMaterializerPillow.Image.PNG
polarsPolarsMaterializerpl.DataFrame, pl.Series.parquet
pycaretPyCaretMaterializerAny sklearn, xgboost, lightgbm or catboost model.pkl
pytorchPyTorchDataLoaderMaterializertorch.Dataset, torch.DataLoader.pt
pytorchPyTorchModuleMaterializertorch.Module.pt
scipySparseMaterializerscipy.spmatrix.npz
sparkSparkDataFrameMaterializerpyspark.DataFrame.parquet
sparkSparkModelMaterializerpyspark.Transformerpyspark.Estimator
tensorflowKerasMaterializertf.keras.ModelDirectory
tensorflowTensorflowDatasetMaterializertf.DatasetDirectory
whylogsWhylogsMaterializerwhylogs.DatasetProfileView.pb
xgboostXgboostBoosterMaterializerxgb.Booster.json
xgboostXgboostDMatrixMaterializerxgb.DMatrix.binary
{% hint style="warning" %} If you are running pipelines with a Docker-based [orchestrator](../../component-guide/orchestrators/orchestrators.md), you need to specify the corresponding integration as `required_integrations` in the `DockerSettings` of your pipeline in order to have the integration materializer available inside your Docker container. See the [pipeline configuration documentation](../pipelining-features/pipeline-settings.md) for more information. @@ -683,4 +683,4 @@ if __name__ == "__main__": -
ZenML Scarf
\ No newline at end of file +
ZenML Scarf
From 36c94102f077d5eeaa8c5e72ece1ef653a9df176 Mon Sep 17 00:00:00 2001 From: Christian Versloot Date: Tue, 19 Mar 2024 15:38:56 +0100 Subject: [PATCH 27/45] Bug fix HyperAI orchestrator: Offload scheduled pipeline execution to bash script (#2535) * Offload pipeline execution to bash script * Move scp to separate def to remain DRY * Replace type of f as IO[str] * Ensure that filename is set correctly when scping files * Ensure import block is sorted * Remove zen_server/dashboard --------- Co-authored-by: Alex Strick van Linschoten --- .../orchestrators/hyperai_orchestrator.py | 69 ++++++++++++++++--- 1 file changed, 59 insertions(+), 10 deletions(-) diff --git a/src/zenml/integrations/hyperai/orchestrators/hyperai_orchestrator.py b/src/zenml/integrations/hyperai/orchestrators/hyperai_orchestrator.py index a0ab70d6ce1..18b62a0bacc 100644 --- a/src/zenml/integrations/hyperai/orchestrators/hyperai_orchestrator.py +++ b/src/zenml/integrations/hyperai/orchestrators/hyperai_orchestrator.py @@ -17,7 +17,7 @@ import re import tempfile from shlex import quote -from typing import TYPE_CHECKING, Any, Dict, Optional, Type, cast +from typing import IO, TYPE_CHECKING, Any, Dict, Optional, Type, cast import paramiko import yaml @@ -129,6 +129,36 @@ def _escape_shell_command(self, command: str) -> str: """ return quote(command) + def _scp_to_hyperai_instance( + self, + paramiko_client: paramiko.SSHClient, + f: IO[str], + directory_name: str, + file_name: str, + description: str, + ) -> None: + """Copies a file to a HyperAI instance using SCP. + + Args: + paramiko_client: The SSH client to use for the SCP transfer. + f: The file to transfer. + directory_name: The directory on the HyperAI instance to transfer + the file to. + file_name: The name of the file being transferred. + description: A description of the file being transferred. + + Raises: + RuntimeError: If the file cannot be written to the HyperAI instance. + """ + try: + scp_client = paramiko_client.open_sftp() + scp_client.put(f.name, f"{directory_name}/{file_name}") + scp_client.close() + except FileNotFoundError: + raise RuntimeError( + f"Failed to write {description} to HyperAI instance. Does the user have permissions to write?" + ) + def prepare_or_run_pipeline( self, deployment: "PipelineDeploymentResponse", @@ -381,14 +411,33 @@ def prepare_or_run_pipeline( f_.write(compose_definition_yaml) # Scp Docker Compose file to HyperAI instance - try: - scp_client = paramiko_client.open_sftp() - scp_client.put(f.name, f"{directory_name}/docker-compose.yaml") - scp_client.close() - except FileNotFoundError: - raise RuntimeError( - "Failed to write Docker Compose file to HyperAI instance. Does the user have permissions to write?" - ) + self._scp_to_hyperai_instance( + paramiko_client, + f, + directory_name, + file_name="docker-compose.yml", + description="Docker Compose file", + ) + + # Create temporary file and write script to it + with tempfile.NamedTemporaryFile(mode="w", delete=True) as f: + # Define bash line and command line + bash_line = "#!/bin/bash\n" + command_line = f'cd {directory_name} && echo {ENV_ZENML_HYPERAI_RUN_ID}="{deployment_id}_$(date +\%s)" > .env && docker compose up -d' + + # Write script to temporary file + with f.file as f_: + f_.write(bash_line) + f_.write(command_line) + + # Scp script to HyperAI instance + self._scp_to_hyperai_instance( + paramiko_client, + f, + directory_name, + file_name="run_pipeline.sh", + description="startup script", + ) # Run or schedule Docker Compose file depending on settings if not deployment.schedule: @@ -421,7 +470,7 @@ def prepare_or_run_pipeline( # Create cron job for scheduled pipeline on HyperAI instance stdin, stdout, stderr = paramiko_client.exec_command( # nosec - f"(crontab -l ; echo '{cron_expression} cd {directory_name} && echo {ENV_ZENML_HYPERAI_RUN_ID}=\"{deployment_id}_$(date +\%s)\" > .env && docker compose up -d') | crontab -" + f"(crontab -l ; echo '{cron_expression} bash {directory_name}/run_pipeline.sh') | crontab -" ) logger.info("Pipeline scheduled successfully.") From a37393c428b365a83ccad513d7c5104c92805637 Mon Sep 17 00:00:00 2001 From: Alex Strick van Linschoten Date: Tue, 19 Mar 2024 16:28:48 +0100 Subject: [PATCH 28/45] Update `pip check` command to use `uv` (#2520) * use uv for pip check * use virtual envs for small checks * missing dot --- .github/actions/setup_environment/action.yml | 2 +- .github/workflows/ci-slow.yml | 23 ++++++++++++++------ .github/workflows/generate-test-duration.yml | 2 +- .github/workflows/integration-test-fast.yml | 2 +- .github/workflows/integration-test-slow.yml | 2 +- .github/workflows/release.yml | 2 +- .github/workflows/unit-test.yml | 2 +- 7 files changed, 22 insertions(+), 13 deletions(-) diff --git a/.github/actions/setup_environment/action.yml b/.github/actions/setup_environment/action.yml index 5ea5d222928..05c3b518d2f 100644 --- a/.github/actions/setup_environment/action.yml +++ b/.github/actions/setup_environment/action.yml @@ -123,4 +123,4 @@ runs: run: |- zenml integration list uv pip list - pip check || true + uv pip check || true diff --git a/.github/workflows/ci-slow.yml b/.github/workflows/ci-slow.yml index 229f247b9e0..28f8683681d 100644 --- a/.github/workflows/ci-slow.yml +++ b/.github/workflows/ci-slow.yml @@ -83,15 +83,22 @@ jobs: uses: actions/setup-python@v5.0.0 with: python-version: '3.8' - - name: Install current package as editable + - name: Install uv run: | curl -LsSf https://astral.sh/uv/install.sh | sh source $HOME/.cargo/env - uv pip install --system -e . - - name: Install mlstacks package - run: uv pip install --system mlstacks + - name: Create virtual environment + run: | + uv venv + - name: Check mlstacks compatibility + run: | + source .venv/bin/activate + uv pip install -e . + uv pip install mlstacks - name: Check for broken dependencies - run: pip check + run: | + source .venv/bin/activate + uv pip check - name: Markdown link check uses: gaurav-nelson/github-action-markdown-link-check@1.0.15 with: @@ -104,13 +111,15 @@ jobs: continue-on-error: true - name: Security check run: | - uv pip install --system bandit + source .venv/bin/activate + uv pip install bandit bash scripts/check-security.sh - name: Check for alembic branch divergence env: ZENML_DEBUG: 0 run: | - uv pip install --system alembic + source .venv/bin/activate + uv pip install alembic bash scripts/check-alembic-branches.sh - name: Install latest dashboard (test gitignore) run: bash scripts/install-dashboard.sh diff --git a/.github/workflows/generate-test-duration.yml b/.github/workflows/generate-test-duration.yml index 2710de2ffb9..a4c441c1bf9 100644 --- a/.github/workflows/generate-test-duration.yml +++ b/.github/workflows/generate-test-duration.yml @@ -55,4 +55,4 @@ jobs: run: |- zenml integration list uv pip list - pip check || true + uv pip check || true diff --git a/.github/workflows/integration-test-fast.yml b/.github/workflows/integration-test-fast.yml index 0db71af4d83..d3e8e239902 100644 --- a/.github/workflows/integration-test-fast.yml +++ b/.github/workflows/integration-test-fast.yml @@ -239,4 +239,4 @@ jobs: run: |- zenml integration list uv pip list - pip check || true + uv pip check || true diff --git a/.github/workflows/integration-test-slow.yml b/.github/workflows/integration-test-slow.yml index d742a50b18f..494233aa43f 100644 --- a/.github/workflows/integration-test-slow.yml +++ b/.github/workflows/integration-test-slow.yml @@ -247,4 +247,4 @@ jobs: run: |- zenml integration list uv pip list - pip check || true + uv pip check || true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9c7a9b78809..6846c059d6d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -29,7 +29,7 @@ jobs: - name: Install mlstacks package run: uv pip install --system mlstacks - name: Check for broken dependencies - run: pip check + run: uv pip check mysql-db-migration-testing: runs-on: arc-runner-set env: diff --git a/.github/workflows/unit-test.yml b/.github/workflows/unit-test.yml index 24c85f2a369..4e4560e40a4 100644 --- a/.github/workflows/unit-test.yml +++ b/.github/workflows/unit-test.yml @@ -116,4 +116,4 @@ jobs: run: |- zenml integration list uv pip list - pip check || true + uv pip check || true From 56f5ba8e0667454d2fe1423f85b417d57eb82dcb Mon Sep 17 00:00:00 2001 From: Safoine El Khabich <34200873+safoinme@users.noreply.github.com> Date: Wed, 20 Mar 2024 13:23:18 +0000 Subject: [PATCH 29/45] Add ZenMLServiceType and update service registration (#2471) * Add ZenMLServiceType and update service registration * Fix service creation and filtering * Fix UUID mutation and add create_service endpoint * full refactor of model deployer classes * Fix typo in perform_delete_model method * Add include_metadata parameter to existing_service.to_model() * Refactor model version and service response classes * Fix relationship indentation in service schemas * Fix service linking issue and update method names * Refactor service start and stop methods in SeldonModelDeployer and create_model_version_service_link in workspaces_endpoints * Update BentoML healthcheck URL path * Update service schema and routes * Update service and model version filtering and deletion * Update BentoML deployment service and service string representation * Refactor model version to service link names and descriptions * Remove unnecessary exception raises and comments * Fix indentation in model deployer classes * Refactor code to improve readability and maintainability * Remove model version services * Add state field to ServiceSchema class * Remove deprecated function and refactor code * Refactor model linking functions and fix model version retrieval * Fix model version ID retrieval in BaseModelDeployer * Refactor service materializer to not store into artifact store but get it from db * Update service status constants * Remove TODO comments and unused code * Refactor HuggingFace deployment and BentoML deployment configurations * Auto-update of Starter template * Add new service state and corresponding emoji * Fix service name generation in service configuration * Refactor model deployment code and update documentation * Add dict_to_bytes function and update service health check * Refactor ServiceSchema state assignment*** * Auto-update of Starter template * Refactor ServiceMaterializer to use BaseDeploymentService * Fix model server UUID serialization issue * Fix indentation in MLflow documentation * Auto-update of Starter template * Fix service method names * Refactor code and remove unused imports and commented out code * Add blank line in ServiceConfig class constructor * Refactor service scoping and include resources in service schema (cherry picked from commit baf8dd342d027bea8195754d50dc280bc8976db2) * Fix model deployer and service start-up issues * Refactor model deployer and service linking code * Update docs/book/stacks-and-components/component-guide/model-deployers/mlflow.md Co-authored-by: Alex Strick van Linschoten * Fix Hugging Face deployment error message and remove commented code * Fix error message in HuggingFaceDeploymentService and remove unused import * Add service-related models and test data * Remove unused import and health check code in SqlZenStore * Remove unused parameter in prediction_service_loader function * Add name to TensorboardServiceConfig * Fix prediction service loader bug * Remove breakpoint in prediction_service_loader function * Remove unused code and update prediction service loader * Refactor model server search in prediction_service_loader() function * Add UUID generation to TensorboardService and TensorboardVisualizer * Refactor imports in tensorboard_service.py and tensorboard_visualizer.py --------- Co-authored-by: GitHub Actions Co-authored-by: Alex Strick van Linschoten --- .../component-guide/model-deployers/custom.md | 60 +-- .../component-guide/model-deployers/mlflow.md | 167 ++++-- .../model-deployers/model-deployers.md | 95 ++-- src/zenml/cli/served_model.py | 74 ++- src/zenml/cli/utils.py | 14 +- src/zenml/client.py | 229 +++++++++ src/zenml/constants.py | 10 +- src/zenml/enums.py | 7 + src/zenml/integrations/bentoml/constants.py | 2 +- .../model_deployers/bentoml_model_deployer.py | 262 ++-------- .../bentoml/services/bentoml_deployment.py | 15 +- .../bentoml/steps/bentoml_deployer.py | 17 +- .../huggingface_model_deployer_flavor.py | 7 +- .../huggingface_model_deployer.py | 374 +++----------- .../services/huggingface_deployment.py | 137 ++--- .../huggingface/steps/huggingface_deployer.py | 11 +- .../model_deployers/mlflow_model_deployer.py | 275 ++-------- .../mlflow/services/mlflow_deployment.py | 3 +- .../mlflow/steps/mlflow_deployer.py | 40 +- .../model_deployers/seldon_model_deployer.py | 159 +----- .../seldon/services/seldon_deployment.py | 3 +- .../seldon/steps/seldon_deployer.py | 27 +- .../services/tensorboard_service.py | 3 +- .../visualizers/tensorboard_visualizer.py | 1 + .../materializers/service_materializer.py | 17 +- src/zenml/model/utils.py | 51 +- .../model_deployers/base_model_deployer.py | 364 +++++++++++-- src/zenml/models/__init__.py | 30 ++ src/zenml/models/v2/core/model_version.py | 6 + src/zenml/models/v2/core/service.py | 479 ++++++++++++++++++ src/zenml/orchestrators/step_runner.py | 4 +- src/zenml/services/__init__.py | 2 - src/zenml/services/container/entrypoint.py | 5 +- .../services/local/local_daemon_entrypoint.py | 7 +- src/zenml/services/service.py | 229 +++++---- src/zenml/services/service_registry.py | 214 -------- src/zenml/services/service_status.py | 3 +- src/zenml/services/service_type.py | 2 + src/zenml/utils/dict_utils.py | 22 + .../deploy/docker/docker_provider.py | 5 +- .../deploy/docker/docker_zen_server.py | 5 +- .../zen_server/deploy/local/local_provider.py | 6 +- .../deploy/local/local_zen_server.py | 5 +- .../terraform/providers/terraform_provider.py | 3 +- .../deploy/terraform/terraform_zen_server.py | 5 +- src/zenml/zen_server/rbac/models.py | 1 + src/zenml/zen_server/rbac/utils.py | 4 + .../zen_server/routers/service_endpoints.py | 180 +++++++ .../routers/workspaces_endpoints.py | 44 ++ src/zenml/zen_server/zen_server_api.py | 2 + src/zenml/zen_stores/rest_zen_store.py | 92 ++++ src/zenml/zen_stores/schemas/__init__.py | 2 + src/zenml/zen_stores/schemas/model_schemas.py | 31 +- .../schemas/pipeline_run_schemas.py | 5 + .../zen_stores/schemas/service_schemas.py | 249 +++++++++ src/zenml/zen_stores/schemas/user_schemas.py | 2 + .../zen_stores/schemas/workspace_schemas.py | 5 + src/zenml/zen_stores/sql_zen_store.py | 183 ++++++- src/zenml/zen_stores/zen_store_interface.py | 85 ++++ .../steps/prediction_service_loader.py | 2 +- .../prediction_service_loader.py | 11 +- .../deployment_inference_pipeline.py | 1 - .../steps/prediction_service_loader_step.py | 11 +- tests/unit/conftest.py | 68 +++ tests/unit/models/test_service_models.py | 130 +++++ tests/unit/services/__init__.py | 13 + tests/unit/services/test_service.py | 112 ++++ 67 files changed, 3079 insertions(+), 1610 deletions(-) create mode 100644 src/zenml/models/v2/core/service.py delete mode 100644 src/zenml/services/service_registry.py create mode 100644 src/zenml/zen_server/routers/service_endpoints.py create mode 100644 src/zenml/zen_stores/schemas/service_schemas.py create mode 100644 tests/unit/models/test_service_models.py create mode 100644 tests/unit/services/__init__.py create mode 100644 tests/unit/services/test_service.py diff --git a/docs/book/stacks-and-components/component-guide/model-deployers/custom.md b/docs/book/stacks-and-components/component-guide/model-deployers/custom.md index e33646e7eb5..df7a09efd94 100644 --- a/docs/book/stacks-and-components/component-guide/model-deployers/custom.md +++ b/docs/book/stacks-and-components/component-guide/model-deployers/custom.md @@ -16,7 +16,7 @@ When present in a stack, the model deployer can also act as a registry for model In ZenML, the base abstraction of the model deployer is built on top of three major criteria: -1. It needs to contain all the stack-related configuration attributes required to interact with the remote model serving tool, service, or platform (e.g. hostnames, URLs, references to credentials, and other client-related configuration parameters). +1. It needs to ensure efficient deployment and management of models in accordance with the specific requirements of the serving infrastructure, by holding all the stack-related configuration attributes required to interact with the remote model serving tool, service, or platform. 2. It needs to implement the continuous deployment logic necessary to deploy models in a way that updates an existing model server that is already serving a previous version of the same model instead of creating a new model server for every new model version (see the `deploy_model` abstract method). This functionality can be consumed directly from ZenML pipeline steps, but it can also be used outside the pipeline to deploy ad-hoc models. It is also usually coupled with a standard model deployer step, implemented by each integration, that hides the details of the deployment process from the user. 3. It needs to act as a ZenML BaseService registry, where every BaseService instance is used as an internal representation of a remote model server (see the `find_model_server` abstract method). To achieve this, it must be able to re-create the configuration of a BaseService from information that is persisted externally, alongside, or even as part of the remote model server configuration itself. For example, for model servers that are implemented as Kubernetes resources, the BaseService instances can be serialized and saved as Kubernetes resource annotations. This allows the model deployer to keep track of all externally running model servers and to re-create their corresponding BaseService instance representations at any given time. The model deployer also defines methods that implement basic life-cycle management on remote model servers outside the coverage of a pipeline (see `stop_model_server` , `start_model_server` and `delete_model_server`). @@ -42,11 +42,11 @@ class BaseModelDeployer(StackComponent, ABC): """Base class for all ZenML model deployers.""" @abstractmethod - def deploy_model( - self, - config: ServiceConfig, - replace: bool = False, - timeout: int = DEFAULT_DEPLOYMENT_START_STOP_TIMEOUT, + def perform_deploy_model( + self, + id: UUID, + config: ServiceConfig, + timeout: int = DEFAULT_DEPLOYMENT_START_STOP_TIMEOUT, ) -> BaseService: """Abstract method to deploy a model.""" @@ -59,43 +59,28 @@ class BaseModelDeployer(StackComponent, ABC): properties for the user.""" @abstractmethod - def find_model_server( - self, - running: bool = False, - service_uuid: Optional[UUID] = None, - pipeline_name: Optional[str] = None, - run_name: Optional[str] = None, - pipeline_step_name: Optional[str] = None, - model_name: Optional[str] = None, - model_uri: Optional[str] = None, - model_type: Optional[str] = None, - ) -> List[BaseService]: - """Abstract method to find one or more model servers that match the - given criteria.""" - - @abstractmethod - def stop_model_server( - self, - uuid: UUID, - timeout: int = DEFAULT_DEPLOYMENT_START_STOP_TIMEOUT, - force: bool = False, - ) -> None: + def perform_stop_model( + self, + service: BaseService, + timeout: int = DEFAULT_DEPLOYMENT_START_STOP_TIMEOUT, + force: bool = False, + ) -> BaseService: """Abstract method to stop a model server.""" @abstractmethod - def start_model_server( - self, - uuid: UUID, - timeout: int = DEFAULT_DEPLOYMENT_START_STOP_TIMEOUT, - ) -> None: + def perform_start_model( + self, + service: BaseService, + timeout: int = DEFAULT_DEPLOYMENT_START_STOP_TIMEOUT, + ) -> BaseService: """Abstract method to start a model server.""" @abstractmethod - def delete_model_server( - self, - uuid: UUID, - timeout: int = DEFAULT_DEPLOYMENT_START_STOP_TIMEOUT, - force: bool = False, + def perform_delete_model( + self, + service: BaseService, + timeout: int = DEFAULT_DEPLOYMENT_START_STOP_TIMEOUT, + force: bool = False, ) -> None: """Abstract method to delete a model server.""" @@ -143,6 +128,7 @@ If you want to create your own custom flavor for a model deployer, you can follo 1. Create a class that inherits from the `BaseModelDeployer` class and implements the abstract methods. 2. If you need to provide any configuration, create a class that inherits from the `BaseModelDeployerConfig` class and add your configuration parameters. 3. Bring both the implementation and the configuration together by inheriting from the `BaseModelDeployerFlavor` class. Make sure that you give a `name` to the flavor through its abstract property. +4. Create a service class that inherits from the `BaseService` class and implements the abstract methods. This class will be used to represent the deployed model server in ZenML. Once you are done with the implementation, you can register it through the CLI. Please ensure you **point to the flavor class via dot notation**: diff --git a/docs/book/stacks-and-components/component-guide/model-deployers/mlflow.md b/docs/book/stacks-and-components/component-guide/model-deployers/mlflow.md index 026effbdee1..214fdd693cb 100644 --- a/docs/book/stacks-and-components/component-guide/model-deployers/mlflow.md +++ b/docs/book/stacks-and-components/component-guide/model-deployers/mlflow.md @@ -52,53 +52,98 @@ the background to serve the latest MLflow model. ### Deploy a logged model -ZenML provides a predefined `mlflow_model_deployer_step` that you can use to -deploy an MLflfow prediction service based on a model that you have -previously logged in your -[MLflow experiment tracker](../experiment-trackers/mlflow.md): +Following [MLflow's documentation](https://mlflow.org/docs/latest/deployment/deploy-model-locally.html#deploy-mlflow-model-as-a-local-inference-server), if we want to deploy a model as a local inference server, we need the model to be +logged in the MLflow experiment tracker first. Once the model is logged, we can use the model URI either from the +artifact path saved with the MLflow run or using model name and version if a model is registered in the MLflow model +registry. + +In the following examples, we will show how to deploy a model using the MLflow Model Deployer, in two different scenarios: + +1. We already know the logged model URI and we want to deploy it as a local inference server. ```python -from zenml import pipeline -from zenml.integrations.mlflow.steps import mlflow_model_deployer_step +from zenml import pipeline, step, get_step_context +from zenml.client import Client -@pipeline -def mlflow_train_deploy_pipeline(): - model = ... - deployed_model = mlflow_model_deployer_step(model=model) +@step +def deploy_model() -> Optional[MLFlowDeploymentService]: + # Deploy a model using the MLflow Model Deployer + zenml_client = Client() + model_deployer = zenml_client.active_stack.model_deployer + mlflow_deployment_config = MLFlowDeploymentConfig( + name: str = "mlflow-model-deployment-example", + description: str = "An example of deploying a model using the MLflow Model Deployer", + pipeline_name: str = get_step_context().pipeline_name, + pipeline_step_name: str = get_step_context().step_name, + model_uri: str = "runs://model" or "models://", + model_name: str = "model", + workers: int = 1 + mlserver: bool = False + timeout: int = DEFAULT_SERVICE_START_STOP_TIMEOUT + ) + service = model_deployer.deploy_model(mlflow_deployment_config) + logger.info(f"The deployed service info: {model_deployer.get_model_server_info(service)}") + return service ``` -{% hint style="warning" %} -The `mlflow_model_deployer_step` expects that the `model` it receives has -already been logged to MLflow in a previous step. E.g., for a scikit-learn -model, you would need to have used `mlflow.sklearn.autolog()` or -`mlflow.sklearn.log_model(model)` in a previous step. See the -[MLflow experiment tracker documentation](../experiment-trackers/mlflow.md) for -more information on how to log models to MLflow from your ZenML steps. -{% endhint %} +2. We don't know the logged model URI, since the model was logged in a previous step. We want to deploy the model as a local inference server. ZenML provides set of functionalities that would make it easier to get the model URI from the current run and deploy it. -### Deploy from model registry +```python +from zenml import pipeline, step, get_step_context +from zenml.client import Client +from mlflow.tracking import MlflowClient, artifact_utils -Alternatively, if you are already using the -[MLflow model registry](../model-registries/mlflow.md), you can use the -`mlflow_model_registry_deployer_step` to directly deploy an MLflow prediction -service based on a model in your model registry: -```python -from zenml import pipeline -from zenml.integrations.mlflow.steps import mlflow_model_registry_deployer_step - -@pipeline -def mlflow_registry_deploy_pipeline(): - deployed_model = mlflow_model_registry_deployer_step( - registry_model_name="tensorflow-mnist-model", - registry_model_version="1", # Either specify a model version - # or use the model stage if you have set it in the MLflow registry: - # registered_model_stage="Staging" +@step +def deploy_model() -> Optional[MLFlowDeploymentService]: + # Deploy a model using the MLflow Model Deployer + zenml_client = Client() + model_deployer = zenml_client.active_stack.model_deployer + experiment_tracker = zenml_client.active_stack.experiment_tracker + # Let's get the run id of the current pipeline + mlflow_run_id = experiment_tracker.get_run_id( + experiment_name=get_step_context().pipeline_name, + run_name=get_step_context().run_name, + ) + # Once we have the run id, we can get the model URI using mlflow client + experiment_tracker.configure_mlflow() + client = MlflowClient() + model_name = "model" # set the model name that was logged + model_uri = artifact_utils.get_artifact_uri( + run_id=mlflow_run_id, artifact_path=model_name + ) + mlflow_deployment_config = MLFlowDeploymentConfig( + name: str = "mlflow-model-deployment-example", + description: str = "An example of deploying a model using the MLflow Model Deployer", + pipeline_name: str = get_step_context().pipeline_name, + pipeline_step_name: str = get_step_context().step_name, + model_uri: str = model_uri, + model_name: str = model_name, + workers: int = 1, + mlserver: bool = False, + timeout: int = 300, ) + service = model_deployer.deploy_model(mlflow_deployment_config) + return service ``` -See the [MLflow model registry documentation](../model-registries/mlflow.md) -for more information on how to register models in the MLflow registry. +#### Configuration + +Within the `MLFlowDeploymentService` you can configure: + +* `name`: The name of the deployment. +* `description`: The description of the deployment. +* `pipeline_name`: The name of the pipeline that deployed the MLflow prediction server. +* `pipeline_step_name`: The name of the step that deployed the MLflow prediction server. +* `model_name`: The name of the model that is deployed in case of model registry the name must be a valid registered model name. +* `model_version`: The version of the model that is deployed in case of model registry the version must be a valid registered model version. +* `silent_daemon`: set to True to suppress the output of the daemon +(i.e., redirect stdout and stderr to /dev/null). If False, the daemon output will be redirected to a log file. +* `blocking`: set to True to run the service in the context of the current process and block until the service is stopped instead of running the service as a daemon process. Useful for operating systems that do not support daemon processes. +* `model_uri`: The URI of the model to be deployed. This can be a local file path, a run ID, or a model name and version. +* `workers`: The number of workers to be used by the MLflow prediction server. +* `mlserver`: If True, the MLflow prediction server will be started as a MLServer instance. +* `timeout`: The timeout in seconds to wait for the MLflow prediction server to start or stop. ### Run inference on a deployed model @@ -106,7 +151,11 @@ The following code example shows how you can load a deployed model in Python and run inference against it: +1. Load a prediction service deployed in another pipeline + ```python +import json +import requests from zenml import step from zenml.integrations.mlflow.model_deployers.mlflow_model_deployer import ( MLFlowModelDeployer, @@ -119,9 +168,8 @@ from zenml.integrations.mlflow.services import MLFlowDeploymentService def prediction_service_loader( pipeline_name: str, pipeline_step_name: str, - running: bool = True, model_name: str = "model", -) -> MLFlowDeploymentService: +) -> None: """Get the prediction service started by the deployment pipeline. Args: @@ -140,7 +188,6 @@ def prediction_service_loader( pipeline_name=pipeline_name, pipeline_step_name=pipeline_step_name, model_name=model_name, - running=running, ) if not existing_services: @@ -150,8 +197,35 @@ def prediction_service_loader( f"'{model_name}' is currently running." ) - return existing_services[0] + service = existing_services[0] + # Let's try run a inference request against the prediction service + + payload = json.dumps( + { + "inputs": {"messages": [{"role": "user", "content": "Tell a joke!"}]}, + "params": { + "temperature": 0.5, + "max_tokens": 20, + }, + } + ) + response = requests.post( + url=service.get_prediction_url(), + data=payload, + headers={"Content-Type": "application/json"}, + ) + + response.json() +``` + +2. Within the same pipeline, use the service from previous step to run inference this time using pre-built predict method + +```python +from typing_extensions import Annotated +import numpy as np +from zenml import step +from zenml.integrations.mlflow.services import MLFlowDeploymentService # Use the service for inference @step @@ -161,23 +235,10 @@ def predictor( ) -> Annotated[np.ndarray, "predictions"]: """Run a inference request against a prediction service""" - service.start(timeout=10) # should be a NOP if already started prediction = service.predict(data) prediction = prediction.argmax(axis=-1) return prediction - - -@pipeline -def mlflow_deployment_inference_pipeline( - pipeline_name: str, pipeline_step_name: str = "mlflow_model_deployer_step", -): - inference_data = ... - model_deployment_service = prediction_service_loader( - pipeline_name=pipeline_name, - pipeline_step_name=pipeline_step_name, - ) - predictions = predictor(model_deployment_service, inference_data) ``` For more information and a full list of configurable attributes of the MLflow Model Deployer, check out diff --git a/docs/book/stacks-and-components/component-guide/model-deployers/model-deployers.md b/docs/book/stacks-and-components/component-guide/model-deployers/model-deployers.md index 428cfb6a13a..5d73ce44e1a 100644 --- a/docs/book/stacks-and-components/component-guide/model-deployers/model-deployers.md +++ b/docs/book/stacks-and-components/component-guide/model-deployers/model-deployers.md @@ -23,11 +23,9 @@ stored as files or in a database for end users or business applications. ### When to use it? The model deployers are optional components in the ZenML stack. They are used to deploy machine learning models to a -target environment either a development (local) or a production (Kubernetes), the model deployers are mainly used to -deploy models for real-time inference use cases. With the model deployers and other stack components, you can build -pipelines that are continuously trained and deployed to production. +target environment, either a development (local) or a production (Kubernetes or cloud) environment. The model deployers are mainly used to deploy models for real-time inference use cases. With the model deployers and other stack components, you can build pipelines that are continuously trained and deployed to production. -### How they experiment trackers slot into the stack +### How model deployers slot into the stack Here is an architecture diagram that shows how model deployers fit into the overall story of a remote stack. @@ -67,10 +65,7 @@ zenml model-deployer register seldon --flavor=seldon \ #### The role that a model deployer plays in a ZenML Stack -1. Holds all the stack-related configuration attributes required to interact with the remote model serving tool, - service, or platform (e.g. hostnames, URLs, references to credentials, and other client-related configuration - parameters). The following are examples of configuring the MLflow and Seldon Core Model Deployers and registering - them as a Stack component: +* Seamless Model Deployment: Facilitates the deployment of machine learning models to various serving environments, such as local servers, Kubernetes clusters, or cloud platforms, ensuring that models can be deployed and managed efficiently in accordance with the specific requirements of the serving infrastructure by holds all the stack-related configuration attributes required to interact with the remote model serving tool, service, or platform (e.g. hostnames, URLs, references to credentials, and other client-related configuration parameters). The following are examples of configuring the MLflow and Seldon Core Model Deployers and registering them as a Stack component: ```bash zenml integration install mlflow @@ -87,46 +82,52 @@ zenml model-deployer register seldon --flavor=seldon \ zenml stack register seldon_stack -m default -a aws -o default -d seldon ``` -2. Implements the continuous deployment logic necessary to deploy models in a way that updates an existing model server - that is already serving a previous version of the same model instead of creating a new model server for every new - model version. Every model server that the Model Deployer provisions externally to deploy a model is represented - internally as a `Service` object that may be accessed for visibility and control over a single model deployment. This - functionality can be consumed directly from ZenML pipeline steps, but it can also be used outside the pipeline to - deploy ad-hoc models. See the [seldon_model_deployer_step](https://sdkdocs.zenml.io/latest/integration_code_docs/integrations-seldon/#zenml.integrations.seldon.steps.seldon_deployer.seldon_model_deployer_step) for an example of using the Seldon Core Model Deployer to deploy a model inside a ZenML pipeline step. -3. Acts as a registry for all Services that represent remote model servers. External model deployment servers can be - listed and filtered using a variety of criteria, such as the name of the model or the names of the pipeline and step - that was used to deploy the model. The Service objects returned by the Model Deployer can be used to interact with - the remote model server, e.g. to get the operational status of a model server, the prediction URI that it exposes, or - to stop or delete a model server: +* Lifecycle Management: Provides mechanisms for comprehensive lifecycle management of model servers, including the ability to start, stop, and delete model servers, as well as to update existing servers with new model versions, thereby optimizing resource utilization and facilitating continuous delivery of model updates. Some core methods that can be used to interact with the remote model server include: + +`deploy_model` - Deploys a model to the serving environment and returns a Service object that represents the deployed model server. +`find_model_server` - Finds and returns a list of Service objects that represent model servers that have been deployed to the serving environment, the +services are stored in the DB and can be used as a reference to know what and where the model is deployed. +`stop_model_server` - Stops a model server that is currently running in the serving environment. +`start_model_server` - Starts a model server that has been stopped in the serving environment. +`delete_model_server` - Deletes a model server from the serving environment and from the DB. + +{% hint style="info" %} +ZenML uses the Service object to represent a model server that has been deployed to a serving environment. The Service object is saved in the DB and can be used as a reference to know what and where the model is deployed. The Service object consists of 2 main attributes, the `config` and the `status`. The `config` attribute holds all the deployment configuration attributes required to create a new deployment, while the `status` attribute holds the operational status of the deployment, such as the last error message, the prediction URL, and the deployment status. +{% endhint %} ```python - from zenml.integrations.seldon.model_deployers import SeldonModelDeployer + from zenml.integrations.huggingface.model_deployers import HuggingFaceModelDeployer - model_deployer = SeldonModelDeployer.get_active_model_deployer() + model_deployer = HuggingFaceModelDeployer.get_active_model_deployer() services = model_deployer.find_model_server( - pipeline_name="continuous-deployment-pipeline", - pipeline_step_name="seldon_model_deployer_step", - model_name="my-model", + pipeline_name="LLM_pipeline", + pipeline_step_name="huggingface_model_deployer_step", + model_name="LLAMA-7B", ) if services: if services[0].is_running: print( - f"Seldon deployment service started and reachable at:\n" - f" {services[0].prediction_url}\n" - ) - elif services[0].is_failed: - print( - f"Seldon deployment service is in a failure state. " - f"The last error message was: {services[0].status.last_error}" - ) - else: - print(f"Seldon deployment service is not running") - - # start the service - services[0].start(timeout=100) - - # delete the service - model_deployer.delete_service(services[0].uuid, timeout=100, force=False) + f"Model server {services[0].config['model_name']} is running at {services[0].status['prediction_url']}" + ) + else: + print(f"Model server {services[0].config['model_name']} is not running") + model_deployer.start_model_server(services[0]) + else: + print("No model server found") + service = model_deployer.deploy_model( + pipeline_name="LLM_pipeline", + pipeline_step_name="huggingface_model_deployer_step", + model_name="LLAMA-7B", + model_uri="s3://zenprojects/huggingface_model_deployer_step/output/884/huggingface", + revision="main", + task="text-classification", + region="us-east-1", + vendor="aws", + token="huggingface_token", + namespace="zenml-workloads", + endpoint_type="public", + ) + print(f"Model server {service.config['model_name']} is deployed at {service.status['prediction_url']}") ``` #### How to Interact with a model deployer after deployment? @@ -187,20 +188,6 @@ deployer_step = pipeline_run.steps[""] deployed_model_url = deployer_step.run_metadata["deployed_model_url"].value ``` -Services can be passed through steps like any other object, and used to interact with the external systems that they -represent: - -```python -from zenml import step - - -@step -def my_step(my_service: MyService) -> ...: - if not my_service.is_running: - my_service.start() # starts service - my_service.stop() # stops service -``` - The ZenML integrations that provide Model Deployer stack components also include standard pipeline steps that can directly be inserted into any pipeline to achieve a continuous model deployment workflow. These steps take care of all the aspects of continuously deploying models to an external server and saving the Service configuration into the diff --git a/src/zenml/cli/served_model.py b/src/zenml/cli/served_model.py index 77540039744..3d708651121 100644 --- a/src/zenml/cli/served_model.py +++ b/src/zenml/cli/served_model.py @@ -29,6 +29,7 @@ ) from zenml.console import console from zenml.enums import StackComponentType +from zenml.model_deployers import BaseModelDeployer if TYPE_CHECKING: from zenml.model_deployers import BaseModelDeployer @@ -71,14 +72,6 @@ def models(ctx: click.Context) -> None: help="Get a list of all served models within the model-deployer stack " "component.", ) - @click.option( - "--pipeline", - "-p", - type=click.STRING, - default=None, - help="Show only served models that were deployed by the indicated " - "pipeline.", - ) @click.option( "--step", "-s", @@ -88,13 +81,21 @@ def models(ctx: click.Context) -> None: "pipeline step.", ) @click.option( - "--run-name", + "--pipeline-run-id", "-r", type=click.STRING, default=None, help="Show only served models that were deployed by the indicated " "pipeline run.", ) + @click.option( + "--pipeline-name", + "-p", + type=click.STRING, + default=None, + help="Show only served models that were deployed by the indicated " + "pipeline.", + ) @click.option( "--model", "-m", @@ -102,6 +103,20 @@ def models(ctx: click.Context) -> None: default=None, help="Show only served model versions for the given model name.", ) + @click.option( + "--model-version", + "-v", + type=click.STRING, + default=None, + help="Show only served model versions for the given model version.", + ) + @click.option( + "--flavor", + "-f", + type=click.STRING, + default=None, + help="Show only served model versions for the given model flavor.", + ) @click.option( "--running", is_flag=True, @@ -110,31 +125,38 @@ def models(ctx: click.Context) -> None: @click.pass_obj def list_models( model_deployer: "BaseModelDeployer", - pipeline: Optional[str], step: Optional[str], - run_name: Optional[str], + pipeline_name: Optional[str], + pipeline_run_id: Optional[str], model: Optional[str], + model_version: Optional[str], + flavor: Optional[str], running: bool, ) -> None: """List of all served models within the model-deployer stack component. Args: model_deployer: The model-deployer stack component. - pipeline: Show only served models that were deployed by the - indicated pipeline. step: Show only served models that were deployed by the indicated pipeline step. - run_name: Show only served models that were deployed by the + pipeline_run_id: Show only served models that were deployed by the indicated pipeline run. + pipeline_name: Show only served models that were deployed by the + indicated pipeline. model: Show only served model versions for the given model name. running: Show only model servers that are currently running. + model_version: Show only served model versions for the given model + version. + flavor: Show only served model versions for the given model flavor. """ services = model_deployer.find_model_server( running=running, - pipeline_name=pipeline, - run_name=run_name, + pipeline_name=pipeline_name, + pipeline_run_id=pipeline_run_id if pipeline_run_id else None, pipeline_step_name=step, model_name=model, + model_version=model_version, + flavor=flavor, ) if services: pretty_print_model_deployer( @@ -386,14 +408,16 @@ def get_model_service_logs( ) return - for line in model_deployer.get_model_server_logs( + model_logs = model_deployer.get_model_server_logs( served_models[0].uuid, follow=follow, tail=tail - ): - # don't pretty-print log lines that are already pretty-printed - if raw or line.startswith("\x1b["): - console.print(line, markup=False) - else: - try: - console.print(line) - except MarkupError: + ) + if model_logs: + for line in model_logs: + # don't pretty-print log lines that are already pretty-printed + if raw or line.startswith("\x1b["): console.print(line, markup=False) + else: + try: + console.print(line) + except MarkupError: + console.print(line, markup=False) diff --git a/src/zenml/cli/utils.py b/src/zenml/cli/utils.py index 09cd25eb483..29fab25bd07 100644 --- a/src/zenml/cli/utils.py +++ b/src/zenml/cli/utils.py @@ -1132,6 +1132,10 @@ def get_service_state_emoji(state: "ServiceState") -> str: return ":pause_button:" if state == ServiceState.ERROR: return ":heavy_exclamation_mark:" + if state == ServiceState.PENDING_STARTUP: + return ":hourglass:" + if state == ServiceState.SCALED_TO_ZERO: + return ":chart_decreasing:" return ":hourglass_not_done:" @@ -1146,15 +1150,18 @@ def pretty_print_model_deployer( """ model_service_dicts = [] for model_service in model_services: - served_model_info = model_deployer.get_model_server_info(model_service) dict_uuid = str(model_service.uuid) dict_pl_name = model_service.config.pipeline_name dict_pl_stp_name = model_service.config.pipeline_step_name - dict_model_name = served_model_info.get("MODEL_NAME", "") + dict_model_name = model_service.config.model_name + type = model_service.SERVICE_TYPE.type + flavor = model_service.SERVICE_TYPE.flavor model_service_dicts.append( { "STATUS": get_service_state_emoji(model_service.status.state), "UUID": dict_uuid, + "TYPE": type, + "FLAVOR": flavor, "PIPELINE_NAME": dict_pl_name, "PIPELINE_STEP_NAME": dict_pl_stp_name, "MODEL_NAME": dict_model_name, @@ -1281,9 +1288,10 @@ def print_served_model_configuration( **served_model_info, "UUID": str(model_service.uuid), "STATUS": get_service_state_emoji(model_service.status.state), + "TYPE": model_service.SERVICE_TYPE.type, + "FLAVOR": model_service.SERVICE_TYPE.flavor, "STATUS_MESSAGE": model_service.status.last_error, "PIPELINE_NAME": model_service.config.pipeline_name, - "RUN_NAME": model_service.config.run_name, "PIPELINE_STEP_NAME": model_service.config.pipeline_step_name, } diff --git a/src/zenml/client.py b/src/zenml/client.py index 66f144b4ebe..a32fea00152 100644 --- a/src/zenml/client.py +++ b/src/zenml/client.py @@ -150,6 +150,10 @@ ServiceConnectorResponse, ServiceConnectorTypeModel, ServiceConnectorUpdate, + ServiceFilter, + ServiceRequest, + ServiceResponse, + ServiceUpdate, StackFilter, StackRequest, StackResponse, @@ -175,7 +179,11 @@ WorkspaceResponse, WorkspaceUpdate, ) +from zenml.services.service import ServiceConfig +from zenml.services.service_status import ServiceState +from zenml.services.service_type import ServiceType from zenml.utils import io_utils, source_utils +from zenml.utils.dict_utils import dict_to_bytes from zenml.utils.filesync_model import FileSyncModel from zenml.utils.pagination_utils import depaginate from zenml.utils.uuid_utils import is_valid_uuid @@ -1478,6 +1486,227 @@ def _validate_stack_configuration(self, stack: StackRequest) -> None: "an Orchestrator." ) + # ----------------------------- Services ----------------------------------- + + def create_service( + self, + config: ServiceConfig, + service_type: ServiceType, + model_version_id: Optional[UUID] = None, + ) -> ServiceResponse: + """Registers a service. + + Args: + config: The configuration of the service. + service_type: The type of the service. + model_version_id: The ID of the model version to associate with the + service. + + Returns: + The registered service. + """ + service_request = ServiceRequest( + name=config.service_name, + service_type=service_type, + config=config.dict(), + workspace=self.active_workspace.id, + user=self.active_user.id, + model_version_id=model_version_id, + ) + # Register the service + return self.zen_store.create_service(service_request) + + def get_service( + self, + name_id_or_prefix: Union[str, UUID], + allow_name_prefix_match: bool = True, + hydrate: bool = True, + type: Optional[str] = None, + ) -> ServiceResponse: + """Gets a service. + + Args: + name_id_or_prefix: The name or ID of the service. + allow_name_prefix_match: If True, allow matching by name prefix. + hydrate: Flag deciding whether to hydrate the output model(s) + by including metadata fields in the response. + type: The type of the service. + + Returns: + The Service + """ + + def type_scoped_list_method( + hydrate: bool = True, + **kwargs: Any, + ) -> Page[ServiceResponse]: + """Call `zen_store.list_services` with type scoping. + + Args: + hydrate: Flag deciding whether to hydrate the output model(s) + by including metadata fields in the response. + **kwargs: Keyword arguments to pass to `ServiceFilterModel`. + + Returns: + The type-scoped list of services. + """ + service_filter_model = ServiceFilter(**kwargs) + if type: + service_filter_model.set_type(type=type) + service_filter_model.set_scope_workspace(self.active_workspace.id) + return self.zen_store.list_services( + filter_model=service_filter_model, + hydrate=hydrate, + ) + + return self._get_entity_by_id_or_name_or_prefix( + get_method=self.zen_store.get_service, + list_method=type_scoped_list_method, + name_id_or_prefix=name_id_or_prefix, + allow_name_prefix_match=allow_name_prefix_match, + hydrate=hydrate, + ) + + def list_services( + self, + sort_by: str = "created", + page: int = PAGINATION_STARTING_PAGE, + size: int = PAGE_SIZE_DEFAULT, + logical_operator: LogicalOperators = LogicalOperators.AND, + id: Optional[Union[UUID, str]] = None, + created: Optional[datetime] = None, + updated: Optional[datetime] = None, + type: Optional[str] = None, + flavor: Optional[str] = None, + workspace_id: Optional[Union[str, UUID]] = None, + user_id: Optional[Union[str, UUID]] = None, + hydrate: bool = False, + running: Optional[bool] = None, + service_name: Optional[str] = None, + pipeline_name: Optional[str] = None, + pipeline_run_id: Optional[str] = None, + pipeline_step_name: Optional[str] = None, + model_version_id: Optional[Union[str, UUID]] = None, + config: Optional[Dict[str, Any]] = None, + ) -> Page[ServiceResponse]: + """List all services. + + Args: + sort_by: The column to sort by + page: The page of items + size: The maximum size of all pages + logical_operator: Which logical operator to use [and, or] + id: Use the id of services to filter by. + created: Use to filter by time of creation + updated: Use the last updated date for filtering + type: Use the service type for filtering + flavor: Use the service flavor for filtering + workspace_id: The id of the workspace to filter by. + user_id: The id of the user to filter by. + hydrate: Flag deciding whether to hydrate the output model(s) + by including metadata fields in the response. + running: Use the running status for filtering + pipeline_name: Use the pipeline name for filtering + service_name: Use the service name or model name + for filtering + pipeline_step_name: Use the pipeline step name for filtering + model_version_id: Use the model version id for filtering + config: Use the config for filtering + pipeline_run_id: Use the pipeline run id for filtering + + Returns: + The Service response page. + """ + service_filter_model = ServiceFilter( + sort_by=sort_by, + page=page, + size=size, + logical_operator=logical_operator, + id=id, + created=created, + updated=updated, + type=type, + flavor=flavor, + workspace_id=workspace_id, + user_id=user_id, + running=running, + name=service_name, + pipeline_name=pipeline_name, + pipeline_step_name=pipeline_step_name, + model_version_id=model_version_id, + pipeline_run_id=pipeline_run_id, + config=dict_to_bytes(config) if config else None, + ) + service_filter_model.set_scope_workspace(self.active_workspace.id) + return self.zen_store.list_services( + filter_model=service_filter_model, hydrate=hydrate + ) + + def update_service( + self, + id: UUID, + name: Optional[str] = None, + service_source: Optional[str] = None, + admin_state: Optional[ServiceState] = None, + status: Optional[Dict[str, Any]] = None, + endpoint: Optional[Dict[str, Any]] = None, + labels: Optional[Dict[str, str]] = None, + prediction_url: Optional[str] = None, + health_check_url: Optional[str] = None, + model_version_id: Optional[UUID] = None, + ) -> ServiceResponse: + """Update a service. + + Args: + id: The ID of the service to update. + name: The new name of the service. + admin_state: The new admin state of the service. + status: The new status of the service. + endpoint: The new endpoint of the service. + service_source: The new service source of the service. + labels: The new labels of the service. + prediction_url: The new prediction url of the service. + health_check_url: The new health check url of the service. + model_version_id: The new model version id of the service. + + Returns: + The updated service. + """ + service_update = ServiceUpdate() + if name: + service_update.name = name + if service_source: + service_update.service_source = service_source + if admin_state: + service_update.admin_state = admin_state + if status: + service_update.status = status + if endpoint: + service_update.endpoint = endpoint + if labels: + service_update.labels = labels + if prediction_url: + service_update.prediction_url = prediction_url + if health_check_url: + service_update.health_check_url = health_check_url + if model_version_id: + service_update.model_version_id = model_version_id + return self.zen_store.update_service( + service_id=id, update=service_update + ) + + def delete_service(self, name_id_or_prefix: UUID) -> None: + """Delete a service. + + Args: + name_id_or_prefix: The name or ID of the service to delete. + """ + service = self.get_service( + name_id_or_prefix, + allow_name_prefix_match=False, + ) + self.zen_store.delete_service(service_id=service.id) + # -------------------------------- Components ------------------------------ def get_stack_component( diff --git a/src/zenml/constants.py b/src/zenml/constants.py index ab282a0e9d7..89842d8a78b 100644 --- a/src/zenml/constants.py +++ b/src/zenml/constants.py @@ -210,10 +210,6 @@ def handle_int_env_var(var: str, default: int = 0) -> int: LOGIN = "/login" LOGOUT = "/logout" LOGS = "/logs" -MODEL_VERSION_ARTIFACTS = "/model_version_artifacts" -MODEL_VERSION_PIPELINE_RUNS = "/model_version_pipeline_runs" -MODEL_VERSIONS = "/model_versions" -MODELS = "/models" PIPELINE_BUILDS = "/pipeline_builds" PIPELINE_CONFIGURATION = "/pipeline-configuration" PIPELINE_DEPLOYMENTS = "/pipeline_deployments" @@ -232,6 +228,12 @@ def handle_int_env_var(var: str, default: int = 0) -> int: SERVICE_CONNECTOR_RESOURCES = "/resources" SERVICE_CONNECTOR_TYPES = "/service_connector_types" SERVICE_CONNECTOR_VERIFY = "/verify" +SERVICE_CONNECTOR_RESOURCES = "/resources" +MODELS = "/models" +MODEL_VERSIONS = "/model_versions" +MODEL_VERSION_ARTIFACTS = "/model_version_artifacts" +MODEL_VERSION_PIPELINE_RUNS = "/model_version_pipeline_runs" +SERVICES = "/services" SERVICE_CONNECTORS = "/service_connectors" STACKS = "/stacks" STACK_COMPONENTS = "/components" diff --git a/src/zenml/enums.py b/src/zenml/enums.py index e92da7e8871..67f6ace00f6 100644 --- a/src/zenml/enums.py +++ b/src/zenml/enums.py @@ -54,6 +54,13 @@ class VisualizationType(StrEnum): MARKDOWN = "markdown" +class ZenMLServiceType(StrEnum): + """All possible types a service can have.""" + + ZEN_SERVER = "zen_server" + MODEL_SERVING = "model-serving" + + class ExecutionStatus(StrEnum): """Enum that represents the current status of a step or pipeline run.""" diff --git a/src/zenml/integrations/bentoml/constants.py b/src/zenml/integrations/bentoml/constants.py index 318913cd19e..19395866834 100644 --- a/src/zenml/integrations/bentoml/constants.py +++ b/src/zenml/integrations/bentoml/constants.py @@ -15,5 +15,5 @@ DEFAULT_BENTO_FILENAME = "zenml_exported.bento" BENTOML_DEFAULT_PORT = 3000 -BENTOML_HEALTHCHECK_URL_PATH = "healthz" +BENTOML_HEALTHCHECK_URL_PATH = "readyz" BENTOML_PREDICTION_URL_PATH = "" diff --git a/src/zenml/integrations/bentoml/model_deployers/bentoml_model_deployer.py b/src/zenml/integrations/bentoml/model_deployers/bentoml_model_deployer.py index 746d13a8f67..6f782f6ce4d 100644 --- a/src/zenml/integrations/bentoml/model_deployers/bentoml_model_deployer.py +++ b/src/zenml/integrations/bentoml/model_deployers/bentoml_model_deployer.py @@ -15,13 +15,11 @@ import os import shutil -from pathlib import Path -from typing import ClassVar, Dict, List, Optional, Type, cast +from typing import ClassVar, Dict, Optional, Type, cast from uuid import UUID from zenml.config.global_config import GlobalConfiguration from zenml.constants import DEFAULT_SERVICE_START_STOP_TIMEOUT -from zenml.integrations.bentoml.constants import BENTOML_DEFAULT_PORT from zenml.integrations.bentoml.flavors.bentoml_model_deployer_flavor import ( BentoMLModelDeployerConfig, BentoMLModelDeployerFlavor, @@ -32,8 +30,6 @@ ) from zenml.logger import get_logger from zenml.model_deployers import BaseModelDeployer, BaseModelDeployerFlavor -from zenml.services import ServiceRegistry -from zenml.services.local.local_service import SERVICE_DAEMON_CONFIG_FILE_NAME from zenml.services.service import BaseService, ServiceConfig from zenml.utils.io_utils import create_dir_recursive_if_not_exists @@ -126,7 +122,8 @@ def get_model_server_info( # type: ignore[override] ) return { - "PREDICTION_URL": service_instance.prediction_url, + "HEALTH_CHECK_URL": service_instance.get_healthcheck_url(), + "PREDICTION_URL": service_instance.get_prediction_url(), "BENTO_TAG": service_instance.config.bento, "MODEL_NAME": service_instance.config.model_name, "MODEL_URI": service_instance.config.model_uri, @@ -136,10 +133,10 @@ def get_model_server_info( # type: ignore[override] "PREDICTION_APIS_URLS": predictions_apis_urls, } - def deploy_model( + def perform_deploy_model( self, + id: UUID, config: ServiceConfig, - replace: bool = False, timeout: int = DEFAULT_SERVICE_START_STOP_TIMEOUT, ) -> BaseService: """Create a new BentoML deployment service or update an existing one. @@ -171,10 +168,8 @@ def deploy_model( and the others are deleted. Args: + id: the UUID of the BentoML model deployer. config: the configuration of the model to be deployed with BentoML. - replace: set this flag to True to find and update an equivalent - BentoML deployment server with the new model instead of - creating and starting a new deployment server. timeout: the timeout in seconds to wait for the BentoML server to be provisioned and successfully started or updated. If set to 0, the method will return immediately after the BentoML @@ -185,49 +180,11 @@ def deploy_model( interact with the BentoML model http server. """ config = cast(BentoMLDeploymentConfig, config) - service = None - - # if replace is True, remove all existing services - if replace is True: - existing_services = self.find_model_server( - pipeline_name=config.pipeline_name, - pipeline_step_name=config.pipeline_step_name, - model_name=config.model_name, - ) - - for existing_service in existing_services: - if service is None: - # keep the most recently created service - service = cast(BentoMLDeploymentService, existing_service) - try: - # delete the older services and don't wait for them to - # be deprovisioned - self._clean_up_existing_service( - existing_service=cast( - BentoMLDeploymentService, existing_service - ), - timeout=timeout, - force=True, - ) - except RuntimeError: - # ignore errors encountered while stopping old services - pass - if service: - logger.info( - f"Updating an existing BentoML deployment service: {service}" - ) - - # set the root runtime path with the stack component's UUID - config.root_runtime_path = self.local_path - service.stop(timeout=timeout, force=True) - service.update(config) - service.start(timeout=timeout) - else: - # create a new BentoMLDeploymentService instance - service = self._create_new_service(timeout, config) - logger.info(f"Created a new BentoML deployment service: {service}") - - return cast(BaseService, service) + service = self._create_new_service( + id=id, timeout=timeout, config=config + ) + logger.info(f"Created a new BentoML deployment service: {service}") + return service def _clean_up_existing_service( self, @@ -246,12 +203,13 @@ def _clean_up_existing_service( # of workers etc.the step implementation will create a new config using # all values from the user and add values like pipeline name, model_uri def _create_new_service( - self, timeout: int, config: BentoMLDeploymentConfig + self, id: UUID, timeout: int, config: BentoMLDeploymentConfig ) -> BentoMLDeploymentService: """Creates a new BentoMLDeploymentService. Args: - timeout: the timeout in seconds to wait for the BentoML http server + id: the ID of the BentoML deployment service to be created or updated. + timeout: the timeout in seconds to wait for the BentoML server to be provisioned and successfully started or updated. config: the configuration of the model to be deployed with BentoML. @@ -262,197 +220,61 @@ def _create_new_service( # set the root runtime path with the stack component's UUID config.root_runtime_path = self.local_path # create a new service for the new model - service = BentoMLDeploymentService(config) + service = BentoMLDeploymentService(uuid=id, config=config) service.start(timeout=timeout) return service - def find_model_server( + def perform_stop_model( self, - running: bool = False, - service_uuid: Optional[UUID] = None, - pipeline_name: Optional[str] = None, - run_name: Optional[str] = None, - pipeline_step_name: Optional[str] = None, - model_name: Optional[str] = None, - model_uri: Optional[str] = None, - model_type: Optional[str] = None, - ) -> List[BaseService]: - """Finds one or more model servers that match the given criteria. - - Args: - running: If true, only running services will be returned. - service_uuid: The UUID of the service that was originally used - to deploy the model. - pipeline_name: Name of the pipeline that the deployed model was part - of. - run_name: ID of the pipeline run which the deployed model - was part of. - pipeline_step_name: The name of the pipeline model deployment step - that deployed the model. - model_name: Name of the deployed model. - model_uri: URI of the deployed model. - model_type: Type/format of the deployed model. Not used in this - BentoML case. - - Returns: - One or more Service objects representing model servers that match - the input search criteria. - - Raises: - TypeError: if any of the input arguments are of an invalid type. - """ - services = [] - config = BentoMLDeploymentConfig( - model_name=model_name or "", - bento="", - port=BENTOML_DEFAULT_PORT, - model_uri=model_uri or "", - working_dir="", - pipeline_name=pipeline_name or "", - pipeline_run_id=run_name or "", - run_name=run_name or "", - pipeline_step_name=pipeline_step_name or "", - ) - - # find all services that match the input criteria - for root, _, files in os.walk(self.local_path): - if service_uuid and Path(root).name != str(service_uuid): - continue - for file in files: - if file == SERVICE_DAEMON_CONFIG_FILE_NAME: - service_config_path = os.path.join(root, file) - logger.debug( - "Loading service daemon configuration from %s", - service_config_path, - ) - existing_service_config = None - with open(service_config_path, "r") as f: - existing_service_config = f.read() - existing_service = ( - ServiceRegistry().load_service_from_json( - existing_service_config - ) - ) - if not isinstance( - existing_service, BentoMLDeploymentService - ): - raise TypeError( - f"Expected service type BentoMLDeploymentService but got " - f"{type(existing_service)} instead" - ) - existing_service.update_status() - if self._matches_search_criteria(existing_service, config): - if not running or existing_service.is_running: - services.append( - cast(BaseService, existing_service) - ) - - return services - - def _matches_search_criteria( - self, - existing_service: BentoMLDeploymentService, - config: BentoMLDeploymentConfig, - ) -> bool: - """Returns true if a service matches the input criteria. - - If any of the values in the input criteria are None, they are ignored. - This allows listing services just by common pipeline names or step - names, etc. - - Args: - existing_service: The materialized Service instance derived from - the config of the older (existing) service - config: The BentoMlDeploymentConfig object passed to the - deploy_model function holding parameters of the new service - to be created. - - Returns: - True if the service matches the input criteria. - """ - existing_service_config = existing_service.config - - # check if the existing service matches the input criteria - if ( - ( - not config.pipeline_name - or existing_service_config.pipeline_name - == config.pipeline_name - ) - and ( - not config.model_name - or existing_service_config.model_name == config.model_name - ) - and ( - not config.pipeline_step_name - or existing_service_config.pipeline_step_name - == config.pipeline_step_name - ) - and ( - not config.run_name - or existing_service_config.run_name == config.run_name - ) - ): - return True - - return False - - def stop_model_server( - self, - uuid: UUID, + service: BaseService, timeout: int = DEFAULT_SERVICE_START_STOP_TIMEOUT, force: bool = False, - ) -> None: + ) -> BaseService: """Method to stop a model server. Args: - uuid: UUID of the model server to stop. + service: The service to stop. timeout: Timeout in seconds to wait for the service to stop. force: If True, force the service to stop. - """ - # get list of all services - existing_services = self.find_model_server(service_uuid=uuid) - # if the service exists, stop it - if existing_services: - existing_services[0].stop(timeout=timeout, force=force) + Returns: + The stopped service. + """ + service.stop(timeout=timeout, force=force) + return service - def start_model_server( - self, uuid: UUID, timeout: int = DEFAULT_SERVICE_START_STOP_TIMEOUT - ) -> None: + def perform_start_model( + self, + service: BaseService, + timeout: int = DEFAULT_SERVICE_START_STOP_TIMEOUT, + ) -> BaseService: """Method to start a model server. Args: - uuid: UUID of the model server to start. + service: The service to start. timeout: Timeout in seconds to wait for the service to start. - """ - # get list of all services - existing_services = self.find_model_server(service_uuid=uuid) - # if the service exists, start it - if existing_services: - existing_services[0].start(timeout=timeout) + Returns: + The started service. + """ + service.start(timeout=timeout) + return service - def delete_model_server( + def perform_delete_model( self, - uuid: UUID, + service: BaseService, timeout: int = DEFAULT_SERVICE_START_STOP_TIMEOUT, force: bool = False, ) -> None: """Method to delete all configuration of a model server. Args: - uuid: UUID of the model server to delete. + service: The service to delete. timeout: Timeout in seconds to wait for the service to stop. force: If True, force the service to stop. """ - # get list of all services - existing_services = self.find_model_server(service_uuid=uuid) - - # if the service exists, clean it up - if existing_services: - service = cast(BentoMLDeploymentService, existing_services[0]) - self._clean_up_existing_service( - existing_service=service, timeout=timeout, force=force - ) + service = cast(BentoMLDeploymentService, service) + self._clean_up_existing_service( + existing_service=service, timeout=timeout, force=force + ) diff --git a/src/zenml/integrations/bentoml/services/bentoml_deployment.py b/src/zenml/integrations/bentoml/services/bentoml_deployment.py index 138d3039c9b..2a826fb5077 100644 --- a/src/zenml/integrations/bentoml/services/bentoml_deployment.py +++ b/src/zenml/integrations/bentoml/services/bentoml_deployment.py @@ -94,8 +94,8 @@ class SSLBentoMLParametersConfig(BaseModel): ssl_certfile: Optional[str] = None ssl_keyfile: Optional[str] = None ssl_keyfile_password: Optional[str] = None - ssl_version: Optional[str] = None - ssl_cert_reqs: Optional[str] = None + ssl_version: Optional[int] = None + ssl_cert_reqs: Optional[int] = None ssl_ca_certs: Optional[str] = None ssl_ciphers: Optional[str] = None @@ -121,9 +121,9 @@ class BentoMLDeploymentConfig(LocalDaemonServiceConfig): bento: str bento_uri: Optional[str] = None apis: List[str] = [] - workers: Optional[int] = 1 - port: Optional[int] = None - backlog: Optional[int] = 2048 + workers: int = 1 + port: int + backlog: int = 2048 production: bool = False working_dir: str host: Optional[str] = None @@ -147,6 +147,7 @@ class BentoMLDeploymentService(LocalDaemonService, BaseDeploymentService): type="model-serving", flavor="bentoml", description="BentoML prediction service", + logo_url="https://public-flavor-logos.s3.eu-central-1.amazonaws.com/model_deployer/bentoml.png", ) config: BentoMLDeploymentConfig @@ -203,9 +204,9 @@ def run(self) -> None: serve_http_production( self.config.bento, working_dir=self.config.working_dir, - port=self.endpoint.status.port, + port=self.config.port, api_workers=self.config.workers, - host=self.endpoint.status.hostname, + host=self.config.host or DEFAULT_LOCAL_SERVICE_IP_ADDRESS, backlog=self.config.backlog, ssl_certfile=ssl_params.ssl_certfile, ssl_keyfile=ssl_params.ssl_keyfile, diff --git a/src/zenml/integrations/bentoml/steps/bentoml_deployer.py b/src/zenml/integrations/bentoml/steps/bentoml_deployer.py index 225126233ed..4bb11e1957f 100644 --- a/src/zenml/integrations/bentoml/steps/bentoml_deployer.py +++ b/src/zenml/integrations/bentoml/steps/bentoml_deployer.py @@ -87,16 +87,8 @@ def bentoml_model_deployer_step( # get pipeline name, step name and run id step_context = get_step_context() pipeline_name = step_context.pipeline.name - run_name = step_context.pipeline_run.name step_name = step_context.step_run.name - # fetch existing services with same pipeline name, step name and model name - existing_services = model_deployer.find_model_server( - pipeline_name=pipeline_name, - pipeline_step_name=step_name, - model_name=model_name, - ) - # Return the apis endpoint of the defined service to use in the predict. # This is a workaround to get the endpoints of the service defined as functions # from the user code in the BentoML service. @@ -123,7 +115,6 @@ def service_apis(bento_tag: str) -> List[str]: working_dir=working_dir or source_utils.get_source_root(), port=port, pipeline_name=pipeline_name, - run_name=run_name, pipeline_step_name=step_name, ssl_parameters=SSLBentoMLParametersConfig( ssl_certfile=ssl_certfile, @@ -136,8 +127,13 @@ def service_apis(bento_tag: str) -> List[str]: ), ) + # fetch existing services with same pipeline name, step name and model name + existing_services = model_deployer.find_model_server( + config=predictor_cfg.dict(), + service_type=BentoMLDeploymentService.SERVICE_TYPE, + ) + # Creating a new service with inactive state and status by default - service = BentoMLDeploymentService(predictor_cfg) if existing_services: service = cast(BentoMLDeploymentService, existing_services[0]) @@ -159,6 +155,7 @@ def service_apis(bento_tag: str) -> List[str]: replace=True, config=predictor_cfg, timeout=timeout, + service_type=BentoMLDeploymentService.SERVICE_TYPE, ), ) diff --git a/src/zenml/integrations/huggingface/flavors/huggingface_model_deployer_flavor.py b/src/zenml/integrations/huggingface/flavors/huggingface_model_deployer_flavor.py index d9150fe9986..f9f98b65686 100644 --- a/src/zenml/integrations/huggingface/flavors/huggingface_model_deployer_flavor.py +++ b/src/zenml/integrations/huggingface/flavors/huggingface_model_deployer_flavor.py @@ -33,7 +33,6 @@ class HuggingFaceBaseConfig(BaseModel): """Hugging Face Inference Endpoint configuration.""" - endpoint_name: str = "zenml-" repository: Optional[str] = None framework: Optional[str] = None accelerator: Optional[str] = None @@ -41,15 +40,15 @@ class HuggingFaceBaseConfig(BaseModel): instance_type: Optional[str] = None region: Optional[str] = None vendor: Optional[str] = None - token: Optional[str] = None account_id: Optional[str] = None min_replica: int = 0 max_replica: int = 1 revision: Optional[str] = None task: Optional[str] = None custom_image: Optional[Dict[str, Any]] = None - namespace: Optional[str] = None endpoint_type: str = "public" + secret_name: Optional[str] = None + namespace: Optional[str] = None class HuggingFaceModelDeployerConfig( @@ -62,7 +61,7 @@ class HuggingFaceModelDeployerConfig( namespace: Hugging Face namespace used to list endpoints """ - token: str = SecretField() + token: Optional[str] = SecretField() # The namespace to list endpoints for. Set to `"*"` to list all endpoints # from all namespaces (i.e. personal namespace and all orgs the user belongs to). diff --git a/src/zenml/integrations/huggingface/model_deployers/huggingface_model_deployer.py b/src/zenml/integrations/huggingface/model_deployers/huggingface_model_deployer.py index 2ab93405864..eb551d5051a 100644 --- a/src/zenml/integrations/huggingface/model_deployers/huggingface_model_deployer.py +++ b/src/zenml/integrations/huggingface/model_deployers/huggingface_model_deployer.py @@ -13,12 +13,11 @@ # permissions and limitations under the License. """Implementation of the Hugging Face Model Deployer.""" -from typing import Any, ClassVar, Dict, List, Optional, Type, cast +from typing import ClassVar, Dict, Optional, Tuple, Type, cast from uuid import UUID -from huggingface_hub import list_inference_endpoints - -from zenml.artifacts.utils import log_artifact_metadata, save_artifact +from zenml.analytics.enums import AnalyticsEvent +from zenml.analytics.utils import track_handler from zenml.client import Client from zenml.integrations.huggingface import HUGGINGFACE_SERVICE_ARTIFACT from zenml.integrations.huggingface.flavors.huggingface_model_deployer_flavor import ( @@ -35,13 +34,12 @@ DEFAULT_DEPLOYMENT_START_STOP_TIMEOUT, BaseModelDeployerFlavor, ) -from zenml.services import BaseService, ServiceConfig, ServiceRegistry +from zenml.services import BaseService, ServiceConfig +from zenml.stack.stack import Stack +from zenml.stack.stack_validator import StackValidator logger = get_logger(__name__) -ZENM_ENDPOINT_PREFIX: str = "zenml-" -UUID_SLICE_LENGTH: int = 8 - class HuggingFaceModelDeployer(BaseModelDeployer): """Hugging Face endpoint model deployer.""" @@ -61,45 +59,42 @@ def config(self) -> HuggingFaceModelDeployerConfig: return cast(HuggingFaceModelDeployerConfig, self._config) @property - def deployed_endpoints(self) -> Any: - """Get list of deployed endpoint from Hugging Face. + def validator(self) -> Optional[StackValidator]: + """Validates the stack. Returns: - List of deployed endpoints. + A validator that checks that the stack contains a remote artifact + store. """ - return list_inference_endpoints( - token=self.config.token, - namespace=self.config.namespace, - ) - - def modify_endpoint_name( - self, endpoint_name: str, artifact_version: str - ) -> str: - """Modify endpoint name by adding suffix and prefix. - - It adds a prefix "zenml-" if not present and a suffix - of first 8 characters of uuid. - Args: - endpoint_name : Name of the endpoint - artifact_version: Name of the artifact version - - Returns: - Modified endpoint name with added prefix and suffix - """ - # Add prefix if it does not start with ZENM_ENDPOINT_PREFIX - if not endpoint_name.startswith(ZENM_ENDPOINT_PREFIX): - endpoint_name = ZENM_ENDPOINT_PREFIX + endpoint_name + def _validate_if_secret_or_token_is_present( + stack: "Stack", + ) -> Tuple[bool, str]: + """Check if secret or token is present in the stack. + + Args: + stack: The stack to validate. + + Returns: + A tuple with a boolean indicating whether the stack is valid + and a message describing the validation result. + """ + return bool(self.config.token or self.config.secret_name), ( + "The Hugging Face model deployer requires either a secret name" + " or a token to be present in the stack." + ) - endpoint_name += artifact_version - return endpoint_name + return StackValidator( + custom_validation_function=_validate_if_secret_or_token_is_present, + ) def _create_new_service( - self, timeout: int, config: HuggingFaceServiceConfig + self, id: UUID, timeout: int, config: HuggingFaceServiceConfig ) -> HuggingFaceDeploymentService: """Creates a new Hugging FaceDeploymentService. Args: + id: the UUID of the model to be deployed with Hugging Face model deployer. timeout: the timeout in seconds to wait for the Hugging Face inference endpoint to be provisioned and successfully started or updated. config: the configuration of the model to be deployed with Hugging Face model deployer. @@ -109,36 +104,12 @@ def _create_new_service( with the Hugging Face inference endpoint. """ # create a new service for the new model - service = HuggingFaceDeploymentService(config) - - # Use first 8 characters of UUID as artifact version - artifact_version = str(service.dict()["uuid"])[:UUID_SLICE_LENGTH] - # Add same 8 characters as suffix to endpoint name - service.config.endpoint_name = self.modify_endpoint_name( - service.config.endpoint_name, artifact_version - ) + service = HuggingFaceDeploymentService(uuid=id, config=config) logger.info( f"Creating an artifact {HUGGINGFACE_SERVICE_ARTIFACT} with service instance attached as metadata." " If there's an active pipeline and/or model this artifact will be associated with it." ) - - save_artifact( - service, - HUGGINGFACE_SERVICE_ARTIFACT, - version=artifact_version, - is_deployment_artifact=True, - ) - - # Convert UUID object to be json serializable - service_metadata = service.dict() - service_metadata["uuid"] = str(service_metadata["uuid"]) - log_artifact_metadata( - artifact_name=HUGGINGFACE_SERVICE_ARTIFACT, - artifact_version=artifact_version, - metadata={HUGGINGFACE_SERVICE_ARTIFACT: service_metadata}, - ) - service.start(timeout=timeout) return service @@ -159,10 +130,10 @@ def _clean_up_existing_service( # stop the older service existing_service.stop(timeout=timeout, force=force) - def deploy_model( + def perform_deploy_model( self, + id: UUID, config: ServiceConfig, - replace: bool = True, timeout: int = DEFAULT_DEPLOYMENT_START_STOP_TIMEOUT, ) -> BaseService: """Create a new Hugging Face deployment service or update an existing one. @@ -170,11 +141,8 @@ def deploy_model( This should serve the supplied model and deployment configuration. Args: + id: the UUID of the model to be deployed with Hugging Face. config: the configuration of the model to be deployed with Hugging Face. - Core - replace: set this flag to True to find and update an equivalent - Hugging Face deployment server with the new model instead of - starting a new deployment server. timeout: the timeout in seconds to wait for the Hugging Face endpoint to be provisioned and successfully started or updated. If set to 0, the method will return immediately after the Hugging Face @@ -184,263 +152,82 @@ def deploy_model( The ZenML Hugging Face deployment service object that can be used to interact with the remote Hugging Face inference endpoint server. """ - config = cast(HuggingFaceServiceConfig, config) - service = None - - # if replace is True, remove all existing services - if replace: - existing_services = self.find_model_server( - pipeline_name=config.pipeline_name, - pipeline_step_name=config.pipeline_step_name, - ) - - for existing_service in existing_services: - if service is None: - # keep the most recently created service - service = cast( - HuggingFaceDeploymentService, existing_service - ) - try: - # delete the older services and don't wait for them to - # be deprovisioned - self._clean_up_existing_service( - existing_service=cast( - HuggingFaceDeploymentService, existing_service - ), - timeout=timeout, - force=True, - ) - except RuntimeError: - # ignore errors encountered while stopping old services - pass - - if service: - # update an equivalent service in place - logger.info( - f"Updating an existing Hugging Face deployment service: {service}" - ) - - service_metadata = service.dict() - artifact_version = str(service_metadata["uuid"])[ - :UUID_SLICE_LENGTH - ] - config.endpoint_name = self.modify_endpoint_name( - config.endpoint_name, artifact_version - ) - - service.stop(timeout=timeout, force=True) - service.update(config) - service.start(timeout=timeout) - else: + with track_handler(AnalyticsEvent.MODEL_DEPLOYED) as analytics_handler: + config = cast(HuggingFaceServiceConfig, config) # create a new HuggingFaceDeploymentService instance - service = self._create_new_service(timeout, config) + service = self._create_new_service( + id=id, timeout=timeout, config=config + ) logger.info( f"Creating a new Hugging Face inference endpoint service: {service}" ) + # Add telemetry with metadata that gets the stack metadata and + # differentiates between pure model and custom code deployments + stack = Client().active_stack + stack_metadata = { + component_type.value: component.flavor + for component_type, component in stack.components.items() + } + analytics_handler.metadata = { + "store_type": Client().zen_store.type.value, + **stack_metadata, + } - return cast(BaseService, service) - - def find_model_server( - self, - running: bool = False, - service_uuid: Optional[UUID] = None, - pipeline_name: Optional[str] = None, - run_name: Optional[str] = None, - pipeline_step_name: Optional[str] = None, - model_name: Optional[str] = None, - model_uri: Optional[str] = None, - model_type: Optional[str] = None, - ) -> List[BaseService]: - """Find one or more Hugging Face model services that match the given criteria. - - Args: - running: if true, only running services will be returned. - service_uuid: the UUID of the Hugging Face service that was - originally used to create the Hugging Face deployment resource. - pipeline_name: name of the pipeline that the deployed model was part - of. - run_name: Name of the pipeline run which the deployed model was - part of. - pipeline_step_name: the name of the pipeline model deployment step - that deployed the model. - model_name: the name of the deployed model. - model_uri: URI of the deployed model. - model_type: the Hugging Face server implementation used to serve - the model - - Raises: - TypeError: If service type does not match HuggingFaceDeploymentService - - Returns: - One or more Hugging Face service objects representing Hugging Face - model servers that match the input search criteria. - """ - # Use a Hugging Face deployment service configuration to compute the labels - config = HuggingFaceServiceConfig( - pipeline_name=pipeline_name or "", - run_name=run_name or "", - pipeline_run_id=run_name or "", - pipeline_step_name=pipeline_step_name or "", - model_name=model_name or "", - model_uri=model_uri or "", - implementation=model_type or "", - ) - - services: List[BaseService] = [] - - # Find all services that match input criteria - for endpoint in self.deployed_endpoints: - if endpoint.name.startswith("zenml-"): - artifact_version = endpoint.name[-8:] - # If service_uuid is supplied, fetch service for that uuid - if ( - service_uuid is not None - and str(service_uuid)[:8] != artifact_version - ): - continue - - # Fetch the saved metadata artifact from zenml server to recreate service - client = Client() - try: - service_artifact = client.get_artifact_version( - HUGGINGFACE_SERVICE_ARTIFACT, artifact_version - ) - hf_deployment_service_dict = service_artifact.run_metadata[ - HUGGINGFACE_SERVICE_ARTIFACT - ].value - - existing_service = ( - ServiceRegistry().load_service_from_dict( - hf_deployment_service_dict # type: ignore - ) - ) - - if not isinstance( - existing_service, HuggingFaceDeploymentService - ): - raise TypeError( - f"Expected service type HuggingFaceDeploymentService but got " - f"{type(existing_service)} instead" - ) - - existing_service.update_status() - if self._matches_search_criteria(existing_service, config): - if not running or existing_service.is_running: - services.append( - cast(BaseService, existing_service) - ) - - # if endpoint is provisioned externally - # we do not have saved artifact for it. - except KeyError: - logger.error( - f"No key found for endpoint {endpoint.name} provisioned externally" - ) - - return services - - def _matches_search_criteria( - self, - existing_service: HuggingFaceDeploymentService, - config: HuggingFaceServiceConfig, - ) -> bool: - """Returns true if a service matches the input criteria. - - If any of the values in the input criteria are None, they are ignored. - This allows listing services just by common pipeline names or step - names, etc. - - Args: - existing_service: The materialized Service instance derived from - the config of the older (existing) service - config: The HuggingFaceServiceConfig object passed to the - deploy_model function holding parameters of the new service - to be created. - - Returns: - True if the service matches the input criteria. - """ - existing_service_config = existing_service.config - - # check if the existing service matches the input criteria - if ( - ( - not config.pipeline_name - or existing_service_config.pipeline_name - == config.pipeline_name - ) - and ( - not config.pipeline_step_name - or existing_service_config.pipeline_step_name - == config.pipeline_step_name - ) - and ( - not config.run_name - or existing_service_config.run_name == config.run_name - ) - ): - return True - - return False + return service - def stop_model_server( + def perform_stop_model( self, - uuid: UUID, + service: BaseService, timeout: int = DEFAULT_DEPLOYMENT_START_STOP_TIMEOUT, force: bool = False, - ) -> None: + ) -> BaseService: """Method to stop a model server. Args: - uuid: UUID of the model server to stop. + service: The service to stop. timeout: Timeout in seconds to wait for the service to stop. force: If True, force the service to stop. - """ - # get list of all services - existing_services = self.find_model_server(service_uuid=uuid) - # if the service exists, stop it - if existing_services: - existing_services[0].stop(timeout=timeout, force=force) + Returns: + The stopped service. + """ + service.stop(timeout=timeout, force=force) + return service - def start_model_server( - self, uuid: UUID, timeout: int = DEFAULT_DEPLOYMENT_START_STOP_TIMEOUT - ) -> None: + def perform_start_model( + self, + service: BaseService, + timeout: int = DEFAULT_DEPLOYMENT_START_STOP_TIMEOUT, + ) -> BaseService: """Method to start a model server. Args: - uuid: UUID of the model server to start. + service: The service to start. timeout: Timeout in seconds to wait for the service to start. - """ - # get list of all services - existing_services = self.find_model_server(service_uuid=uuid) - # if the service exists, start it - if existing_services: - existing_services[0].start(timeout=timeout) + Returns: + The started service. + """ + service.start(timeout=timeout) + return service - def delete_model_server( + def perform_delete_model( self, - uuid: UUID, + service: BaseService, timeout: int = DEFAULT_DEPLOYMENT_START_STOP_TIMEOUT, force: bool = False, ) -> None: """Method to delete all configuration of a model server. Args: - uuid: UUID of the model server to delete. + service: The service to delete. timeout: Timeout in seconds to wait for the service to stop. force: If True, force the service to stop. """ - # get list of all services - existing_services = self.find_model_server(service_uuid=uuid) - - # if the service exists, clean it up - if existing_services: - service = cast(HuggingFaceDeploymentService, existing_services[0]) - self._clean_up_existing_service( - existing_service=service, timeout=timeout, force=force - ) + service = cast(HuggingFaceDeploymentService, service) + self._clean_up_existing_service( + existing_service=service, timeout=timeout, force=force + ) @staticmethod def get_model_server_info( # type: ignore[override] @@ -455,5 +242,6 @@ def get_model_server_info( # type: ignore[override] Model server information. """ return { - "PREDICTION_URL": service_instance.prediction_url, + "PREDICTION_URL": service_instance.get_prediction_url(), + "HEALTH_CHECK_URL": service_instance.get_healthcheck_url(), } diff --git a/src/zenml/integrations/huggingface/services/huggingface_deployment.py b/src/zenml/integrations/huggingface/services/huggingface_deployment.py index 26af08f7548..ed12e9954d1 100644 --- a/src/zenml/integrations/huggingface/services/huggingface_deployment.py +++ b/src/zenml/integrations/huggingface/services/huggingface_deployment.py @@ -26,6 +26,7 @@ from huggingface_hub.utils import HfHubHTTPError from pydantic import Field +from zenml.client import Client from zenml.integrations.huggingface.flavors.huggingface_model_deployer_flavor import ( HuggingFaceBaseConfig, ) @@ -36,16 +37,11 @@ logger = get_logger(__name__) POLLING_TIMEOUT = 1200 +UUID_SLICE_LENGTH: int = 8 class HuggingFaceServiceConfig(HuggingFaceBaseConfig, ServiceConfig): - """Hugging Face service configurations. - - Attributes: - model_name: the name of the model. - """ - - model_name: str = "default" + """Hugging Face service configurations.""" class HuggingFaceServiceStatus(ServiceStatus): @@ -81,6 +77,35 @@ def __init__(self, config: HuggingFaceServiceConfig, **attrs: Any): """ super().__init__(config=config, **attrs) + def get_token(self) -> str: + """Get the Hugging Face token. + + Raises: + ValueError: If token not found. + + Returns: + Hugging Face token. + """ + client = Client() + token = None + if self.config.secret_name: + secret = client.get_secret(self.config.secret_name) + token = secret.secret_values["token"] + else: + from zenml.integrations.huggingface.model_deployers.huggingface_model_deployer import ( + HuggingFaceModelDeployer, + ) + + model_deployer = client.active_stack.model_deployer + if not isinstance(model_deployer, HuggingFaceModelDeployer): + raise ValueError( + "HuggingFaceModelDeployer is not active in the stack." + ) + token = model_deployer.config.token or None + if not token: + raise ValueError("Token not found.") + return token + @property def hf_endpoint(self) -> InferenceEndpoint: """Get the deployed Hugging Face inference endpoint. @@ -89,22 +114,20 @@ def hf_endpoint(self) -> InferenceEndpoint: Huggingface inference endpoint. """ return get_inference_endpoint( - name=self.config.endpoint_name, - token=self.config.token, + name=self._generate_an_endpoint_name(), + token=self.get_token(), namespace=self.config.namespace, ) @property - def prediction_url(self) -> Any: + def prediction_url(self) -> Optional[str]: """The prediction URI exposed by the prediction service. Returns: The prediction URI exposed by the prediction service, or None if the service is not yet ready. """ - if not self.is_running: - return None - return self.hf_endpoint.url + return self.hf_endpoint.url if self.is_running else None @property def inference_client(self) -> InferenceClient: @@ -123,8 +146,8 @@ def provision(self) -> None: """ try: # Attempt to create and wait for the inference endpoint - _ = create_inference_endpoint( - name=self.config.endpoint_name, + hf_endpoint = create_inference_endpoint( + name=self._generate_an_endpoint_name(), repository=self.config.repository, framework=self.config.framework, accelerator=self.config.accelerator, @@ -139,20 +162,10 @@ def provision(self) -> None: task=self.config.task, custom_image=self.config.custom_image, type=self.config.endpoint_type, + token=self.get_token(), namespace=self.config.namespace, - token=self.config.token, ).wait(timeout=POLLING_TIMEOUT) - # Check if the endpoint URL is available after provisioning - if self.hf_endpoint.url is not None: - logger.info( - "Hugging Face inference endpoint successfully deployed." - ) - else: - logger.error( - "Failed to start Hugging Face inference endpoint service: No URL available." - ) - except Exception as e: self.status.update_state( new_state=ServiceState.ERROR, error=str(e) @@ -162,6 +175,16 @@ def provision(self) -> None: f"An unexpected error occurred while provisioning the Hugging Face inference endpoint: {e}" ) + # Check if the endpoint URL is available after provisioning + if hf_endpoint.url: + logger.info( + f"Hugging Face inference endpoint successfully deployed and available. Endpoint URL: {hf_endpoint.url}" + ) + else: + logger.error( + "Failed to start Hugging Face inference endpoint service: No URL available, please check the Hugging Face console for more details." + ) + def check_status(self) -> Tuple[ServiceState, str]: """Check the the current operational state of the Hugging Face deployment. @@ -170,39 +193,30 @@ def check_status(self) -> Tuple[ServiceState, str]: providing additional information about that state (e.g. a description of the error, if one is encountered). """ - # TODO: Support all different InferenceEndpointStatus try: - _ = self.hf_endpoint.status - except (InferenceEndpointError, HfHubHTTPError): - return (ServiceState.INACTIVE, "") - - if self.hf_endpoint.status == InferenceEndpointStatus.RUNNING: - return ( - ServiceState.ACTIVE, - "Hugging Face Inference Endpoint deployment is available", - ) - - elif self.hf_endpoint.status == InferenceEndpointStatus.SCALED_TO_ZERO: - return ( - ServiceState.ACTIVE, - "Hugging Face Inference Endpoint deployment is scaled to zero", - ) - - elif self.hf_endpoint.status == InferenceEndpointStatus.FAILED: - return ( - ServiceState.ERROR, - "Hugging Face Inference Endpoint deployment failed: ", - ) + status = self.hf_endpoint.status + if status == InferenceEndpointStatus.RUNNING: + return (ServiceState.ACTIVE, "") + + elif status == InferenceEndpointStatus.SCALED_TO_ZERO: + return ( + ServiceState.SCALED_TO_ZERO, + "Hugging Face Inference Endpoint is scaled to zero, but still running. It will be started on demand.", + ) - elif self.hf_endpoint.status == InferenceEndpointStatus.PENDING: + elif status == InferenceEndpointStatus.FAILED: + return ( + ServiceState.ERROR, + "Hugging Face Inference Endpoint deployment is inactive or not found", + ) + elif status == InferenceEndpointStatus.PENDING: + return (ServiceState.PENDING_STARTUP, "") + return (ServiceState.PENDING_STARTUP, "") + except (InferenceEndpointError, HfHubHTTPError): return ( - ServiceState.PENDING_STARTUP, - "Hugging Face Inference Endpoint deployment is being created: ", + ServiceState.INACTIVE, + "Hugging Face Inference Endpoint deployment is inactive or not found", ) - return ( - ServiceState.PENDING_STARTUP, - "Hugging Face Inference Endpoint deployment is being created: ", - ) def deprovision(self, force: bool = False) -> None: """Deprovision the remote Hugging Face deployment instance. @@ -217,7 +231,6 @@ def deprovision(self, force: bool = False) -> None: logger.error( "Hugging Face Inference Endpoint is deleted or cannot be found." ) - pass def predict(self, data: "Any", max_new_tokens: int) -> "Any": """Make a prediction using the service. @@ -238,7 +251,7 @@ def predict(self, data: "Any", max_new_tokens: int) -> "Any": "Hugging Face endpoint inference service is not running. " "Please start the service before making predictions." ) - if self.hf_endpoint.prediction_url is not None: + if self.prediction_url is not None: if self.hf_endpoint.task == "text-generation": result = self.inference_client.task_generation( data, max_new_tokens=max_new_tokens @@ -267,3 +280,13 @@ def get_logs( "your Endpoints through the UI in the “Logs” tab of your Endpoint" ) return # type: ignore + + def _generate_an_endpoint_name(self) -> str: + """Generate a unique name for the Hugging Face Inference Endpoint. + + Returns: + A unique name for the Hugging Face Inference Endpoint. + """ + return ( + f"{self.config.service_name}-{str(self.uuid)[:UUID_SLICE_LENGTH]}" + ) diff --git a/src/zenml/integrations/huggingface/steps/huggingface_deployer.py b/src/zenml/integrations/huggingface/steps/huggingface_deployer.py index fd123e88341..5303d89bda7 100644 --- a/src/zenml/integrations/huggingface/steps/huggingface_deployer.py +++ b/src/zenml/integrations/huggingface/steps/huggingface_deployer.py @@ -58,21 +58,17 @@ def huggingface_model_deployer_step( # get pipeline name, step name and run id context = get_step_context() pipeline_name = context.pipeline.name - run_name = context.pipeline_run.name step_name = context.step_run.name # update the step configuration with the real pipeline runtime information service_config = service_config.copy() service_config.pipeline_name = pipeline_name - service_config.run_name = run_name service_config.pipeline_step_name = step_name # fetch existing services with same pipeline name, step name and # model name existing_services = model_deployer.find_model_server( - pipeline_name=pipeline_name, - pipeline_step_name=step_name, - model_name=service_config.model_name, + config=service_config.dict() ) # even when the deploy decision is negative, if an existing model server @@ -99,7 +95,10 @@ def huggingface_model_deployer_step( service = cast( HuggingFaceDeploymentService, model_deployer.deploy_model( - service_config, replace=True, timeout=timeout + service_config, + replace=True, + timeout=timeout, + service_type=HuggingFaceDeploymentService.SERVICE_TYPE, ), ) diff --git a/src/zenml/integrations/mlflow/model_deployers/mlflow_model_deployer.py b/src/zenml/integrations/mlflow/model_deployers/mlflow_model_deployer.py index d163ae09a5e..1c0de9b43d2 100644 --- a/src/zenml/integrations/mlflow/model_deployers/mlflow_model_deployer.py +++ b/src/zenml/integrations/mlflow/model_deployers/mlflow_model_deployer.py @@ -15,8 +15,7 @@ import os import shutil -from pathlib import Path -from typing import ClassVar, Dict, List, Optional, Type, cast +from typing import ClassVar, Dict, Optional, Type, cast from uuid import UUID from zenml.config.global_config import GlobalConfiguration @@ -31,8 +30,6 @@ ) from zenml.logger import get_logger from zenml.model_deployers import BaseModelDeployer, BaseModelDeployerFlavor -from zenml.services import ServiceRegistry -from zenml.services.local.local_service import SERVICE_DAEMON_CONFIG_FILE_NAME from zenml.services.service import BaseService, ServiceConfig from zenml.utils.io_utils import create_dir_recursive_if_not_exists @@ -120,12 +117,15 @@ def get_model_server_info( # type: ignore[override] "REGISTRY_MODEL_VERSION": service_instance.config.registry_model_version, "SERVICE_PATH": service_instance.status.runtime_path, "DAEMON_PID": str(service_instance.status.pid), + "HEALTH_CHECK_URL": service_instance.endpoint.monitor.get_healthcheck_uri( + service_instance.endpoint + ), } - def deploy_model( + def perform_deploy_model( self, + id: UUID, config: ServiceConfig, - replace: bool = False, timeout: int = DEFAULT_SERVICE_START_STOP_TIMEOUT, ) -> BaseService: """Create a new MLflow deployment service or update an existing one. @@ -157,10 +157,8 @@ def deploy_model( and the others are deleted. Args: + id: the ID of the MLflow deployment service to be created or updated. config: the configuration of the model to be deployed with MLflow. - replace: set this flag to True to find and update an equivalent - MLflow deployment server with the new model instead of - creating and starting a new deployment server. timeout: the timeout in seconds to wait for the MLflow server to be provisioned and successfully started or updated. If set to 0, the method will return immediately after the MLflow @@ -171,49 +169,11 @@ def deploy_model( interact with the MLflow model server. """ config = cast(MLFlowDeploymentConfig, config) - service = None - - # if replace is True, remove all existing services - if replace is True: - existing_services = self.find_model_server( - pipeline_name=config.pipeline_name, - pipeline_step_name=config.pipeline_step_name, - model_name=config.model_name, - ) - - for existing_service in existing_services: - if service is None: - # keep the most recently created service - service = cast(MLFlowDeploymentService, existing_service) - try: - # delete the older services and don't wait for them to - # be deprovisioned - self._clean_up_existing_service( - existing_service=cast( - MLFlowDeploymentService, existing_service - ), - timeout=timeout, - force=True, - ) - except RuntimeError: - # ignore errors encountered while stopping old services - pass - if service: - logger.info( - f"Updating an existing MLflow deployment service: {service}" - ) - - # set the root runtime path with the stack component's UUID - config.root_runtime_path = self.local_path - service.stop(timeout=timeout, force=True) - service.update(config) - service.start(timeout=timeout) - else: - # create a new MLFlowDeploymentService instance - service = self._create_new_service(timeout, config) - logger.info(f"Created a new MLflow deployment service: {service}") - - return cast(BaseService, service) + service = self._create_new_service( + id=id, timeout=timeout, config=config + ) + logger.info(f"Created a new MLflow deployment service: {service}") + return service def _clean_up_existing_service( self, @@ -232,11 +192,12 @@ def _clean_up_existing_service( # of workers etc.the step implementation will create a new config using # all values from the user and add values like pipeline name, model_uri def _create_new_service( - self, timeout: int, config: MLFlowDeploymentConfig + self, id: UUID, timeout: int, config: MLFlowDeploymentConfig ) -> MLFlowDeploymentService: """Creates a new MLFlowDeploymentService. Args: + id: the ID of the MLflow deployment service to be created or updated. timeout: the timeout in seconds to wait for the MLflow server to be provisioned and successfully started or updated. config: the configuration of the model to be deployed with MLflow. @@ -248,213 +209,61 @@ def _create_new_service( # set the root runtime path with the stack component's UUID config.root_runtime_path = self.local_path # create a new service for the new model - service = MLFlowDeploymentService(config) + service = MLFlowDeploymentService(uuid=id, config=config) service.start(timeout=timeout) return service - def find_model_server( + def perform_stop_model( self, - running: bool = False, - service_uuid: Optional[UUID] = None, - pipeline_name: Optional[str] = None, - run_name: Optional[str] = None, - pipeline_step_name: Optional[str] = None, - model_name: Optional[str] = None, - model_uri: Optional[str] = None, - model_type: Optional[str] = None, - registry_model_name: Optional[str] = None, - registry_model_version: Optional[str] = None, - ) -> List[BaseService]: - """Finds one or more model servers that match the given criteria. - - Args: - running: If true, only running services will be returned. - service_uuid: The UUID of the service that was originally used - to deploy the model. - pipeline_name: Name of the pipeline that the deployed model was part - of. - run_name: Name of the pipeline run which the deployed model - was part of. - pipeline_step_name: The name of the pipeline model deployment step - that deployed the model. - model_name: Name of the deployed model. - model_uri: URI of the deployed model. - model_type: Type/format of the deployed model. Not used in this - MLflow case. - registry_model_name: Name of the registered model that the - deployed model belongs to. - registry_model_version: Version of the registered model that - the deployed model belongs to. - - Returns: - One or more Service objects representing model servers that match - the input search criteria. - - Raises: - TypeError: if any of the input arguments are of an invalid type. - """ - services = [] - config = MLFlowDeploymentConfig( - model_name=model_name or "", - model_uri=model_uri or "", - pipeline_name=pipeline_name or "", - pipeline_run_id=run_name or "", - run_name=run_name or "", - pipeline_step_name=pipeline_step_name or "", - registry_model_name=registry_model_name, - registry_model_version=registry_model_version, - ) - - # find all services that match the input criteria - for root, _, files in os.walk(self.local_path): - if service_uuid and Path(root).name != str(service_uuid): - continue - for file in files: - if file == SERVICE_DAEMON_CONFIG_FILE_NAME: - service_config_path = os.path.join(root, file) - logger.debug( - "Loading service daemon configuration from %s", - service_config_path, - ) - existing_service_config = None - with open(service_config_path, "r") as f: - existing_service_config = f.read() - existing_service = ( - ServiceRegistry().load_service_from_json( - existing_service_config - ) - ) - if not isinstance( - existing_service, MLFlowDeploymentService - ): - raise TypeError( - f"Expected service type MLFlowDeploymentService but got " - f"{type(existing_service)} instead" - ) - existing_service.update_status() - if self._matches_search_criteria(existing_service, config): - if not running or existing_service.is_running: - services.append( - cast(BaseService, existing_service) - ) - - return services - - def _matches_search_criteria( - self, - existing_service: MLFlowDeploymentService, - config: MLFlowDeploymentConfig, - ) -> bool: - """Returns true if a service matches the input criteria. - - If any of the values in the input criteria are None, they are ignored. - This allows listing services just by common pipeline names or step - names, etc. - - Args: - existing_service: The materialized Service instance derived from - the config of the older (existing) service - config: The MLFlowDeploymentConfig object passed to the - deploy_model function holding parameters of the new service - to be created. - - Returns: - True if the service matches the input criteria. - """ - existing_service_config = existing_service.config - # check if the existing service matches the input criteria - if ( - ( - not config.pipeline_name - or existing_service_config.pipeline_name - == config.pipeline_name - ) - and ( - not config.model_name - or existing_service_config.model_name == config.model_name - ) - and ( - not config.pipeline_step_name - or existing_service_config.pipeline_step_name - == config.pipeline_step_name - ) - and ( - not config.run_name - or existing_service_config.run_name == config.run_name - ) - and ( - ( - not config.registry_model_name - and not config.registry_model_version - ) - or ( - existing_service_config.registry_model_name - == config.registry_model_name - and existing_service_config.registry_model_version - == config.registry_model_version - ) - ) - ): - return True - - return False - - def stop_model_server( - self, - uuid: UUID, + service: BaseService, timeout: int = DEFAULT_SERVICE_START_STOP_TIMEOUT, force: bool = False, - ) -> None: + ) -> BaseService: """Method to stop a model server. Args: - uuid: UUID of the model server to stop. + service: The service to stop. timeout: Timeout in seconds to wait for the service to stop. force: If True, force the service to stop. - """ - # get list of all services - existing_services = self.find_model_server(service_uuid=uuid) - # if the service exists, stop it - if existing_services: - existing_services[0].stop(timeout=timeout, force=force) + Returns: + The service that was stopped. + """ + service.stop(timeout=timeout, force=force) + return service - def start_model_server( - self, uuid: UUID, timeout: int = DEFAULT_SERVICE_START_STOP_TIMEOUT - ) -> None: + def perform_start_model( + self, + service: BaseService, + timeout: int = DEFAULT_SERVICE_START_STOP_TIMEOUT, + ) -> BaseService: """Method to start a model server. Args: - uuid: UUID of the model server to start. + service: The service to start. timeout: Timeout in seconds to wait for the service to start. - """ - # get list of all services - existing_services = self.find_model_server(service_uuid=uuid) - # if the service exists, start it - if existing_services: - existing_services[0].start(timeout=timeout) + Returns: + The service that was started. + """ + service.start(timeout=timeout) + return service - def delete_model_server( + def perform_delete_model( self, - uuid: UUID, + service: BaseService, timeout: int = DEFAULT_SERVICE_START_STOP_TIMEOUT, force: bool = False, ) -> None: """Method to delete all configuration of a model server. Args: - uuid: UUID of the model server to delete. + service: The service to delete. timeout: Timeout in seconds to wait for the service to stop. force: If True, force the service to stop. """ - # get list of all services - existing_services = self.find_model_server(service_uuid=uuid) - - # if the service exists, clean it up - if existing_services: - service = cast(MLFlowDeploymentService, existing_services[0]) - self._clean_up_existing_service( - existing_service=service, timeout=timeout, force=force - ) + service = cast(MLFlowDeploymentService, service) + self._clean_up_existing_service( + existing_service=service, timeout=timeout, force=force + ) diff --git a/src/zenml/integrations/mlflow/services/mlflow_deployment.py b/src/zenml/integrations/mlflow/services/mlflow_deployment.py index 114f7e66a16..2cdccdbbf09 100644 --- a/src/zenml/integrations/mlflow/services/mlflow_deployment.py +++ b/src/zenml/integrations/mlflow/services/mlflow_deployment.py @@ -101,8 +101,6 @@ class MLFlowDeploymentConfig(LocalDaemonServiceConfig): timeout: timeout in seconds for starting and stopping the service """ - # TODO: ServiceConfig should have additional fields such as "pipeline_run_uuid" - # and "pipeline_uuid" to allow for better tracking of the service. model_uri: str model_name: str registry_model_name: Optional[str] = None @@ -128,6 +126,7 @@ class MLFlowDeploymentService(LocalDaemonService, BaseDeploymentService): type="model-serving", flavor="mlflow", description="MLflow prediction service", + logo_url="https://public-flavor-logos.s3.eu-central-1.amazonaws.com/model_deployer/mlflow.png", ) config: MLFlowDeploymentConfig diff --git a/src/zenml/integrations/mlflow/steps/mlflow_deployer.py b/src/zenml/integrations/mlflow/steps/mlflow_deployer.py index a93a42d75e9..6d0fde1a9df 100644 --- a/src/zenml/integrations/mlflow/steps/mlflow_deployer.py +++ b/src/zenml/integrations/mlflow/steps/mlflow_deployer.py @@ -118,32 +118,30 @@ def mlflow_model_deployer_step( run_id=mlflow_run_id, artifact_path=model_name ) - # Fetch existing services with same pipeline name, step name and model name - existing_services = model_deployer.find_model_server( + predictor_cfg = MLFlowDeploymentConfig( + model_name=model_name or "", + model_uri=model_uri, + workers=workers, + mlserver=mlserver, pipeline_name=pipeline_name, pipeline_step_name=step_name, - model_name=model_name, + timeout=timeout, + ) + + # Fetch existing services with same pipeline name, step name and model name + existing_services = model_deployer.find_model_server( + config=predictor_cfg.dict(), ) # Check whether to deploy a new service if model_uri and deploy_decision: - predictor_cfg = MLFlowDeploymentConfig( - model_name=model_name or "", - model_uri=model_uri, - workers=workers, - mlserver=mlserver, - pipeline_name=pipeline_name, - run_name=run_name, - pipeline_run_id=run_name, - pipeline_step_name=step_name, - timeout=timeout, - ) new_service = cast( MLFlowDeploymentService, model_deployer.deploy_model( replace=True, config=predictor_cfg, timeout=timeout, + service_type=MLFlowDeploymentService.SERVICE_TYPE, ), ) logger.info( @@ -277,26 +275,25 @@ def mlflow_model_registry_deployer_step( f"using this step." ) # fetch existing services with same pipeline name, step name and model name + existing_services = ( model_deployer.find_model_server( - registry_model_name=model_version.registered_model.name, + model_name=registry_model_name, + model_version=model_version.version, ) if replace_existing else None ) - # create a config for the new model service metadata = model_version.metadata or ModelRegistryModelMetadata() predictor_cfg = MLFlowDeploymentConfig( - model_name=model_name or "", + name=model_name or None, + model_name=registry_model_name, + model_version=model_version.version, model_uri=model_version.model_source_uri, - registry_model_name=model_version.registered_model.name, - registry_model_version=model_version.version, - registry_model_stage=model_version.stage.value, workers=workers, mlserver=mlserver, pipeline_name=metadata.zenml_pipeline_name or "", - run_name=metadata.zenml_run_name or "", pipeline_step_name=metadata.zenml_step_name or "", timeout=timeout, ) @@ -308,6 +305,7 @@ def mlflow_model_registry_deployer_step( replace=True, config=predictor_cfg, timeout=timeout, + service_type=MLFlowDeploymentService.SERVICE_TYPE, ), ) diff --git a/src/zenml/integrations/seldon/model_deployers/seldon_model_deployer.py b/src/zenml/integrations/seldon/model_deployers/seldon_model_deployer.py index 9529ccbcc85..8ae9282bee5 100644 --- a/src/zenml/integrations/seldon/model_deployers/seldon_model_deployer.py +++ b/src/zenml/integrations/seldon/model_deployers/seldon_model_deployer.py @@ -15,7 +15,6 @@ import json import re -from datetime import datetime from typing import TYPE_CHECKING, ClassVar, Dict, List, Optional, Type, cast from uuid import UUID @@ -479,10 +478,10 @@ def _delete_kubernetes_secret(self, secret_name: str) -> None: return self.seldon_client.delete_secret(secret_name) - def deploy_model( + def perform_deploy_model( self, + id: UUID, config: ServiceConfig, - replace: bool = False, timeout: int = DEFAULT_SELDON_DEPLOYMENT_START_STOP_TIMEOUT, ) -> BaseService: """Create a new Seldon Core deployment or update an existing one. @@ -517,11 +516,9 @@ def deploy_model( to be updated and the others are deleted. Args: + id: the UUID of the model server to deploy. config: the configuration of the model to be deployed with Seldon. Core - replace: set this flag to True to find and update an equivalent - Seldon Core deployment server with the new model instead of - starting a new deployment server. timeout: the timeout in seconds to wait for the Seldon Core server to be provisioned and successfully started or updated. If set to 0, the method will return immediately after the Seldon Core @@ -541,31 +538,6 @@ def deploy_model( """ with track_handler(AnalyticsEvent.MODEL_DEPLOYED) as analytics_handler: config = cast(SeldonDeploymentConfig, config) - service = None - - # if replace is True, find equivalent Seldon Core deployments - if replace is True: - equivalent_services = self.find_model_server( - running=False, - pipeline_name=config.pipeline_name, - pipeline_step_name=config.pipeline_step_name, - model_name=config.model_name, - ) - - for equivalent_service in equivalent_services: - if service is None: - # keep the most recently created service - service = equivalent_service - else: - try: - # delete the older services and don't wait for - # them to be deprovisioned - service.stop() - except RuntimeError: - # ignore errors encountered while stopping old - # services - pass - # if a custom Kubernetes secret is not explicitly specified in the # SeldonDeploymentConfig, try to create one from the ZenML secret # configured for the model deployer @@ -573,19 +545,9 @@ def deploy_model( config.secret_name or self._create_or_update_kubernetes_secret() ) - - if service: - # update an equivalent service in place - service.update(config) - logger.info( - f"Updating an existing Seldon deployment service: {service}" - ) - else: - # create a new service - service = SeldonDeploymentService(config=config) - logger.info( - f"Creating a new Seldon deployment service: {service}" - ) + # create a new service + service = SeldonDeploymentService(uuid=id, config=config) + logger.info(f"Creating a new Seldon deployment service: {service}") # start the service which in turn provisions the Seldon Core # deployment server and waits for it to reach a ready state @@ -606,95 +568,16 @@ def deploy_model( return service - def find_model_server( - self, - running: bool = False, - service_uuid: Optional[UUID] = None, - pipeline_name: Optional[str] = None, - run_name: Optional[str] = None, - pipeline_step_name: Optional[str] = None, - model_name: Optional[str] = None, - model_uri: Optional[str] = None, - model_type: Optional[str] = None, - ) -> List[BaseService]: - """Find one or more Seldon Core model services that match the given criteria. - - The Seldon Core deployment services that meet the search criteria are - returned sorted in descending order of their creation time (i.e. more - recent deployments first). - - Args: - running: if true, only running services will be returned. - service_uuid: the UUID of the Seldon Core service that was - originally used to create the Seldon Core deployment resource. - pipeline_name: name of the pipeline that the deployed model was part - of. - run_name: Name of the pipeline run which the deployed model was - part of. - pipeline_step_name: the name of the pipeline model deployment step - that deployed the model. - model_name: the name of the deployed model. - model_uri: URI of the deployed model. - model_type: the Seldon Core server implementation used to serve - the model - - Returns: - One or more Seldon Core service objects representing Seldon Core - model servers that match the input search criteria. - """ - # Use a Seldon deployment service configuration to compute the labels - config = SeldonDeploymentConfig( - pipeline_name=pipeline_name or "", - run_name=run_name or "", - pipeline_run_id=run_name or "", - pipeline_step_name=pipeline_step_name or "", - model_name=model_name or "", - model_uri=model_uri or "", - implementation=model_type or "", - ) - labels = config.get_seldon_deployment_labels() - if service_uuid: - # the service UUID is not a label covered by the Seldon - # deployment service configuration, so we need to add it - # separately - labels["zenml.service_uuid"] = str(service_uuid) - - deployments = self.seldon_client.find_deployments(labels=labels) - # sort the deployments in descending order of their creation time - deployments.sort( - key=lambda deployment: datetime.strptime( - deployment.metadata.creationTimestamp, - "%Y-%m-%dT%H:%M:%SZ", - ) - if deployment.metadata.creationTimestamp - else datetime.min, - reverse=True, - ) - - services: List[BaseService] = [] - for deployment in deployments: - # recreate the Seldon deployment service object from the Seldon - # deployment resource - service = SeldonDeploymentService.create_from_deployment( - deployment=deployment - ) - if running and not service.is_running: - # skip non-running services - continue - services.append(service) - - return services - - def stop_model_server( + def perform_stop_model( self, - uuid: UUID, + service: BaseService, timeout: int = DEFAULT_SELDON_DEPLOYMENT_START_STOP_TIMEOUT, force: bool = False, - ) -> None: + ) -> BaseService: """Stop a Seldon Core model server. Args: - uuid: UUID of the model server to stop. + service: The service to stop. timeout: timeout in seconds to wait for the service to stop. force: if True, force the service to stop. @@ -707,15 +590,15 @@ def stop_model_server( "deleting the Seldon Core model server instead." ) - def start_model_server( + def perform_start_model( self, - uuid: UUID, + service: BaseService, timeout: int = DEFAULT_SELDON_DEPLOYMENT_START_STOP_TIMEOUT, - ) -> None: + ) -> BaseService: """Start a Seldon Core model deployment server. Args: - uuid: UUID of the model server to start. + service: The service to start. timeout: timeout in seconds to wait for the service to become active. . If set to 0, the method will return immediately after provisioning the service, without waiting for it to become @@ -729,28 +612,22 @@ def start_model_server( "Starting Seldon Core model servers is not implemented" ) - def delete_model_server( + def perform_delete_model( self, - uuid: UUID, + service: BaseService, timeout: int = DEFAULT_SELDON_DEPLOYMENT_START_STOP_TIMEOUT, force: bool = False, ) -> None: """Delete a Seldon Core model deployment server. Args: - uuid: UUID of the model server to delete. + service: The service to delete. timeout: timeout in seconds to wait for the service to stop. If set to 0, the method will return immediately after deprovisioning the service, without waiting for it to stop. force: if True, force the service to stop. """ - services = self.find_model_server(service_uuid=uuid) - if len(services) == 0: - return - - service = services[0] - - assert isinstance(service, SeldonDeploymentService) + service = cast(SeldonDeploymentService, service) service.stop(timeout=timeout, force=force) if service.config.secret_name: diff --git a/src/zenml/integrations/seldon/services/seldon_deployment.py b/src/zenml/integrations/seldon/services/seldon_deployment.py index 28c6a1c1822..5d3c56a1b04 100644 --- a/src/zenml/integrations/seldon/services/seldon_deployment.py +++ b/src/zenml/integrations/seldon/services/seldon_deployment.py @@ -86,8 +86,6 @@ def get_seldon_deployment_labels(self) -> Dict[str, str]: labels = {} if self.pipeline_name: labels["zenml.pipeline_name"] = self.pipeline_name - if self.run_name: - labels["zenml.run_name"] = self.run_name if self.pipeline_step_name: labels["zenml.pipeline_step_name"] = self.pipeline_step_name if self.model_name: @@ -174,6 +172,7 @@ class SeldonDeploymentService(BaseDeploymentService): type="model-serving", flavor="seldon", description="Seldon Core prediction service", + logo_url="https://public-flavor-logos.s3.eu-central-1.amazonaws.com/model_deployer/seldon.png", ) config: SeldonDeploymentConfig diff --git a/src/zenml/integrations/seldon/steps/seldon_deployer.py b/src/zenml/integrations/seldon/steps/seldon_deployer.py index e89944e9f11..0b527252e4b 100644 --- a/src/zenml/integrations/seldon/steps/seldon_deployer.py +++ b/src/zenml/integrations/seldon/steps/seldon_deployer.py @@ -73,13 +73,11 @@ def seldon_model_deployer_step( # get pipeline name, step name and run id context = get_step_context() pipeline_name = context.pipeline.name - run_name = context.pipeline_run.name step_name = context.step_run.name # update the step configuration with the real pipeline runtime information service_config = service_config.copy() service_config.pipeline_name = pipeline_name - service_config.run_name = run_name service_config.pipeline_step_name = step_name def prepare_service_config(model_uri: str) -> SeldonDeploymentConfig: @@ -143,9 +141,7 @@ def prepare_service_config(model_uri: str) -> SeldonDeploymentConfig: # fetch existing services with same pipeline name, step name and # model name existing_services = model_deployer.find_model_server( - pipeline_name=pipeline_name, - pipeline_step_name=step_name, - model_name=service_config.model_name, + config=service_config.dict() ) # even when the deploy decision is negative, if an existing model server @@ -173,7 +169,10 @@ def prepare_service_config(model_uri: str) -> SeldonDeploymentConfig: service = cast( SeldonDeploymentService, model_deployer.deploy_model( - service_config, replace=True, timeout=timeout + service_config, + replace=True, + timeout=timeout, + service_type=SeldonDeploymentService.SERVICE_TYPE, ), ) @@ -231,21 +230,17 @@ def seldon_custom_model_deployer_step( # get pipeline name, step name, run id context = get_step_context() pipeline_name = context.pipeline.name - run_name = context.pipeline_run.name step_name = context.step_run.name # update the step configuration with the real pipeline runtime information service_config.pipeline_name = pipeline_name - service_config.run_name = run_name service_config.pipeline_step_name = step_name service_config.is_custom_deployment = True # fetch existing services with the same pipeline name, step name and # model name existing_services = model_deployer.find_model_server( - pipeline_name=pipeline_name, - pipeline_step_name=step_name, - model_name=service_config.model_name, + config=service_config.dict() ) # even when the deploy decision is negative if an existing model server # is not running for this pipeline/step, we still have to serve the @@ -325,7 +320,10 @@ def seldon_custom_model_deployer_step( service = cast( SeldonDeploymentService, model_deployer.deploy_model( - service_config, replace=True, timeout=timeout + service_config, + replace=True, + timeout=timeout, + service_type=SeldonDeploymentService.SERVICE_TYPE, ), ) @@ -476,7 +474,10 @@ def seldon_mlflow_registry_deployer_step( service = cast( SeldonDeploymentService, model_deployer.deploy_model( - service_config, replace=True, timeout=timeout + service_config, + replace=True, + timeout=timeout, + service_type=SeldonDeploymentService.SERVICE_TYPE, ), ) diff --git a/src/zenml/integrations/tensorboard/services/tensorboard_service.py b/src/zenml/integrations/tensorboard/services/tensorboard_service.py index adda572fd42..b6c61a8d017 100644 --- a/src/zenml/integrations/tensorboard/services/tensorboard_service.py +++ b/src/zenml/integrations/tensorboard/services/tensorboard_service.py @@ -13,6 +13,7 @@ # permissions and limitations under the License. """Implementation of the TensorBoard service.""" +import uuid from typing import Any, Dict, Union from tensorboard import default, program # type: ignore [import-untyped] @@ -103,7 +104,7 @@ def __init__( ), ) attrs["endpoint"] = endpoint - super().__init__(config=config, **attrs) + super().__init__(config=config, uuid=uuid.uuid4(), **attrs) def run(self) -> None: """Initialize and run the TensorBoard server.""" diff --git a/src/zenml/integrations/tensorboard/visualizers/tensorboard_visualizer.py b/src/zenml/integrations/tensorboard/visualizers/tensorboard_visualizer.py index 5be0e01d878..bc9b0c20f00 100644 --- a/src/zenml/integrations/tensorboard/visualizers/tensorboard_visualizer.py +++ b/src/zenml/integrations/tensorboard/visualizers/tensorboard_visualizer.py @@ -113,6 +113,7 @@ def visualize( service = TensorboardService( TensorboardServiceConfig( logdir=logdir, + name=f"zenml-tensorboard-{logdir}", ) ) service.start(timeout=60) diff --git a/src/zenml/materializers/service_materializer.py b/src/zenml/materializers/service_materializer.py index 7659cabe0ca..a8294433ab0 100644 --- a/src/zenml/materializers/service_materializer.py +++ b/src/zenml/materializers/service_materializer.py @@ -14,13 +14,13 @@ """Implementation of a materializer to read and write ZenML service instances.""" import os +import uuid from typing import TYPE_CHECKING, Any, ClassVar, Dict, Tuple, Type from zenml.client import Client from zenml.enums import ArtifactType from zenml.materializers.base_materializer import BaseMaterializer -from zenml.services.service import BaseService -from zenml.services.service_registry import ServiceRegistry +from zenml.services.service import BaseDeploymentService, BaseService if TYPE_CHECKING: from zenml.metadata.metadata_types import MetadataType @@ -49,8 +49,11 @@ def load(self, data_type: Type[Any]) -> BaseService: artifact_store = Client().active_stack.artifact_store filepath = os.path.join(self.uri, SERVICE_CONFIG_FILENAME) with artifact_store.open(filepath, "r") as f: - service = ServiceRegistry().load_service_from_json(f.read()) - return service + service_id = f.read().strip() + + client = Client() + service = client.get_service(name_id_or_prefix=uuid.UUID(service_id)) + return BaseDeploymentService.from_model(service) def save(self, service: BaseService) -> None: """Writes a ZenML service. @@ -64,7 +67,7 @@ def save(self, service: BaseService) -> None: artifact_store = Client().active_stack.artifact_store filepath = os.path.join(self.uri, SERVICE_CONFIG_FILENAME) with artifact_store.open(filepath, "w") as f: - f.write(service.json(indent=4)) + f.write(str(service.uuid)) def extract_metadata( self, service: BaseService @@ -79,6 +82,6 @@ def extract_metadata( """ from zenml.metadata.metadata_types import Uri - if service.endpoint and service.endpoint.status.uri: - return {"uri": Uri(service.endpoint.status.uri)} + if prediction_url := service.get_prediction_url() or None: + return {"uri": Uri(prediction_url)} return {} diff --git a/src/zenml/model/utils.py b/src/zenml/model/utils.py index 824a4ef71e7..f947d182397 100644 --- a/src/zenml/model/utils.py +++ b/src/zenml/model/utils.py @@ -23,7 +23,10 @@ from zenml.logger import get_logger from zenml.metadata.metadata_types import MetadataType from zenml.model.model import Model -from zenml.models import ModelVersionArtifactRequest +from zenml.models import ( + ModelVersionArtifactRequest, + ServiceUpdate, +) from zenml.new.steps.step_context import get_step_context logger = get_logger(__name__) @@ -219,3 +222,49 @@ def link_artifact_to_model( artifact_version_id=artifact_version_id, model=model, ) + + +def link_service_to_model( + service_id: UUID, + model: Optional["Model"] = None, + model_version_id: Optional[UUID] = None, +) -> None: + """Links a service to a model. + + Args: + service_id: The ID of the service to link to the model. + model: The model to link the service to. + model_version_id: The ID of the model version to link the service to. + + Raises: + RuntimeError: If no model is provided and the model context cannot be + identified. + """ + client = Client() + + # If no model is provided, try to get it from the context + if not model and not model_version_id: + is_issue = False + try: + step_context = get_step_context() + model = step_context.model + except StepContextError: + is_issue = True + + if model is None or is_issue: + raise RuntimeError( + "`link_service_to_model` called without `model` parameter " + "and configured model context cannot be identified. Consider " + "passing the `model` explicitly or configuring it in " + "@step or @pipeline decorator." + ) + + model_version_id = ( + model_version_id or model._get_or_create_model_version().id + if model + else None + ) + update_service = ServiceUpdate(model_version_id=model_version_id) + client.zen_store.update_service( + service_id=service_id, update=update_service + ) diff --git a/src/zenml/model_deployers/base_model_deployer.py b/src/zenml/model_deployers/base_model_deployer.py index ccc3831b850..747c61fd674 100644 --- a/src/zenml/model_deployers/base_model_deployer.py +++ b/src/zenml/model_deployers/base_model_deployer.py @@ -13,9 +13,10 @@ # permissions and limitations under the License. """Base class for all ZenML model deployers.""" +import contextlib from abc import ABC, abstractmethod from typing import ( - TYPE_CHECKING, + Any, ClassVar, Dict, Generator, @@ -27,19 +28,16 @@ from uuid import UUID from zenml.client import Client -from zenml.constants import METADATA_DEPLOYED_MODEL_URL from zenml.enums import StackComponentType -from zenml.metadata.metadata_types import Uri +from zenml.logger import get_logger from zenml.services import BaseService, ServiceConfig from zenml.services.service import BaseDeploymentService +from zenml.services.service_type import ServiceType from zenml.stack import StackComponent from zenml.stack.flavor import Flavor from zenml.stack.stack_component import StackComponentConfig -if TYPE_CHECKING: - from zenml.config.step_run_info import StepRunInfo - from zenml.metadata.metadata_types import MetadataType - +logger = get_logger(__name__) DEFAULT_DEPLOYMENT_START_STOP_TIMEOUT = 300 @@ -125,11 +123,118 @@ def get_active_model_deployer(cls) -> "BaseModelDeployer": return model_deployer - @abstractmethod def deploy_model( self, config: ServiceConfig, + service_type: ServiceType, replace: bool = False, + continuous_deployment_mode: bool = False, + timeout: int = DEFAULT_DEPLOYMENT_START_STOP_TIMEOUT, + ) -> BaseService: + """Deploy a model. + + the deploy_model method is the main entry point for deploying models + using the model deployer. It is used to deploy a model to a model server + instance that is running on a remote serving platform or service. The + method is responsible for detecting if there is an existing model server + instance running serving one or more previous versions of the same model + and deploying the model to the serving platform or updating the existing + model server instance to include the new model version. The method + returns a Service object that is a representation of the external model + server instance. The Service object must implement basic operational + state tracking and lifecycle management operations for the model server + (e.g. start, stop, etc.). + + Args: + config: Custom Service configuration parameters for the model + deployer. Can include the pipeline name, the run id, the step + name, the model name, the model uri, the model type etc. + replace: If True, it will replace any existing model server instances + that serve the same model. If False, it does not replace any + existing model server instance. + continuous_deployment_mode: If True, it will replace any existing + model server instances that serve the same model, regardless of + the configuration. If False, it will only replace existing model + server instances that serve the same model if the configuration + is exactly the same. + timeout: The maximum time in seconds to wait for the model server + to start serving the model. + service_type: The type of the service to deploy. If not provided, + the default service type of the model deployer will be used. + + Raises: + RuntimeError: if the model deployment fails. + + Returns: + The deployment Service object. + """ + # Instantiate the client + client = Client() + if not continuous_deployment_mode: + # Find existing model server + services = self.find_model_server( + config=config.dict(), + service_type=service_type, + ) + if len(services) > 0: + logger.info( + f"Existing model server found for {config.name or config.model_name} with the exact same configuration. Returning the existing service named {services[0].config.service_name}." + ) + return services[0] + else: + # Find existing model server + services = self.find_model_server( + pipeline_name=config.pipeline_name, + pipeline_step_name=config.pipeline_step_name, + model_name=config.model_name, + service_type=service_type, + ) + if len(services) > 0: + logger.info( + f"Existing model server found for {config.pipeline_name} and {config.pipeline_step_name}, since continuous deployment mode is enabled, replacing the existing service named {services[0].config.service_name}." + ) + service = services[0] + self.delete_model_server(service.uuid) + logger.info( + f"Deploying model server for {config.model_name} with the following configuration: {config.dict()}" + ) + service_response = client.create_service( + config=config, + service_type=service_type, + model_version_id=get_model_version_id_if_exists( + config.model_name, config.model_version + ), + ) + try: + service = self.perform_deploy_model( + id=service_response.id, + config=config, + timeout=timeout, + ) + except Exception as e: + client.delete_service(service_response.id) + raise RuntimeError( + f"Failed to deploy model server for {config.model_name}: {e}" + ) from e + # Update the service in store + client.update_service( + id=service.uuid, + name=service.config.service_name, + service_source=service.dict().get("type"), + admin_state=service.admin_state, + status=service.status.dict(), + endpoint=service.endpoint.dict() if service.endpoint else None, + # labels=service.config.get_service_labels() # TODO: fix labels in services and config + prediction_url=service.get_prediction_url(), + health_check_url=service.get_healthcheck_url(), + ) + return service + + @abstractmethod + def perform_deploy_model( + self, + id: UUID, + config: ServiceConfig, timeout: int = DEFAULT_DEPLOYMENT_START_STOP_TIMEOUT, ) -> BaseService: """Abstract method to deploy a model. @@ -146,12 +251,10 @@ def deploy_model( start, stop, etc.) Args: + id: UUID of the service that was originally used to deploy the model. config: Custom Service configuration parameters for the model deployer. Can include the pipeline name, the run id, the step name, the model name, the model uri, the model type etc. - replace: If True, it will replace any existing model server instances - that serve the same model. If False, it does not replace any - existing model server instance. timeout: The maximum time in seconds to wait for the model server to start serving the model. @@ -173,17 +276,20 @@ def get_model_server_info( A dictionary containing the relevant model server properties. """ - @abstractmethod def find_model_server( self, - running: bool = False, + config: Optional[Dict[str, Any]] = None, + running: Optional[bool] = None, service_uuid: Optional[UUID] = None, pipeline_name: Optional[str] = None, - run_name: Optional[str] = None, pipeline_step_name: Optional[str] = None, + service_name: Optional[str] = None, model_name: Optional[str] = None, - model_uri: Optional[str] = None, - model_type: Optional[str] = None, + model_version: Optional[str] = None, + service_type: Optional[ServiceType] = None, + type: Optional[str] = None, + flavor: Optional[str] = None, + pipeline_run_id: Optional[str] = None, ) -> List[BaseService]: """Abstract method to find one or more a model servers that match the given criteria. @@ -191,23 +297,91 @@ def find_model_server( running: If true, only running services will be returned. service_uuid: The UUID of the service that was originally used to deploy the model. - pipeline_name: name of the pipeline that the deployed model was part - of. - run_name: Name of the pipeline run which the deployed model was - part of. - pipeline_step_name: the name of the pipeline model deployment step - that deployed the model. - model_name: the name of the deployed model. - model_uri: URI of the deployed model. - model_type: the implementation specific type/format of the deployed - model. + pipeline_step_name: The name of the pipeline step that was originally used + to deploy the model. + pipeline_name: The name of the pipeline that was originally used to deploy + the model from the model registry. + model_name: The name of the model that was originally used to deploy + the model from the model registry. + model_version: The version of the model that was originally used to + deploy the model from the model registry. + service_type: The type of the service to find. + type: The type of the service to find. + flavor: The flavor of the service to find. + pipeline_run_id: The UUID of the pipeline run that was originally used + to deploy the model. + config: Custom Service configuration parameters for the model + deployer. Can include the pipeline name, the run id, the step + name, the model name, the model uri, the model type etc. + service_name: The name of the service to find. Returns: One or more Service objects representing model servers that match the input search criteria. """ + client = Client() + service_responses = client.list_services( + sort_by="desc:created", + id=service_uuid, + running=running, + service_name=service_name, + pipeline_name=pipeline_name, + pipeline_step_name=pipeline_step_name, + model_version_id=get_model_version_id_if_exists( + model_name, model_version + ), + pipeline_run_id=pipeline_run_id, + config=config, + type=type or service_type.type if service_type else None, + flavor=flavor or service_type.flavor if service_type else None, + hydrate=True, + ) + services = [] + for service_response in service_responses.items: + if not service_response.service_source: + client.delete_service(service_response.id) + continue + service = BaseDeploymentService.from_model(service_response) + service.update_status() + if service.status.dict() != service_response.status: + client.update_service( + id=service.uuid, + admin_state=service.admin_state, + status=service.status.dict(), + endpoint=service.endpoint.dict() + if service.endpoint + else None, + ) + if running and not service.is_running: + logger.warning( + f"Service {service.uuid} is in an unexpected state. " + f"Expected running={running}, but found running={service.is_running}." + ) + continue + services.append(service) + return services @abstractmethod + def perform_stop_model( + self, + service: BaseService, + timeout: int = DEFAULT_DEPLOYMENT_START_STOP_TIMEOUT, + force: bool = False, + ) -> BaseService: + """Abstract method to stop a model server. + + This operation should be reversible. A stopped model server should still + show up in the list of model servers returned by `find_model_server` and + it should be possible to start it again by calling `start_model_server`. + + Args: + service: The service to stop. + timeout: timeout in seconds to wait for the service to stop. If + set to 0, the method will return immediately after + deprovisioning the service, without waiting for it to stop. + force: if True, force the service to stop. + """ + def stop_model_server( self, uuid: UUID, @@ -226,9 +400,43 @@ def stop_model_server( set to 0, the method will return immediately after deprovisioning the service, without waiting for it to stop. force: if True, force the service to stop. + + Raises: + RuntimeError: if the model server is not found. """ + client = Client() + try: + service = self.find_model_server(service_uuid=uuid)[0] + updated_service = self.perform_stop_model(service, timeout, force) + client.update_service( + id=updated_service.uuid, + admin_state=updated_service.admin_state, + status=updated_service.status.dict(), + endpoint=updated_service.endpoint.dict() + if updated_service.endpoint + else None, + ) + except Exception as e: + raise RuntimeError( + f"Failed to stop model server with UUID {uuid}: {e}" + ) from e @abstractmethod + def perform_start_model( + self, + service: BaseService, + timeout: int = DEFAULT_DEPLOYMENT_START_STOP_TIMEOUT, + ) -> BaseService: + """Abstract method to start a model server. + + Args: + service: The service to start. + timeout: timeout in seconds to wait for the service to start. If + set to 0, the method will return immediately after + provisioning the service, without waiting for it to become + active. + """ + def start_model_server( self, uuid: UUID, @@ -242,9 +450,47 @@ def start_model_server( set to 0, the method will return immediately after provisioning the service, without waiting for it to become active. + + Raises: + RuntimeError: if the model server is not found. """ + client = Client() + try: + service = self.find_model_server(service_uuid=uuid)[0] + updated_service = self.perform_start_model(service, timeout) + client.update_service( + id=updated_service.uuid, + admin_state=updated_service.admin_state, + status=updated_service.status.dict(), + endpoint=updated_service.endpoint.dict() + if updated_service.endpoint + else None, + ) + except Exception as e: + raise RuntimeError( + f"Failed to start model server with UUID {uuid}: {e}" + ) from e @abstractmethod + def perform_delete_model( + self, + service: BaseService, + timeout: int = DEFAULT_DEPLOYMENT_START_STOP_TIMEOUT, + force: bool = False, + ) -> None: + """Abstract method to delete a model server. + + This operation is irreversible. A deleted model server must no longer + show up in the list of model servers returned by `find_model_server`. + + Args: + service: The service to delete. + timeout: timeout in seconds to wait for the service to stop. If + set to 0, the method will return immediately after + deprovisioning the service, without waiting for it to stop. + force: if True, force the service to stop. + """ + def delete_model_server( self, uuid: UUID, @@ -262,7 +508,19 @@ def delete_model_server( set to 0, the method will return immediately after deprovisioning the service, without waiting for it to stop. force: if True, force the service to stop. + + Raises: + RuntimeError: if the model server is not found. """ + client = Client() + try: + service = self.find_model_server(service_uuid=uuid)[0] + self.perform_delete_model(service, timeout, force) + client.delete_service(uuid) + except Exception as e: + raise RuntimeError( + f"Failed to delete model server with UUID {uuid}: {e}" + ) from e def get_model_server_logs( self, @@ -288,32 +546,21 @@ def get_model_server_logs( raise RuntimeError(f"No model server found with UUID {uuid}") return services[0].get_logs(follow=follow, tail=tail) - def get_step_run_metadata( - self, info: "StepRunInfo" - ) -> Dict[str, "MetadataType"]: - """Get component- and step-specific metadata after a step ran. - - For model deployers, this extracts the prediction URL of the deployed - model. + def load_service( + self, + service_id: UUID, + ) -> BaseService: + """Load a service from a URI. Args: - info: Info about the step that was executed. + service_id: The ID of the service to load. Returns: - A dictionary of metadata. + The loaded service. """ - existing_services = self.find_model_server( - run_name=info.run_name, - ) - if existing_services: - existing_service = existing_services[0] - if ( - isinstance(existing_service, BaseDeploymentService) - and existing_service.is_running - ): - deployed_model_url = existing_service.prediction_url - return {METADATA_DEPLOYED_MODEL_URL: Uri(deployed_model_url)} - return {} + client = Client() + service = client.get_service(service_id) + return BaseDeploymentService.from_model(service) class BaseModelDeployerFlavor(Flavor): @@ -341,3 +588,26 @@ def config_class(self) -> Type[BaseModelDeployerConfig]: @abstractmethod def implementation_class(self) -> Type[BaseModelDeployer]: """The class that implements the model deployer.""" + + +def get_model_version_id_if_exists( + model_name: Optional[str], + model_version: Optional[str], +) -> Optional[UUID]: + """Get the model version id if it exists. + + Args: + model_name: The name of the model. + model_version: The version of the model. + + Returns: + The model version id if it exists. + """ + client = Client() + if model_name: + with contextlib.suppress(KeyError): + return client.get_model_version( + model_name_or_id=model_name, + model_version_name_or_number_or_id=model_version, + ).id + return None diff --git a/src/zenml/models/__init__.py b/src/zenml/models/__init__.py index db5ff386a69..9e9480f5797 100644 --- a/src/zenml/models/__init__.py +++ b/src/zenml/models/__init__.py @@ -89,6 +89,15 @@ ArtifactVisualizationResponseBody, ArtifactVisualizationResponseMetadata, ) +from zenml.models.v2.core.service import ( + ServiceResponse, + ServiceResponseBody, + ServiceResponseMetadata, + ServiceUpdate, + ServiceFilter, + ServiceRequest, + ServiceResponseResources, +) from zenml.models.v2.core.code_reference import ( CodeReferenceRequest, CodeReferenceResponse, @@ -157,6 +166,7 @@ ModelVersionResponseMetadata, ModelVersionFilter, ModelVersionUpdate, + ModelVersionResponseResources, ) from zenml.models.v2.core.model_version_artifact import ( ModelVersionArtifactFilter, @@ -402,6 +412,15 @@ FlavorResponseMetadata.update_forward_refs( WorkspaceResponse=WorkspaceResponse, ) +ServiceResponseBody.update_forward_refs( + UserResponse=UserResponse, +) +ServiceResponseMetadata.update_forward_refs( + WorkspaceResponse=WorkspaceResponse, +) +ServiceResponseResources.update_forward_refs( + ModelVersionResponse=ModelVersionResponse, +) ModelResponseBody.update_forward_refs( UserResponse=UserResponse, TagResponse=TagResponse, @@ -418,6 +437,9 @@ WorkspaceResponse=WorkspaceResponse, RunMetadataResponse=RunMetadataResponse, ) +ModelVersionResponseResources.update_forward_refs( + ServiceResponse=ServiceResponse, +) ModelVersionArtifactResponseBody.update_forward_refs( ArtifactVersionResponse=ArtifactVersionResponse, ) @@ -639,6 +661,7 @@ "ModelVersionResponse", "ModelVersionResponseBody", "ModelVersionResponseMetadata", + "ModelVersionResponseResources", "ModelVersionUpdate", "ModelVersionArtifactFilter", "ModelVersionArtifactRequest", @@ -765,6 +788,13 @@ "WorkspaceResponse", "WorkspaceResponseBody", "WorkspaceResponseMetadata", + "ServiceResponse", + "ServiceResponseBody", + "ServiceResponseMetadata", + "ServiceUpdate", + "ServiceFilter", + "ServiceRequest", + "ServiceResponseResources", # V2 Misc "AuthenticationMethodModel", "ServiceConnectorResourcesModel", diff --git a/src/zenml/models/v2/core/model_version.py b/src/zenml/models/v2/core/model_version.py index 4e6dc97c489..04d8da143de 100644 --- a/src/zenml/models/v2/core/model_version.py +++ b/src/zenml/models/v2/core/model_version.py @@ -21,6 +21,7 @@ from zenml.constants import STR_FIELD_MAX_LENGTH, TEXT_FIELD_MAX_LENGTH from zenml.enums import ModelStages from zenml.models.v2.base.filter import AnyQuery +from zenml.models.v2.base.page import Page from zenml.models.v2.base.scoped import ( WorkspaceScopedRequest, WorkspaceScopedResponse, @@ -29,6 +30,7 @@ WorkspaceScopedResponseResources, WorkspaceScopedTaggableFilter, ) +from zenml.models.v2.core.service import ServiceResponse from zenml.models.v2.core.tag import TagResponse if TYPE_CHECKING: @@ -176,6 +178,10 @@ class ModelVersionResponseMetadata(WorkspaceScopedResponseMetadata): class ModelVersionResponseResources(WorkspaceScopedResponseResources): """Class for all resource models associated with the model version entity.""" + services: Page[ServiceResponse] = Field( + description="Services linked to the model version", + ) + class ModelVersionResponse( WorkspaceScopedResponse[ diff --git a/src/zenml/models/v2/core/service.py b/src/zenml/models/v2/core/service.py new file mode 100644 index 00000000000..b1bbc2c8210 --- /dev/null +++ b/src/zenml/models/v2/core/service.py @@ -0,0 +1,479 @@ +# Copyright (c) ZenML GmbH 2024. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at: +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express +# or implied. See the License for the specific language governing +# permissions and limitations under the License. +"""Models representing Services.""" + +from datetime import datetime +from typing import ( + TYPE_CHECKING, + Any, + ClassVar, + Dict, + List, + Optional, + Type, + Union, +) +from uuid import UUID + +from pydantic import BaseModel, Field +from sqlalchemy.sql.elements import BinaryExpression, BooleanClauseList +from sqlmodel import SQLModel + +from zenml.constants import STR_FIELD_MAX_LENGTH +from zenml.models.v2.base.scoped import ( + WorkspaceScopedFilter, + WorkspaceScopedRequest, + WorkspaceScopedResponse, + WorkspaceScopedResponseBody, + WorkspaceScopedResponseMetadata, + WorkspaceScopedResponseResources, + WorkspaceScopedTaggableFilter, +) +from zenml.services.service_status import ServiceState +from zenml.services.service_type import ServiceType + +if TYPE_CHECKING: + pass + +# ------------------ Request Model ------------------ + + +class ServiceRequest(WorkspaceScopedRequest): + """Request model for services.""" + + name: str = Field( + title="The name of the service.", + max_length=STR_FIELD_MAX_LENGTH, + ) + + service_type: ServiceType = Field( + title="The type of the service.", + ) + + service_source: Optional[str] = Field( + title="The class of the service.", + description="The fully qualified class name of the service implementation.", + ) + + admin_state: Optional[ServiceState] = Field( + title="The admin state of the service.", + description="The administrative state of the service, e.g., ACTIVE, INACTIVE.", + ) + + config: Dict[str, Any] = Field( + title="The service config.", + description="A dictionary containing configuration parameters for the service.", + ) + + labels: Optional[Dict[str, str]] = Field( + default=None, + title="The service labels.", + ) + + status: Optional[Dict[str, Any]] = Field( + title="The status of the service.", + ) + + endpoint: Optional[Dict[str, Any]] = Field( + default=None, + title="The service endpoint.", + ) + + prediction_url: Optional[str] = Field( + default=None, + title="The service endpoint URL.", + ) + + health_check_url: Optional[str] = Field( + default=None, + title="The service health check URL.", + ) + + model_version_id: Optional[UUID] = Field( + default=None, + title="The model version id linked to the service.", + ) + pipeline_run_id: Optional[Union[UUID, str]] = Field( + default=None, + description="By the event source this trigger is attached to.", + ) + + +# ------------------ Update Model ------------------ + + +class ServiceUpdate(BaseModel): + """Update model for stack components.""" + + name: Optional[str] = Field( + title="The name of the service.", + max_length=STR_FIELD_MAX_LENGTH, + ) + + admin_state: Optional[ServiceState] = Field( + title="The admin state of the service.", + description="The administrative state of the service, e.g., ACTIVE, INACTIVE.", + ) + + service_source: Optional[str] = Field( + title="The class of the service.", + description="The fully qualified class name of the service implementation.", + ) + + status: Optional[Dict[str, Any]] = Field( + title="The status of the service.", + ) + + endpoint: Optional[Dict[str, Any]] = Field( + title="The service endpoint.", + ) + + prediction_url: Optional[str] = Field( + title="The service endpoint URL.", + ) + + health_check_url: Optional[str] = Field( + title="The service health check URL.", + ) + + labels: Optional[Dict[str, str]] = Field( + default=None, + title="The service labels.", + ) + + model_version_id: Optional[UUID] = Field( + default=None, + title="The model version id linked to the service.", + ) + + +# ------------------ Response Model ------------------ + + +class ServiceResponseBody(WorkspaceScopedResponseBody): + """Response body for services.""" + + service_type: ServiceType = Field( + title="The type of the service.", + ) + labels: Optional[Dict[str, str]] = Field( + default=None, + title="The service labels.", + ) + created: datetime = Field( + title="The timestamp when this component was created." + ) + updated: datetime = Field( + title="The timestamp when this component was last updated.", + ) + state: Optional[ServiceState] = Field( + default=None, + title="The current state of the service.", + ) + + +class ServiceResponseMetadata(WorkspaceScopedResponseMetadata): + """Response metadata for services.""" + + service_source: Optional[str] = Field( + title="The class of the service.", + ) + admin_state: Optional[ServiceState] = Field( + title="The admin state of the service.", + ) + config: Dict[str, Any] = Field( + title="The service config.", + ) + status: Optional[Dict[str, Any]] = Field( + title="The status of the service.", + ) + endpoint: Optional[Dict[str, Any]] = Field( + default=None, + title="The service endpoint.", + ) + prediction_url: Optional[str] = Field( + default=None, + title="The service endpoint URL.", + ) + health_check_url: Optional[str] = Field( + default=None, + title="The service health check URL.", + ) + + +class ServiceResponseResources(WorkspaceScopedResponseResources): + """Class for all resource models associated with the service entity.""" + + +class ServiceResponse( + WorkspaceScopedResponse[ + ServiceResponseBody, ServiceResponseMetadata, ServiceResponseResources + ] +): + """Response model for services.""" + + name: str = Field( + title="The name of the service.", + max_length=STR_FIELD_MAX_LENGTH, + ) + + def get_hydrated_version(self) -> "ServiceResponse": + """Get the hydrated version of this artifact. + + Returns: + an instance of the same entity with the metadata field attached. + """ + from zenml.client import Client + + return Client().zen_store.get_service(self.id) + + # Body and metadata properties + + @property + def service_type(self) -> ServiceType: + """The `service_type` property. + + Returns: + the value of the property. + """ + return self.get_body().service_type + + @property + def labels(self) -> Optional[Dict[str, str]]: + """The `labels` property. + + Returns: + the value of the property. + """ + return self.get_body().labels + + @property + def service_source(self) -> Optional[str]: + """The `service_source` property. + + Returns: + the value of the property. + """ + return self.get_metadata().service_source + + @property + def config(self) -> Dict[str, Any]: + """The `config` property. + + Returns: + the value of the property. + """ + return self.get_metadata().config + + @property + def status(self) -> Optional[Dict[str, Any]]: + """The `status` property. + + Returns: + the value of the property. + """ + return self.get_metadata().status + + @property + def endpoint(self) -> Optional[Dict[str, Any]]: + """The `endpoint` property. + + Returns: + the value of the property. + """ + return self.get_metadata().endpoint + + @property + def created(self) -> datetime: + """The `created` property. + + Returns: + the value of the property. + """ + return self.get_body().created + + @property + def updated(self) -> datetime: + """The `updated` property. + + Returns: + the value of the property. + """ + return self.get_body().updated + + @property + def admin_state(self) -> Optional[ServiceState]: + """The `admin_state` property. + + Returns: + the value of the property. + """ + return self.get_metadata().admin_state + + @property + def prediction_url(self) -> Optional[str]: + """The `prediction_url` property. + + Returns: + the value of the property. + """ + return self.get_metadata().prediction_url + + @property + def health_check_url(self) -> Optional[str]: + """The `health_check_url` property. + + Returns: + the value of the property. + """ + return self.get_metadata().health_check_url + + @property + def state(self) -> Optional[ServiceState]: + """The `state` property. + + Returns: + the value of the property. + """ + return self.get_body().state + + +# ------------------ Filter Model ------------------ + + +class ServiceFilter(WorkspaceScopedFilter): + """Model to enable advanced filtering of services. + + The Service needs additional scoping. As such the `_scope_user` field + can be set to the user that is doing the filtering. The + `generate_filter()` method of the baseclass is overwritten to include the + scoping. + """ + + name: Optional[str] = Field( + description="Name of the service. Use this to filter services by their name.", + ) + workspace_id: Optional[Union[UUID, str]] = Field( + default=None, description="Workspace of the service" + ) + user_id: Optional[Union[UUID, str]] = Field( + default=None, description="User of the service" + ) + type: Optional[str] = Field( + default=None, + description="Type of the service. Filter services by their type.", + ) + flavor: Optional[str] = Field( + default=None, + description="Flavor of the service. Use this to filter services by their flavor.", + ) + config: Optional[bytes] = Field( + default=None, + description="Config of the service. Use this to filter services by their config.", + ) + pipeline_name: Optional[str] = Field( + default=None, + description="Pipeline name responsible for deploying the service", + ) + pipeline_step_name: Optional[str] = Field( + default=None, + description="Pipeline step name responsible for deploying the service", + ) + running: Optional[bool] = Field( + default=None, description="Whether the service is running" + ) + model_version_id: Optional[Union[UUID, str]] = Field( + default=None, + description="By the model version this service is attached to.", + ) + pipeline_run_id: Optional[Union[UUID, str]] = Field( + default=None, + description="By the pipeline run this service is attached to.", + ) + + def set_type(self, type: str) -> None: + """Set the type of the service. + + Args: + type: The type of the service. + """ + self.type = type + + def set_flavor(self, flavor: str) -> None: + """Set the flavor of the service. + + Args: + flavor: The flavor of the service. + """ + self.flavor = flavor + + # Artifact name and type are not DB fields and need to be handled separately + FILTER_EXCLUDE_FIELDS = [ + *WorkspaceScopedFilter.FILTER_EXCLUDE_FIELDS, + "flavor", + "type", + "pipeline_step_name", + "running", + "pipeline_name", + "config", + ] + CLI_EXCLUDE_FIELDS: ClassVar[List[str]] = [ + *WorkspaceScopedTaggableFilter.CLI_EXCLUDE_FIELDS, + "workspace_id", + "user_id", + "flavor", + "type", + "pipeline_step_name", + "running", + "pipeline_name", + ] + + def generate_filter( + self, table: Type["SQLModel"] + ) -> Union["BinaryExpression[Any]", "BooleanClauseList[Any]"]: + """Generate the filter for the query. + + Services can be scoped by type to narrow the search. + + Args: + table: The Table that is being queried from. + + Returns: + The filter expression for the query. + """ + from sqlalchemy import and_ + + base_filter = super().generate_filter(table) + + if self.type: + type_filter = getattr(table, "type") == self.type + base_filter = and_(base_filter, type_filter) + + if self.flavor: + flavor_filter = getattr(table, "flavor") == self.flavor + base_filter = and_(base_filter, flavor_filter) + + if self.pipeline_name: + pipeline_name_filter = ( + getattr(table, "pipeline_name") == self.pipeline_name + ) + base_filter = and_(base_filter, pipeline_name_filter) + + if self.pipeline_step_name: + pipeline_step_name_filter = ( + getattr(table, "pipeline_step_name") == self.pipeline_step_name + ) + base_filter = and_(base_filter, pipeline_step_name_filter) + + return base_filter diff --git a/src/zenml/orchestrators/step_runner.py b/src/zenml/orchestrators/step_runner.py index 4ab21d96767..b47d0c8aa37 100644 --- a/src/zenml/orchestrators/step_runner.py +++ b/src/zenml/orchestrators/step_runner.py @@ -44,7 +44,9 @@ from zenml.logger import get_logger from zenml.logging.step_logging import StepLogsStorageContext, redirected from zenml.materializers.base_materializer import BaseMaterializer -from zenml.model.utils import link_step_artifacts_to_model +from zenml.model.utils import ( + link_step_artifacts_to_model, +) from zenml.new.steps.step_context import StepContext, get_step_context from zenml.orchestrators.publish_utils import ( publish_step_run_metadata, diff --git a/src/zenml/services/__init__.py b/src/zenml/services/__init__.py index 55ef932dc48..95646020d5e 100644 --- a/src/zenml/services/__init__.py +++ b/src/zenml/services/__init__.py @@ -51,7 +51,6 @@ TCPEndpointHealthMonitor, TCPEndpointHealthMonitorConfig, ) -from zenml.services.service_registry import ServiceRegistry from zenml.services.service_status import ServiceState, ServiceStatus from zenml.services.service_type import ServiceType @@ -84,5 +83,4 @@ "LocalDaemonServiceEndpointConfig", "LocalDaemonServiceEndpointStatus", "LocalDaemonServiceEndpoint", - "ServiceRegistry", ] diff --git a/src/zenml/services/container/entrypoint.py b/src/zenml/services/container/entrypoint.py index 2f0956a192e..b7476bc19b9 100644 --- a/src/zenml/services/container/entrypoint.py +++ b/src/zenml/services/container/entrypoint.py @@ -19,6 +19,7 @@ import os import sys +from typing import cast import click @@ -50,7 +51,7 @@ def launch_service(service_config_file: str) -> None: # with messages before daemonization is complete from zenml.integrations.registry import integration_registry from zenml.logger import get_logger - from zenml.services import ContainerService, ServiceRegistry + from zenml.services import ContainerService logger = get_logger(__name__) @@ -63,7 +64,7 @@ def launch_service(service_config_file: str) -> None: logger.debug( "Running containerized service with configuration:\n %s", config ) - service = ServiceRegistry().load_service_from_json(config) + service = cast("ContainerService", ContainerService.from_json(config)) if not isinstance(service, ContainerService): raise TypeError( f"Expected service type ContainerService but got " diff --git a/src/zenml/services/local/local_daemon_entrypoint.py b/src/zenml/services/local/local_daemon_entrypoint.py index 3d2cf42f8a3..33d03685cd9 100644 --- a/src/zenml/services/local/local_daemon_entrypoint.py +++ b/src/zenml/services/local/local_daemon_entrypoint.py @@ -18,6 +18,7 @@ """ import os +from typing import cast import click @@ -68,7 +69,7 @@ def launch_service(service_config_file: str) -> None: # with messages before daemonization is complete from zenml.integrations.registry import integration_registry from zenml.logger import get_logger - from zenml.services import LocalDaemonService, ServiceRegistry + from zenml.services import LocalDaemonService logger = get_logger(__name__) @@ -81,7 +82,9 @@ def launch_service(service_config_file: str) -> None: integration_registry.activate_integrations() logger.debug("Running service daemon with configuration:\n %s", config) - service = ServiceRegistry().load_service_from_json(config) + service = cast( + "LocalDaemonService", LocalDaemonService.from_json(config) + ) if not isinstance(service, LocalDaemonService): raise TypeError( f"Expected service type LocalDaemonService but got " diff --git a/src/zenml/services/service.py b/src/zenml/services/service.py index ba2664be586..446cee28709 100644 --- a/src/zenml/services/service.py +++ b/src/zenml/services/service.py @@ -13,10 +13,12 @@ # permissions and limitations under the License. """Implementation of the ZenML Service class.""" +import json import time from abc import abstractmethod from functools import wraps from typing import ( + TYPE_CHECKING, Any, Callable, ClassVar, @@ -26,24 +28,27 @@ Tuple, Type, TypeVar, - cast, ) -from uuid import UUID, uuid4 - -from pydantic import Field +from uuid import UUID from zenml.console import console from zenml.logger import get_logger from zenml.services.service_endpoint import BaseServiceEndpoint -from zenml.services.service_registry import ServiceRegistry +from zenml.services.service_monitor import HTTPEndpointHealthMonitor from zenml.services.service_status import ServiceState, ServiceStatus from zenml.services.service_type import ServiceType -from zenml.utils.typed_model import BaseTypedModel, BaseTypedModelMeta +from zenml.utils import source_utils +from zenml.utils.typed_model import BaseTypedModel logger = get_logger(__name__) T = TypeVar("T", bound=Callable[..., Any]) +if TYPE_CHECKING: + from zenml.models.v2.core.service import ServiceResponse + +ZENM_ENDPOINT_PREFIX = "zenml-" + def update_service_status( pre_status: Optional[ServiceState] = None, @@ -108,107 +113,42 @@ class ServiceConfig(BaseTypedModel): description: str = "" pipeline_name: str = "" pipeline_step_name: str = "" - run_name: str = "" + model_name: str = "" + model_version: str = "" + service_name: str = "" - -class BaseServiceMeta(BaseTypedModelMeta): - """Metaclass responsible for registering different BaseService subclasses. - - This metaclass has two main responsibilities: - 1. register all BaseService types in the service registry. This is relevant - when services are deserialized and instantiated from their JSON or dict - representation, because their type needs to be known beforehand. - 2. ensuring BaseService instance uniqueness by enforcing that no two - service instances have the same UUID value. Implementing this at the - constructor level guarantees that deserializing a service instance from - a JSON representation multiple times always returns the same service object. - """ - - def __new__( - mcs, name: str, bases: Tuple[Type[Any], ...], dct: Dict[str, Any] - ) -> "BaseServiceMeta": - """Creates a BaseService class and registers it in the `ServiceRegistry`. + def __init__(self, **data: Any): + """Initialize the service configuration. Args: - name: name of the class. - bases: tuple of base classes. - dct: dictionary of class attributes. - - Returns: - the created BaseServiceMeta class. + **data: keyword arguments. Raises: - TypeError: if the 'service_type' reserved attribute name is used. + ValueError: if neither 'name' nor 'model_name' is set. """ - service_type = dct.get("SERVICE_TYPE", None) - - # register only classes of concrete service implementations - if service_type: - # add the service type class attribute to the class as a regular - # immutable attribute to include it in the JSON representation - if "service_type" in dct: - raise TypeError( - "`service_type` is a reserved attribute name for BaseService " - "subclasses" - ) - dct.setdefault("__annotations__", dict())["service_type"] = ( - ServiceType + super().__init__(**data) + if self.name or self.model_name: + self.service_name = data.get( + "service_name", + f"{ZENM_ENDPOINT_PREFIX}{self.name or self.model_name}", ) - dct["service_type"] = Field(service_type, allow_mutation=False) - - cls = cast(Type["BaseService"], super().__new__(mcs, name, bases, dct)) - - # register only classes of concrete service implementations - if service_type: - # register the service type in the service registry - ServiceRegistry().register_service_type(cls) - return cls - - def __call__(cls, *args: Any, **kwargs: Any) -> "BaseServiceMeta": - """Validate the creation of a service. + else: + raise ValueError("Either 'name' or 'model_name' must be set.") - Args: - *args: positional arguments. - **kwargs: keyword arguments. + def get_service_labels(self) -> Dict[str, str]: + """Get the service labels. Returns: - the created BaseServiceMeta class. - - Raises: - AttributeError: if the service UUID is untyped. - ValueError: if the service UUID is not a UUID type. + a dictionary of service labels. """ - if not getattr(cls, "SERVICE_TYPE", None): - raise AttributeError( - f"Untyped service instances are not allowed. Please set the " - f"SERVICE_TYPE class attribute for {cls}." - ) - uuid = kwargs.get("uuid", None) - if uuid: - if isinstance(uuid, str): - uuid = UUID(uuid) - if not isinstance(uuid, UUID): - raise ValueError( - f"The `uuid` argument for {cls} must be a UUID instance or a " - f"string representation of a UUID." - ) - - # if a service instance with the same UUID is already registered, - # return the existing instance rather than the newly created one - existing_service = ServiceRegistry().get_service(uuid) - if existing_service: - logger.debug( - f"Reusing existing service '{existing_service}' " - f"instead of creating a new service with the same UUID." - ) - return cast("BaseServiceMeta", existing_service) - - svc = cast("BaseService", super().__call__(*args, **kwargs)) - ServiceRegistry().register_service(svc) - return cast("BaseServiceMeta", svc) + labels = {} + for k, v in self.dict().items(): + label = f"zenml_{k}".upper() + labels[label] = str(v) + return labels -class BaseService(BaseTypedModel, metaclass=BaseServiceMeta): +class BaseService(BaseTypedModel): """Base service class. This class implements generic functionality concerning the life-cycle @@ -227,7 +167,7 @@ class BaseService(BaseTypedModel, metaclass=BaseServiceMeta): SERVICE_TYPE: ClassVar[ServiceType] - uuid: UUID = Field(default_factory=uuid4, allow_mutation=False) + uuid: UUID admin_state: ServiceState = ServiceState.INACTIVE config: ServiceConfig status: ServiceStatus @@ -246,6 +186,49 @@ def __init__( super().__init__(**attrs) self.config.name = self.config.name or self.__class__.__name__ + @classmethod + def from_model(cls, model: "ServiceResponse") -> "BaseService": + """Loads a service from a model. + + Args: + model: The ServiceResponse to load from. + + Returns: + The loaded service object. + + Raises: + ValueError: if the service source is not found in the model. + """ + if not model.service_source: + raise ValueError("Service source not found in the model.") + class_: Type[BaseService] = source_utils.load_and_validate_class( + source=model.service_source, expected_class=BaseService + ) + return class_( + uuid=model.id, + admin_state=model.admin_state, + config=model.config, + status=model.status, + service_type=model.service_type.dict(), + endpoint=model.endpoint, + ) + + @classmethod + def from_json(cls, json_str: str) -> "BaseTypedModel": + """Loads a service from a JSON string. + + Args: + json_str: the JSON string to load from. + + Returns: + The loaded service object. + """ + service_dict = json.loads(json_str) + class_: Type[BaseService] = source_utils.load_and_validate_class( + source=service_dict["type"], expected_class=BaseService + ) + return class_.from_dict(service_dict) + @abstractmethod def check_status(self) -> Tuple[ServiceState, str]: """Check the the current operational state of the external service. @@ -449,19 +432,15 @@ def start(self, timeout: int = 0) -> None: timeout: amount of time to wait for the service to become active. If set to 0, the method will return immediately after checking the service status. - - Raises: - RuntimeError: if the service cannot be started """ with console.status(f"Starting service '{self}'.\n"): self.admin_state = ServiceState.ACTIVE self.provision() - if timeout > 0: - if not self.poll_service_status(timeout): - raise RuntimeError( - f"Failed to start service {self}\n" - + self.get_service_status_message() - ) + if timeout > 0 and not self.poll_service_status(timeout): + logger.error( + f"Failed to start service {self}\n" + + self.get_service_status_message() + ) @update_service_status( pre_status=ServiceState.PENDING_SHUTDOWN, @@ -476,9 +455,6 @@ def stop(self, timeout: int = 0, force: bool = False) -> None: the service status. force: if True, the service will be stopped even if it is not currently running. - - Raises: - RuntimeError: if the service cannot be stopped """ with console.status(f"Stopping service '{self}'.\n"): self.admin_state = ServiceState.INACTIVE @@ -486,12 +462,40 @@ def stop(self, timeout: int = 0, force: bool = False) -> None: if timeout > 0: self.poll_service_status(timeout) if not self.is_stopped: - raise RuntimeError( + logger.error( f"Failed to stop service {self}. Last state: " f"'{self.status.state.value}'. Last error: " f"'{self.status.last_error}'" ) + def get_prediction_url(self) -> Optional[str]: + """Gets the prediction URL for the endpoint. + + Returns: + the prediction URL for the endpoint + """ + prediction_url = None + if isinstance(self, BaseDeploymentService) and self.prediction_url: + prediction_url = self.prediction_url + elif self.endpoint: + prediction_url = ( + self.endpoint.status.uri if self.endpoint.status else None + ) + return prediction_url + + def get_healthcheck_url(self) -> Optional[str]: + """Gets the healthcheck URL for the endpoint. + + Returns: + the healthcheck URL for the endpoint + """ + return ( + self.endpoint.monitor.get_healthcheck_uri(self.endpoint) + if (self.endpoint and self.endpoint.monitor) + and isinstance(self.endpoint.monitor, HTTPEndpointHealthMonitor) + else None + ) + def __repr__(self) -> str: """String representation of the service. @@ -529,3 +533,12 @@ def prediction_url(self) -> Optional[str]: the prediction URL for the endpoint """ return None + + @property + def healthcheck_url(self) -> Optional[str]: + """Gets the healthcheck URL for the endpoint. + + Returns: + the healthcheck URL for the endpoint + """ + return None diff --git a/src/zenml/services/service_registry.py b/src/zenml/services/service_registry.py deleted file mode 100644 index c88cdab8b5b..00000000000 --- a/src/zenml/services/service_registry.py +++ /dev/null @@ -1,214 +0,0 @@ -# Copyright (c) ZenML GmbH 2022. All Rights Reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at: -# -# https://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express -# or implied. See the License for the specific language governing -# permissions and limitations under the License. -"""Implementation of the ZenML service registry.""" - -import json -from typing import TYPE_CHECKING, Any, Dict, Optional, Type, cast -from uuid import UUID - -from zenml.logger import get_logger -from zenml.services.service_type import ServiceType -from zenml.utils.singleton import SingletonMetaClass - -logger = get_logger(__name__) - -if TYPE_CHECKING: - from zenml.services.service import BaseService - - -class ServiceRegistry(metaclass=SingletonMetaClass): - """Registry of service types and service instances. - - The service registry provides a central place to register service types - as well as service instances. - """ - - def __init__(self) -> None: - """Initialize the service registry.""" - self.service_types: Dict[ServiceType, Type["BaseService"]] = {} - self.services: Dict[UUID, "BaseService"] = {} - - def register_service_type(self, cls: Type["BaseService"]) -> None: - """Registers a new service type. - - Args: - cls: a BaseService subclass. - - Raises: - TypeError: if the service type is already registered. - """ - service_type = cls.SERVICE_TYPE - if service_type not in self.service_types: - self.service_types[service_type] = cls - logger.debug( - f"Registered service class {cls} for " - f"service type `{service_type}`" - ) - else: - raise TypeError( - f"Found existing service type for {service_type}: " - f"{self.service_types[service_type]}. Skipping registration " - f"of {cls}." - ) - - def get_service_type( - self, service_type: ServiceType - ) -> Optional[Type["BaseService"]]: - """Get the service class registered for a service type. - - Args: - service_type: service type. - - Returns: - `BaseService` subclass that was registered for the service type or - None, if no service class was registered for the service type. - """ - return self.service_types.get(service_type) - - def get_service_types( - self, - ) -> Dict[ServiceType, Type["BaseService"]]: - """Get all registered service types. - - Returns: - Dictionary of service types indexed by their service type. - """ - return self.service_types.copy() - - def service_type_is_registered(self, service_type: ServiceType) -> bool: - """Check if a service type is registered. - - Args: - service_type: service type. - - Returns: - True, if a service type is registered for the service type, False - otherwise. - """ - return service_type in self.service_types - - def register_service(self, service: "BaseService") -> None: - """Registers a new service instance. - - Args: - service: a BaseService instance. - - Raises: - TypeError: if the service instance has a service type that is not - registered. - Exception: if a preexisting service is found for that UUID. - """ - service_type = service.SERVICE_TYPE - if service_type not in self.service_types: - raise TypeError( - f"Service type `{service_type}` is not registered." - ) - - if service.uuid not in self.services: - self.services[service.uuid] = service - logger.debug(f"Registered service {service}") - else: - existing_service = self.services[service.uuid] - raise Exception( - f"Found existing service {existing_service} for UUID: " - f"{service.uuid}. Skipping registration for service " - f"{service}." - ) - - def get_service(self, uuid: UUID) -> Optional["BaseService"]: - """Get the service instance registered for a UUID. - - Args: - uuid: service instance identifier. - - Returns: - `BaseService` instance that was registered for the UUID or - None, if no matching service instance was found. - """ - return self.services.get(uuid) - - def get_services(self) -> Dict[UUID, "BaseService"]: - """Get all service instances currently registered. - - Returns: - Dictionary of `BaseService` instances indexed by their UUID with - all services that are currently registered. - """ - return self.services.copy() - - def service_is_registered(self, uuid: UUID) -> bool: - """Check if a service instance is registered. - - Args: - uuid: service instance identifier. - - Returns: - True, if a service instance is registered for the UUID, False - otherwise. - """ - return uuid in self.services - - def load_service_from_dict( - self, service_dict: Dict[str, Any] - ) -> "BaseService": - """Load a service instance from its dict representation. - - Creates, registers and returns a service instantiated from the dict - representation of the service configuration and last known status - information. - - If an existing service instance with the same UUID is already - present in the service registry, it is returned instead. - - Args: - service_dict: dict representation of the service configuration and - last known status - - Returns: - A new or existing ZenML service instance. - - Raises: - TypeError: if the service type is not registered. - ValueError: if the service type is not valid. - """ - service_type = service_dict.get("service_type") - if not service_type: - raise ValueError( - "Service type not present in the service dictionary" - ) - service_type = ServiceType.parse_obj(service_type) - service_class = self.get_service_type(service_type) - if not service_class: - raise TypeError( - f"Cannot load service with unregistered service " - f"type: {service_type}" - ) - service = cast("BaseService", service_class.from_dict(service_dict)) - return service - - def load_service_from_json(self, json_str: str) -> "BaseService": - """Load a service instance from its JSON representation. - - Creates and returns a service instantiated from the JSON serialized - service configuration and last known status information. - - Args: - json_str: JSON string representation of the service configuration - and last known status - - Returns: - A ZenML service instance. - """ - service_dict = json.loads(json_str) - return self.load_service_from_dict(service_dict) diff --git a/src/zenml/services/service_status.py b/src/zenml/services/service_status.py index 368a96f90f0..fc21e3f328e 100644 --- a/src/zenml/services/service_status.py +++ b/src/zenml/services/service_status.py @@ -25,11 +25,12 @@ class ServiceState(StrEnum): """Possible states for the service and service endpoint.""" + INACTIVE = "inactive" ACTIVE = "active" PENDING_STARTUP = "pending_startup" - INACTIVE = "inactive" PENDING_SHUTDOWN = "pending_shutdown" ERROR = "error" + SCALED_TO_ZERO = "scaled_to_zero" class ServiceStatus(BaseTypedModel): diff --git a/src/zenml/services/service_type.py b/src/zenml/services/service_type.py index 8942c87bbda..a83539d336d 100644 --- a/src/zenml/services/service_type.py +++ b/src/zenml/services/service_type.py @@ -24,12 +24,14 @@ class ServiceType(BaseModel): flavor: service flavor name: name of the service type description: description of the service type + logo_url: logo of the service type """ type: str flavor: str name: str = "" description: str = "" + logo_url: str = "" class Config: """Pydantic configuration class.""" diff --git a/src/zenml/utils/dict_utils.py b/src/zenml/utils/dict_utils.py index 5c14b548968..fe5e9fb6dfe 100644 --- a/src/zenml/utils/dict_utils.py +++ b/src/zenml/utils/dict_utils.py @@ -13,8 +13,12 @@ # permissions and limitations under the License. """Util functions for dictionaries.""" +import base64 +import json from typing import Any, Dict +from pydantic.json import pydantic_encoder + def recursive_update( original: Dict[str, Any], update: Dict[str, Any] @@ -69,3 +73,21 @@ def _maybe_recurse(value: Any) -> Any: return value return {k: _maybe_recurse(v) for k, v in dict_.items() if v is not None} + + +def dict_to_bytes(dict_: Dict[str, Any]) -> bytes: + """Converts a dictionary to bytes. + + Args: + dict_: The dictionary to convert. + + Returns: + The dictionary as bytes. + """ + return base64.b64encode( + json.dumps( + dict_, + sort_keys=False, + default=pydantic_encoder, + ).encode("utf-8") + ) diff --git a/src/zenml/zen_server/deploy/docker/docker_provider.py b/src/zenml/zen_server/deploy/docker/docker_provider.py index aae7060bc96..2353ecf30ba 100644 --- a/src/zenml/zen_server/deploy/docker/docker_provider.py +++ b/src/zenml/zen_server/deploy/docker/docker_provider.py @@ -15,6 +15,7 @@ import shutil from typing import ClassVar, List, Optional, Tuple, Type, cast +from uuid import uuid4 from zenml.enums import ServerProviderType from zenml.logger import get_logger @@ -131,7 +132,9 @@ def _create_service( config=monitor_cfg, ), ) - service = DockerZenServer(config=service_config, endpoint=endpoint) + service = DockerZenServer( + uuid=uuid4(), config=service_config, endpoint=endpoint + ) service.start(timeout=timeout) return service diff --git a/src/zenml/zen_server/deploy/docker/docker_zen_server.py b/src/zenml/zen_server/deploy/docker/docker_zen_server.py index 188aed6f15f..58c02165833 100644 --- a/src/zenml/zen_server/deploy/docker/docker_zen_server.py +++ b/src/zenml/zen_server/deploy/docker/docker_zen_server.py @@ -132,14 +132,11 @@ def get_service(cls) -> Optional["DockerZenServer"]: The docker ZenML server service or None, if the docker server deployment is not found. """ - from zenml.services import ServiceRegistry - config_filename = os.path.join(cls.config_path(), "service.json") try: with open(config_filename, "r") as f: return cast( - DockerZenServer, - ServiceRegistry().load_service_from_json(f.read()), + "DockerZenServer", DockerZenServer.from_json(f.read()) ) except FileNotFoundError: return None diff --git a/src/zenml/zen_server/deploy/local/local_provider.py b/src/zenml/zen_server/deploy/local/local_provider.py index 26583e48266..b380017ae7e 100644 --- a/src/zenml/zen_server/deploy/local/local_provider.py +++ b/src/zenml/zen_server/deploy/local/local_provider.py @@ -15,6 +15,7 @@ import shutil from typing import ClassVar, List, Optional, Tuple, Type, cast +from uuid import uuid4 from zenml import __version__ from zenml.enums import ServerProviderType @@ -93,7 +94,6 @@ def _get_service_configuration( The service, service endpoint and endpoint monitor configuration. """ assert isinstance(server_config, LocalServerDeploymentConfig) - return ( LocalZenServerConfig( root_runtime_path=LocalZenServer.config_path(), @@ -157,7 +157,9 @@ def _create_service( config=monitor_cfg, ), ) - service = LocalZenServer(config=service_config, endpoint=endpoint) + service = LocalZenServer( + uuid=uuid4(), config=service_config, endpoint=endpoint + ) service.start(timeout=timeout) return service diff --git a/src/zenml/zen_server/deploy/local/local_zen_server.py b/src/zenml/zen_server/deploy/local/local_zen_server.py index 6425b2829bc..8f5041d9de1 100644 --- a/src/zenml/zen_server/deploy/local/local_zen_server.py +++ b/src/zenml/zen_server/deploy/local/local_zen_server.py @@ -127,14 +127,11 @@ def get_service(cls) -> Optional["LocalZenServer"]: The local ZenML server service or None, if the local server deployment is not found. """ - from zenml.services import ServiceRegistry - config_filename = os.path.join(cls.config_path(), "service.json") try: with open(config_filename, "r") as f: return cast( - LocalZenServer, - ServiceRegistry().load_service_from_json(f.read()), + "LocalZenServer", LocalZenServer.from_json(f.read()) ) except FileNotFoundError: return None diff --git a/src/zenml/zen_server/deploy/terraform/providers/terraform_provider.py b/src/zenml/zen_server/deploy/terraform/providers/terraform_provider.py index 0215e7d929c..7f25e4fb87d 100644 --- a/src/zenml/zen_server/deploy/terraform/providers/terraform_provider.py +++ b/src/zenml/zen_server/deploy/terraform/providers/terraform_provider.py @@ -15,6 +15,7 @@ import os from typing import ClassVar, List, Optional, Tuple, Type, cast +from uuid import uuid4 from zenml.config.global_config import GlobalConfiguration from zenml.logger import get_logger @@ -153,7 +154,7 @@ def _create_service( monitor_cfg, ) = self._get_service_configuration(config) - service = TerraformZenServer(config=service_config) + service = TerraformZenServer(uuid=uuid4(), config=service_config) service.start(timeout=timeout) return service diff --git a/src/zenml/zen_server/deploy/terraform/terraform_zen_server.py b/src/zenml/zen_server/deploy/terraform/terraform_zen_server.py index 1b1441ddaf0..61b838afdd9 100644 --- a/src/zenml/zen_server/deploy/terraform/terraform_zen_server.py +++ b/src/zenml/zen_server/deploy/terraform/terraform_zen_server.py @@ -184,13 +184,10 @@ def get_service(cls) -> Optional["TerraformZenServer"]: The terraform ZenML server service or None, if the terraform server deployment is not found. """ - from zenml.services import ServiceRegistry - try: with open(TERRAFORM_ZENML_SERVER_CONFIG_FILENAME, "r") as f: return cast( - TerraformZenServer, - ServiceRegistry().load_service_from_json(f.read()), + TerraformZenServer, TerraformZenServer.from_json(f.read()) ) except FileNotFoundError: return None diff --git a/src/zenml/zen_server/rbac/models.py b/src/zenml/zen_server/rbac/models.py index 4a7459db1a5..eb136685e77 100644 --- a/src/zenml/zen_server/rbac/models.py +++ b/src/zenml/zen_server/rbac/models.py @@ -58,6 +58,7 @@ class ResourceType(StrEnum): PIPELINE_DEPLOYMENT = "pipeline_deployment" PIPELINE_BUILD = "pipeline_build" USER = "user" + SERVICE = "service" RUN_METADATA = "run_metadata" SECRET = "secret" SERVICE_ACCOUNT = "service_account" diff --git a/src/zenml/zen_server/rbac/utils.py b/src/zenml/zen_server/rbac/utils.py index da64e417899..692b7f8d89c 100644 --- a/src/zenml/zen_server/rbac/utils.py +++ b/src/zenml/zen_server/rbac/utils.py @@ -400,6 +400,7 @@ def get_resource_type_for_model( SecretResponse, ServiceAccountResponse, ServiceConnectorResponse, + ServiceResponse, StackResponse, TagResponse, UserResponse, @@ -429,6 +430,7 @@ def get_resource_type_for_model( PipelineRunResponse: ResourceType.PIPELINE_RUN, TagResponse: ResourceType.TAG, ServiceAccountResponse: ResourceType.SERVICE_ACCOUNT, + ServiceResponse: ResourceType.SERVICE, } return mapping.get(type(model)) @@ -536,6 +538,7 @@ def get_schema_for_resource_type( RunMetadataSchema, SecretSchema, ServiceConnectorSchema, + ServiceSchema, StackComponentSchema, StackSchema, TagSchema, @@ -555,6 +558,7 @@ def get_schema_for_resource_type( ResourceType.ARTIFACT: ArtifactSchema, ResourceType.ARTIFACT_VERSION: ArtifactVersionSchema, ResourceType.SECRET: SecretSchema, + ResourceType.SERVICE: ServiceSchema, ResourceType.TAG: TagSchema, ResourceType.SERVICE_ACCOUNT: UserSchema, ResourceType.WORKSPACE: WorkspaceSchema, diff --git a/src/zenml/zen_server/routers/service_endpoints.py b/src/zenml/zen_server/routers/service_endpoints.py new file mode 100644 index 00000000000..1d7925494df --- /dev/null +++ b/src/zenml/zen_server/routers/service_endpoints.py @@ -0,0 +1,180 @@ +# Copyright (c) ZenML GmbH 2024. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at: +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express +# or implied. See the License for the specific language governing +# permissions and limitations under the License. +"""Endpoint definitions for services.""" + +from uuid import UUID + +from fastapi import APIRouter, Depends, Security + +from zenml.constants import API, SERVICES, VERSION_1 +from zenml.models import ( + Page, + ServiceFilter, + ServiceResponse, + ServiceUpdate, +) +from zenml.models.v2.core.service import ServiceRequest +from zenml.zen_server.auth import AuthContext, authorize +from zenml.zen_server.exceptions import error_response +from zenml.zen_server.rbac.endpoint_utils import ( + verify_permissions_and_create_entity, + verify_permissions_and_delete_entity, + verify_permissions_and_get_entity, + verify_permissions_and_list_entities, + verify_permissions_and_update_entity, +) +from zenml.zen_server.rbac.models import ResourceType +from zenml.zen_server.utils import ( + handle_exceptions, + make_dependable, + zen_store, +) + +router = APIRouter( + prefix=API + VERSION_1 + SERVICES, + tags=["services"], + responses={401: error_response, 403: error_response}, +) + + +@router.post( + "", + response_model=ServiceResponse, + responses={401: error_response, 422: error_response}, +) +@handle_exceptions +def create_service( + service: ServiceRequest, + _: AuthContext = Security(authorize), +) -> ServiceResponse: + """Creates a new service. + + Args: + service: The model containing the attributes of the new service. + + Returns: + The created service object. + """ + return verify_permissions_and_create_entity( + request_model=service, + create_method=zen_store().create_service, + resource_type=ResourceType.SERVICE, + ) + + +@router.get( + "", + response_model=Page[ServiceResponse], + responses={401: error_response, 404: error_response, 422: error_response}, +) +@handle_exceptions +def list_services( + filter_model: ServiceFilter = Depends(make_dependable(ServiceFilter)), + hydrate: bool = False, + _: AuthContext = Security(authorize), +) -> Page[ServiceResponse]: + """Gets a page of service objects. + + Args: + filter_model: Filter model used for pagination, sorting, + filtering. + hydrate: Flag deciding whether to hydrate the output model(s) + by including metadata fields in the response. + + Returns: + Page of service objects. + """ + return verify_permissions_and_list_entities( + filter_model=filter_model, + resource_type=ResourceType.SERVICE, + list_method=zen_store().list_services, + hydrate=hydrate, + ) + + +@router.get( + "/{service_id}", + response_model=ServiceResponse, + responses={401: error_response, 404: error_response, 422: error_response}, +) +@handle_exceptions +def get_service( + service_id: UUID, + hydrate: bool = True, + _: AuthContext = Security(authorize), +) -> ServiceResponse: + """Gets a specific service using its unique ID. + + Args: + service_id: The ID of the service to get. + hydrate: Flag deciding whether to hydrate the output model(s) + by including metadata fields in the response. + + Returns: + A specific service object. + """ + return verify_permissions_and_get_entity( + id=service_id, + get_method=zen_store().get_service, + hydrate=hydrate, + ) + + +@router.put( + "/{service_id}", + response_model=ServiceResponse, + responses={401: error_response, 404: error_response, 422: error_response}, +) +@handle_exceptions +def update_service( + service_id: UUID, + update: ServiceUpdate, + _: AuthContext = Security(authorize), +) -> ServiceResponse: + """Updates a service. + + Args: + service_id: The ID of the service to update. + update: The model containing the attributes to update. + + Returns: + The updated service object. + """ + return verify_permissions_and_update_entity( + id=service_id, + update_model=update, + get_method=zen_store().get_service, + update_method=zen_store().update_service, + ) + + +@router.delete( + "/{service_id}", + responses={401: error_response, 404: error_response, 422: error_response}, +) +@handle_exceptions +def delete_service( + service_id: UUID, + _: AuthContext = Security(authorize), +) -> None: + """Deletes a specific service. + + Args: + service_id: The ID of the service to delete. + """ + verify_permissions_and_delete_entity( + id=service_id, + get_method=zen_store().get_service, + delete_method=zen_store().delete_service, + ) diff --git a/src/zenml/zen_server/routers/workspaces_endpoints.py b/src/zenml/zen_server/routers/workspaces_endpoints.py index 042565b6275..36d8918b87f 100644 --- a/src/zenml/zen_server/routers/workspaces_endpoints.py +++ b/src/zenml/zen_server/routers/workspaces_endpoints.py @@ -34,6 +34,7 @@ SECRETS, SERVICE_CONNECTOR_RESOURCES, SERVICE_CONNECTORS, + SERVICES, STACK_COMPONENTS, STACKS, STATISTICS, @@ -80,6 +81,8 @@ ServiceConnectorRequest, ServiceConnectorResourcesModel, ServiceConnectorResponse, + ServiceRequest, + ServiceResponse, StackFilter, StackRequest, StackResponse, @@ -1431,3 +1434,44 @@ def create_model_version_pipeline_run_link( model_version_pipeline_run_link ) return mv + + +@router.post( + WORKSPACES + "/{workspace_name_or_id}" + SERVICES, + response_model=ServiceResponse, + responses={401: error_response, 409: error_response, 422: error_response}, +) +@handle_exceptions +def create_service( + workspace_name_or_id: Union[str, UUID], + service: ServiceRequest, + _: AuthContext = Security(authorize), +) -> ServiceResponse: + """Create a new service. + + Args: + workspace_name_or_id: Name or ID of the workspace. + service: The service to create. + + Returns: + The created service. + + Raises: + IllegalOperationError: If the workspace or user specified in the + model does not match the current workspace or authenticated + user. + """ + workspace = zen_store().get_workspace(workspace_name_or_id) + + if service.workspace != workspace.id: + raise IllegalOperationError( + "Creating models outside of the workspace scope " + f"of this endpoint `{workspace_name_or_id}` is " + f"not supported." + ) + + return verify_permissions_and_create_entity( + request_model=service, + resource_type=ResourceType.SERVICE, + create_method=zen_store().create_service, + ) diff --git a/src/zenml/zen_server/zen_server_api.py b/src/zenml/zen_server/zen_server_api.py index 79c9c01f7c0..c5c5b75ab50 100644 --- a/src/zenml/zen_server/zen_server_api.py +++ b/src/zenml/zen_server/zen_server_api.py @@ -52,6 +52,7 @@ server_endpoints, service_accounts_endpoints, service_connectors_endpoints, + service_endpoints, stack_components_endpoints, stacks_endpoints, steps_endpoints, @@ -234,6 +235,7 @@ def dashboard(request: Request) -> Any: app.include_router(service_accounts_endpoints.router) app.include_router(service_connectors_endpoints.router) app.include_router(service_connectors_endpoints.types_router) +app.include_router(service_endpoints.router) app.include_router(stacks_endpoints.router) app.include_router(stack_components_endpoints.router) app.include_router(stack_components_endpoints.types_router) diff --git a/src/zenml/zen_stores/rest_zen_store.py b/src/zenml/zen_stores/rest_zen_store.py index 8a52010daf3..9b23eba7b06 100644 --- a/src/zenml/zen_stores/rest_zen_store.py +++ b/src/zenml/zen_stores/rest_zen_store.py @@ -80,6 +80,7 @@ SERVICE_CONNECTOR_TYPES, SERVICE_CONNECTOR_VERIFY, SERVICE_CONNECTORS, + SERVICES, STACK_COMPONENTS, STACKS, STEPS, @@ -189,6 +190,10 @@ ServiceConnectorResponse, ServiceConnectorTypeModel, ServiceConnectorUpdate, + ServiceFilter, + ServiceRequest, + ServiceResponse, + ServiceUpdate, StackFilter, StackRequest, StackResponse, @@ -590,6 +595,93 @@ def delete_api_key( route=f"{SERVICE_ACCOUNTS}/{str(service_account_id)}{API_KEYS}", ) + # ----------------------------- Services ----------------------------- + + def create_service( + self, service_request: ServiceRequest + ) -> ServiceResponse: + """Create a new service. + + Args: + service_request: The service to create. + + Returns: + The created service. + """ + return self._create_resource( + resource=service_request, + response_model=ServiceResponse, + route=SERVICES, + ) + + def get_service( + self, service_id: UUID, hydrate: bool = True + ) -> ServiceResponse: + """Get a service. + + Args: + service_id: The ID of the service to get. + hydrate: Flag deciding whether to hydrate the output model(s) + by including metadata fields in the response. + + Returns: + The service. + """ + return self._get_resource( + resource_id=service_id, + route=SERVICES, + response_model=ServiceResponse, + params={"hydrate": hydrate}, + ) + + def list_services( + self, filter_model: ServiceFilter, hydrate: bool = False + ) -> Page[ServiceResponse]: + """List all services matching the given filter criteria. + + Args: + filter_model: All filter parameters including pagination + params. + hydrate: Flag deciding whether to hydrate the output model(s) + by including metadata fields in the response. + + Returns: + A list of all services matching the filter criteria. + """ + return self._list_paginated_resources( + route=SERVICES, + response_model=ServiceResponse, + filter_model=filter_model, + params={"hydrate": hydrate}, + ) + + def update_service( + self, service_id: UUID, update: ServiceUpdate + ) -> ServiceResponse: + """Update a service. + + Args: + service_id: The ID of the service to update. + update: The update to be applied to the service. + + Returns: + The updated service. + """ + return self._update_resource( + resource_id=service_id, + resource_update=update, + response_model=ServiceResponse, + route=SERVICES, + ) + + def delete_service(self, service_id: UUID) -> None: + """Delete a service. + + Args: + service_id: The ID of the service to delete. + """ + self._delete_resource(resource_id=service_id, route=SERVICES) + # ----------------------------- Artifacts ----------------------------- def create_artifact(self, artifact: ArtifactRequest) -> ArtifactResponse: diff --git a/src/zenml/zen_stores/schemas/__init__.py b/src/zenml/zen_stores/schemas/__init__.py index 0ec208fff81..5957605c0c7 100644 --- a/src/zenml/zen_stores/schemas/__init__.py +++ b/src/zenml/zen_stores/schemas/__init__.py @@ -41,6 +41,7 @@ from zenml.zen_stores.schemas.run_metadata_schemas import RunMetadataSchema from zenml.zen_stores.schemas.schedule_schema import ScheduleSchema from zenml.zen_stores.schemas.secret_schemas import SecretSchema +from zenml.zen_stores.schemas.service_schemas import ServiceSchema from zenml.zen_stores.schemas.service_connector_schemas import ( ServiceConnectorSchema, ) @@ -90,6 +91,7 @@ "ScheduleSchema", "SecretSchema", "ServiceConnectorSchema", + "ServiceSchema", "StackComponentSchema", "StackCompositionSchema", "StackSchema", diff --git a/src/zenml/zen_stores/schemas/model_schemas.py b/src/zenml/zen_stores/schemas/model_schemas.py index 52e0355b9eb..4658d094281 100644 --- a/src/zenml/zen_stores/schemas/model_schemas.py +++ b/src/zenml/zen_stores/schemas/model_schemas.py @@ -14,7 +14,7 @@ """SQLModel implementation of model tables.""" from datetime import datetime -from typing import Any, Dict, List, Optional +from typing import TYPE_CHECKING, Any, Dict, List, Optional, cast from uuid import UUID from sqlalchemy import BOOLEAN, INTEGER, TEXT, Column @@ -38,6 +38,8 @@ ModelVersionResponse, ModelVersionResponseBody, ModelVersionResponseMetadata, + ModelVersionResponseResources, + Page, ) from zenml.zen_stores.schemas.artifact_schemas import ArtifactVersionSchema from zenml.zen_stores.schemas.base_schemas import BaseSchema, NamedSchema @@ -46,8 +48,12 @@ from zenml.zen_stores.schemas.schema_utils import build_foreign_key_field from zenml.zen_stores.schemas.tag_schemas import TagResourceSchema from zenml.zen_stores.schemas.user_schemas import UserSchema +from zenml.zen_stores.schemas.utils import get_page_from_list from zenml.zen_stores.schemas.workspace_schemas import WorkspaceSchema +if TYPE_CHECKING: + from zenml.zen_stores.schemas import ServiceSchema + class ModelSchema(NamedSchema, table=True): """SQL Model for model.""" @@ -263,6 +269,10 @@ class ModelVersionSchema(NamedSchema, table=True): ), ) + services: List["ServiceSchema"] = Relationship( + back_populates="model_version", + ) + number: int = Field(sa_column=Column(INTEGER, nullable=False)) description: str = Field(sa_column=Column(TEXT, nullable=True)) stage: str = Field(sa_column=Column(TEXT, nullable=True)) @@ -315,6 +325,8 @@ def to_model( Returns: The created `ModelVersionResponse`. """ + from zenml.models import ServiceResponse + # Construct {name: {version: id}} dicts for all linked artifacts model_artifact_ids: Dict[str, Dict[str, UUID]] = {} deployment_artifact_ids: Dict[str, Dict[str, UUID]] = {} @@ -347,7 +359,6 @@ def to_model( pipeline_run_ids[pipeline_run.name] = pipeline_run.id metadata = None - if include_metadata: metadata = ModelVersionResponseMetadata( workspace=self.workspace.to_model(), @@ -358,6 +369,21 @@ def to_model( }, ) + resources = None + if include_resources: + services = cast( + Page[ServiceResponse], + get_page_from_list( + items_list=self.services, + response_model=ServiceResponse, + include_resources=include_resources, + include_metadata=include_metadata, + ), + ) + resources = ModelVersionResponseResources( + services=services, + ) + body = ModelVersionResponseBody( user=self.user.to_model() if self.user else None, created=self.created, @@ -377,6 +403,7 @@ def to_model( name=self.name, body=body, metadata=metadata, + resources=resources, ) def update( diff --git a/src/zenml/zen_stores/schemas/pipeline_run_schemas.py b/src/zenml/zen_stores/schemas/pipeline_run_schemas.py index 6966a0ccf81..c27cc34bb74 100644 --- a/src/zenml/zen_stores/schemas/pipeline_run_schemas.py +++ b/src/zenml/zen_stores/schemas/pipeline_run_schemas.py @@ -49,6 +49,7 @@ ModelVersionPipelineRunSchema, ) from zenml.zen_stores.schemas.run_metadata_schemas import RunMetadataSchema + from zenml.zen_stores.schemas.service_schemas import ServiceSchema from zenml.zen_stores.schemas.step_run_schemas import StepRunSchema @@ -182,6 +183,10 @@ class PipelineRunSchema(NamedSchema, table=True): pipeline: Optional["PipelineSchema"] = Relationship(back_populates="runs") trigger_execution: Optional["TriggerExecutionSchema"] = Relationship() + services: List["ServiceSchema"] = Relationship( + back_populates="pipeline_run", + ) + @classmethod def from_request( cls, request: "PipelineRunRequest" diff --git a/src/zenml/zen_stores/schemas/service_schemas.py b/src/zenml/zen_stores/schemas/service_schemas.py new file mode 100644 index 00000000000..a38c0b68425 --- /dev/null +++ b/src/zenml/zen_stores/schemas/service_schemas.py @@ -0,0 +1,249 @@ +# Copyright (c) ZenML GmbH 2024. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at: +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express +# or implied. See the License for the specific language governing +# permissions and limitations under the License. +"""SQLModel implementation of service table.""" + +import base64 +import json +from datetime import datetime +from typing import Any, Optional +from uuid import UUID + +from sqlalchemy import TEXT, Column +from sqlmodel import Field, Relationship + +from zenml.models.v2.core.service import ( + ServiceRequest, + ServiceResponse, + ServiceResponseBody, + ServiceResponseMetadata, + ServiceResponseResources, + ServiceUpdate, +) +from zenml.utils.dict_utils import dict_to_bytes +from zenml.zen_stores.schemas.base_schemas import NamedSchema +from zenml.zen_stores.schemas.model_schemas import ModelVersionSchema +from zenml.zen_stores.schemas.pipeline_run_schemas import PipelineRunSchema +from zenml.zen_stores.schemas.schema_utils import build_foreign_key_field +from zenml.zen_stores.schemas.user_schemas import UserSchema +from zenml.zen_stores.schemas.workspace_schemas import WorkspaceSchema + + +class ServiceSchema(NamedSchema, table=True): + """SQL Model for service.""" + + __tablename__ = "service" + + workspace_id: UUID = build_foreign_key_field( + source=__tablename__, + target=WorkspaceSchema.__tablename__, + source_column="workspace_id", + target_column="id", + ondelete="CASCADE", + nullable=False, + ) + workspace: "WorkspaceSchema" = Relationship(back_populates="services") + + user_id: Optional[UUID] = build_foreign_key_field( + source=__tablename__, + target=UserSchema.__tablename__, + source_column="user_id", + target_column="id", + ondelete="SET NULL", + nullable=True, + ) + user: Optional["UserSchema"] = Relationship(back_populates="services") + service_source: Optional[str] = Field( + sa_column=Column(TEXT, nullable=True) + ) + service_type: str = Field(sa_column=Column(TEXT, nullable=False)) + type: str = Field(sa_column=Column(TEXT, nullable=False)) + flavor: str = Field(sa_column=Column(TEXT, nullable=False)) + admin_state: Optional[str] = Field(sa_column=Column(TEXT, nullable=True)) + state: Optional[str] = Field(sa_column=Column(TEXT, nullable=True)) + labels: Optional[bytes] + config: bytes + status: Optional[bytes] + endpoint: Optional[bytes] + prediction_url: Optional[str] = Field( + sa_column=Column(TEXT, nullable=True) + ) + health_check_url: Optional[str] = Field( + sa_column=Column(TEXT, nullable=True) + ) + pipeline_name: Optional[str] = Field(sa_column=Column(TEXT, nullable=True)) + pipeline_step_name: Optional[str] = Field( + sa_column=Column(TEXT, nullable=True) + ) + model_version_id: Optional[UUID] = build_foreign_key_field( + source=__tablename__, + target=ModelVersionSchema.__tablename__, + source_column="model_version_id", + target_column="id", + ondelete="SET NULL", + nullable=True, + ) + model_version: Optional["ModelVersionSchema"] = Relationship( + back_populates="services", + ) + pipeline_run_id: Optional[UUID] = build_foreign_key_field( + source=__tablename__, + target="pipeline_run", + source_column="pipeline_run_id", + target_column="id", + ondelete="SET NULL", + nullable=True, + ) + pipeline_run: Optional["PipelineRunSchema"] = Relationship( + back_populates="services", + ) + + def to_model( + self, + include_metadata: bool = False, + include_resources: bool = False, + **kwargs: Any, + ) -> ServiceResponse: + """Convert an `ServiceSchema` to an `ServiceResponse`. + + Args: + include_metadata: Whether to include metadata in the response. + include_resources: Whether to include resources in the response. + kwargs: Additional keyword arguments. + + Returns: + The created `ServiceResponse`. + """ + body = ServiceResponseBody( + user=self.user.to_model() if self.user else None, + workspace=self.workspace.to_model(), + created=self.created, + updated=self.updated, + service_type=json.loads(self.service_type), + labels=json.loads(base64.b64decode(self.labels).decode()) + if self.labels + else None, + state=self.state, + ) + metadata = None + if include_metadata: + metadata = ServiceResponseMetadata( + workspace=self.workspace.to_model(), + service_source=self.service_source, + config=json.loads(base64.b64decode(self.config).decode()), + status=json.loads(base64.b64decode(self.status).decode()) + if self.status + else None, + endpoint=json.loads(base64.b64decode(self.endpoint).decode()) + if self.endpoint + else None, + admin_state=self.admin_state or None, + prediction_url=self.prediction_url or None, + health_check_url=self.health_check_url, + ) + resources = None + if include_resources: + resources = ServiceResponseResources( + model_version=self.model_version.to_model() + if self.model_version + else None, + pipeline_run=self.pipeline_run.to_model() + if self.pipeline_run + else None, + ) + return ServiceResponse( + id=self.id, + name=self.name, + body=body, + metadata=metadata, + resources=resources, + ) + + def update( + self, + update: ServiceUpdate, + ) -> "ServiceSchema": + """Updates a `ServiceSchema` from a `ServiceUpdate`. + + Args: + update: The `ServiceUpdate` to update from. + + Returns: + The updated `ServiceSchema`. + """ + for field, value in update.dict( + exclude_unset=True, exclude_none=True + ).items(): + if field == "labels": + self.labels = ( + dict_to_bytes(update.labels) if update.labels else None + ) + elif field == "status": + self.status = ( + dict_to_bytes(update.status) if update.status else None + ) + self.state = ( + update.status.get("state") if update.status else None + ) + elif field == "endpoint": + self.endpoint = ( + dict_to_bytes(update.endpoint) if update.endpoint else None + ) + else: + setattr(self, field, value) + self.updated = datetime.utcnow() + return self + + @classmethod + def from_request( + cls, service_request: "ServiceRequest" + ) -> "ServiceSchema": + """Convert a `ServiceRequest` to a `ServiceSchema`. + + Args: + service_request: The request model to convert. + + Returns: + The converted schema. + """ + return cls( + name=service_request.name, + workspace_id=service_request.workspace, + user_id=service_request.user, + service_source=service_request.service_source, + service_type=service_request.service_type.json(), + type=service_request.service_type.type, + flavor=service_request.service_type.flavor, + admin_state=service_request.admin_state, + config=dict_to_bytes(service_request.config), + labels=dict_to_bytes(service_request.labels) + if service_request.labels + else None, + status=dict_to_bytes(service_request.status) + if service_request.status + else None, + endpoint=dict_to_bytes(service_request.endpoint) + if service_request.endpoint + else None, + state=service_request.status.get("state") + if service_request.status + else None, + model_version_id=service_request.model_version_id, + pipeline_run_id=service_request.pipeline_run_id, + prediction_url=service_request.prediction_url, + health_check_url=service_request.health_check_url, + pipeline_name=service_request.config.get("pipeline_name"), + pipeline_step_name=service_request.config.get( + "pipeline_step_name" + ), + ) diff --git a/src/zenml/zen_stores/schemas/user_schemas.py b/src/zenml/zen_stores/schemas/user_schemas.py index 610e45d4c18..72737ffa9b8 100644 --- a/src/zenml/zen_stores/schemas/user_schemas.py +++ b/src/zenml/zen_stores/schemas/user_schemas.py @@ -54,6 +54,7 @@ ScheduleSchema, SecretSchema, ServiceConnectorSchema, + ServiceSchema, StackComponentSchema, StackSchema, StepRunSchema, @@ -124,6 +125,7 @@ class UserSchema(NamedSchema, table=True): code_repositories: List["CodeRepositorySchema"] = Relationship( back_populates="user", ) + services: List["ServiceSchema"] = Relationship(back_populates="user") service_connectors: List["ServiceConnectorSchema"] = Relationship( back_populates="user", ) diff --git a/src/zenml/zen_stores/schemas/workspace_schemas.py b/src/zenml/zen_stores/schemas/workspace_schemas.py index aa9fd28f16c..3da451ac6c1 100644 --- a/src/zenml/zen_stores/schemas/workspace_schemas.py +++ b/src/zenml/zen_stores/schemas/workspace_schemas.py @@ -45,6 +45,7 @@ ScheduleSchema, SecretSchema, ServiceConnectorSchema, + ServiceSchema, StackComponentSchema, StackSchema, StepRunSchema, @@ -120,6 +121,10 @@ class WorkspaceSchema(NamedSchema, table=True): back_populates="workspace", sa_relationship_kwargs={"cascade": "delete"}, ) + services: List["ServiceSchema"] = Relationship( + back_populates="workspace", + sa_relationship_kwargs={"cascade": "delete"}, + ) service_connectors: List["ServiceConnectorSchema"] = Relationship( back_populates="workspace", sa_relationship_kwargs={"cascade": "delete"}, diff --git a/src/zenml/zen_stores/sql_zen_store.py b/src/zenml/zen_stores/sql_zen_store.py index cc8a7c3bb75..7cf37dcad84 100644 --- a/src/zenml/zen_stores/sql_zen_store.py +++ b/src/zenml/zen_stores/sql_zen_store.py @@ -40,6 +40,7 @@ from uuid import UUID from pydantic import Field, SecretStr, root_validator, validator +from pydantic.json import pydantic_encoder from sqlalchemy import asc, desc, func from sqlalchemy.engine import URL, Engine, make_url from sqlalchemy.exc import ( @@ -208,6 +209,10 @@ ServiceConnectorResponse, ServiceConnectorTypeModel, ServiceConnectorUpdate, + ServiceFilter, + ServiceRequest, + ServiceResponse, + ServiceUpdate, StackFilter, StackRequest, StackResponse, @@ -298,6 +303,7 @@ ArtifactVisualizationSchema, ) from zenml.zen_stores.schemas.logs_schemas import LogsSchema +from zenml.zen_stores.schemas.service_schemas import ServiceSchema from zenml.zen_stores.schemas.trigger_schemas import TriggerSchema from zenml.zen_stores.secrets_stores.base_secrets_store import BaseSecretsStore from zenml.zen_stores.secrets_stores.sql_secrets_store import ( @@ -1763,6 +1769,175 @@ def delete_api_key( session.delete(api_key) session.commit() + # -------------------- Services -------------------- + + @staticmethod + def _fail_if_service_with_config_exists( + service_request: ServiceRequest, session: Session + ) -> None: + """Raise an exception if a service with same name/config exists. + + Args: + service_request: The service to check for. + session: The database session to use for the query. + + Raises: + EntityExistsError: If a service with the given name and + type already exists. + """ + # Check if service with the same domain key (name, config, workspace) + # already exists + + existing_domain_service = session.exec( + select(ServiceSchema).where( + ServiceSchema.config + == base64.b64encode( + json.dumps( + service_request.config, + sort_keys=False, + default=pydantic_encoder, + ).encode("utf-8") + ) + ) + ).first() + + if existing_domain_service: + raise EntityExistsError( + f"Unable to create service '{service_request.name}' with the given configuration: " + "A service with the same configuration already exists." + ) + + def create_service(self, service: ServiceRequest) -> ServiceResponse: + """Create a new service. + + Args: + service: The service to create. + + Returns: + The newly created service. + """ + with Session(self.engine) as session: + # Check if a service with the given name already exists + self._fail_if_service_with_config_exists( + service_request=service, + session=session, + ) + + # Create the service. + service_schema = ServiceSchema.from_request(service) + logger.debug("Creating service: %s", service_schema) + session.add(service_schema) + session.commit() + + return service_schema.to_model( + include_metadata=True, include_resources=True + ) + + def get_service( + self, service_id: UUID, hydrate: bool = True + ) -> ServiceResponse: + """Get a service. + + Args: + service_id: The ID of the service to get. + hydrate: Flag deciding whether to hydrate the output model(s) + by including metadata fields in the response. + + Returns: + The service. + + Raises: + KeyError: if the service doesn't exist. + """ + with Session(self.engine) as session: + service = session.exec( + select(ServiceSchema).where(ServiceSchema.id == service_id) + ).first() + if service is None: + raise KeyError( + f"Unable to get service with ID {service_id}: No " + "service with this ID found." + ) + return service.to_model( + include_metadata=hydrate, include_resources=hydrate + ) + + def list_services( + self, filter_model: ServiceFilter, hydrate: bool = False + ) -> Page[ServiceResponse]: + """List all services matching the given filter criteria. + + Args: + filter_model: All filter parameters including pagination + params. + hydrate: Flag deciding whether to hydrate the output model(s) + by including metadata fields in the response. + + Returns: + A list of all services matching the filter criteria. + """ + with Session(self.engine) as session: + query = select(ServiceSchema) + return self.filter_and_paginate( + session=session, + query=query, + table=ServiceSchema, + filter_model=filter_model, + hydrate=hydrate, + ) + + def update_service( + self, service_id: UUID, update: ServiceUpdate + ) -> ServiceResponse: + """Update a service. + + Args: + service_id: The ID of the service to update. + update: The update to be applied to the service. + + Returns: + The updated service. + + Raises: + KeyError: if the service doesn't exist. + """ + with Session(self.engine) as session: + existing_service = session.exec( + select(ServiceSchema).where(ServiceSchema.id == service_id) + ).first() + if not existing_service: + raise KeyError(f"Service with ID {service_id} not found.") + + # Update the schema itself. + existing_service.update(update=update) + logger.debug("Updated service: %s", existing_service) + session.add(existing_service) + session.commit() + session.refresh(existing_service) + return existing_service.to_model( + include_metadata=True, include_resources=True + ) + + def delete_service(self, service_id: UUID) -> None: + """Delete a service. + + Args: + service_id: The ID of the service to delete. + + Raises: + KeyError: if the service doesn't exist. + """ + with Session(self.engine) as session: + existing_service = session.exec( + select(ServiceSchema).where(ServiceSchema.id == service_id) + ).first() + if not existing_service: + raise KeyError(f"Service with ID {service_id} not found.") + + # Delete the service + session.delete(existing_service) + session.commit() + # -------------------- Artifacts -------------------- def create_artifact(self, artifact: ArtifactRequest) -> ArtifactResponse: @@ -8420,7 +8595,9 @@ def get_model_version( f"`{model_version_id}`: No model version with this " f"ID found." ) - return model_version.to_model(include_metadata=hydrate) + return model_version.to_model( + include_metadata=hydrate, include_resources=hydrate + ) def list_model_versions( self, @@ -8569,7 +8746,9 @@ def update_model_version( session.commit() session.refresh(existing_model_version) - return existing_model_version.to_model(include_metadata=True) + return existing_model_version.to_model( + include_metadata=True, include_resources=True + ) # ------------------------ Model Versions Artifacts ------------------------ diff --git a/src/zenml/zen_stores/zen_store_interface.py b/src/zenml/zen_stores/zen_store_interface.py index 7914a5681bd..7163936d506 100644 --- a/src/zenml/zen_stores/zen_store_interface.py +++ b/src/zenml/zen_stores/zen_store_interface.py @@ -104,6 +104,10 @@ ServiceConnectorResponse, ServiceConnectorTypeModel, ServiceConnectorUpdate, + ServiceFilter, + ServiceRequest, + ServiceResponse, + ServiceUpdate, StackFilter, StackRequest, StackResponse, @@ -359,6 +363,87 @@ def delete_api_key( for the given service account. """ + # -------------------- Services -------------------- + + @abstractmethod + def create_service( + self, + service: ServiceRequest, + ) -> ServiceResponse: + """Create a new service. + + Args: + service: The service to create. + + Returns: + The newly created service. + + Raises: + EntityExistsError: If a service with the same name already exists. + """ + + @abstractmethod + def get_service( + self, service_id: UUID, hydrate: bool = True + ) -> ServiceResponse: + """Get a service by ID. + + Args: + service_id: The ID of the service to get. + hydrate: Flag deciding whether to hydrate the output model(s) + by including metadata fields in the response. + + Returns: + The service. + + Raises: + KeyError: if the service doesn't exist. + """ + + @abstractmethod + def list_services( + self, filter_model: ServiceFilter, hydrate: bool = False + ) -> Page[ServiceResponse]: + """List all services matching the given filter criteria. + + Args: + filter_model: All filter parameters including pagination + params. + hydrate: Flag deciding whether to hydrate the output model(s) + by including metadata fields in the response. + + Returns: + A list of all services matching the filter criteria. + """ + + @abstractmethod + def update_service( + self, service_id: UUID, update: ServiceUpdate + ) -> ServiceResponse: + """Update an existing service. + + Args: + service_id: The ID of the service to update. + update: The update to be applied to the service. + + Returns: + The updated service. + + Raises: + KeyError: if the service doesn't exist. + """ + + @abstractmethod + def delete_service(self, service_id: UUID) -> None: + """Delete a service. + + Args: + service_id: The ID of the service to delete. + + Raises: + KeyError: if the service doesn't exist. + """ + # -------------------- Artifacts -------------------- @abstractmethod diff --git a/tests/integration/examples/bentoml/steps/prediction_service_loader.py b/tests/integration/examples/bentoml/steps/prediction_service_loader.py index 3871fe1e8ab..1fa9e669a9c 100644 --- a/tests/integration/examples/bentoml/steps/prediction_service_loader.py +++ b/tests/integration/examples/bentoml/steps/prediction_service_loader.py @@ -29,7 +29,7 @@ def bentoml_prediction_service_loader( """Get the BentoML prediction service started by the deployment pipeline. Args: - pipeline_name: name of the pipeline that deployed the model. + pipeline_name: name of the pipeline_name that deployed the model. step_name: the name of the step that deployed the model. model_name: the name of the model that was deployed. """ diff --git a/tests/integration/examples/huggingface/steps/prediction_service_loader/prediction_service_loader.py b/tests/integration/examples/huggingface/steps/prediction_service_loader/prediction_service_loader.py index 49a763bdca1..91ea669c9cc 100644 --- a/tests/integration/examples/huggingface/steps/prediction_service_loader/prediction_service_loader.py +++ b/tests/integration/examples/huggingface/steps/prediction_service_loader/prediction_service_loader.py @@ -43,19 +43,16 @@ def prediction_service_loader( # get the Huggingface model deployer stack component model_deployer = HuggingFaceModelDeployer.get_active_model_deployer() - # fetch existing services with same pipeline name, step name and model name - services = model_deployer.find_model_server( + if services := model_deployer.find_model_server( pipeline_name=pipeline_name, pipeline_step_name=pipeline_step_name, model_name=model_name, running=running, - ) - - if not services: + ): + return cast(HuggingFaceDeploymentService, services[0]) + else: raise RuntimeError( f"No Huggingface inference endpoint deployed by step " f"'{pipeline_step_name}' in pipeline '{pipeline_name}' with name " f"'{model_name}' is currently running." ) - - return cast(HuggingFaceDeploymentService, services[0]) diff --git a/tests/integration/examples/mlflow/pipelines/deployment_pipelines/deployment_inference_pipeline.py b/tests/integration/examples/mlflow/pipelines/deployment_pipelines/deployment_inference_pipeline.py index fad0bd06331..29bb1b57887 100644 --- a/tests/integration/examples/mlflow/pipelines/deployment_pipelines/deployment_inference_pipeline.py +++ b/tests/integration/examples/mlflow/pipelines/deployment_pipelines/deployment_inference_pipeline.py @@ -36,6 +36,5 @@ def mlflow_deployment_inference_pipeline( model_deployment_service = prediction_service_loader( pipeline_name=pipeline_name, pipeline_step_name=pipeline_step_name, - running=False, ) predictor(model_deployment_service, inference_data) diff --git a/tests/integration/examples/mlflow/steps/prediction_service_loader_step.py b/tests/integration/examples/mlflow/steps/prediction_service_loader_step.py index 36067d0dfbb..4e4e8427b37 100644 --- a/tests/integration/examples/mlflow/steps/prediction_service_loader_step.py +++ b/tests/integration/examples/mlflow/steps/prediction_service_loader_step.py @@ -24,7 +24,6 @@ def prediction_service_loader( pipeline_name: str, pipeline_step_name: str, running: bool = True, - model_name: str = "model", ) -> MLFlowDeploymentService: """Get the prediction service started by the deployment pipeline. @@ -40,19 +39,13 @@ def prediction_service_loader( model_deployer = MLFlowModelDeployer.get_active_model_deployer() # fetch existing services with same pipeline name, step name and model name - existing_services = model_deployer.find_model_server( - pipeline_name=pipeline_name, - pipeline_step_name=pipeline_step_name, - model_name=model_name, - running=running, - ) + existing_services = model_deployer.find_model_server() if not existing_services: raise RuntimeError( f"No MLflow prediction service deployed by the " f"{pipeline_step_name} step in the {pipeline_name} " - f"pipeline for the '{model_name}' model is currently " - f"running." + f"pipeline" ) return existing_services[0] diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index 930d8a48929..df139355033 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -69,10 +69,17 @@ WorkspaceResponseBody, WorkspaceResponseMetadata, ) +from zenml.models.v2.core.service import ( + ServiceResponse, + ServiceResponseBody, + ServiceResponseMetadata, +) from zenml.new.pipelines.pipeline import Pipeline from zenml.orchestrators.base_orchestrator import BaseOrchestratorConfig from zenml.orchestrators.local.local_orchestrator import LocalOrchestrator from zenml.pipelines import pipeline +from zenml.services.service_status import ServiceState +from zenml.services.service_type import ServiceType from zenml.stack.stack import Stack from zenml.stack.stack_component import ( StackComponentConfig, @@ -693,3 +700,64 @@ def sample_hub_plugin_response_model() -> HubPluginResponseModel: updated=datetime.now(), requirements=["ploogin==0.0.1", "zenml>=0.1.0"], ) + + +# Test data +service_id = "12345678-1234-5678-1234-567812345678" +service_name = "test_service" +service_type = ServiceType( + type="model-serving", flavor="test_flavor", name="test_name" +) +service_source = "tests.unit.services.test_service.TestService" +admin_state = ServiceState.ACTIVE +config = { + "type": "zenml.services.service.ServiceConfig", + "name": "test_service", + "description": "", + "pipeline_name": "", + "pipeline_step_name": "", + "model_name": "", + "model_version": "", + "service_name": "zenml-test_service", +} +labels = {"label1": "value1", "label2": "value2"} +status = { + "type": "zenml.services.service_status.ServiceStatus", + "state": ServiceState.ACTIVE, + "last_state": ServiceState.INACTIVE, + "last_error": "", +} +endpoint = None +prediction_url = "http://example.com/predict" +health_check_url = "http://example.com/health" +created_time = datetime(2024, 3, 14, 10, 30) +updated_time = datetime(2024, 3, 14, 11, 45) + + +@pytest.fixture +def service_response( + sample_workspace_model, +): + body = ServiceResponseBody( + service_type=service_type, + labels=labels, + created=created_time, + updated=updated_time, + state=admin_state, + ) + metadata = ServiceResponseMetadata( + service_source=service_source, + admin_state=admin_state, + config=config, + status=status, + endpoint=endpoint, + prediction_url=prediction_url, + health_check_url=health_check_url, + workspace=sample_workspace_model, + ) + return ServiceResponse( + id=service_id, + name=service_name, + body=body, + metadata=metadata, + ) diff --git a/tests/unit/models/test_service_models.py b/tests/unit/models/test_service_models.py new file mode 100644 index 00000000000..2148d576222 --- /dev/null +++ b/tests/unit/models/test_service_models.py @@ -0,0 +1,130 @@ +# Copyright (c) ZenML GmbH 2023. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at: +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express +# or implied. See the License for the specific language governing +# permissions and limitations under the License. + +from datetime import datetime + +import pytest + +from zenml.constants import STR_FIELD_MAX_LENGTH +from zenml.models import ( + ServiceRequest, + ServiceResponse, + ServiceResponseBody, + ServiceResponseMetadata, +) +from zenml.services.service_status import ServiceState +from zenml.services.service_type import ServiceType + +# Test data +service_id = "12345678-1234-5678-1234-567812345678" +service_name = "test_service" +service_type = ServiceType( + type="model-serving", flavor="test_flavor", name="test_name" +) +service_source = "tests.unit.services.test_service.TestService" +admin_state = ServiceState.ACTIVE +config = { + "type": "zenml.services.service.ServiceConfig", + "name": "test_service", + "description": "", + "pipeline_name": "", + "pipeline_step_name": "", + "model_name": "", + "model_version": "", + "service_name": "zenml-test_service", +} +labels = {"label1": "value1", "label2": "value2"} +status = { + "type": "zenml.services.service_status.ServiceStatus", + "state": ServiceState.ACTIVE, + "last_state": ServiceState.INACTIVE, + "last_error": "", +} +endpoint = None +prediction_url = "http://example.com/predict" +health_check_url = "http://example.com/health" +created_time = datetime(2023, 3, 14, 10, 30) +updated_time = datetime(2023, 3, 14, 11, 45) + + +@pytest.fixture +def service_response( + sample_workspace_model, +): + body = ServiceResponseBody( + service_type=service_type, + labels=labels, + created=created_time, + updated=updated_time, + state=admin_state, + ) + metadata = ServiceResponseMetadata( + service_source=service_source, + admin_state=admin_state, + config=config, + status=status, + endpoint=endpoint, + prediction_url=prediction_url, + health_check_url=health_check_url, + workspace=sample_workspace_model, + ) + return ServiceResponse( + id=service_id, + name=service_name, + body=body, + metadata=metadata, + ) + + +def test_service_response_properties(service_response): + assert service_response.service_type == service_type + assert service_response.labels == labels + assert service_response.service_source == service_source + assert service_response.config == config + assert service_response.status == status + assert service_response.endpoint == endpoint + assert service_response.created == created_time + assert service_response.updated == updated_time + assert service_response.admin_state == admin_state + assert service_response.prediction_url == prediction_url + assert service_response.health_check_url == health_check_url + assert service_response.state == admin_state + + +def test_service_request_name_too_long(): + # Test that the service name cannot be longer than the maximum allowed length + long_name = "a" * (STR_FIELD_MAX_LENGTH + 1) + with pytest.raises(ValueError): + ServiceRequest( + name=long_name, + service_type=ServiceType( + type="model-serving", flavor="test_flavor", name="test_name" + ), + service_source="path.to.ServiceClass", + admin_state=ServiceState.ACTIVE, + config={"param1": "value1"}, + ) + + +def test_service_request_invalid_service_type(): + # Test that an invalid service type raises an error + invalid_service_type = "invalid_type" + with pytest.raises(ValueError): + ServiceRequest( + name="test_service", + service_type=invalid_service_type, + service_source="path.to.ServiceClass", + admin_state=ServiceState.ACTIVE, + config={"param1": "value1"}, + ) diff --git a/tests/unit/services/__init__.py b/tests/unit/services/__init__.py new file mode 100644 index 00000000000..cd90a82cfc2 --- /dev/null +++ b/tests/unit/services/__init__.py @@ -0,0 +1,13 @@ +# Copyright (c) ZenML GmbH 2024. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at: +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express +# or implied. See the License for the specific language governing +# permissions and limitations under the License. diff --git a/tests/unit/services/test_service.py b/tests/unit/services/test_service.py new file mode 100644 index 00000000000..b0875e9d62d --- /dev/null +++ b/tests/unit/services/test_service.py @@ -0,0 +1,112 @@ +# Copyright (c) ZenML GmbH 2024. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at: +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express +# or implied. See the License for the specific language governing +# permissions and limitations under the License. +from typing import Generator, Optional, Tuple +from uuid import UUID + +import pytest + +from zenml.services import ( + BaseService, + ServiceConfig, + ServiceState, + ServiceStatus, +) +from zenml.services.service import ZENM_ENDPOINT_PREFIX + + +# Create a concrete subclass of BaseService +class TestService(BaseService): + """Test service class for testing BaseService.""" + + SERVICE_TYPE = { + "type": "model-serving", + "flavor": "test_flavor", + "name": "test_name", + } + + @property + def is_running(self): + return True + + @property + def is_stopped(self): + return not self.is_running + + @property + def is_failed(self): + return False + + def check_status(self) -> Tuple[ServiceState, str]: + return ServiceState.ACTIVE, "Service is running" + + def get_logs( + self, follow: bool = False, tail: Optional[int] = None + ) -> Generator[str, bool, None]: + return (f"log line {i}" for i in range(5)) + + +# Modify the base_service fixture to use the TestService subclass +@pytest.fixture +def base_service(): + return TestService( + uuid=UUID("12345678-1234-5678-1234-567812345678"), + admin_state=ServiceState.ACTIVE, + config=ServiceConfig(name="test_service", param1="value1", param2=2), + status=ServiceStatus( + state=ServiceState.ACTIVE, + last_error="", + last_status=ServiceState.INACTIVE, + ), + endpoint=None, + ) + + +# Update the test_from_model to handle the case when service_source is missing +def test_from_model(service_response): + service = BaseService.from_model(service_response) + assert isinstance(service, TestService) + assert service.uuid == service_response.id + assert service.admin_state == service_response.admin_state + assert service.config == service_response.config + assert service.status == service_response.status + assert service.SERVICE_TYPE["type"] == service_response.service_type.type + assert ( + service.SERVICE_TYPE["flavor"] == service_response.service_type.flavor + ) + assert service.endpoint == service_response.endpoint + + +def test_update_status(base_service, monkeypatch): + def mock_check_status(self): + return ServiceState.ACTIVE, "Service is running" + + monkeypatch.setattr(BaseService, "check_status", mock_check_status) + base_service.update_status() + + assert base_service.status.state == ServiceState.ACTIVE + assert base_service.status.last_error == "Service is running" + + +def test_service_config_init_without_name_or_model_name(): + """Test initialization without name or model_name.""" + with pytest.raises(ValueError) as excinfo: + ServiceConfig() + assert "Either 'name' or 'model_name' must be set." in str(excinfo.value) + + +def test_service_config_init_with_name(): + """Test initialization with name.""" + config = ServiceConfig(name="test-service") + assert config.name == "test-service" + assert config.service_name == f"{ZENM_ENDPOINT_PREFIX}test-service" From 90c5bda2a2d6b34eade99664d91f1aae2d50bc55 Mon Sep 17 00:00:00 2001 From: Safoine El Khabich <34200873+safoinme@users.noreply.github.com> Date: Wed, 20 Mar 2024 23:54:09 +0000 Subject: [PATCH 30/45] Prepare release 0.56.0 (#2546) * Prepare release 0.56.0 * Auto-update of LLM Finetuning template * Apply suggestions from code review Co-authored-by: Alex Strick van Linschoten * Refactor model deployment process and enable continuous deployment * Apply suggestions from code review Co-authored-by: Alex Strick van Linschoten * Update model deployment process and service functions * Auto-update of LLM Finetuning template * Fix model deployment issue * Add admin user notion, protection for certain operations, and rate limiting for login API * Add support for LLM template in ZenML --------- Co-authored-by: GitHub Actions Co-authored-by: Alex Strick van Linschoten --- README.md | 2 +- RELEASE_NOTES.md | 172 ++++++++++++++++++ examples/llm_finetuning/.assets/model.png | Bin 542896 -> 288155 bytes pyproject.toml | 2 +- src/zenml/VERSION | 2 +- src/zenml/zen_server/deploy/helm/Chart.yaml | 2 +- src/zenml/zen_server/deploy/helm/README.md | 4 +- .../migrations/versions/0.56.0_release.py | 23 +++ 8 files changed, 201 insertions(+), 6 deletions(-) create mode 100644 src/zenml/zen_stores/migrations/versions/0.56.0_release.py diff --git a/README.md b/README.md index d699f50859e..d12ef0ed80d 100644 --- a/README.md +++ b/README.md @@ -92,7 +92,7 @@ Projects Showcase

- 🎉 Version 0.55.5 is out. Check out the release notes + 🎉 Version 0.56.0 is out. Check out the release notes here.

diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 50c69d796ca..3238b88c295 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,4 +1,176 @@ +# 0.56.0 + +ZenML 0.56.0 introduces a wide array of new features, enhancements, and bug fixes, +with a strong emphasis on elevating the user experience and streamlining machine +learning workflows. Most notably, you can now deploy models using Hugging Face inference endpoints thanks for an open-source community contribution of this model deployer stack component! + +This release also comes with a breaking change to the services +architecture. + +## Breaking Change + +A significant change in this release is the migration of the `Service` (ZenML's technical term for deployment) +registration and deployment from local or remote environments to the ZenML server. +This change will be reflected in an upcoming tab in the dashboard which will +allow users to explore and see the deployed models in the dashboard with their live +status and metadata. This architectural shift also simplifies the model deployer +abstraction and streamlines the model deployment process for users by moving from +limited built-in steps to a more documented and flexible approach. + +Important note: If you have models that you previously deployed with ZenML, you might +want to redeploy them to have them stored in the ZenML server and tracked by ZenML, +ensuring they appear in the dashboard. + +Additionally, the `find_model_server` method now retrieves models (services) from the +ZenML server instead of local or remote deployment environments. As a result, any +usage of `find_model_server` will only return newly deployed models stored in the server. + +It is also no longer recommended to call service functions like `service.start()`. +Instead, use `model_deployer.start_model_server(service_id)`, which will allow ZenML +to update the changed status of the service in the server. + +### Starting a service +**Old syntax:** +```python +from zenml import pipeline, +from zenml.integrations.bentoml.services.bentoml_deployment import BentoMLDeploymentService + +@step +def predictor( + service: BentoMLDeploymentService, +) -> None: + # starting the service + service.start(timeout=10) +``` + +**New syntax:** +```python +from zenml import pipeline +from zenml.integrations.bentoml.model_deployers import BentoMLModelDeployer +from zenml.integrations.bentoml.services.bentoml_deployment import BentoMLDeploymentService + +@step +def predictor( + service: BentoMLDeploymentService, +) -> None: + # starting the service + model_deployer = BentoMLModelDeployer.get_active_model_deployer() + model_deployer.start_model_server(service_id=service.service_id, timeout=10) +``` + +### Enabling continuous deployment + +Instead of replacing the parameter that was used in the `deploy_model` method to replace the +existing service (if it matches the exact same pipeline name and step name without +taking into accounts other parameters or configurations), we now have a new parameter, +`continuous_deployment_mode`, that allows you to enable continuous deployment for +the service. This will ensure that the service is updated with the latest version +if it's on the same pipeline and step and the service is not already running. Otherwise, +any new deployment with different configurations will create a new service. + +```python +from zenml import pipeline, step, get_step_context +from zenml.client import Client + +@step +def deploy_model() -> Optional[MLFlowDeploymentService]: + # Deploy a model using the MLflow Model Deployer + zenml_client = Client() + model_deployer = zenml_client.active_stack.model_deployer + mlflow_deployment_config = MLFlowDeploymentConfig( + name: str = "mlflow-model-deployment-example", + description: str = "An example of deploying a model using the MLflow Model Deployer", + pipeline_name: str = get_step_context().pipeline_name, + pipeline_step_name: str = get_step_context().step_name, + model_uri: str = "runs://model" or "models://", + model_name: str = "model", + workers: int = 1 + mlserver: bool = False + timeout: int = DEFAULT_SERVICE_START_STOP_TIMEOUT + ) + service = model_deployer.deploy_model(mlflow_deployment_config, continuous_deployment_mode=True) + logger.info(f"The deployed service info: {model_deployer.get_model_server_info(service)}") + return service +``` + + +## Major Features and Enhancements: + +* A new `Huggingface Model Deployer` has been introduced, allowing you to seamlessly +deploy your Huggingface models using ZenML. (Thank you so much @dudeperf3ct for the contribution!) +* Faster Integration and Dependency Management ZenML now leverages the `uv` library, +significantly improving the speed of integration installations and dependency management, +resulting in a more streamlined and efficient workflow. +* Enhanced Logging and Status Tracking Logging have been improved, providing better +visibility into the state of your ZenML services. +* Improved Artifact Store Isolation: ZenML now prevents unsafe operations that access +data outside the scope of the artifact store, ensuring better isolation and security. +* Adding admin user notion for the user accounts and added protection to certain operations +performed via the REST interface to ADMIN-allowed only. +* Rate limiting for login API to prevent abuse and protect the server from potential +security threats. +* The LLM template is now supported in ZenML, allowing you to use the LLM template +for your pipelines. + + +## 🥳 Community Contributions 🥳 + +We'd like to give a special thanks to @dudeperf3ct he contributed to this release +by introducing the Huggingface Model Deployer. We'd also like to thank @moesio-f +for their contribution to this release by adding a new attribute to the `Kaniko` image builder. +Additionally, we'd like to thank @christianversloot for his contributions to this release. + + +## All changes: + +* Upgrading SQLModel to the latest version by @bcdurak in https://github.com/zenml-io/zenml/pull/2452 +* Remove KServe integration by @safoinme in https://github.com/zenml-io/zenml/pull/2495 +* Upgrade migration testing with 0.55.5 by @avishniakov in https://github.com/zenml-io/zenml/pull/2501 +* Relax azure, gcfs and s3 dependencies by @strickvl in https://github.com/zenml-io/zenml/pull/2498 +* Use HTTP forwarded headers to detect the real origin of client devices by @stefannica in https://github.com/zenml-io/zenml/pull/2499 +* Update README.md for quickstart colab link by @strickvl in https://github.com/zenml-io/zenml/pull/2505 +* Add sequential migration tests for MariaDB and MySQL by @strickvl in https://github.com/zenml-io/zenml/pull/2502 +* Huggingface Model Deployer by @dudeperf3ct in https://github.com/zenml-io/zenml/pull/2376 +* Use `uv` to speed up pip installs & the CI in general by @strickvl in https://github.com/zenml-io/zenml/pull/2442 +* Handle corrupted or empty global configuration file by @stefannica in https://github.com/zenml-io/zenml/pull/2508 +* Add admin users notion by @avishniakov in https://github.com/zenml-io/zenml/pull/2494 +* Remove dashboard from gitignore by @safoinme in https://github.com/zenml-io/zenml/pull/2517 +* Colima / Homebrew fix by @strickvl in https://github.com/zenml-io/zenml/pull/2512 +* [HELM] Remove extra environment variable assignment by @wjayesh in https://github.com/zenml-io/zenml/pull/2518 +* Allow installing packages using UV by @schustmi in https://github.com/zenml-io/zenml/pull/2510 +* Additional fields for track events by @bcdurak in https://github.com/zenml-io/zenml/pull/2507 +* Check if environment key is set before deleting in HyperAI orchestrator by @christianversloot in https://github.com/zenml-io/zenml/pull/2511 +* Fix the pagination in the database backup by @stefannica in https://github.com/zenml-io/zenml/pull/2522 +* Bump mlflow to version 2.11.1 by @christianversloot in https://github.com/zenml-io/zenml/pull/2524 +* Add docs for uv installation by @schustmi in https://github.com/zenml-io/zenml/pull/2527 +* Fix bug in HyperAI orchestrator depends_on parallelism by @christianversloot in https://github.com/zenml-io/zenml/pull/2523 +* Upgrade pip in docker images by @schustmi in https://github.com/zenml-io/zenml/pull/2528 +* Fix node selector and other fields for DB job in helm chart by @stefannica in https://github.com/zenml-io/zenml/pull/2531 +* Revert "Upgrading SQLModel to the latest version" by @bcdurak in https://github.com/zenml-io/zenml/pull/2515 +* Add `pod_running_timeout` attribute to `Kaniko` image builder by @moesio-f in https://github.com/zenml-io/zenml/pull/2509 +* Add test to install dashboard script by @strickvl in https://github.com/zenml-io/zenml/pull/2521 +* Sort pipeline namespaces by last run by @schustmi in https://github.com/zenml-io/zenml/pull/2514 +* Add support for LLM template by @schustmi in https://github.com/zenml-io/zenml/pull/2519 +* Rate limiting for login API by @avishniakov in https://github.com/zenml-io/zenml/pull/2484 +* Try/catch for Docker client by @christianversloot in https://github.com/zenml-io/zenml/pull/2513 +* Fix config file in starter guide by @schustmi in https://github.com/zenml-io/zenml/pull/2534 +* Log URL for pipelines and model versions when running a pipeline by @wjayesh in https://github.com/zenml-io/zenml/pull/2506 +* Add security exclude by @schustmi in https://github.com/zenml-io/zenml/pull/2541 +* Update error message around notebook use by @strickvl in https://github.com/zenml-io/zenml/pull/2536 +* Cap `fsspec` for Huggingface integration by @avishniakov in https://github.com/zenml-io/zenml/pull/2542 +* Fix integration materializers' URLs in docs by @strickvl in https://github.com/zenml-io/zenml/pull/2538 +* Bug fix HyperAI orchestrator: Offload scheduled pipeline execution to bash script by @christianversloot in https://github.com/zenml-io/zenml/pull/2535 +* Update `pip check` command to use `uv` by @strickvl in https://github.com/zenml-io/zenml/pull/2520 +* Implemented bitbucket webhook event source by @AlexejPenner in https://github.com/zenml-io/zenml/pull/2481 +* Add ZenMLServiceType and update service registration by @safoinme in https://github.com/zenml-io/zenml/pull/2471 + +## New Contributors +* @dudeperf3ct made their first contribution in https://github.com/zenml-io/zenml/pull/2376 +* @moesio-f made their first contribution in https://github.com/zenml-io/zenml/pull/2509 + +**Full Changelog**: https://github.com/zenml-io/zenml/compare/0.55.5...0.56.0 + # 0.55.5 This patch contains a number of bug fixes and security improvements. diff --git a/examples/llm_finetuning/.assets/model.png b/examples/llm_finetuning/.assets/model.png index 2201c2e9e9418614335b99d1d5aad591a68f2926..12b95c69d010d2180cff151bf2a65ca0f1c4d72e 100644 GIT binary patch literal 288155 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

vHI=f6C9OkpBVl8_E-T6B_9EHNJL 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≫-(VE4Tz|Ih%`UG9L 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 VcKkZXAQWS>TTGzYwZZ;}{|}DI)9wHO literal 542896 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

{3aw;P%YXlSf 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
ZenML Scarf
From 260d89be282b2cc9e952a4aeef017fadcef63419 Mon Sep 17 00:00:00 2001 From: Andrei Vishniakov <31008759+avishniakov@users.noreply.github.com> Date: Fri, 22 Mar 2024 10:06:20 +0100 Subject: [PATCH 38/45] add 0.56.0 and 0.56.1 to migration testing (#2557) --- scripts/test-migrations-mariadb.sh | 2 +- scripts/test-migrations-mysql.sh | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/test-migrations-mariadb.sh b/scripts/test-migrations-mariadb.sh index 12167e2894e..459d96853d6 100755 --- a/scripts/test-migrations-mariadb.sh +++ b/scripts/test-migrations-mariadb.sh @@ -45,7 +45,7 @@ docker run --name mariadb -d -p 3306:3306 -e MYSQL_ROOT_PASSWORD=password mariad sleep $DB_STARTUP_DELAY # List of versions to test -VERSIONS=("0.54.0" "0.54.1" "0.55.0" "0.55.1" "0.55.2" "0.55.3" "0.55.4" "0.55.5") +VERSIONS=("0.54.0" "0.54.1" "0.55.0" "0.55.1" "0.55.2" "0.55.3" "0.55.4" "0.55.5" "0.56.0" "0.56.1") # Start completely fresh rm -rf ~/.config/zenml diff --git a/scripts/test-migrations-mysql.sh b/scripts/test-migrations-mysql.sh index 4a52ecfa927..fea29a09a59 100755 --- a/scripts/test-migrations-mysql.sh +++ b/scripts/test-migrations-mysql.sh @@ -63,7 +63,7 @@ if [ "$1" == "mysql" ]; then fi # List of versions to test -VERSIONS=("0.40.0" "0.40.3" "0.41.0" "0.43.0" "0.44.1" "0.44.3" "0.45.2" "0.45.3" "0.45.4" "0.45.5" "0.45.6" "0.46.0" "0.47.0" "0.50.0" "0.51.0" "0.52.0" "0.53.0" "0.53.1" "0.54.0" "0.54.1" "0.55.0" "0.55.1" "0.55.2" "0.55.3" "0.55.4" "0.55.5") +VERSIONS=("0.40.0" "0.40.3" "0.41.0" "0.43.0" "0.44.1" "0.44.3" "0.45.2" "0.45.3" "0.45.4" "0.45.5" "0.45.6" "0.46.0" "0.47.0" "0.50.0" "0.51.0" "0.52.0" "0.53.0" "0.53.1" "0.54.0" "0.54.1" "0.55.0" "0.55.1" "0.55.2" "0.55.3" "0.55.4" "0.55.5" "0.56.0" "0.56.1") # Start completely fresh rm -rf ~/.config/zenml From 8a4dd583ba4e8758bb4a5b59bf7e11d14a1fe997 Mon Sep 17 00:00:00 2001 From: Michael Schuster Date: Fri, 22 Mar 2024 10:28:38 +0100 Subject: [PATCH 39/45] Only install uv once (#2558) --- .../utils/pipeline_docker_image_builder.py | 29 +++++++++---------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/src/zenml/utils/pipeline_docker_image_builder.py b/src/zenml/utils/pipeline_docker_image_builder.py index 32de37d04a5..88a090b8599 100644 --- a/src/zenml/utils/pipeline_docker_image_builder.py +++ b/src/zenml/utils/pipeline_docker_image_builder.py @@ -626,25 +626,24 @@ def _generate_zenml_pipeline_dockerfile( f"--no-install-recommends {apt_packages}" ) + if ( + docker_settings.python_package_installer + == PythonPackageInstaller.PIP + ): + install_command = "pip install --default-timeout=60" + elif ( + docker_settings.python_package_installer + == PythonPackageInstaller.UV + ): + lines.append("RUN pip install uv") + install_command = "uv pip install --system" + else: + raise ValueError("Unsupported python package installer.") + for file, _, options in requirements_files: lines.append(f"COPY {file} .") - option_string = " ".join(options) - if ( - docker_settings.python_package_installer - == PythonPackageInstaller.PIP - ): - install_command = "pip install --default-timeout=60" - elif ( - docker_settings.python_package_installer - == PythonPackageInstaller.UV - ): - lines.append("RUN pip install uv") - install_command = "uv pip install --system" - else: - raise ValueError("Unsupported python package installer.") - lines.append( f"RUN {install_command} --no-cache-dir " f"{option_string} -r {file}" From 2b7a2e65732987a1f634f5e8cc35a31e74da0fde Mon Sep 17 00:00:00 2001 From: Christian Versloot Date: Mon, 25 Mar 2024 10:14:59 +0100 Subject: [PATCH 40/45] Bump mlflow to 2.11.3 (#2559) --- src/zenml/integrations/mlflow/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/zenml/integrations/mlflow/__init__.py b/src/zenml/integrations/mlflow/__init__.py index 753e9191618..3cd8a0d146f 100644 --- a/src/zenml/integrations/mlflow/__init__.py +++ b/src/zenml/integrations/mlflow/__init__.py @@ -35,7 +35,7 @@ class MlflowIntegration(Integration): # does not pin it. They fixed this in a later version, so we can probably # remove this once we update the mlflow version. REQUIREMENTS = [ - "mlflow>=2.1.1,<=2.11.2", + "mlflow>=2.1.1,<=2.11.3", "mlserver>=1.3.3", "mlserver-mlflow>=1.3.3", # TODO: remove this requirement once rapidjson is fixed From d8009209a1d4d7fc45c9802c4136d89f6eeee67b Mon Sep 17 00:00:00 2001 From: Andrei Vishniakov <31008759+avishniakov@users.noreply.github.com> Date: Mon, 25 Mar 2024 10:35:00 +0100 Subject: [PATCH 41/45] Update docs with warning about pickle materializer insecurity (#2561) * update docs with warning about pickle insecurity * Apply suggestions from code review Co-authored-by: Alex Strick van Linschoten --------- Co-authored-by: Alex Strick van Linschoten --- .../advanced-guide/data-management/artifact-versioning.md | 6 ++++++ .../data-management/handle-custom-data-types.md | 6 ++++-- .../advanced-guide/pipelining-features/managing-steps.md | 6 ++++++ 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/docs/book/user-guide/advanced-guide/data-management/artifact-versioning.md b/docs/book/user-guide/advanced-guide/data-management/artifact-versioning.md index 199a48ea356..3675c34fc11 100644 --- a/docs/book/user-guide/advanced-guide/data-management/artifact-versioning.md +++ b/docs/book/user-guide/advanced-guide/data-management/artifact-versioning.md @@ -32,6 +32,12 @@ By tracking the lineage of artifacts across environments and stacks, ZenML enabl Materializers are designed to be extensible and customizable, allowing you to define your own serialization and deserialization logic for specific data types or storage systems. By default, ZenML provides built-in materializers for common data types and uses `cloudpickle` to pickle objects where there is no default materializer. If you want direct control over how objects are serialized, you can easily create custom materializers by extending the `BaseMaterializer` class and implementing the required methods for your specific use case. Read more about materializers [here](handle-custom-data-types.md). +{% hint style="warning" %} +ZenML provides a built-in [CloudpickleMaterializer](https://sdkdocs.zenml.io/latest/core\_code\_docs/core-materializers/#zenml.materializers.cloudpickle\_materializer.CloudpickleMaterializer) that can handle any object by saving it with [cloudpickle](https://github.com/cloudpipe/cloudpickle). However, this is not production-ready because the resulting artifacts cannot be loaded when running with a different Python version. In such cases, you should consider building a [custom Materializer](handle-custom-data-types.md#custom-materializers) to save your objects in a more robust and efficient format. + +Moreover, using the `CloudpickleMaterializer` could allow users to upload of any kind of object. This could be exploited to upload a malicious file, which could execute arbitrary code on the vulnerable system. +{% endhint %} + When a pipeline runs, ZenML uses the appropriate materializers to save and load artifacts using the ZenML `fileio` system (built to work across multiple artifact stores). This not only simplifies the process of working with different data formats and storage systems but also enables artifact caching and lineage tracking. You can see an example of a default materializer (the `numpy` materializer) in action [here](https://github.com/zenml-io/zenml/blob/main/src/zenml/materializers/numpy\_materializer.py). diff --git a/docs/book/user-guide/advanced-guide/data-management/handle-custom-data-types.md b/docs/book/user-guide/advanced-guide/data-management/handle-custom-data-types.md index 4c4dac0ebdf..c925359c55a 100644 --- a/docs/book/user-guide/advanced-guide/data-management/handle-custom-data-types.md +++ b/docs/book/user-guide/advanced-guide/data-management/handle-custom-data-types.md @@ -14,8 +14,10 @@ ZenML already includes built-in materializers for many common data types. These
MaterializerHandled Data TypesStorage Format
BuiltInMaterializerbool, float, int, str, None.json
BytesInMaterializerbytes.txt
BuiltInContainerMaterializerdict, list, set, tupleDirectory
NumpyMaterializernp.ndarray.npy
PandasMaterializerpd.DataFrame, pd.Series.csv (or .gzip if parquet is installed)
PydanticMaterializerpydantic.BaseModel.json
ServiceMaterializerzenml.services.service.BaseService.json
StructuredStringMaterializerzenml.types.CSVString, zenml.types.HTMLString, zenml.types.MarkdownString.csv / .html / .md (depending on type)
-{% hint style="info" %} -ZenML also provides a built-in [CloudpickleMaterializer](https://sdkdocs.zenml.io/latest/core\_code\_docs/core-materializers/#zenml.materializers.cloudpickle\_materializer.CloudpickleMaterializer) that can handle any object by saving it with [cloudpickle](https://github.com/cloudpipe/cloudpickle). However, this is not production-ready because the resulting artifacts cannot be loaded when running with a different Python version. In such cases, you should consider building a [custom Materializer](handle-custom-data-types.md#custom-materializers) to save your objects in a more robust and efficient format. +{% hint style="warning" %} +ZenML provides a built-in [CloudpickleMaterializer](https://sdkdocs.zenml.io/latest/core\_code\_docs/core-materializers/#zenml.materializers.cloudpickle\_materializer.CloudpickleMaterializer) that can handle any object by saving it with [cloudpickle](https://github.com/cloudpipe/cloudpickle). However, this is not production-ready because the resulting artifacts cannot be loaded when running with a different Python version. In such cases, you should consider building a [custom Materializer](handle-custom-data-types.md#custom-materializers) to save your objects in a more robust and efficient format. + +Moreover, using the `CloudpickleMaterializer` could allow users to upload of any kind of object. This could be exploited to upload a malicious file, which could execute arbitrary code on the vulnerable system. {% endhint %} ## Integration Materializers diff --git a/docs/book/user-guide/advanced-guide/pipelining-features/managing-steps.md b/docs/book/user-guide/advanced-guide/pipelining-features/managing-steps.md index 6d305f04a98..f29fd83feaf 100644 --- a/docs/book/user-guide/advanced-guide/pipelining-features/managing-steps.md +++ b/docs/book/user-guide/advanced-guide/pipelining-features/managing-steps.md @@ -13,6 +13,12 @@ Your functions will work as ZenML steps even if you don't provide any type annot * **Type validation of your step inputs**: ZenML makes sure that your step functions receive an object of the correct type from the upstream steps in your pipeline. * **Better serialization**: Without type annotations, ZenML uses [Cloudpickle](https://github.com/cloudpipe/cloudpickle) to serialize your step outputs. When provided with type annotations, ZenML can choose a [materializer](../../../getting-started/core-concepts.md#materializers) that is best suited for the output. In case none of the builtin materializers work, you can even [write a custom materializer](../data-management/handle-custom-data-types.md). +{% hint style="warning" %} +ZenML provides a built-in [CloudpickleMaterializer](https://sdkdocs.zenml.io/latest/core\_code\_docs/core-materializers/#zenml.materializers.cloudpickle\_materializer.CloudpickleMaterializer) that can handle any object by saving it with [cloudpickle](https://github.com/cloudpipe/cloudpickle). However, this is not production-ready because the resulting artifacts cannot be loaded when running with a different Python version. In such cases, you should consider building a [custom Materializer](handle-custom-data-types.md#custom-materializers) to save your objects in a more robust and efficient format. + +Moreover, using the `CloudpickleMaterializer` could allow users to upload of any kind of object. This could be exploited to upload a malicious file, which could execute arbitrary code on the vulnerable system. +{% endhint %} + ```python from typing import Tuple from zenml import step From 9e6815fcaf3d09551d47dff0ce927464e76ed6e1 Mon Sep 17 00:00:00 2001 From: Safoine El Khabich <34200873+safoinme@users.noreply.github.com> Date: Mon, 25 Mar 2024 13:50:37 +0000 Subject: [PATCH 42/45] Add service table migration (#2563) --- .../0701da9951a0_added_service_table.py | 94 +++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 src/zenml/zen_stores/migrations/versions/0701da9951a0_added_service_table.py diff --git a/src/zenml/zen_stores/migrations/versions/0701da9951a0_added_service_table.py b/src/zenml/zen_stores/migrations/versions/0701da9951a0_added_service_table.py new file mode 100644 index 00000000000..b32a6fe8b72 --- /dev/null +++ b/src/zenml/zen_stores/migrations/versions/0701da9951a0_added_service_table.py @@ -0,0 +1,94 @@ +"""Added service table [0701da9951a0]. + +Revision ID: 0701da9951a0 +Revises: 0.56.1 +Create Date: 2024-03-25 12:24:32.928543 + +""" + +import sqlalchemy as sa +import sqlmodel +from alembic import op +from sqlalchemy.engine.reflection import Inspector + +# revision identifiers, used by Alembic. +revision = "0701da9951a0" +down_revision = "0.56.1" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + """Upgrade database schema and/or data, creating a new revision.""" + # If the tables already exist, skip this migration. + conn = op.get_bind() + inspector = Inspector.from_engine(conn) + tables = inspector.get_table_names() + if "service" in tables: + return + + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "service", + sa.Column( + "workspace_id", sqlmodel.sql.sqltypes.GUID(), nullable=False + ), + sa.Column("user_id", sqlmodel.sql.sqltypes.GUID(), nullable=True), + sa.Column("service_source", sa.TEXT(), nullable=True), + sa.Column("service_type", sa.TEXT(), nullable=False), + sa.Column("type", sa.TEXT(), nullable=False), + sa.Column("flavor", sa.TEXT(), nullable=False), + sa.Column("admin_state", sa.TEXT(), nullable=True), + sa.Column("state", sa.TEXT(), nullable=True), + sa.Column("prediction_url", sa.TEXT(), nullable=True), + sa.Column("health_check_url", sa.TEXT(), nullable=True), + sa.Column("pipeline_name", sa.TEXT(), nullable=True), + sa.Column("pipeline_step_name", sa.TEXT(), nullable=True), + sa.Column( + "model_version_id", sqlmodel.sql.sqltypes.GUID(), nullable=True + ), + sa.Column( + "pipeline_run_id", sqlmodel.sql.sqltypes.GUID(), nullable=True + ), + sa.Column("id", sqlmodel.sql.sqltypes.GUID(), nullable=False), + sa.Column("created", sa.DateTime(), nullable=False), + sa.Column("updated", sa.DateTime(), nullable=False), + sa.Column("name", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column("labels", sa.LargeBinary(), nullable=True), + sa.Column("config", sa.LargeBinary(), nullable=False), + sa.Column("status", sa.LargeBinary(), nullable=True), + sa.Column("endpoint", sa.LargeBinary(), nullable=True), + sa.ForeignKeyConstraint( + ["model_version_id"], + ["model_version.id"], + name="fk_service_model_version_id_model_version", + ondelete="SET NULL", + ), + sa.ForeignKeyConstraint( + ["pipeline_run_id"], + ["pipeline_run.id"], + name="fk_service_pipeline_run_id_pipeline_run", + ondelete="SET NULL", + ), + sa.ForeignKeyConstraint( + ["user_id"], + ["user.id"], + name="fk_service_user_id_user", + ondelete="SET NULL", + ), + sa.ForeignKeyConstraint( + ["workspace_id"], + ["workspace.id"], + name="fk_service_workspace_id_workspace", + ondelete="CASCADE", + ), + sa.PrimaryKeyConstraint("id"), + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade database schema and/or data back to the previous revision.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table("service") + # ### end Alembic commands ### From 68bcb3ba60cba9729c9713a49c39502d40fb945e Mon Sep 17 00:00:00 2001 From: Safoine El Khabich <34200873+safoinme@users.noreply.github.com> Date: Mon, 25 Mar 2024 21:23:48 +0000 Subject: [PATCH 43/45] Prepare release 0.56.2 (#2564) * git commit -m 'Prepare release 0.56.2' * Fix migration issues and introduce new features --- README.md | 2 +- RELEASE_NOTES.md | 47 ++++++++++--------- pyproject.toml | 2 +- src/zenml/VERSION | 2 +- src/zenml/zen_server/deploy/helm/Chart.yaml | 2 +- src/zenml/zen_server/deploy/helm/README.md | 4 +- .../migrations/versions/0.56.2_release.py | 23 +++++++++ 7 files changed, 53 insertions(+), 29 deletions(-) create mode 100644 src/zenml/zen_stores/migrations/versions/0.56.2_release.py diff --git a/README.md b/README.md index 338e481a5c3..d511f6e24eb 100644 --- a/README.md +++ b/README.md @@ -92,7 +92,7 @@ Projects Showcase

- 🎉 Version 0.56.1 is out. Check out the release notes + 🎉 Version 0.56.2 is out. Check out the release notes here.

diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index ca25e3f6380..673a1a6ca7f 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,26 +1,14 @@ -# 0.56.1 +# 0.56.2 -This is a patch release aiming to solve a dependency problem which was brought in with the new rate -limiting functionality. With 0.56.1 you no longer need `starlette` to run client code or to -run ZenML CLI commands. +This release replaces 0.56.0 and 0.56.1, and fixes the major migration bugs that were in +that yanked release. Please upgrade directly to 0.56.2 and avoid upgrading to +0.56.0 to avoid unexpected migration issues. -## 🥳 Community Contributions 🥳 - -We'd like to thank @christianversloot for his contribution to this release. - -## What's Changed -* Fix pipelines and model links for the cloud dashboard by @wjayesh in https://github.com/zenml-io/zenml/pull/2554 -* Make starlette non-must for client by @avishniakov in https://github.com/zenml-io/zenml/pull/2553 -* Bump MLFlow to version 2.11.2 by @christianversloot in https://github.com/zenml-io/zenml/pull/2552 - - -**Full Changelog**: https://github.com/zenml-io/zenml/compare/0.56.0...0.56.1 - -# 0.56.0 - -ZenML 0.56.0 introduces a wide array of new features, enhancements, and bug fixes, -with a strong emphasis on elevating the user experience and streamlining machine +Note that 0.56.0 and 0.56.1 were removed from PyPI due to an issue with the +alembic versions + migration which could affect the database state. This release +fixes that issue. +This release introduces introduces a wide array of new features, enhancements, and bug fixes, with a strong emphasis on elevating the user experience and streamlining machine learning workflows. Most notably, you can now deploy models using Hugging Face inference endpoints thanks for an open-source community contribution of this model deployer stack component! This release also comes with a breaking change to the services @@ -140,8 +128,7 @@ for their contribution to this release by adding a new attribute to the `Kaniko` Additionally, we'd like to thank @christianversloot for his contributions to this release. -## All changes: - +## What's Changed * Upgrading SQLModel to the latest version by @bcdurak in https://github.com/zenml-io/zenml/pull/2452 * Remove KServe integration by @safoinme in https://github.com/zenml-io/zenml/pull/2495 * Upgrade migration testing with 0.55.5 by @avishniakov in https://github.com/zenml-io/zenml/pull/2501 @@ -182,12 +169,26 @@ Additionally, we'd like to thank @christianversloot for his contributions to thi * Update `pip check` command to use `uv` by @strickvl in https://github.com/zenml-io/zenml/pull/2520 * Implemented bitbucket webhook event source by @AlexejPenner in https://github.com/zenml-io/zenml/pull/2481 * Add ZenMLServiceType and update service registration by @safoinme in https://github.com/zenml-io/zenml/pull/2471 +* Prepare release 0.56.0 by @safoinme in https://github.com/zenml-io/zenml/pull/2546 +* Fix formatting and release workflow by @strickvl in https://github.com/zenml-io/zenml/pull/2549 +* Fix release workflow by @strickvl in https://github.com/zenml-io/zenml/pull/2550 +* Fix pipelines and model links for the cloud dashboard by @wjayesh in https://github.com/zenml-io/zenml/pull/2554 +* Make starlette non-must for client by @avishniakov in https://github.com/zenml-io/zenml/pull/2553 +* Bump MLFlow to version 2.11.2 by @christianversloot in https://github.com/zenml-io/zenml/pull/2552 +* Prepare release 0.56.1 by @avishniakov in https://github.com/zenml-io/zenml/pull/2555 +* Updated neptune documentation by @SiddhantSadangi in https://github.com/zenml-io/zenml/pull/2548 +* 0.56.0 and 0.56.1 in testing by @avishniakov in https://github.com/zenml-io/zenml/pull/2557 +* Only install uv once by @schustmi in https://github.com/zenml-io/zenml/pull/2558 +* Bump MLFlow to version 2.11.3 by @christianversloot in https://github.com/zenml-io/zenml/pull/2559 +* Update docs with warning about pickle materializer insecurity by @avishniakov in https://github.com/zenml-io/zenml/pull/2561 +* Add service table migration by @safoinme in https://github.com/zenml-io/zenml/pull/2563 ## New Contributors * @dudeperf3ct made their first contribution in https://github.com/zenml-io/zenml/pull/2376 * @moesio-f made their first contribution in https://github.com/zenml-io/zenml/pull/2509 +* @SiddhantSadangi made their first contribution in https://github.com/zenml-io/zenml/pull/2548 -**Full Changelog**: https://github.com/zenml-io/zenml/compare/0.55.5...0.56.0 +**Full Changelog**: https://github.com/zenml-io/zenml/compare/0.55.5...0.56.2 # 0.55.5 diff --git a/pyproject.toml b/pyproject.toml index 164bc764717..dadc1fe0620 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zenml" -version = "0.56.1" +version = "0.56.2" packages = [{ include = "zenml", from = "src" }] description = "ZenML: Write production-ready ML code." authors = ["ZenML GmbH "] diff --git a/src/zenml/VERSION b/src/zenml/VERSION index a4c4386b107..cc169d8ce70 100644 --- a/src/zenml/VERSION +++ b/src/zenml/VERSION @@ -1 +1 @@ -0.56.1 \ No newline at end of file +0.56.2 \ No newline at end of file diff --git a/src/zenml/zen_server/deploy/helm/Chart.yaml b/src/zenml/zen_server/deploy/helm/Chart.yaml index 3ea33596a13..e6bc01d1a2b 100644 --- a/src/zenml/zen_server/deploy/helm/Chart.yaml +++ b/src/zenml/zen_server/deploy/helm/Chart.yaml @@ -1,6 +1,6 @@ apiVersion: v2 name: zenml -version: "0.56.1" +version: "0.56.2" description: Open source MLOps framework for portable production ready ML pipelines keywords: - mlops diff --git a/src/zenml/zen_server/deploy/helm/README.md b/src/zenml/zen_server/deploy/helm/README.md index 3010ff3fd5f..0f678b869bf 100644 --- a/src/zenml/zen_server/deploy/helm/README.md +++ b/src/zenml/zen_server/deploy/helm/README.md @@ -20,8 +20,8 @@ ZenML is an open-source MLOps framework designed to help you create robust, main To install the ZenML chart directly from Amazon ECR, use the following command: ```bash -# example command for version 0.56.1 -helm install my-zenml oci://public.ecr.aws/zenml/zenml --version 0.56.1 +# example command for version 0.56.2 +helm install my-zenml oci://public.ecr.aws/zenml/zenml --version 0.56.2 ``` Note: Ensure you have OCI support enabled in your Helm client and that you are authenticated with Amazon ECR. diff --git a/src/zenml/zen_stores/migrations/versions/0.56.2_release.py b/src/zenml/zen_stores/migrations/versions/0.56.2_release.py new file mode 100644 index 00000000000..47431e949fe --- /dev/null +++ b/src/zenml/zen_stores/migrations/versions/0.56.2_release.py @@ -0,0 +1,23 @@ +"""Release [0.56.2]. + +Revision ID: 0.56.2 +Revises: 0701da9951a0 +Create Date: 2024-03-25 14:49:49.021147 + +""" + +# revision identifiers, used by Alembic. +revision = "0.56.2" +down_revision = "0701da9951a0" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + """Upgrade database schema and/or data, creating a new revision.""" + pass + + +def downgrade() -> None: + """Downgrade database schema and/or data back to the previous revision.""" + pass From 76fc71992a183853bd9647add64a3e1f12c7590d Mon Sep 17 00:00:00 2001 From: Andrei Vishniakov <31008759+avishniakov@users.noreply.github.com> Date: Tue, 26 Mar 2024 10:05:44 +0100 Subject: [PATCH 44/45] Really run migration testing (#2562) * really run migration testing * support old versions * 0.44.1 already has starter in it * try without `--test` * give email prompt * take out buggy revision * restore legacy version behavior * revert restore legacy version behavior * port 0.53.0 fix for lower versions * add comments * remove yanked versions * `importlib_metadata` fix --------- Co-authored-by: Safoine El Khabich <34200873+safoinme@users.noreply.github.com> --- scripts/test-migrations-mariadb.sh | 12 ++++++------ scripts/test-migrations-mysql.sh | 20 ++++++++++++++------ 2 files changed, 20 insertions(+), 12 deletions(-) diff --git a/scripts/test-migrations-mariadb.sh b/scripts/test-migrations-mariadb.sh index 459d96853d6..30494823381 100755 --- a/scripts/test-migrations-mariadb.sh +++ b/scripts/test-migrations-mariadb.sh @@ -7,22 +7,22 @@ function run_tests_for_version() { set -e # Exit immediately if a command exits with a non-zero status local VERSION=$1 + export ZENML_ANALYTICS_OPT_IN=false + export ZENML_DEBUG=true + echo "===== Testing version $VERSION =====" mkdir test_starter - zenml init --template starter --path test_starter --template-with-defaults --test + zenml init --template starter --path test_starter --template-with-defaults <<< $'my@mail.com\n' cd test_starter - export ZENML_ANALYTICS_OPT_IN=false - export ZENML_DEBUG=true - echo "===== Installing sklearn integration =====" zenml integration export-requirements sklearn --output-file sklearn-requirements.txt uv pip install -r sklearn-requirements.txt rm sklearn-requirements.txt echo "===== Running starter template pipeline =====" - python3 run.py + python3 run.py --feature-pipeline --training-pipeline --no-cache # Add additional CLI tests here zenml version @@ -45,7 +45,7 @@ docker run --name mariadb -d -p 3306:3306 -e MYSQL_ROOT_PASSWORD=password mariad sleep $DB_STARTUP_DELAY # List of versions to test -VERSIONS=("0.54.0" "0.54.1" "0.55.0" "0.55.1" "0.55.2" "0.55.3" "0.55.4" "0.55.5" "0.56.0" "0.56.1") +VERSIONS=("0.54.0" "0.54.1" "0.55.0" "0.55.1" "0.55.2" "0.55.3" "0.55.4" "0.55.5") # Start completely fresh rm -rf ~/.config/zenml diff --git a/scripts/test-migrations-mysql.sh b/scripts/test-migrations-mysql.sh index fea29a09a59..804e7cda48d 100755 --- a/scripts/test-migrations-mysql.sh +++ b/scripts/test-migrations-mysql.sh @@ -17,7 +17,11 @@ function run_tests_for_version() { local VERSION=$1 # versions pre-templates and pre-init test flag # (zenml init --test allows for a non-interactive init) - local PRE_TEMPLATE_VERSIONS=("0.40.0" "0.40.3" "0.41.0" "0.43.0" "0.44.1" "0.44.3" "0.45.2" "0.45.3" "0.45.4" "0.45.5" "0.45.6" "0.46.0" "0.47.0") + local PRE_TEMPLATE_VERSIONS=("0.40.0" "0.40.3" "0.41.0" "0.43.0") + local PRE_ARGS_VERSIONS=("0.40.0" "0.40.3" "0.41.0" "0.43.0" "0.44.1" "0.44.3" "0.45.2" "0.45.3" "0.45.4" "0.45.5" "0.45.6" "0.46.0" "0.47.0" "0.50.0" "0.51.0" "0.52.0") + + export ZENML_ANALYTICS_OPT_IN=false + export ZENML_DEBUG=true echo "===== Testing version $VERSION =====" @@ -26,7 +30,7 @@ function run_tests_for_version() { copier copy -l --trust -r release/0.43.0 https://github.com/zenml-io/template-starter.git test_starter else mkdir test_starter - zenml init --template starter --path test_starter --template-with-defaults --test + zenml init --template starter --path test_starter --template-with-defaults <<< $'my@mail.com\n' fi cd test_starter @@ -40,7 +44,11 @@ function run_tests_for_version() { rm sklearn-requirements.txt echo "===== Running starter template pipeline =====" - python3 run.py + if printf '%s\n' "${PRE_ARGS_VERSIONS[@]}" | grep -q "^$VERSION$"; then + python3 run.py --no-cache + else + python3 run.py --feature-pipeline --training-pipeline --no-cache + fi # Add additional CLI tests here zenml version @@ -63,7 +71,7 @@ if [ "$1" == "mysql" ]; then fi # List of versions to test -VERSIONS=("0.40.0" "0.40.3" "0.41.0" "0.43.0" "0.44.1" "0.44.3" "0.45.2" "0.45.3" "0.45.4" "0.45.5" "0.45.6" "0.46.0" "0.47.0" "0.50.0" "0.51.0" "0.52.0" "0.53.0" "0.53.1" "0.54.0" "0.54.1" "0.55.0" "0.55.1" "0.55.2" "0.55.3" "0.55.4" "0.55.5" "0.56.0" "0.56.1") +VERSIONS=("0.40.0" "0.40.3" "0.41.0" "0.43.0" "0.44.1" "0.44.3" "0.45.2" "0.45.3" "0.45.4" "0.45.5" "0.45.6" "0.46.0" "0.47.0" "0.50.0" "0.51.0" "0.52.0" "0.53.0" "0.53.1" "0.54.0" "0.54.1" "0.55.0" "0.55.1" "0.55.2" "0.55.3" "0.55.4" "0.55.5") # Start completely fresh rm -rf ~/.config/zenml @@ -88,10 +96,10 @@ do # Get the major and minor version of Python PYTHON_VERSION=$(python3 -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")') - # Check if the Python version is 3.9 and VERSION is > 0.47.0 + # Check if the Python version is 3.9 and VERSION is > 0.44.0 if [[ "$PYTHON_VERSION" == "3.9" ]]; then case "$VERSION" in - "0.47.0"|"0.50.0"|"0.51.0"|"0.52.0") + "0.44.1"|"0.44.3"|"0.45.2"|"0.45.3"|"0.45.4"|"0.45.5"|"0.45.6"|"0.46.0"|"0.47.0"|"0.50.0"|"0.51.0"|"0.52.0") uv pip install importlib_metadata ;; esac From 5406fa7ee846342d1e6ed4fb5f1cc92d70e522bc Mon Sep 17 00:00:00 2001 From: Alexej Penner Date: Tue, 26 Mar 2024 11:04:56 +0100 Subject: [PATCH 45/45] Interact with feature gate (#2492) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Interact with feature gate * Properly handle entitlement violations * Apply suggestions from code review Co-authored-by: Barış Can Durak <36421093+bcdurak@users.noreply.github.com> Co-authored-by: Safoine El Khabich <34200873+safoinme@users.noreply.github.com> * Auto-update of Starter template * Applied code reviews * reformatted * Reformatted * Disable feature_gate when no source specified. * Auto-update of Starter template * Auto-update of E2E template * Auto-update of NLP template * Handle corrupted or empty global configuration file (#2508) * Handle corrupted or empty global configuration file * Auto-update of Starter template --------- Co-authored-by: GitHub Actions * Linted * Add admin users notion (#2494) * add admin users to OSS * skip missing methods * increase readability * doc string * lint * lint * missing arg * add some edge-cases * wip commit to carve out clean_client changes * revert irrelevant changes * revert irrelevant changes * rework tests to run on rest * Apply suggestions from code review Co-authored-by: Alex Strick van Linschoten Co-authored-by: Stefan Nica * polish test cases * fix branching * admin user mgmt CLI/Client * close activation vulnerability * revert rbac changes * verify admin permissions in endpoints * add `is_admin` to external users * only reg users will be migrated as admins * default is always admin * extend tests * lint * default `is_admin` None * Auto-update of Starter template * review suggestions * review suggestions * calm down linter * Update src/zenml/cli/user_management.py Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * Apply suggestions from code review Co-authored-by: Alex Strick van Linschoten * review suggestion --------- Co-authored-by: Alex Strick van Linschoten Co-authored-by: Stefan Nica Co-authored-by: GitHub Actions Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * remove dashboard from gitignore (#2517) * Colima / Homebrew fix (#2512) * attempt fix * Auto-update of Starter template * colima qemu fix trial * remove qemu * logs * logs better * testing brew workaround * try second possible fix for python gha * actually apply the fix * try the second possible solution for unbreaking python * make the CI whole again * linting * fix python 3.11 on mac (test) * one more attempt * formatting * different fix * restore the CI to full glory (fixed now!) --------- Co-authored-by: GitHub Actions * remove extra env var assignment (#2518) * Allow installing packages using UV (#2510) * Allow installing packages using UV * Auto-update of Starter template * actually make it work * Auto-update of Starter template --------- Co-authored-by: GitHub Actions * Additional fields for track events (#2507) * additional fields for track events * formatting * Auto-update of Starter template * adding a few recommendations * formatting * Auto-update of Starter template --------- Co-authored-by: GitHub Actions Co-authored-by: Alex Strick van Linschoten * Auto-update of Starter template * Auto-update of NLP template * Auto-update of E2E template * Update src/zenml/zen_server/exceptions.py Co-authored-by: Stefan Nica * Update src/zenml/zen_server/cloud_utils.py Co-authored-by: Stefan Nica * Applied code review. * Properly reformatted * Reformatted * Fixed test * Fixed docstring * Model deletion works now, fixed error message * Show correct error message when creating models that exceed subscription limit * Send resource id * Auto-update of LLM Finetuning template * Fix error * Limit pipeline namespaces * Remove billing url * Linted * Potential fix --------- Co-authored-by: Barış Can Durak <36421093+bcdurak@users.noreply.github.com> Co-authored-by: Safoine El Khabich <34200873+safoinme@users.noreply.github.com> Co-authored-by: GitHub Actions Co-authored-by: Stefan Nica Co-authored-by: Alex Strick van Linschoten Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Co-authored-by: Jayesh Sharma Co-authored-by: Michael Schuster Co-authored-by: Michael Schuster --- src/zenml/config/server_config.py | 19 +- src/zenml/constants.py | 64 ++++++ src/zenml/exceptions.py | 4 + src/zenml/model/model.py | 4 +- src/zenml/zen_server/cloud_utils.py | 201 ++++++++++++++++++ src/zenml/zen_server/exceptions.py | 3 + src/zenml/zen_server/feature_gate/__init__.py | 13 ++ .../zen_server/feature_gate/endpoint_utils.py | 59 +++++ .../feature_gate/feature_gate_interface.py | 49 +++++ .../feature_gate/zenml_cloud_feature_gate.py | 119 +++++++++++ src/zenml/zen_server/rbac/endpoint_utils.py | 45 +++- src/zenml/zen_server/rbac/zenml_cloud_rbac.py | 188 +--------------- .../zen_server/routers/models_endpoints.py | 9 +- .../zen_server/routers/pipelines_endpoints.py | 20 +- .../routers/workspaces_endpoints.py | 25 ++- src/zenml/zen_server/utils.py | 61 ++++-- src/zenml/zen_server/zen_server_api.py | 2 + src/zenml/zen_stores/rest_zen_store.py | 1 + tests/unit/test_constants.py | 41 +++- 19 files changed, 710 insertions(+), 217 deletions(-) create mode 100644 src/zenml/zen_server/cloud_utils.py create mode 100644 src/zenml/zen_server/feature_gate/__init__.py create mode 100644 src/zenml/zen_server/feature_gate/endpoint_utils.py create mode 100644 src/zenml/zen_server/feature_gate/feature_gate_interface.py create mode 100644 src/zenml/zen_server/feature_gate/zenml_cloud_feature_gate.py diff --git a/src/zenml/config/server_config.py b/src/zenml/config/server_config.py index 4883ada6e45..2fef2890e04 100644 --- a/src/zenml/config/server_config.py +++ b/src/zenml/config/server_config.py @@ -87,13 +87,13 @@ class ServerConfiguration(BaseModel): construct the OAuth 2.0 device authorization endpoint. If not set, a partial URL is returned to the client which is used to construct the full URL based on the server's root URL path. - device_expiration: The time in minutes that an OAuth 2.0 device is + device_expiration_minutes: The time in minutes that an OAuth 2.0 device is allowed to be used to authenticate with the ZenML server. If not set or if `jwt_token_expire_minutes` is not set, the devices are allowed to be used indefinitely. This controls the expiration time of the JWT tokens issued to clients after they have authenticated with the ZenML server using an OAuth 2.0 device. - trusted_device_expiration: The time in minutes that a trusted OAuth 2.0 + trusted_device_expiration_minutes: The time in minutes that a trusted OAuth 2.0 device is allowed to be used to authenticate with the ZenML server. If not set or if `jwt_token_expire_minutes` is not set, the devices are allowed to be used indefinitely. This controls the expiration @@ -116,6 +116,11 @@ class ServerConfiguration(BaseModel): the RBAC interface defined by `zenml.zen_server.rbac_interface.RBACInterface`. If not specified, RBAC will not be enabled for this server. + feature_gate_implementation_source: Source pointing to a class + implementing the feature gate interface defined by + `zenml.zen_server.feature_gate.feature_gate_interface.FeatureGateInterface`. + If not specified, feature usage will not be gated/tracked for this + server. workload_manager_implementation_source: Source pointing to a class implementing the workload management interface. pipeline_run_auth_window: The default time window in minutes for which @@ -156,6 +161,7 @@ class ServerConfiguration(BaseModel): external_server_id: Optional[UUID] = None rbac_implementation_source: Optional[str] = None + feature_gate_implementation_source: Optional[str] = None workload_manager_implementation_source: Optional[str] = None pipeline_run_auth_window: int = ( DEFAULT_ZENML_SERVER_PIPELINE_RUN_AUTH_WINDOW @@ -244,6 +250,15 @@ def rbac_enabled(self) -> bool: """ return self.rbac_implementation_source is not None + @property + def feature_gate_enabled(self) -> bool: + """Whether feature gating is enabled on the server or not. + + Returns: + Whether feature gating is enabled on the server or not. + """ + return self.feature_gate_implementation_source is not None + @property def workload_manager_enabled(self) -> bool: """Whether workload management is enabled on the server or not. diff --git a/src/zenml/constants.py b/src/zenml/constants.py index 89842d8a78b..6da982c828d 100644 --- a/src/zenml/constants.py +++ b/src/zenml/constants.py @@ -13,10 +13,61 @@ # permissions and limitations under the License. """ZenML constants.""" +import json +import logging import os +from typing import Any, List, Optional, Type, TypeVar from zenml.enums import AuthScheme +T = TypeVar("T") + + +def handle_json_env_var( + var: str, + expected_type: Type[T], + default: Optional[List[str]] = None, +) -> Any: + """Converts a json env var into a Python object. + + Args: + var: The environment variable to convert. + default: The default value to return if the env var is not set. + expected_type: The type of the expected Python object. + + Returns: + The converted list value. + + Raises: + TypeError: In case the value of the environment variable is not of a + valid type. + + """ + # this needs to be here to avoid mutable defaults + if default is None: + default = [] + + value = os.getenv(var) + if value: + try: + loaded_value = json.loads(value) + # check if loaded value is of correct type + if expected_type is None or isinstance( + loaded_value, expected_type + ): + return loaded_value + else: + raise TypeError # if not correct type, raise TypeError + except (TypeError, json.JSONDecodeError): + # Use raw logging to avoid cyclic dependency + logging.warning( + f"Environment Variable {var} could not be loaded, into type " + f"{expected_type}, defaulting to: {default}." + ) + return default + else: + return default + def handle_bool_env_var(var: str, default: bool = False) -> bool: """Converts normal env var to boolean. @@ -100,6 +151,9 @@ def handle_int_env_var(var: str, default: int = 0) -> int: ENV_ZENML_SERVER_PREFIX = "ZENML_SERVER_" ENV_ZENML_SERVER_DEPLOYMENT_TYPE = f"{ENV_ZENML_SERVER_PREFIX}DEPLOYMENT_TYPE" ENV_ZENML_SERVER_AUTH_SCHEME = f"{ENV_ZENML_SERVER_PREFIX}AUTH_SCHEME" +ENV_ZENML_SERVER_REPORTABLE_RESOURCES = ( + f"{ENV_ZENML_SERVER_PREFIX}REPORTABLE_RESOURCES" +) # Logging variables IS_DEBUG_ENV: bool = handle_bool_env_var(ENV_ZENML_DEBUG, default=False) @@ -181,6 +235,16 @@ def handle_int_env_var(var: str, default: int = 0) -> int: DEFAULT_ZENML_SERVER_LOGIN_RATE_LIMIT_MINUTE = 5 DEFAULT_ZENML_SERVER_LOGIN_RATE_LIMIT_DAY = 1000 +# Configurations to decide which resources report their usage and check for +# entitlement in the case of a cloud deployment. Expected Format is this: +# ENV_ZENML_REPORTABLE_RESOURCES='["Foo", "bar"]' +REPORTABLE_RESOURCES: List[str] = handle_json_env_var( + ENV_ZENML_SERVER_REPORTABLE_RESOURCES, + expected_type=list, + default=["pipeline_run", "model"], +) +REQUIRES_CUSTOM_RESOURCE_REPORTING = ["pipeline"] + # API Endpoint paths: ACTIVATE = "/activate" ACTIONS = "/action-flavors" diff --git a/src/zenml/exceptions.py b/src/zenml/exceptions.py index f7e67339033..5ef6b0315af 100644 --- a/src/zenml/exceptions.py +++ b/src/zenml/exceptions.py @@ -253,6 +253,10 @@ class InputResolutionError(ZenMLBaseException): """Raised when step input resolving failed.""" +class SubscriptionUpgradeRequiredError(ZenMLBaseException): + """Raised when user tries to perform an action outside their current subscription tier.""" + + class HydrationError(ZenMLBaseException): """Raised when the model hydration failed.""" diff --git a/src/zenml/model/model.py b/src/zenml/model/model.py index b75dfe7861c..5374ffe2ce0 100644 --- a/src/zenml/model/model.py +++ b/src/zenml/model/model.py @@ -549,12 +549,10 @@ def _get_or_create_model(self) -> "ModelResponse": ) logger.info(f"New model `{self.name}` was created implicitly.") except EntityExistsError: - # this is backup logic, if model was created somehow in between get and create calls - pass - finally: model = zenml_client.zen_store.get_model( model_name_or_id=self.name ) + self._model_id = model.id return model diff --git a/src/zenml/zen_server/cloud_utils.py b/src/zenml/zen_server/cloud_utils.py new file mode 100644 index 00000000000..eabac1396de --- /dev/null +++ b/src/zenml/zen_server/cloud_utils.py @@ -0,0 +1,201 @@ +"""Utils concerning anything concerning the cloud control plane backend.""" + +import os +from typing import Any, Dict, Optional + +import requests +from pydantic import BaseModel, validator +from requests.adapters import HTTPAdapter, Retry + +from zenml.exceptions import SubscriptionUpgradeRequiredError + +ZENML_CLOUD_RBAC_ENV_PREFIX = "ZENML_CLOUD_" + + +class ZenMLCloudConfiguration(BaseModel): + """ZenML Cloud RBAC configuration.""" + + api_url: str + + oauth2_client_id: str + oauth2_client_secret: str + oauth2_audience: str + auth0_domain: str + + @validator("api_url") + def _strip_trailing_slashes_url(cls, url: str) -> str: + """Strip any trailing slashes on the API URL. + + Args: + url: The API URL. + + Returns: + The API URL with potential trailing slashes removed. + """ + return url.rstrip("/") + + @classmethod + def from_environment(cls) -> "ZenMLCloudConfiguration": + """Get the RBAC configuration from environment variables. + + Returns: + The RBAC configuration. + """ + env_config: Dict[str, Any] = {} + for k, v in os.environ.items(): + if v == "": + continue + if k.startswith(ZENML_CLOUD_RBAC_ENV_PREFIX): + env_config[k[len(ZENML_CLOUD_RBAC_ENV_PREFIX) :].lower()] = v + + return ZenMLCloudConfiguration(**env_config) + + class Config: + """Pydantic configuration class.""" + + # Allow extra attributes from configs of previous ZenML versions to + # permit downgrading + extra = "allow" + + +class ZenMLCloudSession: + """Class to use for communication between server and control plane.""" + + def __init__(self) -> None: + """Initialize the RBAC component.""" + self._config = ZenMLCloudConfiguration.from_environment() + self._session: Optional[requests.Session] = None + + def _get( + self, endpoint: str, params: Optional[Dict[str, Any]] + ) -> requests.Response: + """Send a GET request using the active session. + + Args: + endpoint: The endpoint to send the request to. This will be appended + to the base URL. + params: Parameters to include in the request. + + Raises: + RuntimeError: If the request failed. + SubscriptionUpgradeRequiredError: In case the current subscription + tier is insufficient for the attempted operation. + + Returns: + The response. + """ + url = self._config.api_url + endpoint + + response = self.session.get(url=url, params=params, timeout=7) + if response.status_code == 401: + # Refresh the auth token and try again + self._clear_session() + response = self.session.get(url=url, params=params, timeout=7) + + try: + response.raise_for_status() + except requests.HTTPError: + if response.status_code == 402: + raise SubscriptionUpgradeRequiredError(response.json()) + else: + raise RuntimeError( + f"Failed with the following error {response.json()}" + ) + + return response + + def _post( + self, + endpoint: str, + params: Optional[Dict[str, Any]] = None, + data: Optional[Dict[str, Any]] = None, + ) -> requests.Response: + """Send a POST request using the active session. + + Args: + endpoint: The endpoint to send the request to. This will be appended + to the base URL. + params: Parameters to include in the request. + data: Data to include in the request. + + Raises: + RuntimeError: If the request failed. + + Returns: + The response. + """ + url = self._config.api_url + endpoint + + response = self.session.post( + url=url, params=params, json=data, timeout=7 + ) + if response.status_code == 401: + # Refresh the auth token and try again + self._clear_session() + response = self.session.post( + url=url, params=params, json=data, timeout=7 + ) + + try: + response.raise_for_status() + except requests.HTTPError as e: + raise RuntimeError( + f"Failed while trying to contact the central zenml cloud " + f"service: {e}" + ) + + return response + + @property + def session(self) -> requests.Session: + """Authenticate to the ZenML Cloud API. + + Returns: + A requests session with the authentication token. + """ + if self._session is None: + self._session = requests.Session() + token = self._fetch_auth_token() + self._session.headers.update({"Authorization": "Bearer " + token}) + + retries = Retry(total=5, backoff_factor=0.1) + self._session.mount("https://", HTTPAdapter(max_retries=retries)) + + return self._session + + def _clear_session(self) -> None: + """Clear the authentication session.""" + self._session = None + + def _fetch_auth_token(self) -> str: + """Fetch an auth token for the Cloud API from auth0. + + Raises: + RuntimeError: If the auth token can't be fetched. + + Returns: + Auth token. + """ + # Get an auth token from auth0 + auth0_url = f"https://{self._config.auth0_domain}/oauth/token" + headers = {"content-type": "application/x-www-form-urlencoded"} + payload = { + "client_id": self._config.oauth2_client_id, + "client_secret": self._config.oauth2_client_secret, + "audience": self._config.oauth2_audience, + "grant_type": "client_credentials", + } + try: + response = requests.post( + auth0_url, headers=headers, data=payload, timeout=7 + ) + response.raise_for_status() + except Exception as e: + raise RuntimeError(f"Error fetching auth token from auth0: {e}") + + access_token = response.json().get("access_token", "") + + if not access_token or not isinstance(access_token, str): + raise RuntimeError("Could not fetch auth token from auth0.") + + return str(access_token) diff --git a/src/zenml/zen_server/exceptions.py b/src/zenml/zen_server/exceptions.py index 31d3464d82d..0a3d379fc93 100644 --- a/src/zenml/zen_server/exceptions.py +++ b/src/zenml/zen_server/exceptions.py @@ -27,6 +27,7 @@ SecretExistsError, StackComponentExistsError, StackExistsError, + SubscriptionUpgradeRequiredError, ValidationError, ZenKeyError, ) @@ -77,6 +78,8 @@ class ErrorModel(BaseModel): (IllegalOperationError, 403), # 401 Unauthorized (AuthorizationException, 401), + # 402 Payment required + (SubscriptionUpgradeRequiredError, 402), # 404 Not Found (DoesNotExistException, 404), (ZenKeyError, 404), diff --git a/src/zenml/zen_server/feature_gate/__init__.py b/src/zenml/zen_server/feature_gate/__init__.py new file mode 100644 index 00000000000..b6bdfa91873 --- /dev/null +++ b/src/zenml/zen_server/feature_gate/__init__.py @@ -0,0 +1,13 @@ +# Copyright (c) ZenML GmbH 2024. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at: +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express +# or implied. See the License for the specific language governing +# permissions and limitations under the License. \ No newline at end of file diff --git a/src/zenml/zen_server/feature_gate/endpoint_utils.py b/src/zenml/zen_server/feature_gate/endpoint_utils.py new file mode 100644 index 00000000000..3b509e9a494 --- /dev/null +++ b/src/zenml/zen_server/feature_gate/endpoint_utils.py @@ -0,0 +1,59 @@ +# Copyright (c) ZenML GmbH 2024. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at: +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express +# or implied. See the License for the specific language governing +# permissions and limitations under the License. +"""All endpoint utils for the feature gate implementations.""" + +from uuid import UUID + +from zenml.zen_server.rbac.models import ResourceType +from zenml.zen_server.utils import feature_gate, server_config + + +def check_entitlement(resource_type: ResourceType) -> None: + """Queries the feature gate to see if the operation falls within the tenants entitlements. + + Raises an exception if the user is not entitled to create an instance of the + resource. Otherwise, simply returns. + + Args: + resource_type: The type of resource to check for. + """ + if not server_config().feature_gate_enabled: + return + return feature_gate().check_entitlement(resource=resource_type) + + +def report_usage(resource_type: ResourceType, resource_id: UUID) -> None: + """Reports the creation/usage of a feature/resource. + + Args: + resource_type: The type of resource to report a usage for + resource_id: ID of the resource that was created. + """ + if not server_config().feature_gate_enabled: + return + feature_gate().report_event( + resource=resource_type, resource_id=resource_id + ) + + +def report_decrement(resource_type: ResourceType, resource_id: UUID) -> None: + """Reports the deletion/deactivation of a feature/resource. + + Args: + resource_type: The type of resource to report a decrement in count for. + resource_id: ID of the resource that was deleted. + """ + feature_gate().report_event( + resource=resource_type, resource_id=resource_id, is_decrement=True + ) diff --git a/src/zenml/zen_server/feature_gate/feature_gate_interface.py b/src/zenml/zen_server/feature_gate/feature_gate_interface.py new file mode 100644 index 00000000000..df4a5d3fc70 --- /dev/null +++ b/src/zenml/zen_server/feature_gate/feature_gate_interface.py @@ -0,0 +1,49 @@ +# Copyright (c) ZenML GmbH 2024. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at: +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express +# or implied. See the License for the specific language governing +# permissions and limitations under the License. +"""Definition of the feature gate interface.""" + +from abc import ABC, abstractmethod +from uuid import UUID + +from zenml.zen_server.rbac.models import ResourceType + + +class FeatureGateInterface(ABC): + """RBAC interface definition.""" + + @abstractmethod + def check_entitlement(self, resource: ResourceType) -> None: + """Checks if a user is entitled to create a resource. + + Args: + resource: The resource the user wants to create + + Raises: + UpgradeRequiredError in case a subscription limit is reached + """ + + @abstractmethod + def report_event( + self, + resource: ResourceType, + resource_id: UUID, + is_decrement: bool = False, + ) -> None: + """Reports the usage of a feature to the aggregator backend. + + Args: + resource: The resource the user created + resource_id: ID of the resource that was created/deleted. + is_decrement: In case this event reports an actual decrement of usage + """ diff --git a/src/zenml/zen_server/feature_gate/zenml_cloud_feature_gate.py b/src/zenml/zen_server/feature_gate/zenml_cloud_feature_gate.py new file mode 100644 index 00000000000..f928539ad4b --- /dev/null +++ b/src/zenml/zen_server/feature_gate/zenml_cloud_feature_gate.py @@ -0,0 +1,119 @@ +# Copyright (c) ZenML GmbH 2024. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at: +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express +# or implied. See the License for the specific language governing +# permissions and limitations under the License. +"""ZenML Cloud implementation of the feature gate.""" + +from typing import Any, Dict +from uuid import UUID + +from pydantic import BaseModel, Field + +from zenml.config.server_config import ServerConfiguration +from zenml.exceptions import SubscriptionUpgradeRequiredError +from zenml.logger import get_logger +from zenml.zen_server.cloud_utils import ZenMLCloudSession +from zenml.zen_server.feature_gate.feature_gate_interface import ( + FeatureGateInterface, +) +from zenml.zen_server.rbac.models import ResourceType + +logger = get_logger(__name__) + +server_config = ServerConfiguration.get_server_config() + +ORGANIZATION_ID = server_config.metadata.get("organization_id", "unknown") + +USAGE_EVENT_ENDPOINT = "/usage-event" +ENTITLEMENT_ENDPOINT = f"/organizations/{ORGANIZATION_ID}/entitlement" + + +class RawUsageEvent(BaseModel): + """Model for reporting raw usage of a feature. + + In case of consumables the UsageReport allows the Pricing Backend to + increment the usage per time-frame by 1. + """ + + organization_id: str = Field( + description="The organization that this usage can be attributed to.", + ) + feature: ResourceType = Field( + description="The feature whose usage is being reported.", + ) + total: int = Field( + description="The total amount of entities of this type." + ) + metadata: Dict[str, Any] = Field( + default={}, + description="Allows attaching additional metadata to events.", + ) + + +class ZenMLCloudFeatureGateInterface(FeatureGateInterface, ZenMLCloudSession): + """Feature Gate interface definition.""" + + def check_entitlement(self, resource: ResourceType) -> None: + """Checks if a user is entitled to create a resource. + + Args: + resource: The resource the user wants to create + + Raises: + SubscriptionUpgradeRequiredError: in case a subscription limit is reached + """ + try: + response = self._get( + endpoint=ENTITLEMENT_ENDPOINT + "/" + resource, params=None + ) + except SubscriptionUpgradeRequiredError: + raise SubscriptionUpgradeRequiredError( + f"Your subscription reached its `{resource}` limit. Please " + f"upgrade your subscription or reach out to us." + ) + + if response.status_code != 200: + logger.warning( + "Unexpected response status code from entitlement " + f"endpoint: {response.status_code}. Message: " + f"{response.json()}" + ) + + def report_event( + self, + resource: ResourceType, + resource_id: UUID, + is_decrement: bool = False, + ) -> None: + """Reports the usage of a feature to the aggregator backend. + + Args: + resource: The resource the user created + resource_id: ID of the resource that was created/deleted. + is_decrement: In case this event reports an actual decrement of usage + """ + data = RawUsageEvent( + organization_id=ORGANIZATION_ID, + feature=resource, + total=1 if not is_decrement else -1, + metadata={ + "tenant_id": str(server_config.external_server_id), + "resource_id": str(resource_id), + }, + ).dict() + response = self._post(endpoint=USAGE_EVENT_ENDPOINT, data=data) + if response.status_code != 200: + logger.error( + "Usage report not accepted by upstream backend. " + f"Status Code: {response.status_code}, Message: " + f"{response.json()}." + ) diff --git a/src/zenml/zen_server/rbac/endpoint_utils.py b/src/zenml/zen_server/rbac/endpoint_utils.py index 6cc78ddcc97..1f8abe8d6ea 100644 --- a/src/zenml/zen_server/rbac/endpoint_utils.py +++ b/src/zenml/zen_server/rbac/endpoint_utils.py @@ -1,3 +1,16 @@ +# Copyright (c) ZenML GmbH 2024. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at: +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express +# or implied. See the License for the specific language governing +# permissions and limitations under the License. """High-level helper functions to write endpoints with RBAC.""" from typing import Any, Callable, TypeVar, Union @@ -5,6 +18,10 @@ from pydantic import BaseModel +from zenml.constants import ( + REPORTABLE_RESOURCES, + REQUIRES_CUSTOM_RESOURCE_REPORTING, +) from zenml.exceptions import IllegalOperationError from zenml.models import ( BaseFilter, @@ -14,6 +31,10 @@ UserScopedRequest, ) from zenml.zen_server.auth import get_auth_context +from zenml.zen_server.feature_gate.endpoint_utils import ( + check_entitlement, + report_usage, +) from zenml.zen_server.rbac.models import Action, ResourceType from zenml.zen_server.rbac.utils import ( dehydrate_page, @@ -58,12 +79,21 @@ def verify_permissions_and_create_entity( f"Not allowed to create resource '{resource_type}' for a " "different user." ) + verify_permission(resource_type=resource_type, action=Action.CREATE) - verify_permission( - resource_type=resource_type, - action=Action.CREATE, + needs_usage_increment = ( + resource_type in REPORTABLE_RESOURCES + and resource_type not in REQUIRES_CUSTOM_RESOURCE_REPORTING ) - return create_method(request_model) + if needs_usage_increment: + check_entitlement(resource_type) + + created = create_method(request_model) + + if needs_usage_increment: + report_usage(resource_type, resource_id=created.id) + + return created def verify_permissions_and_get_entity( @@ -141,18 +171,23 @@ def verify_permissions_and_delete_entity( id: UUIDOrStr, get_method: Callable[[UUIDOrStr], AnyResponse], delete_method: Callable[[UUIDOrStr], None], -) -> None: +) -> AnyResponse: """Verify permissions and delete an entity. Args: id: The ID of the entity to delete. get_method: The method to fetch the entity. delete_method: The method to delete the entity. + + Returns: + The deleted entity. """ model = get_method(id) verify_permission_for_model(model, action=Action.DELETE) delete_method(model.id) + return model + def verify_permissions_and_prune_entities( resource_type: ResourceType, diff --git a/src/zenml/zen_server/rbac/zenml_cloud_rbac.py b/src/zenml/zen_server/rbac/zenml_cloud_rbac.py index deeed246c51..fd534b313a9 100644 --- a/src/zenml/zen_server/rbac/zenml_cloud_rbac.py +++ b/src/zenml/zen_server/rbac/zenml_cloud_rbac.py @@ -13,13 +13,9 @@ # permissions and limitations under the License. """Cloud RBAC implementation.""" -import os -from typing import TYPE_CHECKING, Any, Dict, List, Optional, Set, Tuple - -import requests -from pydantic import BaseModel, validator -from requests.adapters import HTTPAdapter, Retry +from typing import TYPE_CHECKING, Dict, List, Set, Tuple +from zenml.zen_server.cloud_utils import ZenMLCloudSession from zenml.zen_server.rbac.models import Action, Resource from zenml.zen_server.rbac.rbac_interface import RBACInterface from zenml.zen_server.utils import server_config @@ -28,7 +24,6 @@ from zenml.models import UserResponse -ZENML_CLOUD_RBAC_ENV_PREFIX = "ZENML_CLOUD_" PERMISSIONS_ENDPOINT = "/rbac/check_permissions" ALLOWED_RESOURCE_IDS_ENDPOINT = "/rbac/allowed_resource_ids" RESOURCE_MEMBERSHIP_ENDPOINT = "/rbac/resource_members" @@ -79,60 +74,9 @@ def _convert_from_cloud_resource(cloud_resource: str) -> Resource: return Resource(type=resource_type_and_id) -class ZenMLCloudRBACConfiguration(BaseModel): - """ZenML Cloud RBAC configuration.""" - - api_url: str - - oauth2_client_id: str - oauth2_client_secret: str - oauth2_audience: str - auth0_domain: str - - @validator("api_url") - def _strip_trailing_slashes_url(cls, url: str) -> str: - """Strip any trailing slashes on the API URL. - - Args: - url: The API URL. - - Returns: - The API URL with potential trailing slashes removed. - """ - return url.rstrip("/") - - @classmethod - def from_environment(cls) -> "ZenMLCloudRBACConfiguration": - """Get the RBAC configuration from environment variables. - - Returns: - The RBAC configuration. - """ - env_config: Dict[str, Any] = {} - for k, v in os.environ.items(): - if v == "": - continue - if k.startswith(ZENML_CLOUD_RBAC_ENV_PREFIX): - env_config[k[len(ZENML_CLOUD_RBAC_ENV_PREFIX) :].lower()] = v - - return ZenMLCloudRBACConfiguration(**env_config) - - class Config: - """Pydantic configuration class.""" - - # Allow extra attributes from configs of previous ZenML versions to - # permit downgrading - extra = "allow" - - -class ZenMLCloudRBAC(RBACInterface): +class ZenMLCloudRBAC(RBACInterface, ZenMLCloudSession): """RBAC implementation that uses the ZenML Cloud API as a backend.""" - def __init__(self) -> None: - """Initialize the RBAC component.""" - self._config = ZenMLCloudRBACConfiguration.from_environment() - self._session: Optional[requests.Session] = None - def check_permissions( self, user: "UserResponse", resources: Set[Resource], action: Action ) -> Dict[Resource, bool]: @@ -234,129 +178,3 @@ def update_resource_membership( "actions": [str(action) for action in actions], } self._post(endpoint=RESOURCE_MEMBERSHIP_ENDPOINT, data=data) - - def _get(self, endpoint: str, params: Dict[str, Any]) -> requests.Response: - """Send a GET request using the active session. - - Args: - endpoint: The endpoint to send the request to. This will be appended - to the base URL. - params: Parameters to include in the request. - - Raises: - RuntimeError: If the request failed. - - Returns: - The response. - """ - url = self._config.api_url + endpoint - - response = self.session.get(url=url, params=params, timeout=7) - if response.status_code == 401: - # Refresh the auth token and try again - self._clear_session() - response = self.session.get(url=url, params=params, timeout=7) - - try: - response.raise_for_status() - except requests.HTTPError as e: - raise RuntimeError( - f"Failed while trying to contact RBAC service: {e}" - ) - - return response - - def _post( - self, - endpoint: str, - params: Optional[Dict[str, Any]] = None, - data: Optional[Dict[str, Any]] = None, - ) -> requests.Response: - """Send a POST request using the active session. - - Args: - endpoint: The endpoint to send the request to. This will be appended - to the base URL. - params: Parameters to include in the request. - data: Data to include in the request. - - Raises: - RuntimeError: If the request failed. - - Returns: - The response. - """ - url = self._config.api_url + endpoint - - response = self.session.post( - url=url, params=params, json=data, timeout=7 - ) - if response.status_code == 401: - # Refresh the auth token and try again - self._clear_session() - response = self.session.post( - url=url, params=params, json=data, timeout=7 - ) - - try: - response.raise_for_status() - except requests.HTTPError as e: - raise RuntimeError( - f"Failed while trying to contact RBAC service: {e}" - ) - - return response - - @property - def session(self) -> requests.Session: - """Authenticate to the ZenML Cloud API. - - Returns: - A requests session with the authentication token. - """ - if self._session is None: - self._session = requests.Session() - token = self._fetch_auth_token() - self._session.headers.update({"Authorization": "Bearer " + token}) - - retries = Retry(total=5, backoff_factor=0.1) - self._session.mount("https://", HTTPAdapter(max_retries=retries)) - - return self._session - - def _clear_session(self) -> None: - """Clear the authentication session.""" - self._session = None - - def _fetch_auth_token(self) -> str: - """Fetch an auth token for the Cloud API from auth0. - - Raises: - RuntimeError: If the auth token can't be fetched. - - Returns: - Auth token. - """ - # Get an auth token from auth0 - auth0_url = f"https://{self._config.auth0_domain}/oauth/token" - headers = {"content-type": "application/x-www-form-urlencoded"} - payload = { - "client_id": self._config.oauth2_client_id, - "client_secret": self._config.oauth2_client_secret, - "audience": self._config.oauth2_audience, - "grant_type": "client_credentials", - } - try: - response = requests.post( - auth0_url, headers=headers, data=payload, timeout=7 - ) - response.raise_for_status() - except Exception as e: - raise RuntimeError(f"Error fetching auth token from auth0: {e}") - - access_token = response.json().get("access_token", "") - - if not access_token or not isinstance(access_token, str): - raise RuntimeError("Could not fetch auth token from auth0.") - - return str(access_token) diff --git a/src/zenml/zen_server/routers/models_endpoints.py b/src/zenml/zen_server/routers/models_endpoints.py index c43026d0bdf..124660e5cfc 100644 --- a/src/zenml/zen_server/routers/models_endpoints.py +++ b/src/zenml/zen_server/routers/models_endpoints.py @@ -22,6 +22,7 @@ API, MODEL_VERSIONS, MODELS, + REPORTABLE_RESOURCES, VERSION_1, ) from zenml.models import ( @@ -34,6 +35,7 @@ ) from zenml.zen_server.auth import AuthContext, authorize from zenml.zen_server.exceptions import error_response +from zenml.zen_server.feature_gate.endpoint_utils import report_decrement from zenml.zen_server.rbac.endpoint_utils import ( verify_permissions_and_delete_entity, verify_permissions_and_get_entity, @@ -48,6 +50,7 @@ from zenml.zen_server.utils import ( handle_exceptions, make_dependable, + server_config, zen_store, ) @@ -160,12 +163,16 @@ def delete_model( Args: model_name_or_id: The name or ID of the model to delete. """ - verify_permissions_and_delete_entity( + model = verify_permissions_and_delete_entity( id=model_name_or_id, get_method=zen_store().get_model, delete_method=zen_store().delete_model, ) + if server_config().feature_gate_enabled: + if ResourceType.MODEL in REPORTABLE_RESOURCES: + report_decrement(ResourceType.MODEL, resource_id=model.id) + ################# # Model Versions diff --git a/src/zenml/zen_server/routers/pipelines_endpoints.py b/src/zenml/zen_server/routers/pipelines_endpoints.py index f4e5ad27808..fb1510ac772 100644 --- a/src/zenml/zen_server/routers/pipelines_endpoints.py +++ b/src/zenml/zen_server/routers/pipelines_endpoints.py @@ -18,7 +18,14 @@ from fastapi import APIRouter, Depends, Security from zenml.config.pipeline_spec import PipelineSpec -from zenml.constants import API, PIPELINE_SPEC, PIPELINES, RUNS, VERSION_1 +from zenml.constants import ( + API, + PIPELINE_SPEC, + PIPELINES, + REPORTABLE_RESOURCES, + RUNS, + VERSION_1, +) from zenml.models import ( Page, PipelineFilter, @@ -31,6 +38,7 @@ ) from zenml.zen_server.auth import AuthContext, authorize from zenml.zen_server.exceptions import error_response +from zenml.zen_server.feature_gate.endpoint_utils import report_decrement from zenml.zen_server.rbac.endpoint_utils import ( verify_permissions_and_delete_entity, verify_permissions_and_get_entity, @@ -154,12 +162,20 @@ def delete_pipeline( Args: pipeline_id: ID of the pipeline to delete. """ - verify_permissions_and_delete_entity( + pipeline = verify_permissions_and_delete_entity( id=pipeline_id, get_method=zen_store().get_pipeline, delete_method=zen_store().delete_pipeline, ) + should_decrement = ( + ResourceType.PIPELINE in REPORTABLE_RESOURCES + and zen_store().count_pipelines(PipelineFilter(name=pipeline.name)) + == 0 + ) + if should_decrement: + report_decrement(ResourceType.PIPELINE, resource_id=pipeline_id) + @router.get( "/{pipeline_id}" + RUNS, diff --git a/src/zenml/zen_server/routers/workspaces_endpoints.py b/src/zenml/zen_server/routers/workspaces_endpoints.py index 36d8918b87f..4b747ae6f0c 100644 --- a/src/zenml/zen_server/routers/workspaces_endpoints.py +++ b/src/zenml/zen_server/routers/workspaces_endpoints.py @@ -28,6 +28,7 @@ PIPELINE_BUILDS, PIPELINE_DEPLOYMENTS, PIPELINES, + REPORTABLE_RESOURCES, RUN_METADATA, RUNS, SCHEDULES, @@ -93,6 +94,10 @@ ) from zenml.zen_server.auth import AuthContext, authorize from zenml.zen_server.exceptions import error_response +from zenml.zen_server.feature_gate.endpoint_utils import ( + check_entitlement, + report_usage, +) from zenml.zen_server.rbac.endpoint_utils import ( verify_permissions_and_create_entity, verify_permissions_and_delete_entity, @@ -512,12 +517,30 @@ def create_pipeline( f"not supported." ) - return verify_permissions_and_create_entity( + # We limit pipeline namespaces, not pipeline versions + needs_usage_increment = ( + ResourceType.PIPELINE in REPORTABLE_RESOURCES + and zen_store().count_pipelines(PipelineFilter(name=pipeline.name)) + == 0 + ) + + if needs_usage_increment: + check_entitlement(ResourceType.PIPELINE) + + pipeline_response = verify_permissions_and_create_entity( request_model=pipeline, resource_type=ResourceType.PIPELINE, create_method=zen_store().create_pipeline, ) + if needs_usage_increment: + report_usage( + resource_type=ResourceType.PIPELINE, + resource_id=pipeline_response.id, + ) + + return pipeline_response + @router.get( WORKSPACES + "/{workspace_name_or_id}" + PIPELINE_BUILDS, diff --git a/src/zenml/zen_server/utils.py b/src/zenml/zen_server/utils.py index 6eccb5eac13..2d6d9af132e 100644 --- a/src/zenml/zen_server/utils.py +++ b/src/zenml/zen_server/utils.py @@ -43,6 +43,9 @@ LocalServerDeploymentConfig, ) from zenml.zen_server.exceptions import http_exception_from_error +from zenml.zen_server.feature_gate.feature_gate_interface import ( + FeatureGateInterface, +) from zenml.zen_server.pipeline_deployment.workload_manager_interface import ( WorkloadManagerInterface, ) @@ -53,6 +56,7 @@ _zen_store: Optional["SqlZenStore"] = None _rbac: Optional[RBACInterface] = None +_feature_gate: Optional[FeatureGateInterface] = None _workload_manager: Optional[WorkloadManagerInterface] = None _plugin_flavor_registry: Optional[PluginFlavorRegistry] = None @@ -100,6 +104,50 @@ def rbac() -> RBACInterface: return _rbac +def initialize_rbac() -> None: + """Initialize the RBAC component.""" + global _rbac + + if rbac_source := server_config().rbac_implementation_source: + from zenml.utils import source_utils + + implementation_class = source_utils.load_and_validate_class( + rbac_source, expected_class=RBACInterface + ) + _rbac = implementation_class() + + +def feature_gate() -> FeatureGateInterface: + """Return the initialized Feature Gate component. + + Raises: + RuntimeError: If the RBAC component is not initialized. + + Returns: + The RBAC component. + """ + global _feature_gate + if _feature_gate is None: + raise RuntimeError("Feature gate component not initialized.") + return _feature_gate + + +def initialize_feature_gate() -> None: + """Initialize the Feature Gate component.""" + global _feature_gate + + if ( + feature_gate_source + := server_config().feature_gate_implementation_source + ): + from zenml.utils import source_utils + + implementation_class = source_utils.load_and_validate_class( + feature_gate_source, expected_class=FeatureGateInterface + ) + _feature_gate = implementation_class() + + def workload_manager() -> WorkloadManagerInterface: """Return the initialized workload manager component. @@ -115,19 +163,6 @@ def workload_manager() -> WorkloadManagerInterface: return _workload_manager -def initialize_rbac() -> None: - """Initialize the RBAC component.""" - global _rbac - - if rbac_source := server_config().rbac_implementation_source: - from zenml.utils import source_utils - - implementation_class = source_utils.load_and_validate_class( - rbac_source, expected_class=RBACInterface - ) - _rbac = implementation_class() - - def initialize_workload_manager() -> None: """Initialize the workload manager component. diff --git a/src/zenml/zen_server/zen_server_api.py b/src/zenml/zen_server/zen_server_api.py index c5c5b75ab50..b5f01f940da 100644 --- a/src/zenml/zen_server/zen_server_api.py +++ b/src/zenml/zen_server/zen_server_api.py @@ -63,6 +63,7 @@ workspaces_endpoints, ) from zenml.zen_server.utils import ( + initialize_feature_gate, initialize_plugins, initialize_rbac, initialize_workload_manager, @@ -159,6 +160,7 @@ def initialize() -> None: # race conditions initialize_zen_store() initialize_rbac() + initialize_feature_gate() initialize_workload_manager() initialize_plugins() diff --git a/src/zenml/zen_stores/rest_zen_store.py b/src/zenml/zen_stores/rest_zen_store.py index 9b23eba7b06..d03cec9f79b 100644 --- a/src/zenml/zen_stores/rest_zen_store.py +++ b/src/zenml/zen_stores/rest_zen_store.py @@ -3908,6 +3908,7 @@ def _create_resource( The created resource. """ response_body = self.post(f"{route}", body=resource, params=params) + return response_model.parse_obj(response_body) def _create_workspace_scoped_resource( diff --git a/tests/unit/test_constants.py b/tests/unit/test_constants.py index 1a5e76faa3b..78ab52076fa 100644 --- a/tests/unit/test_constants.py +++ b/tests/unit/test_constants.py @@ -12,19 +12,50 @@ # or implied. See the License for the specific language governing # permissions and limitations under the License. -import os -from zenml.constants import handle_int_env_var +from zenml.constants import handle_int_env_var, handle_json_env_var -def test_handle_int_env_var(): +def test_handle_int_env_var(monkeypatch): """Check handle_int_env_var in all cases.""" env_var = "ZENML_TEST_HANDLE_INT_ENV_VAR" # check value error (when it can't be converted to int) - os.environ[env_var] = "test" + monkeypatch.setenv(env_var, "test") assert 0 == handle_int_env_var(env_var, 0) # check if it isn't there (in case it doesn't exist) - del os.environ[env_var] + monkeypatch.delenv(env_var, raising=False) assert 0 == handle_int_env_var(env_var, 0) + + +def test_handle_json_env_var(monkeypatch): + # Given an environment variable that is json + monkeypatch.setenv("TEST_VAR", '["hello", "world"]') + + # When we ask for that variable and expect it to be a List + result = handle_json_env_var("TEST_VAR", expected_type=list) + + # Then we should get the list ["hello", "world"] + assert result == ["hello", "world"] + + # Given an environment variable that is not json + monkeypatch.setenv("TEST_VAR", "hello world") + + # When we ask for that variable and expect it to be a List + result = handle_json_env_var("TEST_VAR", expected_type=list) + + # Then we should get an empty list (the default) + assert result == [] + + # Given an environment variable that is json but not the expected type + monkeypatch.setenv("TEST_VAR", '{"hello": "world"}') + + # When we ask for that variable and expect it to be a List + result = handle_json_env_var("TEST_VAR", expected_type=list) + + # Then we should get an empty list (the default) + assert result == [] + + # Unset environment variable + monkeypatch.delenv("TEST_VAR", raising=False)

p#0Mp(JI*g(KGj#*JO!LnJ0Q$`(y>>) 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 diff --git a/pyproject.toml b/pyproject.toml index 9697557dc20..9cdf07e30f0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zenml" -version = "0.55.5" +version = "0.56.0" packages = [{ include = "zenml", from = "src" }] description = "ZenML: Write production-ready ML code." authors = ["ZenML GmbH "] diff --git a/src/zenml/VERSION b/src/zenml/VERSION index 9aaab801597..c11ca46df94 100644 --- a/src/zenml/VERSION +++ b/src/zenml/VERSION @@ -1 +1 @@ -0.55.5 +0.56.0 \ No newline at end of file diff --git a/src/zenml/zen_server/deploy/helm/Chart.yaml b/src/zenml/zen_server/deploy/helm/Chart.yaml index 673505615ab..c45ced3f603 100644 --- a/src/zenml/zen_server/deploy/helm/Chart.yaml +++ b/src/zenml/zen_server/deploy/helm/Chart.yaml @@ -1,6 +1,6 @@ apiVersion: v2 name: zenml -version: "0.55.5" +version: "0.56.0" description: Open source MLOps framework for portable production ready ML pipelines keywords: - mlops diff --git a/src/zenml/zen_server/deploy/helm/README.md b/src/zenml/zen_server/deploy/helm/README.md index 2b228e3f33e..92d803ffc60 100644 --- a/src/zenml/zen_server/deploy/helm/README.md +++ b/src/zenml/zen_server/deploy/helm/README.md @@ -20,8 +20,8 @@ ZenML is an open-source MLOps framework designed to help you create robust, main To install the ZenML chart directly from Amazon ECR, use the following command: ```bash -# example command for version 0.55.5 -helm install my-zenml oci://public.ecr.aws/zenml/zenml --version 0.55.5 +# example command for version 0.56.0 +helm install my-zenml oci://public.ecr.aws/zenml/zenml --version 0.56.0 ``` Note: Ensure you have OCI support enabled in your Helm client and that you are authenticated with Amazon ECR. diff --git a/src/zenml/zen_stores/migrations/versions/0.56.0_release.py b/src/zenml/zen_stores/migrations/versions/0.56.0_release.py new file mode 100644 index 00000000000..85dc2ccdf5e --- /dev/null +++ b/src/zenml/zen_stores/migrations/versions/0.56.0_release.py @@ -0,0 +1,23 @@ +"""Release [0.56.0]. + +Revision ID: 0.56.0 +Revises: 1a9a9d2a836d +Create Date: 2024-03-20 13:30:40.013587 + +""" + +# revision identifiers, used by Alembic. +revision = "0.56.0" +down_revision = "1a9a9d2a836d" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + """Upgrade database schema and/or data, creating a new revision.""" + pass + + +def downgrade() -> None: + """Downgrade database schema and/or data back to the previous revision.""" + pass From aa72f8b4a3d1de6db52ed19c5663a17416ca2841 Mon Sep 17 00:00:00 2001 From: Alex Strick van Linschoten Date: Thu, 21 Mar 2024 09:18:19 +0100 Subject: [PATCH 31/45] Fix formatting and release workflow (#2549) * formatting and fix release workflow * Auto-update of E2E template --------- Co-authored-by: GitHub Actions --- .github/workflows/release.yml | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6846c059d6d..05d597872bb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -22,14 +22,21 @@ jobs: uses: actions/setup-python@v5.0.0 with: python-version: '3.8' - - name: Install current package as editable + - name: Install uv run: | - pip install -U uv - uv pip install --system -e . - - name: Install mlstacks package - run: uv pip install --system mlstacks + curl -LsSf https://astral.sh/uv/install.sh | sh + source $HOME/.cargo/env + - name: Create virtual environment + run: uv venv + - name: Check mlstacks compatibility + run: | + source .venv/bin/activate + uv pip install -e . + uv pip install mlstacks - name: Check for broken dependencies - run: uv pip check + run: | + source .venv/bin/activate + uv pip check mysql-db-migration-testing: runs-on: arc-runner-set env: From 75f5ece68342e1c7fb7fffe9a9dc330316e2dfdd Mon Sep 17 00:00:00 2001 From: Alex Strick van Linschoten Date: Thu, 21 Mar 2024 10:11:31 +0100 Subject: [PATCH 32/45] Fix release workflow (#2550) * fix * Add source command to create virtual environment * Add source command for cargo environment setup * Update GitHub Actions workflows --- .github/workflows/release.yml | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 05d597872bb..53e717efa8b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,7 +1,5 @@ --- -# This is a basic workflow to help you get started with Actions name: Release Package & Docker Image -# Controls when the action will run. Triggers the workflow on push of a tag on: push: tags: ['*'] @@ -27,15 +25,19 @@ jobs: curl -LsSf https://astral.sh/uv/install.sh | sh source $HOME/.cargo/env - name: Create virtual environment - run: uv venv + run: | + source $HOME/.cargo/env + uv venv - name: Check mlstacks compatibility run: | source .venv/bin/activate + source $HOME/.cargo/env uv pip install -e . uv pip install mlstacks - name: Check for broken dependencies run: | source .venv/bin/activate + source $HOME/.cargo/env uv pip check mysql-db-migration-testing: runs-on: arc-runner-set From 767b15b115e9704ebefb5d26cc0e67c0c843553e Mon Sep 17 00:00:00 2001 From: Jayesh Sharma Date: Thu, 21 Mar 2024 16:59:20 +0530 Subject: [PATCH 33/45] fix links (#2554) --- src/zenml/utils/dashboard_utils.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/zenml/utils/dashboard_utils.py b/src/zenml/utils/dashboard_utils.py index 73d9e63c522..23b59bdc3cc 100644 --- a/src/zenml/utils/dashboard_utils.py +++ b/src/zenml/utils/dashboard_utils.py @@ -97,8 +97,13 @@ def get_run_url(run: PipelineRunResponse) -> Optional[str]: Returns: the URL to the pipeline run if the dashboard is available, else None. """ + client = Client() base_url = get_base_url() if base_url: + server_model = client.zen_store.get_store_info() + # if the server is a zenml cloud tenant, use a different URL + if server_model.metadata.get("organization_id"): + return f"{base_url}{constants.RUNS}/{run.id}" if run.pipeline: return f"{base_url}{constants.PIPELINES}/{run.pipeline.id}{constants.RUNS}/{run.id}/dag" else: @@ -124,9 +129,7 @@ def get_model_version_url(model_version_id: UUID) -> Optional[str]: if base_url: # TODO MODEL_VERSIONS resolves to /model_versions but on the # cloud, the URL is /model-versions. This should be fixed? - return ( - f"{base_url}{constants.MODEL_VERSIONS}/{str(model_version_id)}" - ) + return f"{base_url}/model-versions/{str(model_version_id)}" return None From 66c58ce8dc36f171f20e53ad53b6ee80e93ade80 Mon Sep 17 00:00:00 2001 From: Andrei Vishniakov <31008759+avishniakov@users.noreply.github.com> Date: Thu, 21 Mar 2024 14:39:58 +0100 Subject: [PATCH 34/45] move rate limiting to a separate file (#2553) --- src/zenml/zen_server/rate_limit.py | 184 ++++++++++++++++++ .../zen_server/routers/auth_endpoints.py | 2 +- src/zenml/zen_server/utils.py | 158 --------------- 3 files changed, 185 insertions(+), 159 deletions(-) create mode 100644 src/zenml/zen_server/rate_limit.py diff --git a/src/zenml/zen_server/rate_limit.py b/src/zenml/zen_server/rate_limit.py new file mode 100644 index 00000000000..520025778d6 --- /dev/null +++ b/src/zenml/zen_server/rate_limit.py @@ -0,0 +1,184 @@ +# Copyright (c) ZenML GmbH 2024. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at: +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express +# or implied. See the License for the specific language governing +# permissions and limitations under the License. +"""Rate limiting for the ZenML Server.""" + +import inspect +import time +from collections import defaultdict +from functools import wraps +from typing import ( + Any, + Callable, + Dict, + List, + Optional, + TypeVar, + cast, +) + +from starlette.requests import Request + +from zenml.logger import get_logger +from zenml.zen_server.utils import server_config + +logger = get_logger(__name__) +F = TypeVar("F", bound=Callable[..., Any]) + + +class RequestLimiter: + """Simple in-memory rate limiter.""" + + def __init__( + self, + day_limit: Optional[int] = None, + minute_limit: Optional[int] = None, + ): + """Initializes the limiter. + + Args: + day_limit: The number of requests allowed per day. + minute_limit: The number of requests allowed per minute. + + Raises: + ValueError: If both day_limit and minute_limit are None. + """ + self.limiting_enabled = server_config().rate_limit_enabled + if not self.limiting_enabled: + return + if day_limit is None and minute_limit is None: + raise ValueError("Pass either day or minuter limits, or both.") + self.day_limit = day_limit + self.minute_limit = minute_limit + self.limiter: Dict[str, List[float]] = defaultdict(list) + + def hit_limiter(self, request: Request) -> None: + """Increase the number of hits in the limiter. + + Args: + request: Request object. + + Raises: + HTTPException: If the request limit is exceeded. + """ + if not self.limiting_enabled: + return + from fastapi import HTTPException + + requester = self._get_ipaddr(request) + now = time.time() + minute_ago = now - 60 + day_ago = now - 60 * 60 * 24 + self.limiter[requester].append(now) + + from bisect import bisect_left + + # remove failures older than a day + older_index = bisect_left(self.limiter[requester], day_ago) + self.limiter[requester] = self.limiter[requester][older_index:] + + if self.day_limit and len(self.limiter[requester]) > self.day_limit: + raise HTTPException( + status_code=429, detail="Daily request limit exceeded." + ) + minute_requests = len( + [ + limiter_hit + for limiter_hit in self.limiter[requester][::-1] + if limiter_hit >= minute_ago + ] + ) + if self.minute_limit and minute_requests > self.minute_limit: + raise HTTPException( + status_code=429, detail="Minute request limit exceeded." + ) + + def reset_limiter(self, request: Request) -> None: + """Resets the limiter on successful request. + + Args: + request: Request object. + """ + if self.limiting_enabled: + requester = self._get_ipaddr(request) + if requester in self.limiter: + del self.limiter[requester] + + def _get_ipaddr(self, request: Request) -> str: + """Returns the IP address for the current request. + + Based on the X-Forwarded-For headers or client information. + + Args: + request: The request object. + + Returns: + The ip address for the current request (or 127.0.0.1 if none found). + """ + if "X_FORWARDED_FOR" in request.headers: + return request.headers["X_FORWARDED_FOR"] + else: + if not request.client or not request.client.host: + return "127.0.0.1" + + return request.client.host + + +def rate_limit_requests( + day_limit: Optional[int] = None, + minute_limit: Optional[int] = None, +) -> Callable[..., Any]: + """Decorator to handle exceptions in the API. + + Args: + day_limit: Number of requests allowed per day. + minute_limit: Number of requests allowed per minute. + + Returns: + Decorated function. + """ + limiter = RequestLimiter(day_limit=day_limit, minute_limit=minute_limit) + + def decorator(func: F) -> F: + request_arg, request_kwarg = None, None + parameters = inspect.signature(func).parameters + for arg_num, arg_name in enumerate(parameters): + if parameters[arg_name].annotation == Request: + request_arg = arg_num + request_kwarg = arg_name + break + if request_arg is None or request_kwarg is None: + raise ValueError( + "Rate limiting APIs must have argument of `Request` type." + ) + + @wraps(func) + def decorated( + *args: Any, + **kwargs: Any, + ) -> Any: + if request_kwarg in kwargs: + request = kwargs[request_kwarg] + else: + request = args[request_arg] + limiter.hit_limiter(request) + + ret = func(*args, **kwargs) + + # if request was successful - reset limiter + limiter.reset_limiter(request) + return ret + + return cast(F, decorated) + + return decorator diff --git a/src/zenml/zen_server/routers/auth_endpoints.py b/src/zenml/zen_server/routers/auth_endpoints.py index c3296cdd550..41137a1e18a 100644 --- a/src/zenml/zen_server/routers/auth_endpoints.py +++ b/src/zenml/zen_server/routers/auth_endpoints.py @@ -65,12 +65,12 @@ ) from zenml.zen_server.exceptions import error_response from zenml.zen_server.jwt import JWTToken +from zenml.zen_server.rate_limit import rate_limit_requests from zenml.zen_server.rbac.models import Action, ResourceType from zenml.zen_server.rbac.utils import verify_permission from zenml.zen_server.utils import ( get_ip_location, handle_exceptions, - rate_limit_requests, server_config, zen_store, ) diff --git a/src/zenml/zen_server/utils.py b/src/zenml/zen_server/utils.py index 6dff76b6f47..6eccb5eac13 100644 --- a/src/zenml/zen_server/utils.py +++ b/src/zenml/zen_server/utils.py @@ -15,15 +15,10 @@ import inspect import os -import time -from collections import defaultdict from functools import wraps from typing import ( - TYPE_CHECKING, Any, Callable, - Dict, - List, Optional, Tuple, Type, @@ -33,7 +28,6 @@ from urllib.parse import urlparse from pydantic import BaseModel, ValidationError -from starlette.requests import Request from zenml.config.global_config import GlobalConfiguration from zenml.config.server_config import ServerConfiguration @@ -55,10 +49,6 @@ from zenml.zen_server.rbac.rbac_interface import RBACInterface from zenml.zen_stores.sql_zen_store import SqlZenStore -if TYPE_CHECKING: - pass - - logger = get_logger(__name__) _zen_store: Optional["SqlZenStore"] = None @@ -336,154 +326,6 @@ def decorated(*args: Any, **kwargs: Any) -> Any: return cast(F, decorated) -class RequestLimiter: - """Simple in-memory rate limiter.""" - - def __init__( - self, - day_limit: Optional[int] = None, - minute_limit: Optional[int] = None, - ): - """Initializes the limiter. - - Args: - day_limit: The number of requests allowed per day. - minute_limit: The number of requests allowed per minute. - - Raises: - ValueError: If both day_limit and minute_limit are None. - """ - self.limiting_enabled = server_config().rate_limit_enabled - if not self.limiting_enabled: - return - if day_limit is None and minute_limit is None: - raise ValueError("Pass either day or minuter limits, or both.") - self.day_limit = day_limit - self.minute_limit = minute_limit - self.limiter: Dict[str, List[float]] = defaultdict(list) - - def hit_limiter(self, request: Request) -> None: - """Increase the number of hits in the limiter. - - Args: - request: Request object. - - Raises: - HTTPException: If the request limit is exceeded. - """ - if not self.limiting_enabled: - return - from fastapi import HTTPException - - requester = self._get_ipaddr(request) - now = time.time() - minute_ago = now - 60 - day_ago = now - 60 * 60 * 24 - self.limiter[requester].append(now) - - from bisect import bisect_left - - # remove failures older than a day - older_index = bisect_left(self.limiter[requester], day_ago) - self.limiter[requester] = self.limiter[requester][older_index:] - - if self.day_limit and len(self.limiter[requester]) > self.day_limit: - raise HTTPException( - status_code=429, detail="Daily request limit exceeded." - ) - minute_requests = len( - [ - limiter_hit - for limiter_hit in self.limiter[requester][::-1] - if limiter_hit >= minute_ago - ] - ) - if self.minute_limit and minute_requests > self.minute_limit: - raise HTTPException( - status_code=429, detail="Minute request limit exceeded." - ) - - def reset_limiter(self, request: Request) -> None: - """Resets the limiter on successful request. - - Args: - request: Request object. - """ - if self.limiting_enabled: - requester = self._get_ipaddr(request) - if requester in self.limiter: - del self.limiter[requester] - - def _get_ipaddr(self, request: Request) -> str: - """Returns the IP address for the current request. - - Based on the X-Forwarded-For headers or client information. - - Args: - request: The request object. - - Returns: - The ip address for the current request (or 127.0.0.1 if none found). - """ - if "X_FORWARDED_FOR" in request.headers: - return request.headers["X_FORWARDED_FOR"] - else: - if not request.client or not request.client.host: - return "127.0.0.1" - - return request.client.host - - -def rate_limit_requests( - day_limit: Optional[int] = None, - minute_limit: Optional[int] = None, -) -> Callable[..., Any]: - """Decorator to handle exceptions in the API. - - Args: - day_limit: Number of requests allowed per day. - minute_limit: Number of requests allowed per minute. - - Returns: - Decorated function. - """ - limiter = RequestLimiter(day_limit=day_limit, minute_limit=minute_limit) - - def decorator(func: F) -> F: - request_arg, request_kwarg = None, None - parameters = inspect.signature(func).parameters - for arg_num, arg_name in enumerate(parameters): - if parameters[arg_name].annotation == Request: - request_arg = arg_num - request_kwarg = arg_name - break - if request_arg is None or request_kwarg is None: - raise ValueError( - "Rate limiting APIs must have argument of `Request` type." - ) - - @wraps(func) - def decorated( - *args: Any, - **kwargs: Any, - ) -> Any: - if request_kwarg in kwargs: - request = kwargs[request_kwarg] - else: - request = args[request_arg] - limiter.hit_limiter(request) - - ret = func(*args, **kwargs) - - # if request was successful - reset limiter - limiter.reset_limiter(request) - return ret - - return cast(F, decorated) - - return decorator - - # Code from https://github.com/tiangolo/fastapi/issues/1474#issuecomment-1160633178 # to send 422 response when receiving invalid query parameters def make_dependable(cls: Type[BaseModel]) -> Callable[..., Any]: From a6e9819d48de1f73415a6be24a2be8d47db509c1 Mon Sep 17 00:00:00 2001 From: Christian Versloot Date: Thu, 21 Mar 2024 14:46:05 +0100 Subject: [PATCH 35/45] Bump MLFlow to version 2.11.2 (#2552) --- src/zenml/integrations/mlflow/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/zenml/integrations/mlflow/__init__.py b/src/zenml/integrations/mlflow/__init__.py index fd7ecee6580..753e9191618 100644 --- a/src/zenml/integrations/mlflow/__init__.py +++ b/src/zenml/integrations/mlflow/__init__.py @@ -35,7 +35,7 @@ class MlflowIntegration(Integration): # does not pin it. They fixed this in a later version, so we can probably # remove this once we update the mlflow version. REQUIREMENTS = [ - "mlflow>=2.1.1,<=2.11.1", + "mlflow>=2.1.1,<=2.11.2", "mlserver>=1.3.3", "mlserver-mlflow>=1.3.3", # TODO: remove this requirement once rapidjson is fixed From 55305bca8e7c8218cca8a5cf7a52cc07ba78b0e4 Mon Sep 17 00:00:00 2001 From: Andrei Vishniakov <31008759+avishniakov@users.noreply.github.com> Date: Thu, 21 Mar 2024 16:29:11 +0100 Subject: [PATCH 36/45] Prepare release 0.56.1 (#2555) * Prepare release 0.56.1 * Apply suggestions from code review Co-authored-by: Alex Strick van Linschoten --------- Co-authored-by: Alex Strick van Linschoten --- README.md | 2 +- RELEASE_NOTES.md | 18 +++++++++++++++ pyproject.toml | 2 +- src/zenml/VERSION | 2 +- src/zenml/zen_server/deploy/helm/Chart.yaml | 2 +- src/zenml/zen_server/deploy/helm/README.md | 4 ++-- .../migrations/versions/0.56.1_release.py | 23 +++++++++++++++++++ 7 files changed, 47 insertions(+), 6 deletions(-) create mode 100644 src/zenml/zen_stores/migrations/versions/0.56.1_release.py diff --git a/README.md b/README.md index d12ef0ed80d..338e481a5c3 100644 --- a/README.md +++ b/README.md @@ -92,7 +92,7 @@ Projects Showcase

- 🎉 Version 0.56.0 is out. Check out the release notes + 🎉 Version 0.56.1 is out. Check out the release notes here.

diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 3238b88c295..ca25e3f6380 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,4 +1,22 @@ +# 0.56.1 + +This is a patch release aiming to solve a dependency problem which was brought in with the new rate +limiting functionality. With 0.56.1 you no longer need `starlette` to run client code or to +run ZenML CLI commands. + +## 🥳 Community Contributions 🥳 + +We'd like to thank @christianversloot for his contribution to this release. + +## What's Changed +* Fix pipelines and model links for the cloud dashboard by @wjayesh in https://github.com/zenml-io/zenml/pull/2554 +* Make starlette non-must for client by @avishniakov in https://github.com/zenml-io/zenml/pull/2553 +* Bump MLFlow to version 2.11.2 by @christianversloot in https://github.com/zenml-io/zenml/pull/2552 + + +**Full Changelog**: https://github.com/zenml-io/zenml/compare/0.56.0...0.56.1 + # 0.56.0 ZenML 0.56.0 introduces a wide array of new features, enhancements, and bug fixes, diff --git a/pyproject.toml b/pyproject.toml index 9cdf07e30f0..164bc764717 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zenml" -version = "0.56.0" +version = "0.56.1" packages = [{ include = "zenml", from = "src" }] description = "ZenML: Write production-ready ML code." authors = ["ZenML GmbH "] diff --git a/src/zenml/VERSION b/src/zenml/VERSION index c11ca46df94..a4c4386b107 100644 --- a/src/zenml/VERSION +++ b/src/zenml/VERSION @@ -1 +1 @@ -0.56.0 \ No newline at end of file +0.56.1 \ No newline at end of file diff --git a/src/zenml/zen_server/deploy/helm/Chart.yaml b/src/zenml/zen_server/deploy/helm/Chart.yaml index c45ced3f603..3ea33596a13 100644 --- a/src/zenml/zen_server/deploy/helm/Chart.yaml +++ b/src/zenml/zen_server/deploy/helm/Chart.yaml @@ -1,6 +1,6 @@ apiVersion: v2 name: zenml -version: "0.56.0" +version: "0.56.1" description: Open source MLOps framework for portable production ready ML pipelines keywords: - mlops diff --git a/src/zenml/zen_server/deploy/helm/README.md b/src/zenml/zen_server/deploy/helm/README.md index 92d803ffc60..3010ff3fd5f 100644 --- a/src/zenml/zen_server/deploy/helm/README.md +++ b/src/zenml/zen_server/deploy/helm/README.md @@ -20,8 +20,8 @@ ZenML is an open-source MLOps framework designed to help you create robust, main To install the ZenML chart directly from Amazon ECR, use the following command: ```bash -# example command for version 0.56.0 -helm install my-zenml oci://public.ecr.aws/zenml/zenml --version 0.56.0 +# example command for version 0.56.1 +helm install my-zenml oci://public.ecr.aws/zenml/zenml --version 0.56.1 ``` Note: Ensure you have OCI support enabled in your Helm client and that you are authenticated with Amazon ECR. diff --git a/src/zenml/zen_stores/migrations/versions/0.56.1_release.py b/src/zenml/zen_stores/migrations/versions/0.56.1_release.py new file mode 100644 index 00000000000..d1eb6c0c982 --- /dev/null +++ b/src/zenml/zen_stores/migrations/versions/0.56.1_release.py @@ -0,0 +1,23 @@ +"""Release [0.56.1]. + +Revision ID: 0.56.1 +Revises: 0.56.0 +Create Date: 2024-03-21 14:50:20.869911 + +""" + +# revision identifiers, used by Alembic. +revision = "0.56.1" +down_revision = "0.56.0" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + """Upgrade database schema and/or data, creating a new revision.""" + pass + + +def downgrade() -> None: + """Downgrade database schema and/or data back to the previous revision.""" + pass From 285b1fa5f7d9e3a00b4ef3ab834f7c13981e406d Mon Sep 17 00:00:00 2001 From: Siddhant Sadangi Date: Thu, 21 Mar 2024 17:35:13 +0100 Subject: [PATCH 37/45] Updated neptune documentation (#2548) * Updated neptune documentation * Update docs/book/stacks-and-components/component-guide/experiment-trackers/neptune.md Co-authored-by: Alex Strick van Linschoten * Removed screenshot --------- Co-authored-by: Alex Strick van Linschoten --- .../experiment-trackers/neptune.md | 35 +++++++------------ 1 file changed, 12 insertions(+), 23 deletions(-) diff --git a/docs/book/stacks-and-components/component-guide/experiment-trackers/neptune.md b/docs/book/stacks-and-components/component-guide/experiment-trackers/neptune.md index 0b5f8d5bc61..7625ec0187e 100644 --- a/docs/book/stacks-and-components/component-guide/experiment-trackers/neptune.md +++ b/docs/book/stacks-and-components/component-guide/experiment-trackers/neptune.md @@ -168,29 +168,6 @@ def tf_trainer(...): ``` {% endhint %} -### Neptune UI - -Neptune comes with a web-based UI that you can use to find further details about -your tracked experiments. Each pipeline run will be logged as a separate -experiment run in Neptune, which you can inspect in the Neptune UI: - -![Neptune UI](../../../.gitbook/assets/NeptuneUI.png) - -You can find the URL of the Neptune experiment linked to a specific ZenML run -via the metadata of the step in which the experiment tracker was used: - -```python -from zenml.client import Client - -last_run = client.get_pipeline("").last_run -trainer_step = last_run.get_step("") -tracking_url = trainer_step.run_metadata["experiment_tracker_url"].value -print(tracking_url) -``` - -Alternatively, you can see an overview of all experiment runs at -https://app.neptune.ai/{ACCOUNT_USERNAME}/{PROJECT_NAME}. - #### Additional configuration You can pass a set of tags to the Neptune run by using the `NeptuneExperimentTrackerSettings` class, like in the example @@ -226,5 +203,17 @@ def my_step( ... ``` +### Neptune UI + +Neptune comes with a web-based UI that you can use to find further details about +your tracked experiments. Each pipeline run will be logged as a separate +experiment run in Neptune, which you can inspect in the Neptune UI. + +You can find the URL of the Neptune run linked to a specific ZenML run printed on the console whenever a Neptune run is initialized. + +### Further reading + +Check [Neptune's docs](https://docs.neptune.ai/integrations/zenml/) for further information on how to use this integration and Neptune in general. +

pg(1+Od-I?=^>_Zg+-Lx#~sqm zeJ;QW{oTX3{wX{QDWnKP3~^rn>+Jo%NAQ3AgRhSk74I(BVNo1)*mfsR=EN4i;cO#U zy1>K6Gjv&P?1B7I>wA$C2YD|B3D(P^3|b#W(b#^?*3;Dv{hnLUV8i$E#y2GB@l$ms z=jsxz7xQu$)ZVLSH7_J|Fk}N^AMi%*{|tl(_by}1z~+BMA^*IaR1-KK?Jv#!HJ^Tp8H}KVig)6E-thibBLs2lR z={NkNGfiAOfh^JA#0@!?#E)B=O+*T171v##{OG?u>!CQAETf{5eVii#|JjYcM6Oi$ z^t_W^cYX&s%u`pzS#L|l;ckCL(|ld`{Ar{wg&ww_Vzf~Gx~elvnIj?W>9}^7j{#2x z_-Fqyx`HMibD%%wq8Pj@U})6lSQ2#J!ZBRuaj~bBB_~g}B|<+K#6-W!_>pE%oZf*h z<|S0Qhcr$t(xZI7KNO>|echy4YO3h#rmmR(9Ra=X!81Peda$}0Be764JLT-6t8F^& za#2QiCW357`5>Us^b3|8bT;nyERMwaI0lJW-x3+6b4|Z1M|4^G2i|7lxxal8ne;fV z9fF56C$ke(#o#r(d+O!P|u!7p&?u4@pTYZ$=Bb{k1^)olg8dgYc~>* zKCpY-*|$GF#!(xbn=%n+wu3VjjHj|H7E(c0dt1>T{f7EvVH&3q;;_7MsNt@&y-nrD2 z+svPtBBT(Nf$te_1n@LE-r#)c2^1?hOVDk+uF^HQ36f~NYY??7f|ieHG}KY3M`9oA zAk(Yu{prWw0B6cEIvbzTB(jqQ31{`^%^xerzW)1-lu}q!p~Qa{2^e_g?*v|A(iSV{ zLp(KFQEdM2u;X0mz{EL+$B(bT@Np012j&*vJ2dW-yZPkH*7HNl*~&cr`yjGqHSa@u zKe)>ZhS}ri5$WE&;P(W~7+6;lT5OUQXhsI1Jzkol4^8B!${BS3I?07FVe@bEl(huG zMsZVdI<8k#%c~ruWUo*Mpwa=NzoDJMqcFK$>fZ!VbXYd}8i^Zfk!AC=zmkD z^N>+b9OoxtkVYQ3DTR(67Myje`FPH$@#xcjS5We;de1wQI~{-BKD^!np%#=EAH){h z+OXz$E)5jN20dg7&AxQkT9;9XU(fM)qykyAc-3AkB#{Y+k-;9zM4eZBx|+;S_wcmZ zv*GE%`(nTPOQ5=4u<0ptrH>Rg@P^Haiez=Xn^UK-W`{)=mp=?U^Ek4*om%Rpb9O5+c#gM_Tjusyg*84_J2L=AZHAb!5IJqzv@IWU^zdp0Kc|Atc| zlRZ7uj4u8K>HeIMVff0_b=HX)Isn@OHh^f=qEnF7S*BNQo8#-N_ui^SZ;z)C3Vu54 z?N_XIUDDr;yyB!F^;C(v$$aAXYF)32rg4E;azF0GV;6Bk#|Ui#UM-P}2KG!J5up}Q zl>2I1{ti$jiiVbUZWeTIHW%(bt!&QeG)ey3E{m25oA7pg6|rLL%VP9;EdxUqKCH(P z?KkTq;(#GvXp7^GMTbb>jTH8iz~5xyA54b^3!Y1HNg!Qc_B_<`_jEodyYEX&=>y&s znOffMr=REo*;CKuRz3g-V+8ue{!gR(uaXJA5?WuDIVxotY?6e}iHA=~I|i;m9?moY zOtIL1O&ld}y`p78Bj<3VZNoJ2S@ONh@5xu-+05!Uu9A9qE#5{bfU>O`;6F$89$QyW zum|$22ie#E{R4fNoF6#|-{*Ext*$cA0xW^mEac>g9V;b)caJ+apElw|Ve-&o;_Im> za=c0x+pQZJ>wC2dN_UVK1M*_2;YOi5J=kLahkDKDr^1pz$amp#5 zBv%sOAewpH|7_-kzmT4P4`xM%*h<>y^9%MOQn>Nsk&nY>3lDWp^AL>gkUq$S_xjV@ zeJR;>HNwh(d!YNq!Ma{p|Gf{3({S(2moomqa723&4@Se^lMp!Di$5P5owfZ>sPI46 zl7A4$e>|8CLR|)|J5P9pN`u)!5G;&1DD5DoQXFrt(DVo(d6KnTA&2PbgJGbW$POYn z>$V-7QuFuxHhYM487(73!j%gb>9N-LZ&onZ6uCWYGxUcOTsS#9##Q|A!dTO9?EAm# zn-E`;ArU)P;`M66<1v&moScgBj>jx82m6LexVU@{!&TDEnd~KFN45=huJP_0#biv3 z5C8cCIZ5s0*)Wi9;Ql@Wfp8wR6hS1J94M ze_32K{9V**Xm_C$T7;p{greR>{OcR@-&@a6IRhkMaN?pm#3V6rSX73Lxf9sL0L$Jj zl)L6vW(b81j1w&Mtoss3qeMy}-RwuRyy7~^UQhgy9xB>hH^ni2Rd{MRsRO_{>MF`4aUZ$+Y!|L)EPwc zDIGU>(nM?J!Icfl^2vIE>~kx%JTH@uY}`Fv3a$PkC!d4NIQOcl7C$%HUmTMOt!3Qw z_W>5uMm)Oj<>6t?H0h2D-$?gon5dRK!7|R)e8($?ktgZtIkgdRl!MkD$Sc{3;K7N! zY?|g9mARpIM`IGsyDB1wUnj#^>8YrBEUMCTJ|Mx62BI7feTvxc8Q5r<*X_4+H3DA` zej>ofW{%bVga8%xx!exh`gS-MO{-SxNJ&@av4hw_EkF2f(|C9{0n{tKp+ ziGUm$_vL2u`8k$_+CCg(K>_=nYPI2GP|U>xzPH@626vI-zWzZJ%R>kmz;e&q1vMiTt8VCcO`{xA$S`y6UGik#8kc2wIX;Y{q_r91i&*%AR-1R2{5}geHj`|^tOWPx_AMRJZ!BcI# zRMnSS!*4S5^#v6rAiYy8su!|Zzk=+aJ}EBL_X5L%r!t6TYUPwy33p z>3c33>#-ah86A%jQa*37gg&cpvvk4PreqGV|`Nt}ckPT0AvS zIMYr-NqA=C~4vhKAYxPR}c?B^Fb^35{`B}^nx`>g` z*O63ffkI7?+z-DtlyddjHLDF1gQ{_imP!zJ<{6r%+;PymOs3M^gf(HMD{AxFo{=3s z(EKdSe2w$r{oaXF2mWz)9XN$zE}C-B+dlHI<{u-(r+q&L*J_JeN{uRsW$d1yw4~>t&R1w%Qsi6K zYwrfG+#6>nCDcXlq=_^xaAf7R3Q3^ZYq2un_)_P~AMfoVLqmN;Gelma z%8>!WsGY>H6szkMWE2Z`YJayP<-}n>b=(cUU~1DmT0D2N6Z7c~qAG_9H1aSJ!l_Mk zdPWTo10Cxmi>z}b{)8}gILA&8lxotD*>CVY18qHa~_I0M2eqxlt14{$MDVnFjq(Cv^PlWX2%p%5oKCoD!FnIN>j6`PoS| z&2e}lDmN%z=<1!dGB^F|Y+W!-yinTSAU&1DYqZ<7S4+Wmqp}2n&na#^3TQdr)P7E% zY{K(xkG8#z{CSeifuB6%U!y#yrG?EK%UL^fvuqh1=_7$gW%;E3nZv>!A)Vu7pQ%;)i{sy``=W&%+p*f)~#k!C+2@1c` zro{Wb*AZ=T#XocY1~0Mj#Ixe+Ki>uLR%hJ!b6&pO#p7l%uBwP<0&wjzRVO0PmG~o= zF*f+*_BQq7UvmJ1YG?evtiz!_TA}4_Pp((@NA<`^W*DFIs$(Uzk-y&KseGi)@v5BJ zIp&zQs0MN~4|%RE-Y?z)=et^XA~A&MqIrI12fCqK?G2|+DHwU0h>WA0Nq|Vm>GS)r zd^EJaaJUSHSzxoN{uX%<4RL)o#>sBespw*k=n&pzGT%Ess{YM9lD!kUX}Jo%a~7e# zl3syrFe3v#aZGdu^>2MHC^Sv9_eBClO@wM>!%NV~16}cCL@y6Z0lJ^fBWGvT!Nb|^ zgl{d5&ysCWkp19pn#@j$FkQBJtWB-dXLg$ATO5sWbAt+r_jKepWb9AMr_4Xhu883x z({DESlcrG8imh`~pKOD)a6pfR^wjFpazPTEGbPo?=74+KCbHz0coy*#>tDj9V1%rtA;$16yo&ks)f6I_j}n18R~9s-rP zAoqIrNa4KnvVl+opUxS1za2lJ-%DRcGH3I2W;}Q!1;7WUrZ>FeDL?5>$~M^ zUiS~p=yv(2iW;goSO8A>2~}=cmx#P}ML=6MF^}hA{;dFVW)w1j_@}w>?yHps;9S%R z4I?KKXrA*gYAtQ(&eKLpb4(W%s;k6x_1l~(mGWu*>+`dYWx3TNO*2s@j4mX9tX)fA zGJx=Da;>!C*X^Y?_o_F=N7}U=NgA5rHQ={c-(uX=#JChThQ-F;%N^J-C5N~ureQ?V z1(~qNUZGIW^IR?<12AyidPL@}8Dn^TgL6E`dYYc1c@8(Xg-Yi|ut|oBJtAglWhwNe6QUR#ST?qeDGPYL6d{K9uQRvH<#3C>EDDTL=+D!xWCrn+Y~Fk>+f4EsYxm z(e5w!jLTUqH77h)O%EeG;JMhp2-zv(m|{{LASG^kkZQuu;uZXc zKk%Wqb93lmO=DT!mWI);r&rKrc%Gz zQxb}^>Sb<9nfhkY3D1)PI&OM`OVM*xLiwT|s9ifx2KuU0C;oomPRNSNi7`>|dUFq0 zU5_D8dA@~T((Zz9v0<|ZuP(9`!vCCCa-U=xPYR}V%(?g@@RV6RX zv&-%>f<4BGw@do96)0$c7?5d{4$nk(BId(8j&w-tR88D`*00wT^M9KMYcnPcpuKV8 zW}v-Sr{obyW6yK`P&PoY`$y{X>K~#-Csf2C< zbfTa~uBGeoIrNj&(aq!XVoj4EV?o?5U%D!065CM){h)y4c-oKX zpYZ#UGxna-$lv0?qu{Ala;5v&PpVWEna=qi&pEn*dcrrg=3?KnKp$-lP?3b58IXE? zKByD#VEX)Q&8S?w4yIk^WAmWeJ|Wq?nsL0OHy_965%;ysr+&Hr7(47nz-s~&tF^66 z3(x%4F}o+Qd4p7Y#!4yFG!80}0pv{Lznfmte~&guc{SJXjyGQW^?8EuHDKbOqGO^Z zgymbbWL%f$zh1oOt`R3yxEmfq8QA^rL^}k40S~Y-bR5j7DioYWViIPfPv$v3qHZ8` zugmCMFzV>^2INymY@ysuAdJL~w4WGW*h``%C4n0`J=OWAP8jaEs@YK(d663anBJA& zgPu}(#l?e6=&)1ByCe|V^Ve>^?{NV7@5#Q-%|hvp+`suwzgDpWC-2SOcB{V)`&TCO znTW(oykN$J7xsMz+n{Io`JE59Gvdljb3a+LWJH6}#9+Y4e2Ut$dt(|lS}P}=M2%Xd zdy36#AETaUwz6dYiB1Lm^)xJ)>v&~6z@K8(D&qM&leTEbqott9`LL5Q4A?u!1MaeJ z*zJsQd$s#AtX!p|#B~_ERoEF{8c=$0%Sc-p9mMQ^trkTEt8ONHgibLnERUa#%}V3$ zUXgg6$hWy8fbg!=PB)AXvCu)+8yS!RmSI`B^OV%`oh1@?n^qOEy#iXS(nq2sA>@}& znhDWnL9Z=vggT$A`B#nFCM>a1Yh!Ekxmr-b@nFkBd9UAF?hnD!2=~a9}y^|HWta&`Kdh> zZA>&{ZEN&!r2}ss&mJD2wE0_~hu5;38Q?;M(9+S_db|Ni#)z{<{*ndpiXt&&;f#}C z#Ij^5pxl6bECO_yyYyVo;=`5vzDUS2Y=Jh6GU-2-S{A&~!2j4Q2P=lg}ysRfVsZvi_yXSH}^SnN?D{V>( zy?wTHA$-(KhDr-IFi`#?kRAQt#ntqFYQ^_%^gBYdp78eVr#CnYa1WIZO>8tl_19EN zmx+8Xz7X~FaSW22G5Q(hbDwd%H4Jf%crNz4k|jL7_OZ6#crgo-kg`$5UJ2P&32~bZ z*vi=5Fs2_2CmD->D?~INsC$CjQXw*B;x|`kh$%FV+E6#>pdq-Qw)=$&(^>q+Ghq2U zBVwD;CvQz|gmiEdOrPwB33aNSv=vWAQNSdx|0Q*o>90UOv9y<*?(6=`MyW*FO2GNB z2le4cJN=l50Fb$R4g7&K=3YlkxT>R|Z5Z!1n%;QPj~kCx#5wAzc`y0&NAQ*}lB|>- zEc={7X()Bb3tP4r7jY*6&?_g)`?G3B|FI1|nH?;1VA;{yGTMpQ@E#kE4dbH-_VRsn zg~a(vTguX2_N`vnyl#QruqByUdC(0ae3b~D(h4zUDa-Qen;!c{0FThEjt3_)&NLb- z+(~XJqv7&rx%!h!&cljY47=!YzvB$i@#V;m-cnS9e81!u7rnh=`Z}2BUluxfFG5e| zC$+|Nbc2W|S!b>e0&_p~Q+T>XuM|22gjn1J0940|@6xAKBh(Z;j?>nSo^upR?=C!= z3{v>|JhqxLbhZ)y?=+BW|1Vzb|3dtQkO}LOCajm+Rbd$53{25aW?|IO$bdWJPOXnxn0-d#yOT!>%H|UTunrwGITFJUW;-L zsT{#uFq5c!8?H?fhHeT!a#B`L*$gB0m|$A-*VZm?WLD#7V4F)*pf?CVX)SIf67^D3 z^i{!*{T};7S|Xp8?PH4GdM{swSx#3BBE8QI3c@IqeFL}c7@Ud2AVt%8Kj6A;#FQDj z!OYJ2VYC}2jWX>rE%OD@T2=!6%ulKn_g9zyao=Y1%r#Sco4fR4tk=2^-DyxuHeh=PF)%*|e&IzyG-S+Y4v*c*$V(y8HE5qpD_4oJr?nGn&02YoJhhwAgF%$uFW zJ%;;Jjj(^mqe_$eqMQ3TiH`8x`)}_7QVdO~|NM4f2kxF2>#C^9Z9281EX$%TSk>G~ zs}l{x@wq_ChVeK;un`V|4b$QU%Xusn=+U#SFXPTsWO`COiSq$4^6Iu9G!HB3sB=;G z#_La#%u(a(NVw%Ec*n|69fB!Y}XZx#o5#Mf;pX*0{e}W(*Ts97U9L%s$B{ zhvzuxZ!M{Lqb!=%lDimIR7A=_HPDd55}6uzYnMGX#v62{<$|s*4b;k0ShJIC0J>lB zV51LSl_)0*$;mGn2-mRoY;)AhmkUs_IhU7E9rYu#SPR!P%aF?SM{hUibBJzE9FUQF z=zY8u0b%NblTGtBo$2G0&O9YIq`?A>sR1_x{_7GCqD&ELFr9@}u{gnRF`7!hhVd}n zUQP`BFuZPr*hf&mt#$-*t{YMzHF7N;7n&sCXSY&`llxP!^e6~1!j?N`^p5lV>XF{_ zxJ2A9**HJHaeqfmD4f`LOQmnqMcqpAnX6!t3XU$-Y3FI}cp;*- zKz@|xkpOKE-;yJG7KAm4&AHtlb-Til+<&cMNQkZ=uvqp=AaR5sVxpRdV2erj* zJqz3Z7u^1=ukjlSbjhj@2lkEG!CU1UZ&)4vPr+PRlm)qEJh@45rXNinPaJ_INKRR6 zA1H!KmJ=mKB5Iep+E_%aYs9R3n15+vp7gpMXf|SWvJeL3Fq$kQfarK3Y4CvEG`)Gf z8^Ok(N3Nm0s|y6bQY(^i91}Mzkw&K}t+>S0e~5q-r9)KPy$>ew?>rnH5gi7l14-(-K(-sl&Xk;A6~RY$;$Pa)>n2 zOVUnhiP*;}zaPxISBC#1llDJdGmj@f*{~dcY$wJ)PThz`TNgm?8ZB>EoBuSlNQ1;^129LDoySyWqU~}p zgLw$>zp5@*G@q0Wthz#=c!3=39lv|#18q4jdHSuQu4jj!bGB~7nd)bLj7>0RdGpi7 zW2aiB?c3pq$aTLQK~(84(tve{3C3BQNg-0CyyTWLVG#+@UnW{40r-WvKoQT?nS%24 zoO%j)Z~9I;g!`V56x_@Bka~gzZ$OsO;gKpY*y*KMq}ga*w{ip{4J93>uM1)grFb(w zlZAZa8(C~zN@f*}?2h+& zvvSG4%j7^#`EQE_dU4=EE`KRHHxp6L-xL%27TF{&WU?z`O=ca80Bct#g3Cr=h&gi& z0tVThQGzl~r3|7wwG8~bB+O!vL!$&M>?1nXOv(0@N{AA8FZ0kTp_PL;t}~%8uVgR% zq=qAGPi2OzV&lyf4;CKz*2^NH>j~FC;4<_2t7%w2fg)kAvcT^}124*PmEan6Sou3Y zKQ~ll2I)oJ5R0S~W8D$)h-+aT5k|4E)D! zpJpoll69fEhbvXmym_OL?%12zD}0O9K(OhHs&*8rCFh>UTDXV|5M!{?Xgg9dn+GoMrCUTy&6LMF)LNnPc!%`PoivqAEOeLO2m*qXVv|1+O2HIf zxo;$Q(jh;pWC7a^EJLtd*OzdY8|LH$WP`_>%o-vw$?pd{j!-=O8L9|bB_5h935F$M z2+Nwx<=L1s-Zv-#cZ(kAO!U?rB}H>V01xxVFm&S`@~cD|$rogjLBqn)pBZt1JeNyh z+p+fz;E&Z_^7o1KBzpKpnjUT%cQ@fi;dJbC-NUY5F9U-^aWv!`vohnoz3d0;zU<~E zaVL5UcNqa&%BBE|EZ<%uS)KX5Tk1_68;FuxHNamZl#8C5BLEeJ8fFi@R=+tW!0c#y zx)3My;OqLG=Z4x&eBQOyn*}iV4p`}yGV!+>Z%TmQK0c*H39D6QUE26pmfvTEPaVXa z8`R_lgQ>!Pp*}(xN;EgcjdMq8(ur_I_HYPR{+h?5m*)wmGO(Shph|szp|pE7I=^QU z=dIZO7`$C*Hy!*0D(2X!gY_#mUSu5uYW=U0Jx3*VZgqD(N9UD**~BrLx8&RYu!_Pz zSt0ETssT-9m>r}ZSV#)vO2X?L(2~uOSN%Sw!UK2;B-c$J-~9-_hjf%96LfDxFo`)% zU<$oes?6Ir-ZJCtzbqW~O8y5Gr63V2-rPi2%y)4?hGrSQ=l%t||5MCjd^o=y(Vf9X zsrs=-d8{}RnOg1-#5gPpKPY{5xv_tGy6Jb%%>y>81{~+xL5f4vS-s}f@}Bc0AY{mb z*-w`FFQkZAB>1oj%(!kx_%$WnqKSUDTbVJP4j*j@Y3@+nY%=0qPVOZIWu6KQTF=wY z+=w>deLC_%<UrPBLx>{R1YyO&Gw1dBMFVjaZx+4Cs=+Dj`?aR3u$6sS3uFMuGDY|o|94VyJ>0AivD9L#TfRqr2 z^AYQZ(sSQzX8^pC+79|S3&y;juISZP(Z``dh8Gs(W$IA-`*J}#vhlCvAU$BtL@-?s*x*<~VFvG@MtRiiK#{Jj_sL3>&d0K~LeHULe9PJI zlT}{D8L;RL4D)}<83#a?Q$$0No)JG_zV-oCM?Gr``&Zqp9qgQ+tM8o8P3xTxfgCuI zqymNlE1i=9nOX}YT^!m`8GLuJ$l!(R%+f;v<1`kus{z9l&kEFegd5DXNN?BRwwaCm zv{DKT+OLqMjD~uRE3j;pi)J+Wju0NII{Zr;fo_8AVTufBI(sgF)P*1{4wIkJm{@}c3c#pM-|3PE<9Hxg>kD|L-G+=lhPP}qM>cfrf z{eCQUyW(s@7Y{(C((G-43H$pUkMQth?=XjU6Wkq_=H%Z3bQPXuVI(Mwzqk@`*wzj< zCr{bS?9ijyfc%Jsn*w1sj)(-)*6H-bXl`i{_H0LO#d6FTFClQ)vWh#`9&>@Wp)7cXl zySS8xQwZMqF_3-pv;@C1T<8Vui0C&x-BH(JMc~TEpu<5i_K=MkP&?~G8}-K!IerSP z`8?<(i$Ms$WD~pjP>aruD;`yzjq{7ge3)yjAM?;0(Vde2TPuZ&e)s4!-X%7JCm3=8aL?;wUVu&y!Oyz^w1B)wR_w8Kj}2UJ)u3a%e})y zefizb@*hwYo{qh~zke7Tq>g>J;#$lU2jSQ!40W+>V|Cti3DOC>p?xA!Ro;w@Cw}WR^Kuc28T;l^t_i>7Vm%;NkQZXL*R)Z@>-@7X z|MH?xWx2^cn*e^=ptY^jJ;0liO0A#0lF`1|v207i0U9_w+GS6P3#2_7xX}!*|ia=38~FVa~Ec9xTWY z`|V&$uA7XlEdQ4b75A37>p{8{0y+BXkUu>SQKeHl&qL z?+K(}g4-&VUc-PXP=rD~t$HDznumaG|0vb}>jav1f%BC+`(FL%D>u@vlC;FV=sUn) zr5y255XMa?`q*VS{1GuO108$Pk^sVMxq2t3wHQKuK{#?sQX|lAJ76n0uT*0E*YXgU z{bo^Ma00NJSiuN+LrLmECD`chX*1ABm=o%K5{>rGh>?!HgpKE)DWU&sX8$k4W%n!T z4XMo;k4>MSbVm+>GGI2n@X=o4jJZ5sO42u5!dSHi_ig>ey9`qtX57>6Oi8)UVvFRA zZ~2vwjk&%9aXkp_18=1WbWc;Iqb>R=bCDS{gv--_kKGN8tnsL8FB%zS_8c6KK`Rtm z@}K6L^ebH$B}BiL-XM3uOKD}^{vtd7+Jq^K6GS7rL>KZ+&_c-YD*TvZx0;B}=IM=J zhoz6@GjFudeOicbmcK|L(Zb9z<^~_Q_IUgA9x61G8S{yV@b%*mT5K9aPsza!hli&e z<-*SBt!)nq&tCK+E;V<5TAF6jrGn}m7XQ5FcMAE|P1U;w=PL(~+D3{4H(;1?B#n=% zs1~Hbon~GTX>?dts)SeLj>sS?#MLGwFRgu>fQTZb7W+2IA;C8`z` z<7bT%+q0IMF!bkWryJ*LoH=VQOBp5>kaue(*xYU|*a-y|kry0derR zw}-W{4~~S6NyK?fETx!*kMcf#+o)3apbz~zpxs1aJmUAU((8&g?;*X&eMH`P0N0Y3 znQ0@rnY{jFBdnZd0*dMA<#bTz{zu+EnkUS_aNfGn~p$547M?u)%{FI>y# zPv9+K+>S=KVW{88Nq@8!A07BIU;Cc!ZMk#6N((`P$G|bc zk@FrftX$H|*4jMl0seWT?NgHriH2%qvAzn@em~M0g!$0}&qFk~e4eKPq*L$YfE{A~ zDvmzL+Vns)@nH}=UPNqftFwv_XApFTMU9w!4|BDqLEWHH;GC+t_~xf%zqhbSf*+W6 z{1UeYfAgxw#Uvn~12ZMdjl;$>do*dZt%t{Veta1g1dEUFWY9u1MlkAA{?Pn;{sN;7 znfsA3IUQbGFdZ6*x^&rdQ^||EI^7vSEj+$oA#=|b>rb$4)cwp$#{uEOBT@>zL^FpN zi9^~wHFmnhnS}|N9-a;yUlE@c?iGTJyPio28TyzSU;OJaD#VZkD71Xlw6aaNePJO@ zprD%e(B;Uef-55I@O_nL7;E)q?6ta5P87EG%bd5H(%ZumHbMK54;g~~qNo!L3GYhF zIm3c0Nk9YR&LwiJ*v*u~u46vK{)YOOZCdl+4OJ%EnH;nea8E>AHj0Q|P1HeWy6B=l zR$I4S^cs7ZVcgs^h=F)on(&s=Ls-NbqRQnA?e`y&Z)Z68a?Z4{4tZ0ROdz+~bgj45 zPoHluW=cx1UM7Vd_h|*Gp>gll`PhdPMEPSW8pMI4RQ9aw6wcPx@eaPLAZcxdY!d&i zv{7OD^Wpg=fU*fm;Z@~_M0_th=(!d$5>|szC_Z+F9n>1L3U|l*?8?5970Ho(pSXZ8 zQWf_s#TjQ6z6;k0dHQV5|0lzRgiAXb{(E-bUrilrV!9=pw&i`Vqg}$&FCE14#j>sb zZ6%)LLfaupWi8?w8n?HqfK!EAa;BN7V|IN0o`RIDPGKM)GhX|hFr|y^x%TT@tDQ)= zhT{8p9QVh|WDzO1vhKqOP~$d!#$JQ#U-aFXAC@{6bh0{;OzKf==DBF=sPl3WcwQ!g z4M@!o%UOr+x0lZ5v*HAtX{&(rIlo+DB(1WOcVHv#PQN0&F}D-ZmvZY}gS@^O5fJ9# z@{IEz@#z`F-&kMO`~!!%$TzCwm$0tuBp1p;Xh^i}jg;oNZ#qK~@6!wnlCf{w_M;*( zKO2Ptwun)?UlMmGK^at$i?q^xWEX%kwWy8Zhb39l0$oTHLCb^&XP^2CyKT>u8;K)d zr7>6xIFkFzcE`SI1z#+QGG_WhHIkWDj2bOJySbc%spq56{w>d*6 z%()LJCtc#g5vnop zOw;GQOq{+^Y_;q%nfJY&XOI1Mvx!=PzvJ!H7c6Q>ATy3xO=x9Ghh9MQ`P6}`KF5?| zh|DwPQf~3<#JDP3W+o=)2`PH_+oPJpk@-W3mP?0T^3nHw$)>QcS7#2$#M4G3LwfJ| zQG&Wf8kW&4p=zO*kVHDnX7_fCwfhzcWdTCop(L%Ru-!z5h?0+O@%0642R`Z>!Ea^| zissUy>h%Xv=L3O3tp7l^i9y}JWcK?d$+zjvw+!$3c&cxoZ$sTc;(@IzD5YkEtr&J6 z@0NcZPu@El+NNpcsdqApOQqHjGNV^CW(|wLa2Gz_F+?4Dkp3_HgUU!ge8D>0SQg%8 zJNplBM!&V8U-ft(Ju?YsErLQ|p0Ml?)WaMJy0yLN8w(Q&2?*s|Q26bg1q795rZWWY z$h8WXfHDNbJqHL(&=H0TW>_{`1^z>xV`^6P*Z9{6+sE4cR9*lq*OM3WznPQDMrMaa zJEVqI)`j^9Z=9X!=5qq5-dd7Oq@lKQ*ckgDAY zIF|B)(6v)pA{jui)QhF3FMyBa6BYk9bTVl=M=2L!O;;(3q~@OTfJ(?|s)dDcd2=_! zRpFtQj&eV)txD!lER0Stw47hS`gJN=>lM`t*OO@Uv_(Bt!={K|A*3x+;-9bZa#OBa zf)Sn!flA<0Nz*eXT?9fFl?%}$3DY8Mj(*DOzMkI*@kgN(c-zfzlNY~P8~?L&lY!=n z@fI=Mw7(!XF4oV#%Qz?(VJ}-SyvEx(T20r7RI$6~nI7XV;uc#{@xAXXjzD2;Uer^q zph7$%qDWL*Dp65TR$Eu!0-1KQZ8C|OupC{6?CNigUwHwTM&Azw&^d<5br2?P9ClJ% zP8?d6>3<+8!U8CzjvuQ#bD_+W`{G8Kr{VrKZ9BTzOt|C)06-fhUE>3i-H(>4fQPRp zY(_IqHP#{1N2waGq+hF`;L@fb2Sx8+BE>!DV$%F1XbQr#GOEcz`Z{*q*;l8|HMiS_ zlVq`?Vn1PQh;L*;H%<@~^W8Gs)BgS)--1_4+ zY)s0*qAz>LNRx03%htvYco>b^WOyNm!g-hbnsb4}gbLGv-(QxUG)^;=8U=yKg3 zqwlWOSd5|KNhjiVv1=IaM4VJZexw^^A|^FF^>-^78m04bam{5m6jCehdG$JHGR5F+ zId_wV{8x`+-bpt(=Qv&?j;!;irkKGjV&X&^WhD8)F%9N8OkcSGD)%O561l|D7qA~& z-HYv{$@lghj|0o{DrRDOC;HWSgIS0K6NzN?xlv<8Nn@GjA|6d!$Su|fl3BPgTT4K& zH*)!&zFU42AkFHjKTL$*UMR^I^Ao>?v8YgF931qHt3p`+B={1F9K&1Y#Xl~UDC^Oa zk_yOAU1fLH!0vFhO{&)?pb%Pm$ej~cRXdg_ozi=dSoLw2q8eIL|C#BmH*WVh67++4 z?x(M_x5xKwKlGFVNsD=W954X`Aml3i<;g`q$Nn8q#2=p}WY^QV4nu30c5sDa@KN1Q z#9~j}Ca;!ZJu_*eHbUD!ux>!8 zO9?uZ@>g*pUJTIR+fPUeWJNq4Y(C9XYW(DS#A>D>hDYBjhh= zNV%2wUOreh=`G|avx0wKJzh`(Vm?&sXZCHBSpQBkHbKc35b<{RSv%XuLGAAd46sVAJR1| zVPI-cf4macD<)~9H8Z9$BJp>tYahcf6uB`fHC(}b=n2^>3N)T$jxi6jx3!|Nc#(Mc zqT7BWig@t`)!4A*CsEX$g&7Lral?m13Q->q8IAVx-sWCFlmzBbwa? z^{~HBqnIek3}0rY7Uh!UsTf7(bR%7ORP(4Gz)Fx0_Ea|~VL+X~9Ax%aKkDEs|FQWhpZDB&{xed45O`z6oY9)5zeDGYRmV zfFbo`e4pbwe6TXt;B2Ia2$OAPiJ89WlesD3<}A}F5u~M%x*#J3outqPb@bvyYZ+@_ zGaGMM;(T{npb`RtU6kw+jrtk+6PrNv$**Wwj%?#KwYopGnK_rgSy!78kYTH6;h67jY&dN?|4Uf>eJ7+8ly@W>1`Hs{A*8jOY0m=`)9kn zsK4|wb(%};qr~)YrHBrQx`M|Xm%;@iv&+Lj6(a+ZCh=>+d@vLoUO;p1Fy<%ao3_Ws zLh6QTw(nnH6%NlCJq(5~j^Q7Ya8J+mVngOWRKrnae067>=1#Z2R`QSKQT=TDa>X$}H4Fu~& z;K}ijhA)lmMf8kGZITCz-G@-0ddXyk#7;vZL z&pa+SmnPD4-kS*_Oob-Y-vqPMp%7oq3Y>hoy{+)u0~3x4EO<2he={&Eg(g^fP7M&2 z>(6)CC=bsq&?TSMHiW*^x89O?Ix90;FrjTiN9Fm{@3sGl{}*q0PCBj}k5k|F5(DPx zF2ZZQOc0w_TVHzJeQ!-40T<}XdUFrsKOYz97;_>56Q-@XTR(VP!Ui zIrQPaW32P!c!=<02i1p5gepay!Skh&6fwr~u{tEUgq$B05b{I>z|_KcioSpK-nY&O z=>-{&1Fm%4J%L+K_ZdxYrE=KpdoJ8y3+kkpB{BxBYNzO+0PQfkmchX7gC2xsFXx7J zAUl&|KZKWIhl602tlH_L9Z8FO?N3PlJEfu9rT}#k<8R%*G$+@%Bm#%aa_7v!o0yEX zW%Bg|chD?5lO*{C<#>de*~9u=J`YG>d=y1RiT(Z~DZp-RU#t`FNMA{^|CZ|I19!e* z`?!HwqGqF|wI5%p@nI+u)AcJt@K@eLOPQ!URY?nK%XzEeuvIl*CmWOH& zL+Fb38*&@ruW#SEFwkZ0g55fB=oR)YEOB#$*egWG}e_7<(7<9pF6CZP&=e?F@`j{UPt&noXpg9t2> zPl7sBaNaz?)o2w{tU<8L%;~ZXlkw#<253|XkaG-uPsP<)#e2;z^=K(;1ONyJ1{yKI zrq0B_GPH*G&Z+2%V7h!sN+%^IU#idKKnx*w&D8NSSlsSzP-c zYCm6KK%vS?&g{F@>LudmvyfQ8%Kt;#cgHokWow5b1}TDwh;$H95Q6j~O#}p_2x5>H z5b3?w5SpMMU_+FyG^wF?2t`2YJ<_H3PG||q_j+dLe)rDIan76>|Kn#c?7j9{&w5tb zYioa`xhi!jYm|}2?;^Uzx}Th(TrG&0E?tyOVf8ePDGLyTymV8&>D;6(7w-_Z;=UA5 zx{40vrN1>uKPK*eXSHSpfAt)@-)RdY{fwnO|G`)LVS4aeM=dp|Oi4kugS5H9qgH_) zY6++8f}C6X53@X=yW}34*!*(5N4X+YQh9CW_Q&^(sq1#HFs16~HB>+bl%zZYsd`rW z()2{e%N}EgG4951v$`a`XeBo$GpEEJ^V^I=WibmWp4NOD6D+a6V$hmS-P zUG%P`&tB5Rpk{{c4^c3^(4^U>jP*gdM{<^kp{<;c;Mhoh*_9UalH8Mbqc@VRwDX5} ztmXViWAQSe(Gy~je)Bccc2dvycr+vXfK4%t*Q6;Iqjsnp$ci+HXJuj(I|fx}@>QwtTg+s={X&vM&3XGk8h0<^cr9J3_B#x*}f;h9dUo}T3_w0stNg+~7}TTnEKvRbOaw$6wb`npZ%K-RJ-b6{aZv4aNKqaSO?CDi&c zDD_EyHeES7LP-eb_APHMT|OnvZzXbXq41@V*X`bp1x%y!ZQ8i@G%5O~mrup+?b_z= zwx1Nkh8`RlxKl@FcAsJ~7WA*Bo=x*sM>&CmA4SJs4{|wgiGF~sS31z6xyMS3Jmy%^ zTp#1R;M*9f%N%s`i-wdi%O-S{fflzed?y+iR&Du7J5_-8wm2zA>O&CdDJbLsmK}17 zl=6%qTLx;JLy(84$U1S!JVPO*n2IHdn8)ND(FAtcP7O#*GYd;n0rPo2gz%1YYly{u zV4eA>?o=g4F6WZ&lG8+}KS{kH8&JX;e?6WKB{cBJam8X8d@B~dmSmNaYG1=F_u!O` zgLGrg-R;9ZZ~Z5B=%w9Z3FNmrfFrtsnH;GsUXtNE)d+fa#1mTd(3JAze zee$t#eLE)A1jwzzHpYQskvp1;CXQE^=7QCj5sxVEjWBe+g2xp+k=D0Qy8SkdjyOG+ zm!IqnEL3yi`Mw4HXvNFww?=%~8hYrW)A`fGp4nh@c2=ID(sS(xFNZ()=QWhUK%iVc zn#%VLCy_bA`DN3lokc4KX2B;Op92C+QE)k}R*O$?Xw`+6S8~$oqMfs|rP!+2md;HFP_*4L4E^|eHT|Y~VI*2L!Eb$I1^@VL-Bw`e zbHDZQOov;fd)FV%l7D+TtEn08B%6Z{@NW=% z>~zj|eK-}>V(HZ%W|uEpL_;){W&A$FrZ)}61Ws4$Jq${lV|8q-81@4JHGkf~#^^7- z5s#0;8^%{+^7P5n3VGz>)C(JEFK^Br3R^LB_?v2mApdy#7d6W%|HB?Ns2d;Qx`HP%81Fk~j zz4taiG55qzyAI>~Eb84%^A@^(!?Vx-C;pzR0f_KXot20=sA{Igg z;9+0f^ZU6P-mj<)x9{ZQUnU=r58!PH9~jCj9ACK^aE@N?cjXZp&m7Xx4OT5ZAdx%w z-m285rijVN*X>FrP`em_>IMik*7)vw5GgE&`fZNb9_~JGe~}^$kv3iGNQ;Rx1*v7R ze(=p@_RL)@^{>{v24C2Ak!%9XV0^cXNUvg=BtImh!q0aD~Ku5Oimlz}6^)8z%sQxTl^XSt+!4Y&1B=6WJifQEyEG)MWUGHdRKdPYmcB!~e2 z&`OhSxVbc(H;FHMdhTdDQp)fURugkiGxwI(XIIATTTE;_*;c#VI#;I|*t z;<4m0CFO+dNvvo}pp~JxvaAn~%ut=RQ!h&NIuo90+pE&IvB0I!d;f4LY`!?=U2}Q6 z2L-W)c)=Tw&G+AZ+?&sgD5u+P;y+-g-Uttvu4F8aW;vbr%RN_`MDEOKG+o%cst6+` zp|#hl#ALc|+&V0CevBw}u$q#8qO|XGPi~@K)wb8mI)VfCGF-xQb3J(O%Uvp3?xo6> z#}EeUatNE!Mg}M{QvlB2RbeNU6*K+aO0TZvETt-U2Y*b)( zQ`wshPC1wB_mUZDo(qXj)l+BNNFevACQw(6FP-LN@FQW9ewTSQWWuGd!gYhQcZM-8 zEYU24%MBYOgR7a>!Gkv1NDXe53eaSt1jnqN6>q*mWg&1dOq`a@oD#Ae8KV&bywmV z{f0j$LuS{P(671!_Cyl7h*vhrt~M9akHc>$(A-zi4x4GIhH2JQM9V?Ko7zFu0@bb`-AkeQx~Mg#)FkCPs_* z*_7s2j;2=H+=#ziPhtqD7bd|2m6BT#k5_E;uv@XCAn%x)1(p0f_H`7rB9bS`H=A+N z2kNs@1>CGHY+8!K*RcwjH*6pAQtE;NDNr|}r?0*ui!XuH(2ikxs+w7-c#A{t^iVL-iWdHi{M&k(y0((xg=619&t%*#vk*h;q!QMzP6}gn#;2qtNY;sBW z$?RMTf%h+2?&Q1IF-#(=#GF|+(nZ%poh>N55OX)KIb)E*)8a9UO%5>&w~DD|vmP6- zxd{iHkQ?Q|i_rBk^c@Q%XSuH{F z31%o69I1IzJ-V;U?xwchHT11qi5!?ZGB%Q4I??n_TN3ZsLw<%Nn7blbx>dU`1TVYq zGmz++ht_EC{pw^z+TRjyd3k%VdXeM4*c-=ei8V*Wbq2>;9;WX1Z>cBzRH?YW+OY(O z+2zbt)-T14lxvHuAgqiNPVtz$$%e0d1iAOc*IRzDANaaxX}g-B$7pj>xpc+G{1waX z{TC~0^k9)AHo2k71A4QsK<%5@SAF^cdpcAiSUcJIt%n@*c3IWR>J}T*w`*N;3~8Budk3;3~8XX1DtW(gZ z)V`ak?Pm#4AX}u41>(NDy;N!A;w8(tm+v2Y4_nqx>7a2&1sQM3d)mHkFMjqnh3V8c z|M9D4SFe7z6+_IXW~k>YY;SdpUjl149WL=bCLUdEc6!l^KIfs$uCRxj1}@tExaZ6D zyeE9RRN_n`1cViyM^hHa8xZ%aOa*_~%f8;!lLcJxJ?zj}wqsk5Z&yCJ?PGCMFg(rf zdGj9~ECIxJTlY;{ZXTTz8*;vS3juyWADNpQ3ko}_nfDQ~xi@*AZ0JN};IOIMchgxm zB^>er8oMghuZ|c0-Z5|NM~eDv+}n2wijx9L7Mfuk;6jex^Np)B9_BQkC4hNG>=NP5 zhY)3nqj8aoA_l%2*SDL(9&vrU6&karUwfapj4VpE%s;4%;uf{jp}Awas?<5heU)<& zN**X2TC%r$k5jCXkU5de0-BV3Pl@F7**l|8xYY3_MJ1xLdXtw@H`2T?Oh#dRgF7T@$Pm~|c9c7r!UN&*9I20fdA zVX%$!6_1G;Pd~x_U=uX&z;qjT%-u&FM&wTvJO*I`HKJ|WVUlbe0o+YjK5E<^&G)!` z*em`G9s1*b9)m`Y2&if)nSwjP)$&qr;(%KqQ?#V0F^SJp?;_TL7s5&>2L3yvmu;M1 z(5hrLh=N&buxt*gDhO2;zOk7nF_SOmGyKV~3f&?CIqF}KEL3}zJ5}Y z&M!!$(D2CU0s9*#IPv}wcfWpPpA*gn%h9(g)mb}6u-(~V!^*v(H zwN7r~V$;E*YX(?t-=vzV?>R6FTR6`kI&xSbZdoUQB>d|>gHIOM^&nG$6=seNNuRsT zRZFANB|*`1;By#YEavxECCUV2yD@Szii@0}sQhWVfuKOW35p48LsF7L@TAvD#<`0s zUmf&JUvRuaJKlroXMvpBk`gV+FJ1mJJebQ}$Y)gDvEbLZ$|huttpo<{^qnU zlR6!l|N4t-?@eVpvn&DEo3E)I#&~SXdAkmp;0G>y`*0vTw2Q)kx`LXLp25?-%(hr< zX1-<{z%EP=f0TJl_BFzf-)B6pbK=^U&@{;sZlh((6tnRpX&H)ER)$3J5%aj2_}+XZ z)0yzBrqJy7_cmevl_LHp0dOFz{v;aJA0NJ2ab`{$vvXa-_LGYPmvrzNQd1@he_qZ2{Zx0n7Lu_b>CoG)^!;L4 zmh)pT25MI()3KT4qoqOrxi@uY0x1J%V3I?Hd5UocXwQ0G62E6EAsw{UUHzbmHJAD@ zaP8EPWJV3(Ar_g?qKxcrBsAsz?MKS;5+f)PBr9B}(AYHgv6fwb|4yaN{>d%Gr=CeeCX>Hl%TOT{4Y4EUAl+r&0SL--lmxnitxul^`gceOB1O3TpUUutd^juE3tYa_DRWNkyiFI(KUIyy_}-y-%BKjCc#cHIy;~Mz z+V-S`i=51@3;-7dn#hMPZ9U6*|8hQ9I=MZ3A_F{bxcnqujm>>+BdihJss5ny5?lRs z7)^{`@~Nr8=1;22^1ad7P>V(b(j}VjMGQ?XYkQlQUIMdj$*-y|&t zOQ(Ddr{cSGV@jD+4YbFaNDSQIwf)W)Tr^jv3l=J}yrgjR5*r`wD^*h^R}syCg9Cs4 zaGFBbP%jM2S{U^=vN`Z!KO+&SivPYC|G-6p{jI~M3bw(fxm}^M^WgfOEa;g0vMn;J>?G1D=r(| z;!IlbqwN|i%#CW7ScV;1JeqA+{jDO|1NNtlXDET(+_UtxXVk)UL|Q-;Y-1|dZa02y zS#YLOx%&!vkLDUOTPX0+`Dt?>qT#01}43%F8d~j$K0m!))=9dmro3w zzGo1Lie%O=JGTf~VItlVvvs|6{k>D?cP#SeH!a)gP>Q{xJLbXi#Kc#$4c93GZ+3cc z6prvXwW7V0;#Z-5A6!vdA$#OsUJ` z7aKo++`jA8W~ouln5|;z{t7NTJ#<4yAS6hop7#G=t-7&0hg3C3|?5A07ad2=ce$|?@ zlO3L|G`WFfT@*5xORKWedJjLMzF^36Z=T&+bcB}FwSL#B@`9sc0`agc7Uts8(pYDl2&>eDahF9&yO0ic?KUdimshprLeW;`JIu+e;o*kU-OuFO0 z>m)i;tQW&HEMoIr(kz7L?#3XWfA3tQ(4#svvC)GeZwmZ^u;|tps=93Z_9!j*sMF3_ zt74p_hUU?5fiSUtpQVmNt0db{6TYr*ENCh~bKiuwd&>9psvIZ%hMy$8nz*;w>ZtNK zAGabhO{Z)>&9^UJY*)t5Z@6HC)Fbg3O~Gq{VYFQ_VKbnBig;wj3{t>fijX|BnqFXK zNYixKr84YE(wL=HcPspyZ6HQnsI1g3*Po~+t-9%S$s|&7{c>%uk!*3296X<_yUygS z;`+%h*2Y2c3&;LIG+28x>IG+e$Z(m^9+RNj=)uAsXBB=g-?vn^pV))trWif`FwxL3 zOXiE5&X^}2is4Xj1PZ2_BTO#`<%R_{*O0*2thNhH^d&gI_ilv~F|yaF*4dDjXFVG0 zu0|1E@|pwV_J(1(qJ9boeFUk$I2#fCWUJNb3eG$&keOE9O*&-_%_ry9N*+`6NpM^vVpEZF6I+O}gUfd#noYbo&^$bIk#JB5X@Bdf{+T zB+kq!YobB!aUANvb=w?xNT^Z z^6ZyM;QU3MyNr)nBkVusB3_edP3SSwr%J`&T<-(&a zq&W!SjfFrXO{8j2D8FW6@kmnXv_Xkho=NJagH1BM+q$Lyg;Md(nC}vz2wl4Thrxbc zMguJ+ZP7C0&a37}ml_7vJx|#uzXwBDUEOo`$G?5FG5j2v(oy!b(@faqw>!L{{DPY0Jk-IW+&^(z3&L#=a9?5 z9GnL`c4TpHvjPd^@+rx(3sJ|tZQ;|8FV@}NiEsK2Ka(2^ZRPrs!jVXb$17_5TXkO8 zDcM6SQKN(Karsl^y~6uIncpUw_WpUWsoSm8dSJ+EMHSPeU%!nF+4qYrsl_Z`9qH@8 z9j!F$IWHq!MGjeP6dK5hl61>BdI#Ge=my)+#m}q5{0^Zk3Gv_8fKdjJ?-Zcqc!8ke z$wPH|dlwMYcZ89mNnQ*h3? zB~J=ol9x{@;%P7EFonKW^16JnQH-?2{Qxztd6a)l{)~gV8ZwpL%#iY0goc*Lvi0uE z`Ew&QBGYUK_irh}-JMBkVay5f@zYdmJi@FliZ#&!V>i@HpQ3LhCb?B>tnJvSBG`5e z2`&hFV64?!vODRu<%adBN0 zOTC{Z4H~%P*LDfjF*V6)Sg;&b#rk$m|{L?G(^0QkGq?c(!rOtuaW9_CZ zlG#DugWrDuCUOA8|C6it3peHO6)>5#Ta}!nIpE6s9C06I$cYb-ZR_Y;wA~xPr6_Kq zNSJRgIE2$r`&+0j;{gZh#*@>$A7%m*XNR!-a82zFErMQJ8aPbAsL8T0{D?*LOO`~dB4!u-u(kK8#3%AU52sZ^dpzY;)Ih8waV$q z@rD>MjgetPq*DCjF}j<$rPq0)ADw}#Q2;o9#A&i6eS<+CESaw!*OZj`yluY&@;!cM z%+>YvFpcst4={eII$?m?OQYA@x87xbU6SaIo&tjrVLMGK9tEysqXsSAxN)H4LSTsD z(#Gndu7%qnQm}-ziPP_lhTw9zlC)TDhI2~9ef;wGb?qafu8DnjyvscM%2K+Bjz{y+ zVoEKcc5_#D6Dz9~@mj*=T$`tT8x=zX8Pna<1ElXJxvQ-|o(a{4+>zc*o=U1J5w6MA zOBgz{?1p>3QMZ`v*IkuTbBdVfmp1Y{H_KA^6uuL_6gSgrb-WP@SQu}3o9QN z;t(|NjK5ixfwG!%^`I`3kv&~LFJEzW+s$o!>h-SQgxc>$@(siP*bFQ2xA26pB>E& zClOn5wKkEtSXk6aFjlQHCdJIJgNOMPNmqfy8rr+%;=Hd(>pKg1*kBuCdx&y}4{gra z2ygLBgS*w_Z6b{Npz1ZQHOyR1MD)M^OjwWuls(|9WRnEvA@X_(u0NDHvqVDa)70Cm z!j{o@tO6X3#$C&*HlTlSq+o#jj$NnyG?zyYRj)HwQ!tcMe|wBjd0k0<{;Snixm(zQ#i20(=Ot^AZ$dnK-LC43?*=|#^$Z<5_8 zF^H|+Z{l=IeL1M1M#%ECx>45DNtVSjoQ*9Bcu$GTkc5eobbdbf;MYt!9B^4qt%z2e;PQ(x!P!#3?=X8*;ws-EAN16s>{nY%2vUTo=Yl!@2wy>+W1bjwWc$MW5}jk;kym|x&0Y(4`Vrv z+~F3JuZQ-A{E_%>Sab)6ep>(2w|=5cmpMPrN2DM%!&!#{k_wPh)8leRQ*>eniLkE+ z)6n`-iKN4NmWWkZ;&g+K9QvX2=xHj3{L#715EletZH%wKqX(0&+ig#vj44`JwA;iH zOicWzqCgE){4qBR#rR=54q+ZGWp11Ks|4mTgZfp<5fZyguu{Tn_P?g~?VxV) zngP#73vCvaEf=#P?`X+PzK&j-ec;rGx9a4pAL#MR<4KBFbT$k*I~(95Ag9uEGFm)S zqsT)HGT%FbJQzR`x@O#uRg|;Lwb%yscbD7eht7Xkr_~efKX0_jNjyoK`=D+;J0}-?O#!qsi_4;OyoN#@- z;{N1GQPEM2Cl-EQ4q@_!U@|*7Hk_U|HN3;^z(O+ayqr@y#et#udX5oS~P)l`_Jh3dHEboak<9OZLwRJ~g?KibSK=#=B}+SxQUXtObnxxqy$m@Jny z*ONxFyPH+^dFtU!>|wn7s$TBEL`5C}duO!>j)oj>x0iZRM@;0>4iolqyJK``3p5Vw z8x9id;U2br5{TsSS5qah4x(z<$ymm+c?#2O%LP=*wdU_$7j;#o*L74WcDxpg-H(N{ z*Su0Kvg%8`cBIFhjp@!#)?8^>G&(v^-!54T!%@-{B>4uYz#y_Pf?SCNH^F%Fxp)k3 z@Y^(5e8K8lz7@Hiumk6~O3jnHac2|-W?R~W;IZOU=<(^@&z?DK1)m>59L3;z0-K?Z z5`;NUcL-jllyW`y^znRZ9S-I?)O_r#h#wuDBSdF|v3$WtA05nVxZRPA`_RF%P{KR` zJy8r7W)^I`MWUoP5(y^|6hGwV*z32~ zFC8P)l3p}3Wp2uFDm5tM`nQWS6s0Xmoz}~Go{C6WohSuC1vf*e_c!Mr5A!xQ=>k5DkqkbKZUrvd?{p~bT3fE>ZZXeJ&GIQbl-hAFVytLn z9))Z9XnDr~SK?J`RkQIxm9~`uRangt6xP*ErtgCe_q@Y~bC)R8|MY=R%+Z!Ic4MI^uzwl@{|+_~g<=wXv1-uSW(QJ%!*W&-|nVXl0v3Void) z$yyh8qIG#CwR7Sx%nLrvkbLZF=S{ue?K+E|F-fd6KamtR(}B7mI(w!?5_XuA-o?cD z%?&X(6lWqbXUH@JZZUfG9IR;8MBhQyetW%u+G|B!2w(MUA`3d(e>FM5rLL{S?0)xz z|FzTm{6^ZV-t5*!2)~7`67Pl27b7bgygqp(?GNu5TYXxf-CQnRj`i<>Mp&H$<9sTw z9UNEV+K>5<9!m1ohil~n%a@AM9yz4+6Ges1)VA-g4hkhx)oA9X%gpiuOpz0vCS2;=lDa(?qRg-JmM8tOFeH+k(qqbLGv z9hYr<6p)|3xO-CVPSTWk`V!AZN+ls|rs`Zz&)1{lXe+{wKeA2O-VO1Vd5Kvp(-9YX z+_Ule3bdc^2y2JCDSCdf{)XG=D~Isa#jpG)GSUI8&wGk1%h3m3!b!Nhm)7dm=DhYb z1H#8%p+6J0ho0FSz*ienOPqXU;G29MWL!+nu&m@Mi^ZC`%`6$2veKn8oEKTbmY0@= zTtdy>folR^<$Uz!=M(hT#m2YNZd||r4sbbD5b9*}7$G9ak&K5eO52U=#(_0XMGS^VbwF@@$=jsS~880nr;s)=18O#Kj+Y~}PF z<^&I2jx`IgkT;0}efKAmMkel+Gvhpcp%)*nv1E!OB$5e5~?X6#^B4P^Mi%&lQ8%w|5re{?Or-uE95i_HI(I(f8sI^Rd{|%OBHi*sphUXhkF5hj zoLf6-m(LP5$sMKOBFMulhk|h7}EoakQ z`X%gv<`f}3V>ze>hhlu=qz_9OCYVPTYw*P3O$uJeA9McwbM(NM|Mi=^xyUJ1K&D<^ zUYZq53+EcWEv#X*__#%3?JqFKZ_u;bc4udqL~xMcL)Vn9EtG$|1o_IW+;Gbq3j|9MICMiv_J<-=nOxuPF1HvF3*O%uKTSpGz>YN$KEhcG zD0dXBsH?DL^_}@n< zZdO|ARqq7rovi5n|Ks|fbNlf_(1#1}B(nLE_JUaYi(AUvKa)$R*PUH+r1lw!iOWhZ z>(b!+J%#*>jJ{=_;>02kV0Xip?plcoq{2qDBzuImkj#*k$nqNjMac|5d!X5q z!?|1hL3(V5?Q;6r7#ry+@EZ13Kv(@d#vNOh+n?wswjs`PRwhT91zXU!Tv&lQi;oQY zi!wOOi2ZY&GF3mr?CS32i*|}x7oXETTbI01>!X44rT-TacyNk0R&AwV$n~=E50uDa z3_`pNVQBO|X(B|V*5NUftxhMQ~7-{>+$ z6+7&q^&!XYyPGu=%v=?G=-J0<#y*`}pO|8QUmZ;=Nd<2DS@`0!%a%LkSmRos=a*`5 z5#wgcTzy1kUNnQOruKae%09lWuvIw5#A|-cwZUFvd?FlT!PvQlG?+ z`_AX=MQ$SRlpVtm2V==c_6_>n8GX`i(WYH8 zDgs|LA2w2!7eFc-EOAq6!;Yy)j}w2=js)Q#+NMy~r}-J|_T%OIN9| zt91A{uWLU!vayr~7V!dAH#Vb0*&~M8{^!jq7?P**D03|)4KHBz;#%96#fzReNolU;J$T!wKf$e^k>3CL`0~mfU{bvq;r6 z&m}cT*Q#od?9l5AwbzfFjGb~(afWfveSbd{Kt$~1A5@U2fo4HaT3ri1+&OQ1q!$WW z0M(USr~{sc>Jnf$2VvyH&HFPcAswcur3&yYKtdI95f;Lqol>_@zA%g@|1?5Lt_~Zo zfVem19Xm^}5L8t#9xbVq^fQ!!pSdkKg(gf{EElHL47^WaO37R^SO$M?!cn|t2(Wq> z4?h@<-pc}>J1=(%LSGo}Z;^Xg)NKLo zB`Sc^AEKvYcL-xu4{Ue`X&)QM~{8ND(5qMihv7QRlm5B1|eseBkP6QpMr`Xnf@UNDk|zi*3ri zFs@FdL^=06$VwXgD1sgJsIITR%6M4(Eq@z52ZDdSeH}2Ds;TlV)Wt0DN$vq7_WaKj zii4&R?#A};W%p5Q1Sh3)>j$oZ=grjv@3vz_;HZ7Xupjp$twE6Hio3esChDGcEwHL*2yJoq!@7ALd$gaz ziT2ao6;fEl{<*{diQ@hBBk$%jgSKUZ#`z0ZYt<~iX?|oZK%c`yLqTFqM?cmdi~(kv z?0AZ%s4}iWLn)LKlxr-lVMP<4dKmqcu$C81A5IY}EnmRWdR%Mt%ZKMNoL2?kMjZni z{?sw6#Pm~Y@n;o^6ktMMPF7jFw@~Z?w~k$DOlXhecDD?ElXzr9ed{(R5TypRVGk<( z1xN`UF5#FP5vKL~+ba$`c+OPF{yem=fOQz!nO|hwUBb8(w;GAz97M9R>O01nA^M=9 zc>1v@jL{EH`LAX8@BU7Mm_To=@KSX+mf24cvtkxFzYkcs&kTV4K0uRb`oTBli?B3f z#u@0@{oL@~A=lR*;iNlCHTB3PSTmWI4z+liBL@EkbNP1{ z`Bz_7xK43DcW96Wai2HVOnS_gHVn<$`oqDK?$3|3x?tr0D(HsaJl1xH)d(e+SpryH zjI7&KOKu;6c9H=uTHevyVA%jGfDA_pqI`kuFh36YP77cN&`uSOUm#&n(7IBppF}f( zxQaCo4F+|Z`2yTb2@I3XWW+P9r^P7C!({Fj(hj57YQ`O}yOF^dXV8?G$bb%Lhf5bb zsjAtM!!=TJeK= zY!J5lNWKR+h5x=L|6n!M4TYyc4-_TBX8}hIC0zw_MM_wIea1D04}pOF0qgEXQ*J~a zkEbIJVKSA-Nr6qsXc_4dW&{WYRy#1Y{Y=1-aIGQB3IMIBa7gs5bIAJ{qxm1l@^Al8 z$WH-)oyZSw)LZ9!(WHzvk->v@1$uTNQoHW$7V5qY1tC&QDbSQTVQdHsoVXfoA#|$X z07gbOyb9n(A!HxJ3JpCd>rA7hx=uovdG|;r_yiF3I)HbNWHIGMavno%9jECtma>-8 z@`^7164iFlOXXYacVHn3eCXYNY0y1+tRR=zouGy#vaXh^ni>Nx|E`c8Wj;{vNp9dBnN>J3I?$GUE~BIbPo z|MOfo{cs3tFC#FV(DvEaytcT0DeDvF^q=;7KO+P7XMv)gL3E)V=^#}bIh2bD5;H}K zjbyJk8mci?%?A)=16t}-$`?Y&$?gZ!Jr}GG0vtQu-+s@y_L0mn?{PK~eV~$Xe9>a+ z#-zLg!oedd;G@F{fF5VH-#p4|^}b$E>_ za(`tU{|rh^}}-h+V(b_V#2i?5{wQ=vB&RVrEp!+ak0e zmADpVWAJM>ae?OPIBLTTNSE2un+~-xr`T^bt#IoC?E01c5Q1-g^Z8*b~*>}U9U6>`gil3zne+C#o-=is( zW(mmglp2!`Q$e$ze^Kxr!sX1#`85;TY3exH1y^>NXZ5<)$<6|Jo#xO7-E>j%hJ|j3 z9$!1Q4&9CQpzRjd?u8i|(~hGrtw7n3bCLBCFHlQ0eF&L7q|RU*{D}_~TJY2G=I0Ci z37IiXVEFWaOT+11$Kj2d!H^SI6BF9@xRC7}+RtczOxPi2xCO6_1<%=)vv}8m_w8(S z9pgHLN}W z>D$i|#~Sd@?1UjZ$Jgg9e$~1`hw3r|H0kR z{tKQ&Bf;BL%KJb!UcqmOxfhQ54aj>&e)Mr0G+2N z`(X!4#-!Uy2&J*N>*dwTa&?UXnC%?}B|zS?5zw@hH;dcNWiu#9WyR`a&-7QEMemC zPp<3dkW=`CRQdVEm%N77c^GQ91v5kf^?Oep;ZkIL)&(SZgb9cofzW)T%A0=O6$A?f zUY`Xte%DtN#p#cngqq8&Tw0HS7fl*K9T#UGAtH))a<2n5mev~>F^s3uSnKxxh@Q|B zdiKr4FXD!t?7ag6ud5>itMlg`Yb9)lFMrSG$&`dlB`)y$h}4xI4>7kh0Ik0mB~#dD z?u})Dq~yn>q82=Xw|h78?eZ#0`Sz8>^Vg5}m^ea|Y$OZnjnA?_Z%#}3m{7|a-gd)S zQp7pDJ-rnDWqEmy#WGV-MU#(u_ZRU-IpUWm=(@6`jXiQ~miIM7<=-;i7Gy&InCbnA zhyJ4F>`DItd-)H>W=NP`{L%mXbKUxT;$W{uEF&Y6T`ZGS=*Io`AO30Q z@_(SdnWJ}TD3*6S{3rR<|L8AMCdubNP>=nvYSQyd>`^{Y{u(=|@J-<_2~~-R6)*n~ z5Gjb7w!rbvrYL_O&VL-D{IwtX^9+Ohw_|UAk%$^-z(Drv4J?*p$sq0*{SEnRKLnLd zh`scSc47LIG-t>*=QGAmw1xtxzjlw)h>*#)dV1L@!RgY65|Bv&;K%w%GSvp68zm2{PWHK-&OwP zcNDMEj38nQ3JN%wZ=rwkd;iHt^5FY9L%ihw{t^D6U6_JTRaaN*aL4`19h4bp#2Vx) z{~D4Ernz_vFpPrx&*XkuFZ>VAz|{H_Z<&FEwMwt_FR^TzZ{4SU0Jr@jnP$qL#?GH^ zkg`fXLr3*DRb~4E`suI!X=5?|M0m7ahwN^VdDsSy;C)C zh=W=A7eSvX_QI$8ya0eZOo6+Tf0133{noIPEEsV29I?dUpdpN){#Ib>_=<({uTX(+ z%D=&FMs99#RC4mbPg)TF6M|(J{v)F3CzMAYG+qBCN?^*w@P#O_%mH^kiS^e&;zg4m zp#FvgR1@ZPLHQREkg>|&La*fHWCxtcFWiY*#;cuNq1qBbsb3?kZ>>Mn>eU{~0jJ^V z`zsWeT@Xh+WGkvSbN1I%TL3R^5Y+yZmg`qW7uij}D7K)v=o)&5S zx%rQ*rn`rSLmR1P(l5foI{C*0J=?OC-k8Uvzs9>3vivoyup7G(r2C7kS`qSFSm8l+ za!2_;fzhAX(HKCd9*lLLN|gOI27U2SkO4K}$9Yq23s(OkIgtXSHkSC;Y=B)blo+5O z*{?cis6W{K8fB7CHOS9YD>LBwHC->HV9F2jP$)EKPAF7yDgM{k91ZvfaB%;^9h4aW z7auv86@C#(3zW|RI9iS0=clFRS*K42Ij8|w3F4FC_?Epn-JbMQ$ojYYqvi*tJ(GT! z*)kUVpQe*}`-Qzl)beq&|(x--Rriaz0|1+=n? zqZ{8x>}j@u>-qXsIZL*iGG35^3=Bq^Fb^Hzis|An2r}4s9G8@pelk_+R8O5*u-O~E z9#aMJ_AvAHo!ni1@6(e}WZpXGXBJZml?~sXZS}NIq*}hJW3^f9=UKg)j2-AxVM$&T zl441o+Qq~s;}}J;jcxtAklHsFAP)_UAOl`0560E$t~@uKn(jJDaMKc#X3UJO+)RdV zJeiR(Ivkzxr_$h?Y@5j3h?naW$9>rUth?Jk>z2aHtP*u8cK^%CTG`^BzszRoJ*#q2 zqcJ9-Y3a=Xb_e_J?c@P3^%{*ai-wKa5ezPJcc9iEQn%ILVY8TYWpQ!Jko>i8q2c7fK^7e+V3^Dru=lKyn#FmlQP|3U&}|{(h@!vN zKS^Z-K3ZTCvphhyCeAC4sT$AjY4Xst4`fNIr#1|KYBj^+H4&E*vpdjun&pM(h>hCz z`|;7%{~{4GjFb1$(mR|Q#=L#kwQy$KZ7O6rORnasQiIM^@xU9Ffkph*Yo$W%DR|O2 z9^)aN#aB?xk^iu!99D57Tr1H-nYLii$tvy9$j|HW5_z1|mi}yZ06T>(aL3(H z*&jn!%=ERTs4e1gP=^!TIt(GJvFQvlCB4%s5z_^)mV6T9e<%||22$4ke#BssyisrE zNu9moME}9d3O;wz<(zNX#$w`QgXde8o|3Bovon8MO^cDbe$w66eTS5ONFD?vIGS`aK+#`p!FNnWrx4HPOUPdST)|c|Y1-3O$AhJ+ zf5JPnQ?(air`w50wYzqUSuSa_hQxABcvm!*OiVpkTdRm8y7q)(!EHw1uiaeX5``lk5!WIZef`K;`3xp0|mai6@%sJgC{q zfD=sq(qM_5?nYL<+j0&3B+oF?dH54ypy$M7mr;~3e~Jn7{{B7|h2JS*q6ZyrrNv~y z@GChpTLY6mO;RKv@%bv|B+r?W!kZOQ4?TCJ)}%Ki<*<6bOE0UGFcSyes%ei zehr%3kt9j8iM1ls=4-5SS#Q6xxYqQ%pob%5m6s2iuepfB&Fo>Ek|u_Gsz;q{;WydM zSz&mzQ~47@J`$zp6EJnSAJcCvc|A|GFduW1?cMI3VD>ZbX+|)cR~xsIKi-v zhKEy2ebVW)T)bVfT&Y`fsO^x_$IHc*6WaN=P0^-kocye^_NZ4qLKE1-YTbE`6b2)2 zB^NoW^J)+GN1pi=ui2>!%;M_3_I<7`z)aL#SfU)rtBlJ1m5vv@0^S6;jZLg3j(%)W z??=}kg#hi5OIfv7xE^mU7;dP+@a^LLP3Q~Al9c@UQIkc|aBCGH4ai{Ru5^5l+QNbA zEz6BZx7|LM+4=QD1geF?MNQCot2O;!0sI-)f-mkWk53LRx+dE3$Wl~@m4I=4&4lvu zs`qFAdvs;>!V>Fs5u1td5k=Egx=OCo1xg^@H{f#r#jj54;mq#x@ztxEHuw%?CLglC z9_$)Plj*dmU4%F!;IBk|4YE$Uj_#`FHg-Mwf3&@4RFl!VEsS(T5JW_ppaK?}O7AGr zyGZZSdj~0@2!cvgdhfmW5(p>)QbRNJ1dtX21c4AB1isgO&)K*9*!O(ry8mQkjEs@I zS?gKznR7nRTB~`K&x%^H>olQh;K%`)ZfCCKhmeq1dV9z!Ho{$8S(Ba|CwjizgmVQ8 zFQjru8xa!3OCOB?L5XBAsfostP3aYsgpDVhBz2=5`l$c5gs)lv8u_4@5x1MzAJ3MV z6SO@SG&WO2UK9YpfxOnTIsTLllL=KlWGKqb!VTLd%-tp1%|#TpmV&Tz_xnyG2X_77 z<}BD@bJOJGd9Q$gDM(;5Mqo34U=qsL#=&dU*;K}s7(NcGGEBS|RGG-RaW<7}nv@f~ z(O$4Xp3N*_tM;j`8Iyb%?BS4+iaPH#k$vnt|CJ$$ zD`hC+KObbPsvVAnvxNmC@L&B$X@+{K#n`idhfV!S?OCwnDs@dsvD&x%_uPL2sj@UT z$4~q0+%Ckga9}QtXcc7KXuC_wb^v6wPy5{mX>!-4*}H!24y>VE(MLdpA-_hksivCx zgJ^%;dL9Pc5Mw9)6pc0(_t8Aw^;2~=_v~v%Ud+Ycl}7;*IbX) zS+Ewiv^sOunPRwji%;R!t5K0T`3;SRwweeWk91*jhBXb#aNzgWPi`Vf^tDE#*57DuctJ?^GmiIXJ#=xkrK|r^1 zm=7(fZc1UCD2IAxi|W~C2u@GB)G=Tix#>x>{O+E8l{4M&a7|~6=%#^;MLz@?FOB&< zy(D!{c#2O#^AWtUrbeY>H!d)`Bbpz~1wvBhK7tL)1n2Z(hW5LAuS-42#(chBQhSbT zom%C@$o4cWZSd7Xj{9v-5Ux|c>fML+0+Se#r4%41ALp6MT&|vbYf=+Mzbb$IiM^Rz zoM-s6*uF<=Ik+SQ4@DRKJPr7`-0_yR?Gj>>7{`NdLtOx0BprzUTrH!$$S`sGV{cDT zz+5xr)Gk*!1gmMzopX6z>RBtfOy)EHSBm}Ug~4pGtV1Au^Hf|{lF#O9^FmWOaseUnfNUAF7Px(Olu}3yeI8qwY3g&3Z8rJk^~|mkT~6$ zPr{Xpt*D!Ptoun-AEPz)B--;ebuATCBUv59H*k`D!n2rlI7T`i})28d(!Av)tW8{SS*xf;g<_XfjIJ8Xa-u{ zF?raWU{6}JTRs5Ot9aYI7mp!deLTFzD#Dvot-~dyUst0^tSFNIr-t_DYA$nTze zQ?H!ul*&xpvWC^J*y_dH)>i&)hDUdw{c6IRO^KAX8j38zuXT%yN0~QAUM91uWp;Rp zpRx`lXF9;tTZL!Hj6TwI`p>P@ut>kf$F`OP?giE{ZV7AQ$^uW5^)tcL5^JGAi+1C) z+Lv3aM@ywbl^flidF}AQ|w5`e1>6%0Qu^i#?eMa5uz*=C06G{?B-<&}2PK|8%b z(-tiJy7<02^292hr{YZ(%C6bi7YI^Tzqtc?hq2yx252OBFI%UR&1TEc2|Q{TuQg2m zG3EyL`*X5Rhn~GS$@2tTyexWUDOPsK-n*YnlWjjIz4O=iqm}Cr@Eo*Q-(jZw)J5voKEzm(4u*1Jsr%|L-^&Xc;*&22BopiC~|XrPq+f zj3pwNEo3!a(pQQ{l}Rzs&|ZA8gvZ0~T6%+4ifvv{R}N_auq4 zUlNl<_lj7|Rn4CV+REc5bvC;0*uDOH;7)Q)$3}mF^t-HzpGMsY5c0uKp;dr#v+S`b z#~%va3i}wShNk+>P4)>}!E;|B=Ak~0*BIM;X9`#duT9<#CXb$mY!Q_ff28ZLZEe-R z)d_QMG^S2v5ph-X0;7kn2pp@5AYOGVuw~+`146`$=ZX@s*4`bqqx0Su?^8>`p7wJY z%}$xWz7NelEu6x^y;Q`J5mpP^B0hx}JW!T8^`F`do7!pvgR%Hn9B`nJ4`%yYhYbtl zG8osi=W2`yQ9wgVbNy>XXFlH#?7ruHFdrAj7GUk#vAx+%DSHFQdUq{Fqm#wORjqZU zY>ySDZ8)3L4C!<`Y}o}6*AGxvSwB9pYFfUu8YPR&L}``b=`K%;bELpnQ!$5hyWp$`JQPrmrgjj*S@tb831-kQQ| z{<-EHQ*{(=ZaPt0`|RYQ?{`U3?&`04(2>9z^C@{=OKBZX0*fqFVmdgsr&rz`R!PZ# zktlJKf1vh_e@txX50h4iWiqT%e6mc*56QZdc4+^Hcx2>j_KjV+cq<7`Ss#sJeO*J{ zmL=?yY70VnXrHdIu!~FsCzXsndT^&3xD1oh+&HTjwv8U%VpS+6!Xo%PnCf1R8#uH( z{@gy=@#1f49(MElBcig#F4x!9FYU(zc%?7CPa&BRr`iFh$?)aDhD7{vIfAGjKi=y# zyxp=sSv)+|X@~ly;J&ygW?wtS`HpS)$pHR%NuUeg<`d=B64w=g+1}VVBP23L7mMb$ z=?_f~20lB(uW0fqwZelnyMcYx{O`W%dchkff=M0}l@(idJLk>w?Jxgqri@iOB6 zE{Y+vZP+EYED;M}GjsPQglr0Nc{pQDcSt&y}blObXJjZNuB1;jWqrSkEEqo06r-32Ia#Tb;EYVXo)HluMR)@~Z?Aoy9q?yg1{@&$kqiu`+7a%}En@DMpu_ec z_T7$Qjq0C$0i|k2;Ta{?3tQO8Ojqs~MS2@OjSVl;mj2-I*!eP;?Ao}|>xU}9-h-*Mq}p0o9VDzo*yf;!CA1}PmVRKbYv#zd(b%-ry7WJG;-nX} zJE@mHS49TbSBz{Od#fq1%-fHH`Gw418exoq-a7(nSMK-Cs)c>)>+NymoQ zq8^&BqcuVUD$~D)yxurR9uvOHboE%Tu*j z;q1h@dk_Kr;x)w=z5^T)QeLi zs9PQ;>P~v*>e;7;Lyg44!;24L8B|6L{)v7|5)$|WeSP}fd$RjorMZ$@Pcs|5y!J|O zqx%ty+Qcm>R&1cPeB@{pTh?(F#5foxnZDPCf_P)Qyo681+)b&m+~Fj@8tc)UuOy@S zAUheJCpW|L+@!Ym1rK`|k?jm-kKu>pQ1HeXLU6(RO|eey`=_atd>x&P9FhPp@5B3Q z<%IYJLzZMVSB_9DHMg-U5vyYzr!C&|5{UZerFBndR)B1$+zr{=C_tF{=6r#B1zhI! zQ8B%~|M$v5k?QOR;~n6R$6~}4cisv+3p{-iHcDgA^i~-XiDFgD5X~naaG+v(46a#M zXk-f#91Zb*el{$F^Ue^p3~H}5fE;XpyJu4QfXqq$?F8G!*MZEtuCxW1Jsr)FFh~B4 zyn?9cV@;VuXJdlpVBFr)j+-mB-5-jKd(SQ(?hl1%jTAaBG8HlSc<@Ev;^*~Md^>S@ zo_B{puZ2IAmIiBn3GzK#Wspwv&6vDXG4m)9kiUFUsZj%=wq5sp zp~`KuKg;Tf{`m(Rg5^)Qa%UM_(`c)Fhm;4d-+tZP?y7GlRVg;+fR?cdTWQWucuX1)@&?g7gP4RNJ_QBa5LlG%4FXEy$BS%$-gO?-YMS{=foAKDn9}ZpO*4aO`7|ToaLHd?aXpjKeC^`ArX7-a6ma)(H6x`?-Xp6bqsfF-14 z5F`(t1>CG2LxTr#)*t^7~iUOjJ;04uGLykpU2KGGaDN>OEJ11u`?=+5&B=8XHD zyKRM^{8Q*9(B9tS8(Si!A*Wb3;QWkr2$dn9amzh2_>!4mtkysnAq{``c86`7>!L)+ z#e-ZGT6bp7Zw&FGJ9#Wc`h(9iuna!gXNAC;qAg%V5cy`dIQ_nc2YfMpM06M8?y9(1 zrko}}kg?xv+tjT^W(`D|Mg3k{UOeR2_zY)Vx9Y}!wJk$M#uQThUm4baxi0}|9$L%y zabwkaN=cZ>w{Jw9%{|-*?MeWMkUQ;~_rb@3^(W;b#kBVoiWJ!b?~ze1Wda2(J>o+U z-q^0R`+N#oA{_SK=|DncmDl9^uhM?^0Ti*$Swv1a;Z_d5PIz6M1 zuwtuNB7!Ba>MbUpXH2WMsVsCH8+=T{<+%+oOJlkhKPzk~>1_SSuNJBZtt2xMZt{so zNw{XeS-MUYIyL@~D%U$zIW_BnT|CQPjc1F~YDz*H&mqLvIkwm`BY;=;5z{r_v;F5> zi?!A<>FD4$@7*xFOG}Ija?cP!I3g6I`{U6_od3a$KslNktk$_@62C74HY1uo-dS>c zFXEWZdfT>D9~(H$C|5n+4P3^Xk1-n*&2oTSabC$6FM%)!osnj>XK}7}nt;%m2UI{g7wLy&; zQ4lOzfnNUc zgS`tmW!yjjC~H+PhffGb&4y58eB1Doo}rlE5Gud>B8@9f698W;fq%NhYuykE3j75 z9sFj!jPiWG_r!hyU$*$$h>W?fhEPe7-m5B;UZeQq!1nD1iZRiPXA=p}!S*M)kz-3% z&i6Bf>nm+6y3M%k>nlgzqClv5y&AYhm1ZJv;h`u807QGl|?^o84 zc*jZ(YT{~*Hh+HI{;Urv>77Fl^Bk=Bw3WB1J-M}tW5$#aH+SjlE!a6aBuUJi*Zp0) z;C{7Fr9B<7?{|t(+^T8jLLDbiML}D=W;`wVt@Y>K@4M~Q_}t3JZLoM0k2{Wb5>SjIZHss z4|wX*QC(}b&DECqzQ}n_7@P+n~f$2m!4*AQ7!mCKCQ%1RbaAVhStucH=i4^v~~X7MY6oDcRe9 zXhg$5JnhzD@-AITPUcNdpKI6}_|jYrMZC2_ntJ26jebt@+*`B@Eol+5r*VA06%eKE zXzUXWtc#^hY%ssmaxG~IpAX4<+tU+Vk5pur>6}m5N?-2SBmYQ@Tf{IQqucebB7b*1 z{8?Yq0#`<#JiQXi)%<~`C%_k#R%6^FF|u{;Vdo!lIR`RUXiccBX;U%_{3TWC*f->} z%1yBUfr8$+**1JM_Shz=_Bz_UaRmLl)=9{*oDQW@Q`31fG z)IcGgX@!_TLHO_*!E`WF(aoRtCeLOE+Pg7EC2Ek@W^Zjubh|u%2GB_hKNpNpi|$r? zAt$FwdVboiZ!T2-x+g=JY6me)oIoXX*KW8vXIQ@mLr!pw(usqKy-YrwR>DW})|kC+ z$3>zIA=B>UEQ|hB!E?LIlSBMEH66>2L}=||cqh}6*Uvz6?cZ%0bX#kc_GPmb&CHy4 z?<#+xE=pU5s5O>6rgkxH3~mfMn45u}QC_>2G|bP+-y3zUiLm8JQhbbzE#$6~x(ro9 zkbghV=~As*DwlfPXkmw|438Nf7`xNTXZO8ss>oRExf`Tru7>jJ)uhbYZWXg&JnLMi zO_Xl6lVYwh*gMAILF-ab^Z8*tQZG5OABpKuRnJwXy4KvwglYd2=eTzV@@he>Ca?ziWv&Q;A*L5{v?8u>F1OLM*{jY|cA9dyF;s9n8#YKt*?AYAtP0qi8;H6p!wGK;>A$j6Y(iKJ0^4WPVt?WD|TGh zo~XiHX75dzpVyur&$Omve?$4#JQcaSC)l8g5f3ayp)%Uo^A=)V4?I$?4s5imf)|xY?p>^D3LgQNRWBqxc%TtqckHilaTY42V=Mfi69PQpx*)Y-Ur8towNcCX&X_y()Lxwx> zS*CC1T(L~T?j=QcEde+Y#le}SI($3`2@(@^q%tzw2=}NMG9$zTT3|RE+)jc@Nyi62A2^;3ecx{ZOnwtcFo~jjhJyQev$|Rj)_PJth zCi^zUe(BoDZMuUgN^lMsQp0w~Y>rZvCc+Cr4(IV6NC7lJTl@3x$A$uyz9w`7I9Mh)L6Ud15C^@4pi)om&>02v91@+CxS zea3HzC;oC%p8NKJfR)S$v6O-K?;;>)XIWHAXKMcd@g4%xFcg@! z_$qHJq~GV0v0oZauj~8s_Sc=XN}tam&w93|BV?{W6W;{4U*n zaSMh1)x!FF3_Rm{h%rs}DSIB~1aoPLL{4;hB@=OjvUA1%dPviVdJtyJ+^D*o2xYxW zh|$gKgMQT#523K$WmpP*gL#wyDafQ5%k17I@I9*xPV+wEcK@ zp*eZ0Y-6&(Utq4?srC<)>jB3a73OPgW(y3RoouHjq^-Sn5;gSsG$v6Y?u*(K|T$QD~tTr|&t&xa=_CxL9QU$LB(rq0jC$L3Cx@ z(_K#Sj`pS)N6RjTJmxk6m=V4UZkiQr50c>XnRa%U_YKUu63 z1N}-}%;x*;Qn0Gp;iNvaC0DQ7>)m(0^d}nsNO!mWYxbX^6{Id}mT*;$RN>o`b6A$9 z-Y+(l)xk_XbG4e+ofal(l^)yGEOkH`M}nO5IqPx@DQH{( zkc(br>-Ad~`?p!9)EuP-mS#32qjUHn+}Oy7X9h-m(K1dWd?u1Lt>k%#FM$P+ma7nT zecOz;ZyJ;o`kr{qsn~tLf|n#B#{X7xe48HWvVExc`ZWhr-7G-RH?x|XS0M2&Mr>eB z$XF>>n~0!!XDM}=mY~dOl$sYc*VP#DUg$t5qDtWFTf;dqy@k+=oczxhMhm0&GQ>Bj zsi=g|?+xrO-#XiCtuc5ph?a_9E;bH>q?-yON#^s5C%D$CV=2yfDz|X+e2k3g$F|Mg zc#I?x9cKE4I*B-X9=GRaiWq#fPIfeh7ofHqidR+1k;1b|ge=ba>Xf_rg<8FOrBd$e zCojLd2T7hgo^e}=R5#ArEbFES=tOpKtCEB{irU+JyR7J-z7B?0o=NJL%%Xn+0_O&S z0ppXizpe+}Sx5v?oLZN1&+{OZ*O+E!+9QBeD7FA+MhQFnR?IiMxO~ec%*IP`iE2fY z66N@8JSl~D)mv@4VrvF`Lm5%sZXxUf@UvV=++@hQg;9qhxQnjUr<88rcRm!pO5Hap z%~XUPit($4M&$-Mu<30F4!30Mv8M~$Ync=(P)VJg^u8R3MUKOm^ohoWUHK$}?3d20 zT3D^-{YQ$OHsdDqo|owYpvz<2iaxM!^%Hb=pv8kOx-zK3#!6hxRZ6Rq!>I@d3M&TzIrpU_1$a4vzsVCtBsFIF^HkA1|~FNT^dW z3npYlT#6B$THfanoxSQfWru7QfhXqbx$TQpkizgyp4mTd%~h3OJGxqPI3VEg&a>DL zYK67`$%OEl(5kgF574>eziQ}zbeWU7cwZ4T*4~i3QsX}P>4m@&b^?@@ql)m~F3X+s zf?)j53JTtw23rmW{6w)zIle3pzA#vDRRpEp;?9UK__?4CW>wlMRBf(+W%x~BL=eI1 zC$vXxw(>6M;|jmJ9pg@_4cU?xWL$@-OS{_r{b0$HT?_TtExp4Lrza(~4Zff(`h)Gt zQScthpHeB&^P#uB61E^nc+QvV9iq2x_f zTfG)K13R{aFBx2_skJSBsvwbQ@>W9Bk>sDWwE}8|i_TrW*(8CEL2%dNu~LEh3b{TV zjV^B9sL6?|43kdMrFZY*-kWY) zFtrF_7rx-~aq#C6M*2TJalA8WTCE51LxZEqGUw~cm5Fdfw{%c$Gix5@fbP)+^rDla z&h^;NJd*}eb7SLb`^d;h3X&hPLcAOMEI)fsR+Dwe99jh7hSeafO&3}zZz<34)x&~J zri7qK$GJB-`n+8M-k%kynAxqUNYq>?mQj2P(;w?{YEpaK@{?LwUI)5R9y?czGb1+iI;T* zEEoPr=mWazx=iYzzKO}UNpxz}6BnUNm$uf(=JwKIb4K@fZfJeh!TkH;N*is(Bnm_v0G=~kJ3-h1`x`ke;2`f=bQ zex%rrCB;wXr0Uh-G(?7v*HGtdBvuBy;~78Y2L(9$D0-8r5>a&tdYmopOXFuye6i;k z>gpd>yq;5(r4Vv{t0hMISj{>Oh2;}>!{VmFOYqt;@t}jxGP6;sr`6rvOb6vQKk|Y- zch{6*Em(;O;K;kbYUlmK9(4ak@=o*QS{eaiFwJ;fHN~%ip+L4E*Vqz7rCx2S8J8dr z2gl1*wI@1@Fjg+X>K~45;%y=2p*=ZVO->7OOM1BB+V1;y>9X~AmcRyK$`Ze@Y3{jf z91;?xi*t^7xjBlxF)+8qd+~T2338@1DqXH_XR%qaCG+< zuxcpgwC4WtlOtc?@t(*`+nnRn1=Epl7{ykF8z)cIXZlFYM~bScgE@x7YKvxwE!E95 zTn$Q#=-%$Lvntv@sg9_F~e(hQvEPdds zts)vH&k&P*UHanN)vBrSRy>6=Ox$^H2p+ zv((}8fidb6(R77V?DkvAkq&i|$QXG|_tb|-imQ`mT5wZgrr2Am0X=-^Hq}uaBMJHd zDjR5($x)^{0$YC*J4UQQ*(?_cdXO4H)>1KcFb zy~6;pgrM=O9H5PM#%GHW_gdU$6z*lpE4`z*vee;Hs<^dict6T;rF2in-rizU<0%YR zI;(bpv@;igddX-DP*G1~KeDpV{*d&e;}abKwKeX>H&=%*`5p=#kV=ARgC|EwA8+lzyJ=mxj9`Ul+AQvm0^~;gI^0JsAmzyob z=*Hg0#K~bC>HDSN;hX;WQS5pdh4^I8BMI-JrOO@d-gQs9AFuV&p9IGMB)4g-7`k;9 zYg`@g7eaLxydGJpHVduaXYr!l6J zO=^52|4X?(t_&?PbOH7i(Yf@6cMUxUrng|dfXxpH7t&*5Dg{zpTV=KjRqb>_n+W3c z$JO(xWm!NlK4qOBn$F~!5kr@(sIOg^t0yycL{{zIhGyX7_MtEp_e)xEgUnxD zef-n@@i|5hH=Hd>Ax>Z)4+Qb$YqZG$R zZpV%m9~LR7X{)Il39Tc6jUhBD5`hdH*H@~L${h+uNla6_OLpZ^bT-V`o0nD7{A!Cxdl9{YrdgV{NUYGJ7|pM$V31_+L@6 z|DLIsXGUp%OL@FE!2+<)KxOn7-iH|d4EHJ+SA^4hT{hOA zi`!`LGk-Z0E=|nZ#x652uTak-NBDhYq|)Z>Z82=tXD*IyXYMCZoB=ktG>)~Xkb+g* zbL|7g@Nn~sfgN9}0cNjbq-Qb3A~*NBCJr|JT{?FRwC{j_S*z~i~w!s!}tE{Hh61lh3Soz)8*?hwx0JG(28g67`v;OwTX`(aT#P-`fpQu|5 z)Tb;P18;ShQd#|lHM$afOyDRu6oE4Z{d{TY_U#go#763+*V(KIFC~fSjGJQ`2rH*c z+S-&F)E_3}Yi9OkO^pM(YqBK=Bh-Z(G__F(60UPp=4o`*VMKZYc)~9B;bE3|ZN9C# zy@7R(zuo#;l0k(WxTV@=E8)sBn}e4QhbrQ0b+M~)(WFoNjLN`QT8Fl+g4sDC487N|d`2ldIr!1WCSl|BIPv^9IWVQK|?6C5_+ zRo=~ew%|1wacI4U46hEfr%$A-;1oBs6UV1qh8T=9}VD zwx#(20jT%GSm1C+L9p7)qC^JPlCgy|M7Tr=G`{S8>f_?rV`a)eH`ngT&Z%pp(44@9 z;Zf2@n+~3-i*&ot_DdeLdD&%jKR@a!TgmQ75qg4to04&H%P}-RlBCuCjzci`UOetR zzLb9R#eYj9{|hi)w!WpLqSC;}|Ez%{s!xK_202+Q)Nu~9z`SM2O;2~Q@gqrNZ(H3a z{^%dmuhB&(N>50DT2i*NP^TmzQTEFo?FZu>W_DAWcF~k1WMC>Z|Vv|IJJ63b7-w zoa@T!QZ&Er|GNh*ory9^^Z!6ncyM7ssNMvUgcJm{=>;wssYsn0jJ2X6XjiM)? zZPnNR80&yqN(TMAC%%G*=H^X~uUD?z>wDlpniqM-Yu^6#>T%^KjX@6-XD~LHQMj#^ zp4=e$7c%-U_n-Zrm>O!FS68P+D6dAitZYDSV?DI!FQX@R4psl0m0=s3Z>9Y&QssX+ znf$BhF8d!4sgGUm2+AVGf?D0*1N-8E(s+vYC})A8&c_(?)-(GabA>ZYZk{K>n(qcY zPDx0Bjt(}Pqq2OM$DP=ne?mp@%^#zWzEG(iTI0@w=DI7js7r3Dx@y$3L3^}*(%Rp= z#m-wmRAgGpj-2Ozc@{^WtYFEdnECGCFvPE)vp;#FMcB~b^(k7jz=V9u8kjf@R_6bFK3R1V+wK%zN@G8@OUL;k7&0?){i_du6pys z?(Eo7vde$MP2LdU$KUS{j;GQa-24U+mUcPJkL}DCwTyg4kq7CO&N?&H<23zTU2?^V zr!nnn9R6-$L6>v1hUe-z^zb$p5Jc_Nr>*HDmH3_x|HFq#RXZ$$gHpHd`7b{BN`a~h zjg-AbPj7Gq31b?hp>Ud(%@CwcTweawZHD5xN%ueE>#wZnKddA!QP#twKI=7w0Q0Kv z+wo=>I$qy|TtzufzoS{8Ti{DV+A2%_COg6SiUrYsphLyoIxD%H&NKU#wT+vqWOPh^ zbJGfXZ6-pIs|j@Md3R&=;d3t|W5yEW%?6XZxJEqYd!yE2QB10-ko*A`g(v~9s(l$; z1l0?Prcimmd}{UlRitiiTAY4R~*}Ixfj{#WQV`VRg}NE(}!Os>VzpQdBE-v4yJvv7fTE1EGPfXIKeTze?ar0=% z{c=44z15{Ic4MU<$Js{_Y~!m(zW}WfM-e6*S#a#MkB+*26CbovnRIww++Z1_L@XwL@&#(S}Oiq9T zv9QQX)3n&lDZZs3V@-Gh$+;|aj#;=1WKn78__O)=E1@ulr~>C6?tQZON0s6Id0aWE8%ushzq``NrCMB?jN=*7k6@gYdpVVs_09eSHu7J zXXGj|F|ksY>gCjaH#*pW54i!ZdgiQUPCQO>k2Hj;mvvgch)y4#PTvba;h@3DaMuWGwsSA zSGUW6uk(Ym34(7C--#M*j5~p#5&z|6(f{uK@Oo#xov4rVG$`ylQF+7ufG+H%V)j#| z`KC{g{U&vH&G#$>@BLwRSiccHDUc$3y7{w>IAXs! z=CoFac-h*d5;qD_xdlB2yuW6iJk(<$-d-a+%UGrl7r0MP_GgMM?_mJI!oL8Is!9=vufP?=h(MQ8??xjA&JUL}cW& zw`iPVUQ%ccx2d_&4o0tnj{YyLs(-H1U|RB{;fDj33Fqg8a~&?N1L=aHZnMk5H*R>I zhkGAo1-PPVWU~v$l3&RXFJ5`Dx7%Qg-ungTtCabVy(MKm$C%SwFQLx|F5PbJ_55b2 zLpOpDPS3(uSKk2H+mUKQv`Sk&a@Bx%h`w$nwMN9$@9)PaEqM+93N?<7S}FKzy+s!g znPSx;we#BL-^#?JK(?-cn+MYtSIl;D9`Mz@{l11gD}WIE3p+HxHu^a!={2KhU9O35 zpZtxRH@&x?yZ4c^yQ`4zAaoD>UI2IY5NN(jidPac1_{1>L9C);@Wj);)3NIHUGPgQc+qcFfbIyoWXK+gLQ zEB*L!@Jm((*WIsQZvah3^dk(+Q!uNljGZkOZ$4%bw0q=4`wfCE_qs6IM`2>QAYPFr z_xQE7HE|by$;*zB(*sk7o3J0{R)08*s39>RH;hwnEJ4n@O*K+bQa@{ZAy6PBKMqWE z<`NMwN5HIa$I0itBBUA-pCXVo^&=+G`xK+`cVkjEnOG3V-`3Vvrb3ooHil$0yi@AB zP?9_V349>aEqjZ{gb&G>uHgeT_R$lUS%w1ptA^9Gdmmq;@use^klS3PCKwqMd-au8 z@V@)@Rckw&;a#1OU$hscOv9-z_#&!-ohSg2vPaeoQX>2q% z7pBfYbmWKkpJweHBqNkwdsF7RyoTk-x@ssBSR|H&HT&IAs1YDM2DP9A)wxn~&Fxw5 z7(L(jQ6KwSPd-a{EZ#ViZ3_L{`%*$fh(AR~bGDV0832ZJ0s8@i zX!{MQ4-qZxGF>aX61EvD)oW_H>w<1?3n#0TdPoNQ)bmx2vB72Fj+iy%dWlVv_y9^k z=8>+xS>FhDLn(TL_68&4-KCE2N|cA{kIXHW=GDLC1kc))%2TGERh1r`P*~gADtmf5 zmeV=ne?Rm;J(Y%{YrX93Hcwa{3kWQ1`d?=|?$QHMzlwSsJ#xLX7FVV-EevGeX|}L$ z1z!ky0M2wK;9zUyMzZ5$TMG^dysO6YcR6lZAz@fp*bSE3x7jgIDIWr%_2R`l)ro#I z+}!mf7Ze(K@;!T_B-5Sq z%bK~$rn2lA|0D5^ot9_UE8a(%(21TMsJfl8oVF~~rYcy*2DNB^{0A8d?IsZfPM5q` z(RiNM@<0CHyfoXd`~Ro4@&Cr0N(*G&K@^vi)YMyH6f!?RDaA#dpv(Cj$HZuFD*KfKHiv?a~ty zoF(Po_`5Urzv!7f$*?zc7Yw&)Xtd07#iwA;=7DRRIfX_y7bgW8B)5gm`GIC0Ono@P zF7=>IjAg6-@j@UUE$vFOR8sK3bBl6{V97fxA;-mA?MGQj4J2lnPt{!^7$#Kvv{|-< zUy>gpP=4$VNMVj_;%_sG|7Eger5MQh`1qcsYGLIF7Fr!RDLy`;#==~{#et~?6ciM` z+urv@MU}2zy_(&TkU(Px78X7@q$@+b>bKo^OmFoSM4eoHGyASx&2?k@M(MC!_lKfLm>M!dY0>b252H?9I@Epv? zpCLm(Q-LdYrMN*nth8mrgnzsoU{ODmm+$lO&w(uh9mt$tQNj`{!KYl)wwMmlKi=fP z;{E#>Dnno8G?rG2E3>7C^TfV@=UOOH(M)S*>S-SgW}V)-8yc{8OYY1+E095I1K*Kjo3^-(I3A9kBt{fF=&ToltyXXt|YLtN@r^Auw6!UpiQL7 z%?HrY$^`jRWPvRI;EN)}larHDT+f1(Lb~5ywv)kRpR)$SvBO!8XfFvWXZBFpY6@!` z+u^2C^JB4#Y(E6g=h4hl=fWFdiR$dZGj~pBWO6HDHk@2{%3vNWuVsjZ-RAD58_vP3 zaYs8`vFf6p@|-^-oc=%B-aD+RZ(A2e1RG7IDgvU?qzlp^s6RzPiXb3T6%dgoHM9^B z1r-57nzV>C>Ajbr2$33!bdu0R3jqQoAtd=0`<#95clX`*KHvE+KmSvoCu^-a#~kAw z?-=ixKyR*#Z=@>^5R80^mOedpLuz_G++EjGgi*hFy9{5`1v+j&@8TYhc3}dl1Lawx z|L?r&zhE6fMnKK%mnCrG?uoC`Kfw_06DLl5ng*&O16Nx2jP|zIt{#BWz&nWw)@;*5 z4eQqGDTYPAQjFlm>a&`AD(TkzfNtOs#M(4r;?Fd^cyg}Ssdk>1%l|@_{mKOiU!9A% z{wFb=RnG-y%JE16e0ehxhx8_&ccIjzd;MaX^-r>CZqx&+swUQUBe)6?Nk?p-|2Q0U z@0(@KCxdeNYpx_a=dNRw;`R+POw%776U5r-qU&sN)OH-r5TB}_t zF8A+Ku0~~rd7!IIXJ>2lUc4x{a9`7*MEJg1m4~dnJT5zo{MYT;4b|iLE`dq#J)o1g zP)$U2xN&?OQx;PBS5VY=y|>Ou7hr_lK-8NzSMDif{)Nu5Zd4K!_K8rq=J;d(b_IlD zpz1HDf9cYt`45t^i89ve0YZ;o!0YNBjg(Hhvex|mQKfiKdA0fW!|eJH&ewl3)0djA zHss|TE5>poLBtf&Da=Q?Rl<5u?%u+Ru?c$@7(*4mckfouh#c0s#qK$G9u><5o-ty--1XBJo6Tu!-Oyj2xoP;lRD`RIBxMHriI zwj36+p6oy?`IgqiXbT}UuPGjheSBj8rj zefej9?K34_-HS<8Fcgw9w+akJV6<}7j6)CA%zTZ%0^DFw#T$l+v%#a_(COLPy)!hY zTQLAP-I7xK2yn`{tCH_L?5Ei8$fG4;@!o1Cgh8lMUw6gm6uDCtE z9q@-@@3fUr`}l@G+8(E8CEI)HayC$Qu?c<>u0h!@tQdU*4oG??+O%3@i@pf2r}r6W zdVYzhoPUe8O)|F))H{0?a{QKca3^`|PnAx#?`k6YR?>v5ONmF*P8xTJ`3ghn2Tefi zZbA>Z(UPKo#8%fe#>m~!CW2ljeS~jZ7_j?V(G@j{kuEeZmMt95hm(G~q`3vnt$Khy zDcoVN1Q+AQgkUnIe6sj!xO$z1Ma$e|@S{+T%`wFQ_dS=mCpQ^TB1$#4>W{oiRdO#c zfvzVJAmONS`|GY_+9fF(4?+SEy@OC~kH)=h>WEzXGO}rwum>QTXRE3kUE;l?696$wuk30-Ic;W)3m{^>{0 zH)fnDvKA5pqznl&w~qfIpA!?f8*BcoNwQP)O?A(3SB4|f)qTZXR8|f(5uH{!^^l9^Wcc>9w&iR5-c=(&F(t}S7NQRV@RgND zNEPxGNIh8=2p4_)LAk&1XxVOaw~e~iCt-WKK+Y}bYdT(zoYCrF=lVl1Hu$Iw2$T$Z zdNhy)N8YN97K)~0$Bnv!bYd1eqef98Fo;@hk3vqKkW#46P;vkFaaeEs3C+Y!T)yVwkfKD$7d_;vcbcQ5NH8TmOYg~sI~?u}NE;o=J4)xK*S z-AY7kI~G&+b1Nfo5#}}2o6~lwTceI$sfV%{RKK%mtEa2_#B#Tau(h=S#Y0~}6nzN2 zwYHyX+$<-1hxZm$^LbLU1vIX3P-#MaKh851;9nQSXJ z^zD#Pr1KpvtK0gX_q!_zrfG7M!R=(rYSsb4%XI8G^KfJRnWRq*!Y&3a(Tu1l|5}HG(d39!_L%`!dSTQj=h_;mKjIpt@@^}sI+pXzzsjO{zVAPWub zv9>O)fd#CH)VmHwC7hxYTIASJ8kCQb4(%1gXnoTOtZM$*q#^PT4Bgt;WGIb_q0t2E zr+<2%%w-#{EJt-s|!6h$OrgaJKnQcba??&pptgBT_a8{cuJdSnyc z*EX^wRC=~f1+NN#vHTmmIMlm@Tjf9vPSt3uZ&NUUQ5|_tK54cny z*g8YPQ!jp+khyFqG@oXOEcX7I<{#>rrIXy6bVn@Y5_gX_R%PYm5AN+P4?;nL%K{bb zWbWKA6!PdQp@nQi+e<=-$V=4gB3_WjA@IPu$HKk~=}R$C|G%C=w@d@9ue$2pQx#9T zUw4Q7!rlBb4>w;G*Vok<(A}M@Y_>*57e}oLdKT~y?}TW=degI=W0F(yva*=Qd^8@U zPUXI*Ec^Y}83lbfEn zp+yx`$gmD)J9iPF!6zC8F$em;cVyzL1TXivKH|E%-&CSo$ub)SGV=CYQ&dYo}Ogk z0bp215>PO+m|F@Sw0@GlYel|uXz9sm?XOxV&T1vBI7r^F9W%Dqlir;E{bS0}I~Ix!tB zGq(-t#%=%~yF{4)GqQ!Z$wnHK0>=6vP@ zq~5UHz@CbnLS2C@?(;9fgklqi*4B{05;vCvh^^%MPC+=V^b77*PP9dtUAcxb)wCW@ zQ`fB?9{u3M*|R;hJsNC4F9~u_$Xyh$L2b>@wUXA>E?_srIc2WIW$QecRP{FSngEc)emvp-0ZSTjQS){JZ z9FODZ5`*<7uq}Fs;s>)mlRs+Mf4B0d@ewZg*CwtxeZj;KwMh3zN@55k$mpw|-J%(? zz?3r?k7h#bbM3E*bd6md7}7PMB<$F(ynR&CyWC-VvE&zN`u{(X?7y$#Pm@j+T~}2- zKaI6kTr#Y@)HJ!ae*zzf8V&w@1QHa;(XPelYj*L`QINsnpwzebC%4pjY7)}C`rkNI zZv}LoKUHBj<0VEyuE%G@N&o|bl7NKNvFG~}tuWQKmf*`qqfBSd>itYBX{PB0Y-HNr zbIs#(`alozsL|L+1yNW(RK^VTDWf$k1fjFCgLU`pv18obX5GzTkoc-TLrN)ti*@15 z3@t7{Jr(cwG(P8>r@rPpf3N6>xX z&lzH@*XcOx-CST$R^mAZ()P9vfXvx5~R-%We4wOf>QkRO7p&k;1p3j)ers#&r-=M0oraU6yP46VyU zHoNBv_yBGjjDkc)=$nGqHsGyawaIO5#Li}T%GD>L+nv@eq<#K+JUT-ExkX&##Dsw% zND6TGh5>FJRmN_<#W#=6E0&eEN?z>FzzWewE&2JCmj>k1{O0Q~pq`@;wXZKrB&s(vyb?zqBrE@aQn30}ef{$8h`_2d_Z z)&NYfES$Upb3jD<$JCTqt9M!x(U(j-8qrxTqU!Jlf@r`Z`P}fSBT|4~1(8kSS$awn zJ;8P|wzNl6MqIq==aQftzW(dii@(^Pq znkcgsG|djOq-pKm2|q~zbcLfL?MzOAyaI%yobgs)D!fP0Ue@NtgTK~eU{9pa`Pk5^iEZFqLBlZ2#hiyo1$F~eCkC+$|bM8Q_Is08>T3@|tP?Dzi ziA^xQsqbFdvHUBH77SeOmIUB2Z{pBJZMGBH7mYAXGL6BnHR!XS0fsn`&7Ai1TNCXL z`G-R-+OJrHvd?+&&ujfNd7V127K!r}i-oS3nr?I7>Xn+*(}K2+UB2f2Z|DKU`qg#I zh5b!?je@o|{#2KfpX*>@Z#2%w%=O9Qn;vMTP;KjdS9}Kx!Qhi}vi59O{xGy9N@t~g zDYB@xQCunmT7mKmlmmCyuHvDjzE1 zLQqn@E2?1#n;sb-j2<^2JtYg~RrBwD&xVAZ zdi7s5B z^UC=#EW69dt7>N?T=@KkxtWCQftAKf%h9GP)2CH@(+I<_E7X$);*#Sp$u`qk$dc<3cD zFr!^eNx9$((9(4cOpB=i30wF~0%KtDN=GhmN5noqVYk0d-F*Uhdn|KG>Z>p2PWLN+ zE(GiEUf|sKPCS{jX$t7uK7;5BwJj+{YHYNIxsuf28V}5(`rRHU?5V39$#%lFITi|- znXvU1LrucF;63tvvh$9{z5D1Z?|0V$bPsX%?&9_kLg_N}kS*1q+765<+mS2#rO)PX+z9oeUQg1y`?BnMKcMUC z?z0XmR9uo&cy_>-t|kxldc=g&oS$ceufO1%#w`j=WLb(8Qoo*LN>^Q-yBdWVdGpvN zV4R6id%qKIX{=9LRbNcsnT81_Ez33ud&8-jQ?Kr7S}1Uy;CKG%xns{uRsUJS!p2r! zD6(Le;-eVav}ZX8=qBqI!aElO10Ta1fk9HA2)+25ww!2}BoZparjaQ6yMfKquM*%b z{sLx_`Y(@&m~B4>#sTTBCkGZYC9e)ZKP8yjK^krzI!u{8f0AQ?WIqa!Yn;fq|UFIkAH*g@KO8)B42Am z&KwZzaoI6@*XCaIC;;Ts$;o%IZtA-#IxZWSS;xY3h%Nts=*Dx>rx3HD3fKscOx`l6 zEmrr_18Ar|E&daK97AU=AB$A~(=lpAVOzQk+AphmXd=Xk9-7i%SxB&H4%%}?S0bP{ zzN9z$YzIVxpM{9So90AMdQALsYBcNcarJ<7t-WMCU%+{v7br6c+~G&vMk#B*; zF6f2z8+A&k@c6wDN=}J;;W0v@*u6;M;x?B(VsSJt48Cg{xXDI#Za?o-$D*&DjE-DM6xC0;`|zRnz3a#%9=7vT>MdfI2&e3) z646#^RaMg#_>tDz#&>yegE2+Sk&&U0 zcj6&TX19vgTx1uw{jo^b(6WuLs9_Z84%H$a$Bu1(yzi9C#5G_#N1SEYLat;ZL1+R1vq=|p+w#KM1jXP(FAMZK^jJ6_n~v&zJh`#@XB_-B z6ND`Zb&sKf)WL#I<`x)^{UE&57imgMtoFqlyXkdbU%8+dEFTmYb6ZT^C>|b>p%v7; zuzqZ*Tu|)79Le~B+5B{D+2yZmpEQMou6^#&B7!p_6?z=juY7TMcI|TjsEM7JhSk7Y zps{H|V)S4wTK(P7yd2$A`scrU{VaF-)!{CjPhBQvOWh-Pq?Y24BGzsodcABCD>7Bl z`R1Cwzm+)xRT53n`zp? zh7F}2Uw6%)J^3s|Z8QY?0#_>Y!>FS%z5@8(z6ko_;N8jaHTC%)$AzNCE&9>IEQ?^@ z>k2G)f~YXKXa?+UQS;jE(kQWvMzHR@swrT@y`- zuH`Dl6%p#47-jv@v;NZ@F@K$ko%{sJv-R!W*~DU70&96e`%81yZtUGO_a#Y1xwl7i zr#r+ltRAoq2h9(N&1G|Duurb9*eRZOhVVjvXAJm`S58f`BkDpSPZ3r!#B>SUualGr(Q1qU-!{4gMQM4#a1@Bt(G} ztE&Z%+V;k+nD%C(h7s$d^Zh@-&Zxq_no^_sUrZn^3L5)qsVfQS7@#{U`JJFMx z(vj>SDVIP<>Eh~0G2T=;?W$SnV-qhmdj-UJrBkAYd4bo!w<;Ps$4LB_$#|uj2W<*1 zruWC+Q1@&6_drY#Xz(%<0k5?enylbt_CAqXnsUw8D=pCtlE4KFJ490L)4WH>*81)Z z%OlUfW3=q>wk!VT!mpN_HsINEaw6U8QOYf}HPWvwK{2J-IsQ$jUXSI9M#JjRggjVhHK_A~eOuEZ}^QI{=bBlS~jJ0Khyd|(WPvR3=@ z_GZQPabmA|%Py*JJKZCytE-uw+LB){vPul~*I;R|N`Ff$#*|5H2wvSHNaZb8Pb7 z4maaiGkm-<7ZHTnOnkDbiGkQ$c7y&Td3vt9*qp(T`b^vNp~Nni*;3F(pS8^8x6FQt zz{Du;$}xkh8X0eN~Sn>P2ofy?4X5D40) z!ob9!S;4*12H;N6Utl=I)RSMmMv1m7q-DhVy9-Nbo?s__KnOxEX>D#I+lMWd%=6{G z&1lg>(=ZLb{0%n+!Rtg6zFBUiB1qFH2Y%yO<0)1vSZ`3@l*Q5O)kknnxCvho%B1O6 z+kBR1$c%H#)`g`Q^y>DC*3Dx<*QX*r3~U28@xo};5q}!0SLfd5!G^L*2-DG%_~j|- zkd_s0k9VI9Ra@%lWGL1DLeTzDC_?i*?#GWYPhWYlMr?Ot@!E@KAQ3B}hE7QUCNU1& z$Yt<8l&rD*CTos{0dXC;gW1OYW_q(yv{nHT)0dt#?8j0!S>|ct%CL+lMTh04kX)#lE&3pljz9Lh8i-a_?nMG z%6_$OiWjwC9l{-jcfwmcq;4%A7$$JTwX}hM-`cs&lu8@39<8zAQ{j6J*1hfZgNzmo zU*#(Ps$A+1D0d}yNfl*fR{arj0USppneYr>5%u|&?8dhtUPLdvS@JwktfNj-`N~Ub z)C_|9-c##t4y`ld>D}OtNL#NvR(ly72wd$BYt;(6SzLs^AakkxJVI4Al-Byh;^V2CbH*Y+;B z$x%BGJvB8|N=r%IbBA?f_l8G5DXrhHKmY0mW#wuPJIAwgZuool_9h&C+>+)x6T4MD z;f(I6U0`xXLrHzTNC~}DHqd8trP7wC-fG!N`7Kr2wxvLaVlfUnd#o`2)DbS4vdTm$ zWyri^&d>oaG){eq2LegE1^038QW)x0@`V z13IVa*$*pA8tT0>PCkZQWw8PL0TxB{eM@iNELf%RsMF4b{DHF!urLbWllqQH&!Tli(_tx&5 zV@&=@H|BRFo$N`|66w>#<}{1$0@s|Q3f;ve(R&@05ngo<j==MZg4PI)vSPTEmkS9=BwVh1 z+2~SA3E12Y;{av6J*M=F(su;?+>Cneaz!s0^6m~it%d;y=rv-?vS)IM+` z-~yBu9pN63gyXc0m!tXbIWtZUtQM@*4586z+|n1+f@B7JO^?%$VkC530-3f{LnLu_ zHwf%ZX|DWkn`=3qk%7=x#t7zR1mBeX9334^T1I-T1U#wDx(tA~kZpu^pQ2gdsP0?O zT=R`hv848o%O+VeLC3id<3_MDIJ2=fsuoW4*&X7q+<)E1o({LoQ=D3?%;Ph+rb2=1 zXVgmdLSHRU-S*Hdtt>!bjciBlUmg#-^2zH;m&wNMe7X0UD>Do(4<9@blvO3vm-~zt z_|I?VSl@UhLGqY+O_qnE*N(cjC*HD~KXpOT!=QHIQ`qPDi>mpe1$2`Hl%Z^FEyenRQD;N2h zsB5G6QxZgxwf)%9;dJ)BdXj=;SGLq$ysR3>@ioGakmC$m#$IdhIRTM{Q zLNmcJ4o_?#Zw(Ssh*=htmM*(vZBZS>`5xYM9-RyIWwPsnJEPBTZT~^Q{`#zE-VrSjwTY4}iLSLJbqJ1DRaYaFTf9xH zYQbEqF`)I|n(XfPg+sitJs-IxVKnKcStyjtBV-QNUX}kj=wmE9kcl~K-(^`@r*|yAFDJ_J(~_bEcu-#NEbgGscC?KfR{Cp=!>_#}SRc+n9p73PTOa$J=%I5@-TXYI9~~)j|oaPueFy(VWxsD%_(^6fPl2kpLs_a z8n4ppyvHf%3}yzKe$(`lAey($89aTuaTbWfAAAjqV^ZB4L|Q_~;7iRy={W^2?l%#v zJytcg174j-qjn|zYKzf0v)X`KHP#E7Otlq)QETZRqsEB~LKXNQh9SG%o~}Hv!5dBW zo4@T1J2c@GNOphI!$ko_^*+fn4(0`zzJ^NMoj8)$ZclYhHFp>TovdK{!9H3|<)T3{NzZ=3yUmN%o32nSLk!Z=sBUH67F92AO!*<2+gD&#^unjwwk!6V5(osz z(>`)1!f7TVeJ0qJx(A*kTlBj(l;r2DW;|-pa3_&V?I{LOqOXwH+lMVOmUE*mp0~^Q zHpLPV2+$5*UX>z!4{PQKcUp$o>Np8!EDQBUS-^9;ZhKG(q%~fef|3XNeC<@A`6l7_ zWuA`xutlymW+<30G&?f6zTU9?)=8t*1;c(A7r~9$GN#W!o%h!%i$kE$n`>A3OYq)+ zmD0^}e9ztu&^X(r!E(H+b^iUFJ+8rt@q?24gxd2Xuk!KhB2mR_piui5^=l!dERS|Q z8&aOa@aXC*GW%Y7g^*qIJh(vqM%C8TXJ zdUN62aY27^v=^yCr&ig4D-tuA`^tvib1T-8Cr| z_Ak61{wUSWa>J0!%7Rhsn@#(V2On_*JHSUubQ!2LE1s(x$B-a|GKcy&%YRl;&a!HXEgBgZ6%q_Mr4hK?w5CGdwz}Woy}g$hOISTI?I*iLjh3OG40cWClQ|W*aqwfp3g` zjo%_$#q4q~ER>Vt=PEz7!pBxGF4lWL>o_jkcqJp9xu7paRhos^P2|feDG|-6etsj3 zl2`gpIUQ^e@06LFqwD788$3N2IXjlcfj{%tn5G(@G z=J!>CXV#C(8vg$Ij=t%E;Vo5N&GDSw)NvfWa2APVd|>i@Ht1TRViGEWD}}TUZc>c~ z?Y^^dG4Qs0`TNFZ(s-cF*3Tio(FDRF*~?kd5Rk7p&~!|PlkZ~x83y4lp<7&^M3`QV z-tCLF3T@-Lzy}1|{I&E1+;$RC6872G8ieV2;y?hCjb(-5k$k-?h1r~pZ)>{ZT{ zwXwf9gs!k`4)MEx%*|bNGI$mwF|Wj|-a@eE{ROb)L=~)^Ka16FN$qaXYgy>>a%>GM z;Q*O2Tktv9?>1#?|Jkm6aB3(uj%sFZX12Dzg~~7t4S^VNr^x=L?HCl$#qHY;6)U!>EK%tL5WD^C8&B)D z;28yae>G;*V2@2@j*OHEP7VKi zR=8`%0-_8L1MX|vIk05AhH^9m{Q<+nbY5V-y0px*-J)~t8*rbJ(rbT4cdm2nH1ddJ zcAT9~esm4(b#M@5WMsJGjnuRlGYn^?wONjA-xRyR z3DAb(8M;UQlDO+D2Mo{h*7c)ow8bE}>4Y|aQ~)+!gZ{^4b*{*~Nm$T-Ix`Nd^LU|) zyKs)5zSA5x)FMSWniI$y8YLn-5!q!(8{&Ox!qc#=EEHgSzw_pCb-e~UW^XZJGnNCY zQG@4VoETqhQf?A8wp(~9TS~4pZ+Ft73T^(r+trJ(JY%~&`D`UkN1M_q$r zNr-mdmeQr6xnDvYGEGbSsAIl~4eo}kO=68n4KybV29kAFIvTW@Oj_qoLPW2Phg@q! z8am#H;Z8`958C~h9Mqro{riTK!$~F6FWSC58Onfg=SKBic(Zqflc%ljDO9SOU}F~>GwpKNeNdCiziP5Ty(q19AcO|?1WzRYLQ;%n@T7Bm~r{$$}X|P04^6_ zGO%RJ!6VUuHgIzlVCREg%e>pVnfWTT^@wyCDLc#FjptpBvo~I@_QxYq_WQX@QTePN zwNIf{iA|Mr-Y~LPDZ7WI7x)6?7h(1_3+8lQn%C2wuGg>5uhb$c93^MFfCLAIerX@S z7tNpacCCpj;DH;qC4RUZqo(0b?!}=BKTu*oEs6#zz&a_p={}54LTrB-njNj(uQP89 z{hpk{+$-SSQ#sp05nLNaiSWWfA(Y9HEu5AegOv@IcvPeZrx>j^nQJL2&#j0 zi>IP4u-62_{$+hehmMKZ(*UIzWPkeE4c{fJ5I%bU11bHX;Rs0xVhg3seOBFeuGiNLKefx!-Ydv(N3d|j(Lt z?7)X4q!|XT)3NFx@c?&hm%RPe$k5keJMKP09#wELl=K?s#WFfGP1e8767s57K?#AA#LjNWM++e5q>pO{$j?_`0&>0<834kL2g@tu5V8ucEC zM6(}7qrS2xJ^AbT)wYKh9q9`!Z=;{6X%1bID`Si_R8#8hH~U!pCXuS80RbBLDiiQzNpZC_ zpc12@Y9=R-l>$iom!u7OHpDigWtvV{1;$=UTlM2zB8He@d-L$yr_`tFdy=)Tqy1*c z>gF6>o4hTg&g3}FFsei+os+&Ft3upyB4R6^4D6PIyevG|9TyyfG+|$WP`Lx5hNQh5 zEDu8IKxHoA@b{oaRRRHwF?@|eCrNF~ChYF+)=5$D8iK>0Ck z;Jw}O@oeo(qjy`;(KoZdUV7X5{B&{}B-U`9XOMV6i`tKSK;}x?8vNYpxueY$h#mH0 zhIWBGq}{nyx)_?!$I*@q2{g%;Eu+QPdpos*LS{W?kv5b9&4!|;CXEsjZFKg!6HKU9 zx;`jrbjlN1VL;8QdlmL~a-KH8C+sFWza}tVn{(mDGL1 zDzxYbuztRy2eT5y&_?KqX}vi@byo@s3w1O2m%6sss9U*(b5U693q%xg+P%@RbHH}5 z3;qiL(06a9d#y|@z?;+D~9)^?J&1-!EltN=d<}0YG%=VmoT%o~QLJQ54 zAmmXI%H1a=K*2s|v(u1%tEcnk+dF~ec@W*Qs=8+9aYv7oq67+=DHS1=ECk=*soqo; zqCK(#;|%QqZn8NX2K0zD;l3%ii{SnB?x!l{jFMx`JQpJ~gg7+)Ecd4Y0uRWdQ#~ne z#O?YwU0okkDA#3USY$Pz{XgX>7xrVs!YM(<)}JFg1l~R$8#&`U7j8Hd&NOh!*;CL)78Y{_Kggn3!&pj zL{B6amrA^2`<7|BL#MUH#&mAkK^q#5kfowLVP#TFp-(o^wXh;JR&;f-?+d4QWmzY)Mcr`SrhUY) zSNLSmmTZ#y`-E<_*=C{A$gr627tG8=6*d?RX_+Fl9gp)*``{7gB3S z5Xn;e9$VR)M?n^vXO~3FJcmY}yE{j{9|s5e`qE#2ZuGTJI)p{PQ{LK$p>t@O(cdDs zHP$`7POt;qXSiKw_)biEwXlt6NK)zw4EQeL%&@c!mKB|w3k71g$bo_zQ) z9?WKKZizZbouEKJRZQT)bNH+oBLuAU+O-X4ZFpZQ)8s3w+4n21WP@mXglWBcs!8nwc( zPe)EVFiMOH!?^jF?i9C18rWWtAj!JrX<~n&)V6D>?&^R3;X3cUZG8|a8MzuvBD$5k zzdhZ0l$@JKNnQ88nhqTHQA=}6leMRm=-lVd&M3eU7=BIC4lEfYBl{!qA2F#htEs770t9?Z8oT%G{`^-Rv0WO6F9E({ zea}1NV8!l4X#47G8E7_ts~bxV+4lK8vh;|&Wa)^d>J!yb#8B~t1xM>l*U6Pyj=z%j zF81I0N~qVJfi4G_97@dqeX8dRN`YbHxVf!RRMah4ZEVMVd$6oJvY4VuVG7s1nACk+ zOY+wB49|Z)F3W6syYj^c7x;pc^ya+K<2%J|p-me;wnJ3?96|`kpFl)Z@%s;3ThE={ zB6}@Yju%)`Pm60AF4yY<*3-vzD9bJjAU>@Y!~$?t>w74zCkdzl3PuBFw5_7dcij1- zqS<+cC{**BM(sLwjgwC|6iCYK#SscT|C#mKJv`NuX3OK$F1RpLOc?yVMt+IetZaD zvvf9DA?Qxibd||mZmP#>f!>YaB>E`bM*ts_u9&B&{I-?Gv$VAIE;zFcp7$j9KcCpT zbXsNg^+d9QtdL}44MJJV0;I;HscZIS-Kn8;nU+h@;LB-hk`mSDGiP{UQ@>Z4LAuXglKg%lzn|PKsX2=bLsaC*}#{ z-$_X)1NqpM+b;Zv355;_Z-nb1?)d71@6pG7$JJ zLo6s&j8No#+xqhC@9VCZ2jX1eL-9S1;{S-`>D)%E?s3dt2Q zk2qv{V#vXeBCC-HX;^Ptt8=pcrl$8(EwzfUZeC|r&8phqFoxXnT#5GT53>Gzh({+q32sDHrybQ3|}xve%Ybe6L*+oal4 zYFEvpJtIpEAF!pyKBcB)0k+vC?>WL;ZH=I~U)5B}-G znRdgpPsOM&yfyWlyRJT4!*3Zm_c9?XKIh<@*%w&@G51>j5r+bfncl%LpW1w^AR7mf4b+3Ovdk~0`LO-PPSubIHa84 zBi22lObu%g9XoHIPtD+~Lgmm&S_Lj%@&PYoo(Wm;03ha!j&#Y#m6ZJ!pF{D=|CvSK z$<&rf(mV3%zkE#V%hPu=y~JP^rc>cJ<#)i_Eg6VF2ayHTHoYeYOcIL%pd-* zeLrz}Nu(c*&R}FXl-`$r)2wGF$9&)nyj9P=3VUDVaR~A8-|!hm;#@cPVA#V*p#NV> z^UnvdhHCRLJmP!(g!Qm^#D7_*JEee8MM^QqDF-qarTsk{4SWPZ*8~RzQBJcup7?vV zK`xMaiyZ7=8M1xqk?Q~1wExXA|9^5Me2k%1GQ5NIaE6J~hk;Z67n6}|_y_|A_{ zL*uDQKs5b4@DA}1?DOB5i*}3A($|0YABXWxtlth? zcd*$p&lA?1e}}aKFDW1(5Q|?D|MWNgJkzlU|B=*(sk|xqdyX7K=#@{hTxxg! zp{>jRbh>~jck1oUdGGqEC9|@BFn@<{Tyji|2M+KwtNq^xH@dpIF2TB^Y#1cP{+<(l zOBjI1g5(n?U)>6IWBpq;gRhyf-b#j<;m{;<|Ia1t5HRN^2UIr+>8wt0>CpHg}pU{?Kb#bKAwz@;)C(iAPWk+TXz+e|stZZ*d2^ zkq7C=nCww}_t)ZLx6Q&-57}dM-`&rQAhf{(9uFh$m6A}*|$jgz#b2{|qa;JYx z;u}E60-O4-ldt?82P5?8t$)OfOUkommEI&wAIsez!TG(~37LW(Ul)so{+@#($oTfA z@D{)kwAm{KsYC*LPacVB53oqJMT6_`dJ1kgcm-f}Cvk5MNjCPcMtDZ-6B3)HL6sFb z5*=UR8h|u{=YJOK$QKi^PNTVj(L$@r?(2`caA44BXa3(6g`0u_!4^F*<)DiZL%e?r zBEP_J;5)W1y9rpF`{isIO`oDb1;k=98n^QhM zK7VOZJ+35u`0znEon{oHoE@pacQ`-)FP@4uMZ2Y_$hBWh=wtMOmUjRrt+)POK>d8= zBo3_EKR0D}VeLgzI#!8qZuRruauDz2YLoA#?ly%j{z?Qiq$#@%Y^k#d<^6`$I!d2} z*adC^ws+lTxEi{^;juJfsE*Ab3<@IBkLx8Bf0)FFh(3C0J|}G@2Pa&E{fmn}q}Tl# z0bpJ6?eBc+$0OO(c{w{qaegWJ`nEIB#^yT9us;Z+=ux-ljepC-U%onVkX6Yf@&7-x zy=7Qi-M2joloogQ0>vGQYm2o=DH7b>Ed-ZRio3hJLxA8?+}&LY6nB^W^PY3>$9vCn z?)|-a_O~pO?6sL=jwx$QrxC#&SoaLca5BP^O1eL@r0BZDn2z|r@~xaAh$^=R9AIP&9nhKt>pamy$yu4>g<0y`r&`1gO+(N(7@}&aFny zDEZ$OS#ChMQCG6VqWUqWrs{c7*;Z(m(`ba2pO&;j_a49@mt}*1d6^Dt??2_mQdO>= z36be|%IbjapRqb~`j%04l}9FCD@B&JE4Yy-H&e^xgvCxMXI)|yW#9$Hl*!+A8! z!)5s`wzg`?N+HXaU%9G`trK?p(Yr&Qsz>&sk~GuXb;#2B2a@*Kl(X9nBA0+uadV9X z?=~q`{N%}MVn={x%Bj_(S%HVd={_0?#5~mW(W%5d;InWptrveaZ6P6JyPn&{*+t^? zy2{)_Ue|hv`Gllbz1ej6XH zUgpn89i5fD%68G^6=9mWVnu3BO@xCDDk~P25m^td+oR;S-o+AUTuZ|IDo{IaP4!2* z|B!1Fa74V+3Q|mRiXTDH$IOt{Lh)}WU^JyRJt3ovx@C)&9e~Vvt&C;qC4WY&>#Dwy!mTbPz!Fgtz@_xUeVduOfs1qufMq<}tSE7j-5O^|S5B zXB;=vmHs3}oK!uFRCQk7z4@`QfTKNg_*hWp9PJ@cM_!(F4a}={{Q@AJiXGsMF{==Adn-H%h_Jx2Hr*wZklHj&0&ac)74Ri>o-sHWe2LWkS3SmarH! zmA$PhJRj;W+N-isGpGImFag8OmJPGM=R{Lf|Bsmjxdg&)Hxx6c)r#qbXi)#h?O3$z zO3PE8$rYYx)r5vp1Thag+7=mEGx%^2peFl=g~qv=^5pjLeY-nFrLv`6#Am`&fMr#* zX^C1LtudUoOzr#ow~m#Z>0YONsJvsc;06zjbMpfTLJ zud9X2476lnm$2V&szdlUzC1+tHv2L{g4>p(UPdREZY3?h)W_>%Bfn|(P2c;zf2`@@ z)(iZ;3)UU{Sd)_S76H?(r@m-+MPr~xhblU8$-?666{Q9j-y!(CW%=^3guOg$7(84u znvC_t3#u}z5s%lCmnB4AT8EjVq86j6$wyP_uodmPt~OQ)E;~27d%e-pOoH%!j*tNG z#1>RUs$#K)jdH1;3GH=dUOJ@%r9Y}Ptd@X4IX>_ZkeJ+;!g^gUvT~_c8Dw8fOIF5) zHe@pnDT3TC#s(71H$r55fT~;-oNo!&8_X365e3)@zop>=2P`@XB>CIhDVg5Bo-9tO zp}!uje?`?PuV9RZRZ|&9jz@+sV^#8GyVF{#dtL`|zdY}BR}C( z8O`<~mA+nF$TdYpW%q($bxX*949Y#x@WJTdOIXmbz4dLB)Brc3O-^@{rwuv9Qb3 zVyUG9R%FLTX>I~o-}-Z-`M_B?HAqQk4{X>tq^!ZmWIfDa&O=B$_YiS%$Rk+j*jq0> zYvvmtKBkJj8U-1fn(Sag`>~>luBkvBzQbJ9Kbl5sq5|8{)vWOG&CVx&Q8n~3d9<%b z>Ds(Z>1PEgBIe#nh0Wr5YheYn+kQhf3WP#wkXnKUJs>;2kg>_t{BipV{!uy~K>&ej z({_DXsnc~qPp}!b;UkMlALOygPmfSHC2+qmu7jXxcDBEutSm)SwcX(7YZxBWMd!7t zI_xl@65Gdvs1Y4mBKDc$(0Dx$qU3*oDX%Q=G|m2= z-Ytj4T7X)Q5s0OoO*l+;q`zBof8aU5JkeUL;^?17xp9a22&Nv-tT*D#tVtDCHuY6a zOw0Ttk1lt+CV952tE{FRXv!nWSkEzDD<-l!gx{LDmm4cXEN$3tRrq#mN>ZMF4g$<_ z{d+ds?X0V#30BUcFvdo*vkcVwe+?gK8O~obh8vkSH{KZ%>n~8+DQ{e=5hC2H)yv8| z1*!Iv;{$D!Ldw9kJ;wbj+GN|~7@gsT7)y2OiNoGzqZL_{JY~hw(jB&%qZbmXQZ}+) z6?VErHl*2MNm0ew!4DwdP65-vKZxC9oVuc|y8N$2LC@b&fu}{XfTdcT!BxtP9mP4&3=ZDY_gU@jPQ%#FMx;kKkD_SA$551vy;n~9bU{)=# zsX>&C-k_iJzl{a*WD%hJO#hJJS1hfq2 zwrcO-2vMhQXWOS*j{8GcKeEVw4g;eEOZj#`$K8Ph0Is%Lq$fIAgL|2Q8>aBq1Xtgp zFAoOHNPavqwDE*1M}Q)u`@tt}(q6f2m(7XE;>4Ql})c`XY zCNfCEixk$C9JXiVSEd&6bsFuWKi{q-kNC12T9=#H>W%%V(y>4cQpxEslKp7waO_z$ z5M3Ubc3761@w4_nWL=1uoc=F3a4DCD!?Gs#27cL@dV$cw4i!#A>#I1P?3sVj%Ki^WbVUyOW@D=atezqov?h@5Be!=Z>g%6EPZ z2V-|Vi`}ekgsa|f;N3L#wG_$L$pg6{#&QZ}B8+HWWF$#1dS{OI*$6~OFr+L>+Ew0Y z;mWdXVk2GV>!UVNqPU!Fyw+X4oTa!JgfN7eD3zz-ULEfBr+D`{(u?HmCH*8U1}KG^ zVXzqc%=9;bUNUQIJyb>99)~2MBBr^(PQ7L!m`mg$6E?P1v>PQ8XJ;N6#$>!2?<@G; zQ`*o_YkLG)tTFLgZLtQ}jw0M=!P=U+ysPWhzWdm(&13Qy9JqKE@@h7u1edpZ_ z3jvwmJ)fM0;UlChkHazoZi<;y*8Y%wOW$kRsai9S;qq{BsjO6o{rvX{!;2^wc!P_Iczom@f6HCLdmm6j?xe!2?CF@hxNP$I_Q;^p?oJwID4SqIeqAa`z zE)yh>_87&=&Q>LV|1CsOIkr5?Y7Px7QQPu~LUsQI&vN)Dbe>m?vNEv0ge@CCyg!@L zSi($Vh>in4V8$mutx2$mI7nZgTVP6yDxBKd{ zlSPSm15l?Yr*S)VTSWNyPD2haF9lqt3y9O-Zbw2?vIRvTR;nK%4SjzO(*%I8pOpCr zF;BFRmy6g|@){c0hKE(YEH{-@FjCoF&kKhkYJ)9&fVZQ?7M(O+^9*Ja-k!JrxsDJo z<{HKH0}+c{_BaNLqt)}e!eDK(Gfazt0(;BvYr~VLN|O<)V1#XZ0ALw)OmE8%U}j>X zK34Y&ia~fQcAbtu5`IVDD$7*D^_H*5i(j*_G{2DL*}=ANQNVipzgOvBr~f3*?qDzp zhmVAgRNB zkDuqAzq{wpYH+^5Cm(1jO?abrD4cbbDDYD8x%Eo@rvUy|>U|*k-pI)CZ9yfUAAU(m zi6Iv*A}Yny^WSKiz@5UHwo8%$*#tn;uhg>#^17P>P~lA>sOUxpa_Y|>$k>~|V$c3= zxbVD_WG*~U#O;R`=Er566TPJ$xc+> zgkfO3-Li@Sx`g~E%Xo&UaB!wd!QG9Zv#9?Tb4=FF^{6-=0%L?%(>lu)&HpBTzlR=Y zBDoU)*84ZE*Zn^t_+xvbP*{Z_ghe(ORhL&C5o(C90Twz=56fC14aR2#I0bSX(>5hv ze_a2%ULSFL1*Q(Q^Yg#ZBYHqZk_Caw84sDMLzx~~4|=%o;s2-0{~bb91E>MRMxo)0 z;nln-OhaKoY{iYDrsV8w->!B%aYjlaw4@AIBe&{3iQ~mD;5j4X+^!X=0)vD4NixLG zTHMUftTXp^L!UaYW~XoE`}ah6A|x!tR@okuPL-tFpYs@xv4U~Uaf0#9@q+cm*NTtJ z-p)wVBj37vhA0AF-gWiGaxd9)-tYy#2hsvR0O^4AK#PqX_FEoNo=)FQ8K{4w^wqBY zZNBEO9Lsl=x|E1`s0?)Gu;#gYgZTqEVe}*3MgueFsIl-?>9&sEBp9;6ODVcVj+@_~ z3+KWK9^Q!s;8c4$OK?Opf_~p8)*ZjEKAqLwliOo$KhEI`ULW^Iz9qIyrlw>P2x`jP zS$5)@_}kn$8Vn~<b@M}o3#0#k;J9(naYT)My zS!MDe7iURsZJxS$BF#$cB)=rQB*wfDVNyhfEgOGGJXKi}$O-lmZxV)hQ@XgM+d3E^ za9eP@aR+lJa~D%Y<{jc54=&{oLG!$6wsyAXrr4J{!jT7chcZGZRrkK{f%BEs0D%-F zBxSNWoqllMx=bhyeQHg}R9hd~e(A{OtFQSJ5hhx%4Z2}#(21DDbS)`JjKjYOw`Pzz zM{$1nf%vann+goyb#$V)e+p$NRDIGm#&G62`d%>4GMs23g8~nF$UZJEpi>$yh?-60 zn^u2}=^*~^=USZoBOcr!Boq=EG{x#qGDA=L8ApdenvVuVT%0Z;TBbT*?m*Mvc#Wx? zT{9z^!@kyWTz%Yl+}RIL@z4#3PMBURsjOE1+4%lm=F!Crn9e=8 zr-}OCd-*h!4pC9l4SIa^bZ=_?JtN6#sH{)$j;^lLX(9JY zmjw!koZmS7m)lHA#zK+EDYo!Go?m0`eYDeCacF>ce{!$qzbsRURZXox&mBj0Ihk3v zU#v>UjuA{~P7q9Nwu6r7;`6x+91?)agWI)M#G9h$khwlpnYfc#AryVZTE_5@mLeN>Wc*X!H1;EOd~^h2xmV z)(%R5v_-AEO!@#QWh@&H!qeS4liM=Wc>pau{)2Mdjobrvk$C)~wRu>v8$0iORyXB>RbSnu!c$t~YzXJdm54ig~ zCrk5t6-o#eZ{Y>`|B%N5rN>ENTlVibW*0Yv!amTT!U5?+_V{P10I=c`U%-E%Y0eSy z?EHKfT+Al~3o-w+ikIbY0uM4755gwFqLYGub(g1ihc4YKTRLqVb<%22NhBn)RehpX zxc}q_(x;l1AVr5m9FN99H_fyA5H^#@k&&6QU{8;RLXx8_njGMaAmpurR_gRk`T1pm zSSQ=6=G`%EPB!hMAn+DvlQRC_x!VjT#4xnOm~@ZF58hxqk~ja?o!TG^2ncjd{r1P~ zs`@=v=|brLIE*m6OSRDAc73v@l`fe7I46iqv^BF_Qo<(l36NHH3YX0z0jCMjd;7qt zE!*|67!yXebQ}4?%H(6T)2gCSk$$;fTifx>AMGKUvYUM~vp0*aJ^`-rEb4Q}xlapI?UDmNdHb39;%lk5lT^V&VREL3k9T*^kH& z{@3v3Zxo_bTXMF~n~1`7*@+1ZbS60kn2 zt%HLp+nL@!E+6O%sXKF!=XiO~@2-A#!N-OiS++0z&K#G$SNv?Frj{FLEgN?eo1ru#)Xui8D)lz2`dnC&$CR0^o!s8 z)mA(0ChOT;zP$BYt(a$@d~3g23;Hf<-Z#Ye9jpeQg6&e}Q)eo|bO*_!QHiI^a_I>1 zt#4pnvUDfc1b3?)W#GG}cG(TTkSCnCsHhHa5KnuYBcAV)Ss`{7t=En19DAZh${#<< zQ+vnb_fP6}lNBo7pYy1pWRv2!Q-@DVZs7^D=%yK?d_la<_l8Za&7?;ZioI0wvmI~b z!~^o4eorhEkLeBHjlBupX>eWF&mtCbmneT^WM=;zD-eI&1i+|ZzQIKMr1^J=&M^@v zHJ%7_I5(v~8#S&rt}0`k68{~h^8-;RQ^LyWE(Ura%#16c9Pa+$GRly9H$eAaz#}n( zn7~%qaWHM*^z}zJ2N`v5D@=Uw@=!`=eWEFvLHyL9?he| zsme_^*deuKz3yzM12$;HwsQEg{MMidZ-0=6-a;9G%&>Jk|EvpB@I!mEwzkr_rOHNR zzZ`P^A>REubmR550tasR6ipll4s`;yj)hqfLBh@L>FbG*Na$KgKy@b+%)E+GTiQJE?kYMEau9;1?GhQWvK7@>#FKn9Nap&>4}JBn_P8SrcSwPsQ-D6 z&&wz;_vI0V%Uwl|E?|LZ%LtbqI*&ebzL~-EdDEh75f7kJEp^48Joyy<0 zB9^#YD;SsD$rotAZ@5W>c|P?y^34PCNtK%!eKF6)4<_h)R3@NuS(Q#$S&NWqVp0jA z#E)WG`8ln1boea>^1xz8JEp^LI2cb?6(7of?>j!sbUf0f_=Q^WPx;%64Q`Ss>e59#~LOuw4V~GCgFMmxt41 z*0YPREV!LU+5LL|su}l)se$Mxz9C~~p)DhI_I(xhFV7eJvc<5G)7JgLrTt>L`Mj>S zQ`8G_+4jk1B0-p3&_W81aX+Rt{ixa}x!LbuigqpF{PWs`)YA0v&XV#IYlnz^-dMrr zs^ay2f;q2@3q*1XiY-=XD9X8QIGi|@lAhLdbkaL7s^-l3Wo}r zM6A^+rJBHiBct&H+-T)Ty`46_@r<23VeYeWlelIA_?%IYJVIC7rL&!ex2|r>6?-O^ z30@Qp9DWrW3Y?9#0=J3=(LRqkw8c3`*!6^mkKa*3yq69(#(Cp(wVjH`LVzO0^T3tQyJ=F z(ly{Nt`YAJbA2AAWxD!O&;0lUswJgU8GtbvX;n8kgwts4J(AjM2HKBb3(Qh7KKghD z(|l;MC|_}v?pwCbdEZ#Y!Z(*|H%Q#>O}-`7ClxFJ=K2Sc7HKv&H-FSU>h%7Nm5j-Z z5B}MWtK)6E7y7L9ffYCI)vE7ZzWyhp+F0{E8|2xTD$E$-h48GdLm)1rbI(z$5 z;*YikNmVM$J3X~tvDwK1I41P(OJsLA0{Q|21)BVbxFICr=H#^%aRhjJ};y3;y}7$~rpvmBC1N(mxz>k|3)C5)IKl4bRsVYQ2{f_9F74o->@FEX?MUKZx&sOeZRtPX+P9B5)$Vt*;fqkj7!LCV_IGtaPT9#cXw%_FRCw%)*M6;g|Bgp!ZI9ZPy59$Hx zP&Z^f%7U47;sR98O0t+qn3lb~(-Ho|w)T5I3nG<7Fm zcKsf?7))-?4oa%Clt)}{*u9G4K1Gr6A4ZV!BgrFVe$A<cgYwouyAEQhQ{GLOL$GPlR#5%(ta%3tLrflj$`5IPw>Eqx7YQl75grL|q9 zECUzOvQVWJT}w@3{ijVE@8?q+&06n%m`2kJe}+I& zfy>Q}CjmMsH%wyn-04@)O42$BfllgV^WTg#8+CmfTumY2CSHw7fZ5Q&z{PZ{k~L@| zQvi>Dyd4SpvdKg)>N>e-P@ASi%=@;p_4U&UDSmnH4=Ud$p-WU}HVBv9^Fx!->DlI% zx!^$Ov33BJ->Y`H*G)#c)4{`nWrxwn0ml=98J{LPIVZWB{)G;qCbXejE#JF>9)wW)_V2zz27i+{i)IMgei=b7KOt^Ebbl?2*)YP zPn2Du8@kveP?`DQr=OK2;I}HETMn_PmgNwV%`AVot(f6$XwXhlB?326O1kN7)aqnf z6dSLmY^6D}Gk$-m1a#)aR(@HD?!r?XU@cIci>X(eE3F<2+PQ`yEk3Ww%%))#DBUlpaBX;b6^p-d}UU76FC;i!{ z%>?Z17_VV_%b*2t0~y5dK`#EI!~9!SF4(BAQ=wTNPu+%w8%Ci3#2?)1*%C;*7PZM% zxi&>-`BBx{uD^p_R~(&`QGB&+<5cB}_Z29k2Yr2x=lwK5F05OK zkn*EsN}{o5IQZeY$@LPIZ@3H$vNulF4n(#Ks0iY$nqgs=&XwlwXPgS+cNyyS+yFsr zFXFoKPTan}!r>mApP60@T>}fPb0=C;uZS7Dzi?1Cm&o&FF+Z7Ztbb9Elm7IH>vE2$ zf)sQCAxLjL{r#)9ry=j86;o5~;%?`+Eqno*`k~U$5V-ZJ3zId$9pXpj5FSoF!|fWm$r(7OIpUBsdGOyJtm z?>W{5u_3Gu9|Boi)(N8KQ`@yVoZq6|oSutP&u2UP-?acsmH}jL70Rp`PAdRETeW#L z8DC{e{WNF_^-jF!_&AMTnZ~{=M%k=uQZ*FEBT(@E1;g*82C+f^=IJzp-Boti2J{@^vsX|$f1{Unwu#Sp`ijIu=}!0Dkk~+Ajr(zB zvEv8(&OURB1Z?o@>_(nI-@!p+Rxi-+f#35Ey{=?oIWZvuzwLQQzC@Yk%h8vwqEQU` ze&|>m4{i~Zvy0O&50}*!?QwW%!4LP~y6N*r2B?2sK3-A30$+7eA(|ZOMZxmGYR0IH zSzK(g7p_w01EPKFCA+TB4NbU^Uf+Os{^RN)&`#exlUTli{MdC;1+ddx|RjGVmYMfTbe5lWIv5Je%1z@p(WJH6s| z(nDZWM@FhuXd1Nvy=OTVcbqNjt#1K6QKIEmkI6CfY`NpE`<`9NAbcw2;1{p%e2K+H z(O*})VjoI2DAfdeSN1_hp@f;E!+o;ZKE=L9y*ONlPqmppP9SZm}>5iUa= zPfSLZC4TTNs_fY$Wa!XzRP5Da{`xE=6c&M4Y2CbGy;Ui;bf+!6S51QM{t#5ug zZ2s372@{-dqdm{*(@uWBFHG9C9+uS*p!@Rju-~BBXwDNw9wlpUPZ^d#Jf$qih4B1> zO}>h^uRs!w#x6iq$B_o_wu^7L7~Ca!CmuB#zoczJWWZggh|r~!xT^k4gbSll_fA_X zt1crVcA3yjN(2jt&|=gm!m~HFp`;6JKzPteD_JkMmY~NyECfi`p(G}-;>k|U{F2ju z-G>uJ<0kwP8y_EVx8ePEUqPIxZf|t17j&e~*4NKl{_#ThcwO}VieiB|_B)1s`;&eI zDG$z)R_bECD;-~@PX3UiN?cscr-oFgEi;M@>VG1FR7xA8xCa5j#pK;vpLAHE$6ubK ziPR)GJD_4!!FI6&7is6#DqpMqknFk5imR>N+i9R}Ws;$SY8wyBXeSB~BJLzrUgx_q zkpf&#M8>qvM7GR76ldcB*a+7^v313+gwPN9$)gUx9SnxPNrBFdFfxt~+_tB~s0n2R zv4BkZ(^+SG{@CyED`?bc1v=YV)zRzE-^#QN^^L{Rkos4Lrw)VJXLXa9<@vux@z}Yy zxhf}hlt&N_{y#A1s^4_|Y{O|hsd}ffqX60W7WOt0SCN_fq!{_eMwlCz^F20%vFL@0A~7s(oKk*S)V5 z-COvPrSN#-E_#L?6kf`9%*H*EZBCG6F?*-}xIb4cJFG&qg}>=Tp8*P#g36&_($N`$ zvW?<(kNy~_beU@%{aAm>CoX|3S8}pj+iu7jnX-mX01Uj*^7DdeX_~g)<(A*41kCKK zXWLwHAGrWB5B6l10xH9gJM{Gdf%{_6RoLg;90VNX8*|co!l{74wEz@V{*$GAl&AH_ zEu?!-4--vOgY+F(-nIYEMm=BF@O@DPvH#!m^%8X;_MfBjxoR~mnmuU!Vhl8r{3G&y zBxxYd%Ts<%0iq5jWp^~$d_Dw1Sl;Nc@JKw{YP4=^Ur&$77Z9Lqm z)tLgdn?5}(wzcl~;=hb5z}!2d>fKyg6h<~)6kF*%a+hIxar0|ONw_92C01YHK?S{8 zH5s2nBkW57cCwM=VIk+=$s@(1P7b$Lszi}C_vY)70l(a{eQUd7pf|{IEsiR4kdYDxfNNxSB5767@?XQFuZwROZG4rKdH-A<=ov3FVokS20H13s z+3O9QYy0ALAdmP{UZVn#;c89Z`DDcOdN$$4cbH#E@+g>}Eoo;r@%aA-FP-q-0RL|- zfd8k@vA}|fl<^Z^ihEGqwv;EpaXw~1(BS4$e4d2| zK!)g}Q^y8DZdUHLfsxWL(SQZ==nNN=4za6G~2 zqxgQTPY;e8tMX7cDS8t`8`gj!dC1+8(#1pq08Zyskt;Xgs)s(Gy~@5Cg#-mfkym~# zoUhg{#VC{-DtLWf7~nj4Mr*hU8GI$?G2&ho6}`BM>(gGpFu6!EiuOPHNuR081Dg-9 zU|EP+Q|;8;mPgM2vd2D}VT?<9dqfe5LR-B3d;e6;xZ3t4+5-S0$Z9(?qq|3&>&ITM z<1dP6hf`{MwxGL|pE~?doNd_v=eY2@vOjCxYp3V@4uf+(7$fBOnS(d-Kk-(#NT>#>Y<`L97}kzx~^o9>H+$@*A1J`s`e58U5$#SB~P z>Cks10hA`eN-~bojv}|&;#OJ=D%%Y&pvnDj)s_Q=VgrvE{0kt(_OJIvjaF6mHp8g$ zXj7dVh6M$5+rL*{!={EAs|FI3_uuQLc2l{p_inqZ=(cLXPHk_Gd)#erGZkGaKyXUY z|LjMm20a(tzdvbqpE-+7#%E)c^+`7=Q z_a9-pFwBX@l99d%W+kf9DGq(me|=bC`NX)#OWpCeP{rE9saI`C{C%A$BqSUG;81DOi32<(}#HQ5G zpze%dxxTQFW!`9M7zE+7;c@sKJz}?)ZZWQG@W|0|E^<}z?d#d9UEGk|KF#S2t;k1W z>S1J#`OBZ(#=T6xw{rdl0Rhzv13|}~l<^Fazi$?FtE!o~)-)A}g;6=$o*{=U1A~p< zY?r}rJRSnIA?4+0F!6Nr>th^CH9Tr#%P-aDyY4c(-{efqc8$-w7za5*GWdOPO{1=3 zf+JoYWi~gr1jgps<2tV+Sg$d$rphf5dY*sM*y)*u%s&a__!;~P4z!Gu$d3hVTdq1u z#tA6D3=c&d>=W~Px3xQ-0FtfM{bK2Z0&jmtNV>azxCY9I2MbLTt;GEcW#wm26#V1_ zm2$zrhEj;5m)X3%KYI1cN;F1WIh0UJ(%_{9!l)vDWW3i|5 zJqCrx2j+yAJkeDy!A1D*?Mhi*=F}zabk3ShM%sgK4Y&U?@rg2cPCUHzAiNY8?W~k1 zsf$=EHL3P(1l3WLLGR5P&g@vRvRwX>K*a)G!NMcNa6?8 zJFHL{5C|hKUM04&%HUD>_2Oc{X{*AdwG2+M?3 zJ(4Z7x-A}%AUBrb!~lD>UN;XI(3%(NIZc+OD%P4RzoO&}LJKeh5Px;ibv8q$s9w<6 z+t6F!CZ&D0GK5TAX*Ep-l=u*Tq#l$;4o}RYYY=ISrhFVQzIR?r<$FbdrELQBF$%uY{Ipf_aN^YcFGY=}QpP3R@X6kqfb znko5>apzyapKYj;@g0z11qGY)k*dhYqVsbKKb}8+GDUJQ?6H#*DLu8Fy?Adonh&E) z4t2ss<%w3lPt@^W_4@CES*key`0eeqTR*xSi#Q{erlvAhmvmJ%2`EtVjkblwX7TTM zr(cSfPVx&7-x=89e&Pe!ej^o{#fGCV7~)OyyTfQIhKWrmhc3R6ZKxb+09}{7gU>M? zep5#`vS7UnD{Is-!QpcmrpAqZ9mLoL0NJ4WUpvCVNgLih0QZaCBB=c!hx1>j2Ra_M zKYpIC1s7b4jZkb%M){Y6kd#=-_f(*RT9T+JOc=@G}GzL{Cyyz2-XT2}F3LOa1??Y3Tp`ul%dt*H~n0 zb-C}m{qpQw(1mmV`vt*QvmOCfVj;8hR2b#d6}+Rlf8b+7z<1if3QwY?t#f+X)c@3U z-+e{>k}|MeSpI&6HyTgwM{%@Icaw6{!z0URc*FG}sMBz6S&@;6sg4oo?A%>(Rcw4a zYL#|BnKx-`wx9Jz^Lj?^4-tOqZrL4_z{{%>sH>*?^G@s$l>wK{hyB&JBj&DWp!}}j z#!(&Iuhel*Nn@SR1Xb#kh7KaPFB&)NQ1E2?HGCh5cfz7v+{jz?EX?b6@z&aJv}kWC zz#i!Hz!9t&+VjFnU#D;YePr4GRDKiuaarz*rUNYpC*xod;`oAi zSBYw=l)5X*+9hpU zC%A0-OJl;{4GZjB^2I7ZI&{vD3wT*5Cm_%oi(FIatF6QS^ml81vBZ9DUHes|{Evg+ zTP*?NP9m*l4A-6Q>F#d?aKq&CgBgwZu)f5S(&~mMC!_Gbp=aA;!4@n*L6ZKOF#Paa zWH?!LVxw!LOk#c)^u6q1g>QmUhiTTJ8Ou_VM#}6jTbE&%P9H89Zz+~cT(mTo9cev2 zFkvPV6p^V?ZNOb)a4j824j1FXi}||W&m&dVvCUUDatwrtKBPD(h*O$Y>7ZJGBFmNE z`8@1RrZ$?vh(j8eLI2L#ey}fflEL+jlTg{wYsp_rML0%}r)a81n004K(moxIrRizc zaVU3&#H4uO+T48{ZT+)Ed+lJ@-B1|gG~~o+QTm z3Vja^&23Y4cCp7};T!#GT4NbL*ZjYB+I6~huSBf6ZPVs}*uWu8sgnB~++WSD&3t7H zmt95640Od?bNphzLXb{y=H}+yPsXqsUK&rUB?NzoJs88AGEIL*G2){)7npZgEvh^N zJWVCQeILHI9CI%}DTtqHY`!}`rGNWlyHvo@v{S#KN8avOv6W$E7W(^*Yz#=w`Cw-! z(g{z-=499Bb&Ra>pKQ}VUY(k{o%BX0#vlaasPR3AI8{#&X8+J`0?2G=}GVi)izDf7!Wxld*Qg8m4~P$Sx)uCe}Fn~&Bs)H^FpMl3Mt8BXbTd8$&8 zca@=KW!B{K0{%V0J9DoVGy(#|Q&TTo&a;#A9~TZk^is*%9^;srn!f+|5nnPGQ!2q( zJOsU#_M+EhLTeCeoFH|bd8gmcNyll&EF!YXb=IydTy6MV%d)gF~s$7#l`*f`W_f z#J_uJHM^q!&5R`H9gV@?+ubwCY%!lJu^CFR!o#6lzkg&oNjuu@62uIFfX!+sjm4v2JzW3m!XecWI#GPc&=g{0~3o99?UuJmJ;e7bR!4rLb2!n`#?~zs3vDRuxPcrubeL%L^aOFq-C9tHCSK@K>JD+34MzGN z|IUx}jr{Ce`YC!cW-q!SW;07JZsGmo%*qgl%3rK@-PuTo=$v=C)%vmqL5TdmT{+31 zIc@_{&c$YnPWo&4iNVkI`u;5)sZo7J{oi#}tabBusZGBd2IMTXC|dG75mORIl*SJj z#dBZ7+{&OVrT29t@DLmC8LpkxA!06DY??XOIF;!d zSh3)Q_N>D1aTg(i{553ib&sq{3Km?bSFIbiA$dpPs*R(NxyJo~Sr^#Xw>Q=RmX=p1@pyTXHhfNrC9Zh< zqBt>o3tTwWtk7;HTX{YoX_>zF@jVOA4hH-q!v2udb}L`Pvbz9pEg80v^j5b zKS9%}yNMW<>f^ZOg!w(?eCk2rW7+K}n}=`TqKL_6?lawvWB`dpZrPPrE3LjX4=I;r z_+1l?7f&0qxU(0|!{t-!uVX8z{p*gSsmTqzFVkpHo?p%;i3+waMSb@xl1e)PcVe;+ zOsPd}RP1JGguW3HH<+Cn+5Gx${I1N@T6%VG22c()eJox_LSQ7$n0RmHT={2R#BmSd z?&C1pL}yTAf+M#M%Fd|ddU>{x9qyjrd05;9?-@;u>-1}jjc+)GoREcB2dshOe*4Ic zOFQ!p@_BsdZqF~Qm=fCPT>a72JVFJbt5eLIYEPeQKpn_-cAhIB;tt)B<7_Nc&J^?M zzPK!h_67pS9n|L2A{xs1x95^Kc*|Z-vvkg%!s=|C`#kI=z12ro<^KYo=xj@I0 zdF*9i#wB~^p|sSGXwe6_)0Z9xvPdp~2NQei9~l!b=T!^}F=T#SFaFz-qu9}S$~SYh zwjxjpqM+)Y=I6b(!t?K-VJSFG9Wx?$!(8Kw z(bkD=scq%-bWG*#4)CQ4J^`OMQ>|8Q;OP6#E_0i~F?mc4Mc)JIWKP(V;xR0Kq%sPq;T z0R;gmg47^LZ&E{#iqeZTks9eWAiWb2>Ai*;Y6v}ql0w>>=ia&R+&lA@`OSBJ|0Xk; zIXUO-y*_KNy_PD#TH0Ml5^hCnrXF#KeT+mddl(*!1 zX2LhGi2t4~zxs<247w2fTXRKf$Phe`?)S8DxNd>HD3D6(Tx=)?2XMpZX-ExFZu#JG zJGr$?tSkAYP3(Tym8d^f0-2`=6Yf>^OLw$?HiS$_5B>;=(-YFV^be!Rr^U#>>~sz; zF%h3-pR9JW6B-iC|KZ>RPgJy)l(12NI)}GM4!#5*dM-IxGoAqg{870>Bx)WCoPFwX zqp7KB6%Ss_-%GU=!+5d8?1vju@$Ga`wDsBs?+@9K+B?%p0rYvnBgNbAa@r1r1Ufu@ zr7v{`+#fR=jJWf`2y?-GzDgHUud3yr>Ytfdxmfx+I6cBm3--cC3N?^^fl#A)xT^F)!CLOR!n7kUbw=`wPNmWl(Zttzx0~vRuk2`HhrJC z*pwvRC8LRx){i8lM?FB5G;W1TBIq1F zTihwzH33;POw4dURZ=Ew`j`g~*>c!cCnjcwHEEjL53t=v^_7xKMcfRko6HWNbp3O= zbQcgxsni2Lc)&W+c94IECSutBk&{Z{;t(~DV-rfO3;6Vj+8BUDHSSh3VQ2DalZX4w zXrl#R(g^_%$?k^9ES8}t#Q1otp8YomE*-j7F0D5%rPb`97bAT%PaQ=xwFVA)LC~%S zkhuP{KXlF1id$LwcjMBE9)tzOZ1>F{gPjP2Ro%1(C_+r7ku^&ChFX_EdhO$<{_KN! zDjuYH)e$T2!58;Lp6=wa$YgU~k5Q)L(bw47Bh1`drd$1sii{tiJGEt~d{Wz(Tv&wh z*(o!(c1}OqjokcLsW0^+MlfN^@v$i+u2a;Z(iM;#`2q08oqaxvDa7=<8Gsp8@2Cw z9l-Q}Z_A|oG7Nc_ZH`-0n!{g&Ofvb7(HUh=PH zI{#77BxTTFYb9?zq3MnP{Yu!!c&&lEeH*829P#Z`uYb;ky^`j)>Q+ ze-A8I4)h=L5tnRuIT#A<3T&*P4jG*#Wo2q^mi2lYJ5#?`hA_yEQ&FI^EpvaOg4?9r zO=YWp?y8;^W4{XDvS3gKQKi}&?l2rb*%1;I8QO%`MOLiaC<~^UT}s>e{>SWAzt^N~ z!LyfBLFQ(qi&Rh0NEjoB36VJDo9t+`U$$<$7dgbpyqpK+;MQh5-i!WmX3Pnxdabnm z3L}ZMMpv0riDTjFyn*+=@!nV%=x&cG`U6C0<>Hqu_y&F#{FK^lT^y=9J*73 z?RBnO?=j&}IU4Wu5>5-kA2gORde+}H zps;Fq<%Bq#LF;5iY#9jNeI$3|QjbnCW2BS6Vjn>&Y$nu0lbK+W2jX+ASSswV^-{ml zFZcKVe_#Lqo67rt{?_cD=ac#2sYOCO>jiHWdJ4S6ehEjiHJNX`oKR?1Wn}q9-;+`O zh8=A4t`}rTZq3tFu@z&8xh40!4XF5>?P$w&F!eA5+q)84a%z6Q0iAqqwba_O z2xL)#RFeLrP5jGun)yQmH)2W%4CXKxnkPuzt9K`+c)QN0AzH@+Q`>Dn@-ZC ziCo$XAgV1QW|IYi>fdyjao(L?lMQ)zodrqzCqQ^*wdqNr69a<82+kQAbj=IfD z20-41**KZAmN?$h#Q7?*vVw1|D!_j4!Xfm;@b~ZSa&(~|UZpECcc}@yZSbwZQiI&w z=;UcnIQA(0ydCT#3zD?(j4Jc2Ph7m2aQ2GfOI(`!X)B6r4{o9E;gDD!ZuLU}a# z92d{Crv3y8{P+bTG*upN2lC@9%nFPXykK7c{ze?n%h7t`@I2INxa-NkP?N*P!Ob?B z)z3}IXXsl1Ed=`#we^Vji1{yH_f-TkcCDYyiqGHwyq~e_PBkzrSn^953o1=aRe$iV1a>X#?w3ja$B)kjr_30#c+@ZWlc4ll zYyM@B#au zo*t%}$=2jlV}~P4i>celz||`BhqWPN)e2*a>+_RAOWvJI-6745@@orTMOMx7h0-F} z-8rcY$;fg)9=A3k%iG3HgWlFha~dkoi-tXaX?~`!gx)=)5zkkwrnoH#*6(Pk4sBn5KR*&-lG0mz;o_ zuAT|Dt3;61YY9uj09-03+b6Gd`@v4RbY;=|LdjV{DFxNwCaK`r1n!#YVuUGu}CAJek;1Q-L37_!0_Yh&q3N=j_+$9SG~N%aMF1CIjzy! z$ws*NCSs45OJk~pURlkPk99*HJzMwWlTeayC>g433JPz|8CMM%dPknoJ9WxKeRmby zvVb+0EW+GyMY?HLlaFYq*ukW$J&AfNhR@t^)jEsjrm6nLM79;^V63@@PHDKaw$iSX z8}~G2&dZ{G8>27N!Mc4I1}|phq}&aZnfvlW(DkcgequaN=y>Xjv!pU^YqNx$ep9x` zaUsEh&VUkeX^QAPcrDNt&ly+Zm?oRgCwQgd^hW6Pv8$!VhT5~?JO4e)#`H~(awO+; z-o_-Ur>q z5%V`+lIKxUX{2GflE=M@r%29!*;_O~G4RuHc`>wQd&KC=KX{doaKPaqlU~^saGz$k?ESe~&{}uQA6?BEfh+h2 zXO>Z^gObg~lh4Bn{f`blxSuGDZP)pDs>?Oi`7E@`asILPv-@dx!va@`h=V}` zaYvu$%0g$Gn9!N-wU2Zo=yDKR3nPty&QtfzlwT6mZSB3*9(k-&A$nS;9@hZic=)L$ zARd(e;clc^{h`@fo8R@dHUb8LJx&sL3hpNn6u`Sx*9a6UnB}fcpNfED%DMMrx=`n8 zc~Mmge$$3!IcmVYyD)aXwwy#gzVEwdR_1C@IkDh5Tm42IdtOudzUQ+`*QYG#u=s@@ zI-2ip71J9Ywq#koFJGIoZ z=)vspw*tD!-&}j^+y-zrM!UlA`tZn{o(%=Xnd%x<4}@rwDWme$6Og%9OCN>){#S8# z5@;|p_{F?rKZDgzjF>Jug)QNFD3A_#7B|1Mu^5?mX+Fjap`$8S*6dMHnF_yR1Zf+2 z@Xx~RleCYa#=jg1DQfnIZr;clU5`hyQZ)kNXcbs(b$x!CslB>h+No!HLRRjVS7MuN zb4%8v!JT;@C@Ans8sZhis>bVyXg|!@NjGw}{K%{&L{VzESUzN(J>w9rDbT9*N-8+# z@dL2+v;Qe1HvE>+w`!rkh+VrYCXAe!lzWuq+E05>_F(F&UguTl*w5HV*D5oQPBny6 z&qtpLXoFNx2al}Pk9UuB2l`ahnqJ1u$XI{WCPI&W4rZS2h}E5JBF{~xssfYQIh`oG zvbEf6;cD)Y{xACUdyNUJszO2iswa6`-Tt?~AiS!sYM@LhsCX+^oKYkh7 z%mPBQgP0eE!+PjBbI{$Uq++LMqtoW|ATyJ6*1-b%AMbGFOaKF_-GN69dU!S(`40m)EIxiUc zOa%E5c#a;Jnz}tebqsGs>}Hy;Aon12K1gQDbmfHTo zU|u#Y#g+XAr%a>Bv(JHC#CleJHvBkYl zeXldjafP-WTKZvynLf>2m9h9L1b`sF{p|DIr!KnA$vtqCGsd)%?8s&>Pn~pzIz0rk zon3>dMDScw1%kG`hM)teZ155Vzubv$SJ5ne>4~S{qchkU;<%(HpHYQ^ukf&cO{*F1 z(v3EDvWPO@8|{#dG4v5wUofdZrqe{NC;W0Ipdh4u8-%^!L#8ah z4Xen424gvfvmU)k;{48@x;hIXGtvV0l=9mBt$V*+5RZtsX~q_>bShpBf_D|4jt`7j z84gO=Sg=ANhgnr2eFZfS>1TotAT;pGQgGXgtbw#Sj~acfvnIpMynOJ+A#lqkH&HT< z8<6)rMi2|-;TB3+uPkvMj5#WzYj_lSHI)YakZ)K#?dcT>UtS$D{X&bq?^9h zn*=rQGIiy47bB3*?d&ciLC1P|8&#JWtGtJv@k8b|9o3stAuO^jjZQnBF`F{&>gQ8t zsv+Du_qx*TZ#fbJ=~5Gu?$a_Tg>&|NQ+M;dbvs&;0Q6}$`0)LM+!Tk`@fGWN?gkr% zbnX)B+%`D7!gxMcZ`yt?Ku_P&t$Zgk-nd-Ft-YCAcJUbB zQ+!`*rO2|f!1l$rt;|%Su;KXAo!;Jo&hECMj0V_zZLJuYe)1lEW9GODY%pN)`r99} zVVRRL(cp8TpyBZMbIvJ&Qf__!##{~1iSt#Z&u44jR7L+g;n-80 z8n6{+8EijwtMWI>JKp>e22!2*<;WeEUA>-RY+j!4fA|{h{pH2glW?$rK*9FuG~o&o zLZ^&Tag&1KGN??C?WYkXvSKU$w9`D&y3RdZIqeVvi8$J?dA-0KpEM|UPi&{MBhzWO zjLPSV?YXt&7_|mNMDJ-TUE6ul+eCLH`4YnHC_;|^Z*im;yx2?XZCAEb*9lNZv~ijn z*%8IsE|StuixD1mqsUls$)>aS@+vC$Kp`y`b*V=;9-3Bj(G|by-&wzG9Rh(M_b*@5 z*L@24=I{`+?o#&-bFR0R?*e)(`-6gmj~_^}{yc^sSH1++~c0jO7kVaU6*S>*x+G&U9?#H@B0&MPwcbHgz9YRkEUyMn5(%-O+cGk^iBnQ z+}Z-vgliT#&aXFbYhDSezQ3#){m);@%0tNSTlYS6vwrXl&vU=I9W|b^D@K2=+lFT> zs_?(RhJQKuv^uFTE7$1wtTA`nC3$MSbD(J@9<1fsVnAFD>AZe(l&(9K)4XdKW323V z*`V9v;~hQzge)*?_$uC-zOB*ytta+}b^giUUz;~wkWEHr7Qa@R*xAt?^PP+nf`jTG z-RB#hYcRjz@Rbp`UHW%giJQ(5ejV!+yi;!ck4}EK4G9@LQx8rOEZUCRsTzchk2?Mj zpNmGh=G@dkUAh4je?#>(r3LNW^6zuA4*=7Ui{|EwdUX9TtVMcR)b=t|!sYDgm*=;Y z<@o;HaKJ5cul#I3LCk+MnBA@^QG^H_nKiw;q?4cZJO6spGI}o@RyF?%Gb*OKe}jWB zl1WG^=(^{dL%4%fcc%va4p5v)cZeo53;>?%JY`V}Fp|f0?{%(DF{RJte}@co$=rRU zbe+Vq|2$2e%6Ezh{UPDW+AZuA@b2aH>3#Ef{!+jt4Iz6N#8r)gNC`T2?az#2*WY+c zpW1wQ<25lbz)kn8>NRG${#jAU)MN1h>bQ#tg@_?J>Am*%N`$(m zue2|pT#QX$pDB$#H}P99%vhcrR_MO2ckUWAIsfEjH5Ih_=MvkaTnI4P*l6{I=jD)v z#xwN2u{|WG+10PmpmS{5|ITrWnej`MkX5D!#ZZLcr%0P z;!yMEWPUF#_Y|*3@-m+f2LlPLY~5YOpv;KZEc=JoI3Ya1%Gt@?o4Kba9g-)@??MA! z-@Y>to6%4PD!KTHJok}&8vaG`@8eWSr?et!(&LreRr-s6-@Gw)_M!``$_{m{paI{0 zBxe?;FkvE)Ii}5Rw7#ahchGT}s{cKXPYzKkwX6nv3%q4FFmzp&^7VJxZ|ZtMZEjC4 zC&%a_?XnadAJVa(X>vpDDhlOjm?n8gy{(elrd%fV;0fDiFsYN3T?FiXmo`=o?cDVcU6$FfSf;@Y+hdQ4V-l%v4q&o+(3bF0pcwM zA%i_Ds96cl#~Y9G!dB=*yIl3iY5J@`LQ5?D)AlwOkD^Orai78%g@}qUMyT?(QH8mzK^@ytCuocJ>_yjEC!@ zt{GxtNH!|(j=r3riyD0!g>EOR57dDX=bbtg=l@xs0e@#xfA#SStE|B#S)zvH+25y$ zN!z9BXMQTjbFtLj4Kn4XR_4{_+gxaxO3*`uSVP|_rYvD-~r=1 zr4Xaf%ETW%zc*J7yzzgLZ)a9dJPHXym zHir6_SOkFN9bT5aBFMLdMS7Ks?a$Qg^oI*hJ{V z^gbV3Wce&10ChQ<99Mo3cxh#J?t^idUlPNw?eZH8QHpe9+m{#P->Gsx#147zusvD0 z#t@Y`Y|bESJ)>eS>m^UeT0eaf{2yxs`q?6(lOqG-uM=C^{=Kregn74Su79^h1^*=Z zCxW`Z#q+f*BO8ffBG@Gaa-yc$Th7b`sa9cJ;r0dfDUtg6CE!c^<1OfucQLF-u9G!f zqLoH)iQDJ}u9Iu}`>LoUCto(1;O&22>A~|ylNmlFRv!!T=>EZFktQp@PQE*t8=C70 zP(A#)gEKS5f9z<65d+lJQtBV{T>hW7dH0*qQ5bsZ8qc$f0_I{X8-n+)?Nf?WZkxXdRe5&y3&-s*Ut}*`JA39;kGJ`H zF0w#$ygrQx$Mm)!-y9h^f=`HBa&rbtN7*BCUcPqO=%Cd9JyorUwQ;p0mH9kF$jQ!9 zT#qFs!of{hIn&XPn}h?z=+cfLli4k>GLbGeNQg@sdlQU!G#8fc;@}XWjjrHM=Nozv zvVospqP%mRI-c=r-MAssB`0+8-av;B8w^Kut@1)-n?8wM+uS)A{gb5-U+-0|tTcL< z{M$DVq*GA7x1&M+uDLX z)_ccV!-EtDjK%0yjVF2R1bj?U+kK@P2;}z4!SF-NRtenhtW41KD1l004D0TBTx~m0 zuLo;)Um8dSMiGWiVSY;Twq2Uj3XbNUjF z!%nh#drRQk+4x-$-{rIn=)Mwwe9*!V7mSBtO3FJ}!A`ffk3x*1Zxa zF;i4)r8>jF_j$#+&U$XA-@V1>w@crwK=P)mS8Lhti^_|iVkn-5`V3Z%W9P4TnH#`Ec@}@g! z`CQR`7FWN-1l%+ci$k<2Q3>ZDtiYBvM!h!i&H5k8dWz&Ijh^W75RM_U4-3<&g$#dy z0efa^djLN(m)QWsXvT2s>Q^*~uhMUNU~Pxx+T_g4=uJ4j%smDUC-P%KAlkFz6TVw> zBMTeTGrw@Hep?gnQE#H-Sr!#6l$%s3DV}?o8`z*Y4YM8nv$O{AZbVyn3x7PK$V=wU z)Q?0mjqY3k0FYKW*Wm7LQUQVS?Hr7F@JiTKY)n~YoVJKYVK$2xU~l9+42FfcH49UB zC!lcvWJus;YAY=|x=Z-&L7|Y6+?;9pT3(*(!I|kDU4#v8AmyrtnO8bjZeC3b$Fg+g zc+fb@vpiS#hdZvXK&)58Hc;3nZ;$_CgG{wOB9NbQ%-Ow zp!CR1T-IGxk#tM>Re5$*&(wMZXEtCn zWv4i;{7IaO_|xOBE8B$C)OI71r!#^1C^dryIe@9;@~)BdYZnnbx}wy)NxL6`!jS?If{1&`>ESNPmq4S4DG&)M`Tk%^jC zjnL*Wqel1jxFIk=VJCm$+&Mhj`wD35^U+8wG@s#@dBc*wv3ZDNzrWm5Uge;BzD4L} zX|`9T`cEJaq56 ztf@`VuWM?d+T2J|?Ntbz#cF4r1D5-plzX?;5@R1M_d6g}fq=tLKG=ubS36tVD#s_jujo&hPE< zZ)7=G52dp$0pctgWFKn1zm3AJ2R>6(eYC;cg6C2E0I^!AnrC52;%?s^?TE|ivx$HA z;pwoGAC?Jp3%}rX%=Zlwj`7Dp|H{$N$thkg3W%kHzkYKkx?C>%`9NKELBH{okA+olfO*XUKld9)AR5p(QZMG}Hr+cw;MR)Ahf(=}m_ z;O}gCcIP>=f?J%{@)msw0h}(4l1SP#giCp+5gQKNOz-V1+3o}4%X>Q`x5IjtZu-lb zz}{9rusmHrbTdJ*{WD-I<6U5_%-lT3-|DWiT*)YgmJK;k?2dSV>R#wrMX-WXNp19o z_^r7`hd2)hl;q*JXn-EKd^2;G(mW<+dZYer)q8EpNx)Ts0G~NT-!=qJj@PMocy)9n z7h60%?EG()tv~(#cB=Jmy{~(GmWFT7_690%@%YP*(ss4Rb3SIlXoUusIDeRHmEG3% zooJbb;|!q%u@Q2fF@^dvmAA#}saYVIDuOg8eXI5-S?C#SuEYy_ySq(f+8FabYO~ua z_3PQ8-U|o_T*Zlk)$asfFlljdtsQnPfq^Mgx{->Rr5goDq(ghM=E5Ytx~+oV#tQ||5+a{K7y`Jr=q+vMz9WLq6G()ZqCu7yu4K{Wg5O8r#P|(hGCgZV_`E$735U@(v`?XRIZ9uj zh?)A)7#DcVQ^O73m^o{0FHNl8LIA69Pg{V&A52pIV8bB7Clw8CTZY;({0|=`65zDIjKW*)+l?|B|&8u9t&QZ@N+sw;Pd56TV_gAJR2@F*6)Uy09F94`h z;J|+vq%th_Nx$T#j50pV4*?=;i%yD%x3!UiA%Ky9(<7u-D~ZB$KPc zKw}c2M=8MEs?10%daCSukxBt?l`E-&uqi~9%O(vL@gb&-+i^p+yCA0{arp6{#x1px zir9vRk?7Or=H3x$Z~=PV>*wgfK}DH;Kl&MBQg`ei}B?NWG*wB4NKaASMTNBX;AR zc7h3OGM_ z)#VRHpE%1*cMI*nOEyXGPh89Q%5|Q`19ev7n?g>L=5@K6{d@K@Y$VnznhKgY`emkr zvs@_RAuDi=FEmP5%Murykc*Ca+qU)^9kI2-CSveo<3JwVDTi&`so+y~ezwNMu>h@m zt+N8jz_cn1!>w$}73{UyCw3b3D<;os>2k1w@r(HE4>S^v_hvfWudnx7XUBQ>(?wvE zco3C(rI;S~g2Lt(naea18lfWN6fZwlS$w9SQGf7j8@9J?r&Q+$(4!y<$ouoh=@#iF zZc6Yu)!0f)FPg9Flh_!|+q4QDI9M~mK>Eg~w1=-7*2g!s&U{40iLza0$n3k&Ny_H+ zv`wv=9+%^GKJ$@L=o*9Ubg&NUG*;Nky(@95?wum3@2T1Vhc;3?z5ctuM_*x|b|2rK z2g)m51iDr=?SCXad6ogpGb^>i@Vg^;M3=+Z{NQItpD<~bj@vlP6_l}*PrmfTT=vw= zz_D{M-+q+bvd3QaRNYFlNVe6AA!*$!O$0h$HC=d~$?42Th2|C^b_Ut+6zP0%9wSjp zbMEfy#kUC20vU~+RHYBE*WS+awukr*r118bdZDGWsu+G(;d}%upl-xEcz*)zanJJy z;ln8L*+OR&T=T>J%ke$kDKrlcbz*+MJouKB%-09m#dJBevZO-z{p|AUlh*p_xewJo zf{!5*3=(j={}{21U36LaC7n$mIIq^wa9wlTK7RghJhhQbdM(LM7^CrH^3%ddk_&Px@P*3CcKRc=G2o7>7f4bTO`kmq2@dZQNVd+r?c>0$E?R|EUH9$A5J36$4Kiz!A z!}GNv4k>_w*R^=a0l*p+RZx;uS9RxW2r3;h%_>eQKzD)2Q;d0;hf&Ie72U2quuEL5 zaFEOFcvlse_K9!^f$+QOzI)eXnvU-7tl&u#!LAD_inW7x;+;o^(AV05e)G_t0^#@D zpO2)wr(G4CdYWy}*`|%i>^73~-m7(ZS2V|8CA?cSkgx`f9}sW5Zv->XF5i#9(3Xo- z--Qr~J4MP^51|EZVcRE4^wPZwho8c3BB9~R>(y)(kkT}5?XFq^b!WqHC(hS|ME+W3 zhiYyF&o8QGnES4v>JGYcZY4>0)?$)IM0# zcT4q7usEbkyvu9}7uac>Hc9}uA5n7lycjlO;e_&>t&W7s!f!)KQ#Jrnak2Z04k-S7 z)nd>Qr_N1=nI+&g$Wc2>*LZ=J^_zZ`nMOy|+978plN!|{vkB+6oScyR5l8jVMH425 z!6VND^u`eWuZk1N)txdyfXTG_I3wmHrxb1j!;4&M9B+T3Gm%8m@NJ=V1rnXOkwiLL!2sy$1 zd!=A19Ly3o9iqJTQi#Q#rz$s+?K1nojTMMc@*^+xOUZLG&#Oeg)?PhUM=dWtDa9l$ z&dyAA7fdoZBVd1jnzS=L05+mRffGtANQx>h=e2B<*1 z-WzjsXJZ;_cb3Sr;gvSxNRQfpO&!fyzx2{NE?`-omzVEuCYrQ`;QHNW#TDY}Jb#%} zRg&RXwqJql?w>8vTCP{vkE0!hV`6}i5ysVE49#IL`i~914H%r`cLb*)Mt6^-ku-s& z(ZLCcL=ZPoDiI_?l*;=LJL<(4quY!ONW#JnKO-DfS~v;kaYzj%wg(I}@B_gL^x$i- zj)5ZHL^oE%fH3*oDdh5cKv!k!FODCn^L-tm0NT3!8Vuv7E9!zNYX~ahP=5Q6W35Ct z!2I$Bb2)2pJRG3zh(i!QV9+0^q6Z!p$bZqM(oVSOyoj1 zy)i-@a1C%$C!q*J3<{!TEODo&Y7Ec&>9lU1A1OXRXd7M`OliS_A+$cf0cfiN_0%0G zeV2?mKhm<&hz>pqvGiMe!q3RLu}$~*nQfQ(`fHI4pxuxoyH7OjtTy*3HP;8_(onzt z^0+m6A&{mdf1uo8WnN9$wD*1w6O?xB;0^_kTUhva_5Om>k%qCkGS17gG~PimAYG@q z68o@`{F$W6!SwZ+c>t9%vF3=GarK0HE!k_eEptM7y~drvze3jP++!+(h&!Q4k0d0n zRR->1u6(7T&X4$Y8-#z??e0BgSC)B8lsYJ&oz5-4-$U;&2Cm_egzwjn!gOoJe*koK zKDmaTa6)i0*miu#hY!`bC*j`IVRu?cD^3c|CZmk8^%%0r=E#_6bE)+qhKli-p4bCh zf*r`lAna-mwt+kb?N*ow-mYN3I5R(Q>=iV|6e8M e_ME%iw+X|V9_MzFNfY*ddb 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