This is an automated email from the ASF dual-hosted git repository.

chamikaramj pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/beam.git


The following commit(s) were added to refs/heads/master by this push:
     new 98fe1763084 Modifies Hugging Face model handlers to not use ML 
packages... (#40541)
98fe1763084 is described below

commit 98fe1763084e1985a86f91a168fb42f547b7ea22
Author: Chamikara Jayalath <[email protected]>
AuthorDate: Tue Oct 13 08:50:42 2026 -0700

    Modifies Hugging Face model handlers to not use ML packages... (#40541)
---
 .../ml/inference/huggingface_inference.py          | 126 ++++++++++++-----
 .../ml/inference/huggingface_inference_test.py     | 157 +++++++++++++++++++--
 sdks/python/apache_beam/yaml/yaml_ml_test.py       |  39 +++++
 3 files changed, 276 insertions(+), 46 deletions(-)

diff --git a/sdks/python/apache_beam/ml/inference/huggingface_inference.py 
b/sdks/python/apache_beam/ml/inference/huggingface_inference.py
index a9893ea9290..93ffc3fc1f5 100644
--- a/sdks/python/apache_beam/ml/inference/huggingface_inference.py
+++ b/sdks/python/apache_beam/ml/inference/huggingface_inference.py
@@ -17,6 +17,9 @@
 
 # pytype: skip-file
 
+# Support forward references
+from __future__ import annotations
+
 import logging
 import sys
 from collections import defaultdict
@@ -24,21 +27,27 @@ from collections.abc import Callable
 from collections.abc import Iterable
 from collections.abc import Sequence
 from enum import Enum
+from typing import TYPE_CHECKING
 from typing import Any
 from typing import Optional
 from typing import Union
 
-import tensorflow as tf
-import torch
-from transformers import AutoModel
-from transformers import Pipeline
-from transformers import TFAutoModel
-from transformers import pipeline
-
 from apache_beam.ml.inference import utils
 from apache_beam.ml.inference.base import ModelHandler
 from apache_beam.ml.inference.base import PredictionResult
-from apache_beam.ml.inference.pytorch_inference import _convert_to_device
+
+# tensorflow, torch and transformers are intentionally not imported at module
+# import time. They are only needed for loading models and running inference
+# when the pipeline is executed (for example, on the workers), and should not
+# be required for constructing and submitting pipelines.
+# Following imports are kept for lint but not actually imported by Python
+# during execution.
+if TYPE_CHECKING:
+  import tensorflow as tf
+  import torch
+  from transformers import AutoModel
+  from transformers import Pipeline
+  from transformers import TFAutoModel
 
 _LOGGER = logging.getLogger(__name__)
 
@@ -50,8 +59,10 @@ __all__ = [
 
 TensorInferenceFn = Callable[
     [
-        Sequence[Union[torch.Tensor, tf.Tensor]],
-        Union[AutoModel, TFAutoModel],
+        # Type aliases are evaluated at import time, so framework types are
+        # quoted (as forward references) to avoid importing them.
+        Sequence[Union['torch.Tensor', 'tf.Tensor']],
+        Union['AutoModel', 'TFAutoModel'],
         str,
         Optional[dict[str, Any]],
         Optional[str],
@@ -60,8 +71,8 @@ TensorInferenceFn = Callable[
 ]
 
 KeyedTensorInferenceFn = Callable[[
-    Sequence[dict[str, Union[torch.Tensor, tf.Tensor]]],
-    Union[AutoModel, TFAutoModel],
+    Sequence[dict[str, Union['torch.Tensor', 'tf.Tensor']]],
+    Union['AutoModel', 'TFAutoModel'],
     str,
     Optional[dict[str, Any]],
     Optional[str]
@@ -69,7 +80,7 @@ KeyedTensorInferenceFn = Callable[[
                                   Iterable[PredictionResult]]
 
 PipelineInferenceFn = Callable[
-    [Sequence[str], Pipeline, Optional[dict[str, Any]]],
+    [Sequence[str], 'Pipeline', Optional[dict[str, Any]]],
     Iterable[PredictionResult]]
 
 
@@ -135,6 +146,7 @@ def no_gpu_available_warning():
 
 
 def is_gpu_available_torch():
+  import torch
   if torch.cuda.is_available():
     return True
   else:
@@ -143,12 +155,14 @@ def is_gpu_available_torch():
 
 
 def get_device_torch(device):
+  import torch
   if device == "GPU" and is_gpu_available_torch():
     return torch.device("cuda")
   return torch.device("cpu")
 
 
 def is_gpu_available_tensorflow(device):
+  import tensorflow as tf
   gpu_devices = tf.config.list_physical_devices(device)
   if len(gpu_devices) == 0:
     no_gpu_available_warning()
@@ -156,6 +170,27 @@ def is_gpu_available_tensorflow(device):
   return True
 
 
+def _is_tensorflow_tensor(value) -> bool:
+  # A TensorFlow tensor can only exist if tensorflow was already imported.
+  # Checking sys.modules, instead of importing tensorflow, avoids requiring
+  # tensorflow to be installed (and the cost of importing it) for PyTorch.
+  tensor_type = getattr(sys.modules.get('tensorflow'), 'Tensor', None)
+  return tensor_type is not None and isinstance(value, tensor_type)
+
+
+def _import_transformers_pipeline() -> Callable[..., Pipeline]:
+  """Returns transformers.pipeline, importing it lazily."""
+  try:
+    from transformers import pipeline
+  except ImportError as e:
+    raise ImportError(
+        'transformers is required to load Hugging Face pipelines, e.g. for '
+        'HuggingFacePipelineModelHandler. Please install it using '
+        '`pip install transformers` in the environment where the pipeline is '
+        'executed (for example, on the workers).') from e
+  return pipeline
+
+
 def _validate_constructor_args_hf_pipeline(task, model):
   if not task and not model:
     raise RuntimeError(
@@ -170,6 +205,9 @@ def _run_inference_torch_keyed_tensor(
     device,
     inference_args: dict[str, Any],
     model_id: Optional[str] = None) -> Iterable[PredictionResult]:
+  import torch
+
+  from apache_beam.ml.inference.pytorch_inference import _convert_to_device
   device = get_device_torch(device)
   key_to_tensor_list = defaultdict(list)
   # torch.no_grad() mitigates GPU memory issues
@@ -193,6 +231,7 @@ def _run_inference_tensorflow_keyed_tensor(
     device,
     inference_args: dict[str, Any],
     model_id: Optional[str] = None) -> Iterable[PredictionResult]:
+  import tensorflow as tf
   if device == "GPU":
     is_gpu_available_tensorflow(device)
   key_to_tensor_list = defaultdict(list)
@@ -207,12 +246,13 @@ def _run_inference_tensorflow_keyed_tensor(
   return utils._convert_to_result(batch, predictions, model_id)
 
 
-class HuggingFaceModelHandlerKeyedTensor(ModelHandler[dict[str,
-                                                           Union[tf.Tensor,
-                                                                 
torch.Tensor]],
-                                                      PredictionResult,
-                                                      Union[AutoModel,
-                                                            TFAutoModel]]):
+# Unlike annotations, generic base class arguments are evaluated when the class
+# is defined, so framework types are quoted (as forward references) to avoid
+# importing them. Type checkers resolve them via the TYPE_CHECKING imports.
+class HuggingFaceModelHandlerKeyedTensor(
+    ModelHandler[dict[str, Union['tf.Tensor', 'torch.Tensor']],
+                 PredictionResult,
+                 Union['AutoModel', 'TFAutoModel']]):
   def __init__(
       self,
       model_uri: str,
@@ -305,6 +345,7 @@ class 
HuggingFaceModelHandlerKeyedTensor(ModelHandler[dict[str,
         self._model_uri, **self._model_config_args)
     if self._framework == 'pt':
       if self._device == "GPU" and is_gpu_available_torch():
+        import torch
         model.to(torch.device("cuda"))
       if callable(getattr(model, 'requires_grad_', None)):
         model.requires_grad_(False)
@@ -377,6 +418,9 @@ def _default_inference_fn_torch(
     device,
     inference_args: dict[str, Any],
     model_id: Optional[str] = None) -> Iterable[PredictionResult]:
+  import torch
+
+  from apache_beam.ml.inference.pytorch_inference import _convert_to_device
   device = get_device_torch(device)
   # torch.no_grad() mitigates GPU memory issues
   # https://github.com/apache/beam/issues/22811
@@ -393,6 +437,7 @@ def _default_inference_fn_tensorflow(
     device,
     inference_args: dict[str, Any],
     model_id: Optional[str] = None) -> Iterable[PredictionResult]:
+  import tensorflow as tf
   if device == "GPU":
     is_gpu_available_tensorflow(device)
   batched_tensors = tf.stack(batch, axis=0)
@@ -400,10 +445,11 @@ def _default_inference_fn_tensorflow(
   return utils._convert_to_result(batch, predictions, model_id)
 
 
-class HuggingFaceModelHandlerTensor(ModelHandler[Union[tf.Tensor, 
torch.Tensor],
+class HuggingFaceModelHandlerTensor(ModelHandler[Union['tf.Tensor',
+                                                       'torch.Tensor'],
                                                  PredictionResult,
-                                                 Union[AutoModel,
-                                                       TFAutoModel]]):
+                                                 Union['AutoModel',
+                                                       'TFAutoModel']]):
   def __init__(
       self,
       model_uri: str,
@@ -525,13 +571,14 @@ class 
HuggingFaceModelHandlerTensor(ModelHandler[Union[tf.Tensor, torch.Tensor],
     """
     inference_args = {} if not inference_args else inference_args
     if not self._framework:
-      if isinstance(batch[0], tf.Tensor):
+      if _is_tensorflow_tensor(batch[0]):
         self._framework = "tf"
       else:
         self._framework = "pt"
 
     if (self._framework == "pt" and self._device == "GPU" and
         is_gpu_available_torch()):
+      import torch
       model.to(torch.device("cuda"))
 
     if self._inference_fn:
@@ -576,7 +623,7 @@ def _default_pipeline_inference_fn(
 
 class HuggingFacePipelineModelHandler(ModelHandler[str,
                                                    PredictionResult,
-                                                   Pipeline]):
+                                                   'Pipeline']):
   def __init__(
       self,
       task: Union[str, PipelineTask] = "",
@@ -619,9 +666,11 @@ class HuggingFacePipelineModelHandler(ModelHandler[str,
 
       device (str): the device (`"CPU"` or `"GPU"`) on which you wish to run
         the pipeline. Defaults to GPU. If GPU is not available then it falls
-        back to CPU. You can also use advanced option like `device_map` with
-        key-value pair as you would do in the usual Hugging Face pipeline using
-        `load_pipeline_args`. Ex: load_pipeline_args={'device_map':auto}).
+        back to CPU. Whether a GPU is available is determined when the model
+        is loaded, i.e. on the workers that execute the pipeline. You can also
+        use advanced option like `device_map` with key-value pair as you would
+        do in the usual Hugging Face pipeline using `load_pipeline_args`.
+        Ex: load_pipeline_args={'device_map':auto}).
       inference_fn: the inference function to use during RunInference.
         Default is _default_pipeline_inference_fn.
       load_pipeline_args (dict[str, Any]): keyword arguments to provide load
@@ -647,7 +696,9 @@ class HuggingFacePipelineModelHandler(ModelHandler[str,
         before loading the model.
 
     **Supported Versions:** HuggingFacePipelineModelHandler supports
-    transformers>=4.18.0.
+    transformers>=4.18.0. transformers and torch are only needed in the
+    environment where the pipeline is executed (for example, on the workers),
+    not for constructing and submitting the pipeline.
     """
     super().__init__(
         min_batch_size=min_batch_size,
@@ -681,14 +732,9 @@ class HuggingFacePipelineModelHandler(ModelHandler[str,
     if 'device' not in self._load_pipeline_args:
       if current_device == 'CPU':
         self._load_pipeline_args['device'] = 'cpu'
-      else:
-        if is_gpu_available_torch():
-          self._load_pipeline_args['device'] = 'cuda:0'
-        else:
-          _LOGGER.warning(
-              "HuggingFaceModelHandler specified a 'GPU' device, "
-              "but GPUs are not available. Switching to CPU.")
-          self._load_pipeline_args['device'] = 'cpu'
+      # Otherwise, a GPU is used if one is available. This is determined in
+      # load_model(), i.e. on the workers that execute the pipeline, instead of
+      # on the machine that constructs and submits the pipeline.
     else:
       if current_device:
         raise ValueError(
@@ -697,8 +743,12 @@ class HuggingFacePipelineModelHandler(ModelHandler[str,
 
   def load_model(self):
     """Loads and initializes the pipeline for processing."""
-    return pipeline(
-        task=self._task, model=self._model, **self._load_pipeline_args)
+    pipeline = _import_transformers_pipeline()
+    load_pipeline_args = dict(self._load_pipeline_args)
+    if 'device' not in load_pipeline_args:
+      load_pipeline_args['device'] = (
+          'cuda:0' if is_gpu_available_torch() else 'cpu')
+    return pipeline(task=self._task, model=self._model, **load_pipeline_args)
 
   def run_inference(
       self,
diff --git a/sdks/python/apache_beam/ml/inference/huggingface_inference_test.py 
b/sdks/python/apache_beam/ml/inference/huggingface_inference_test.py
index 5cd55f1b1f5..f78b72aec51 100644
--- a/sdks/python/apache_beam/ml/inference/huggingface_inference_test.py
+++ b/sdks/python/apache_beam/ml/inference/huggingface_inference_test.py
@@ -17,8 +17,12 @@
 
 # pytype: skip-file
 
+import contextlib
+import importlib.util
 import shutil
+import sys
 import tempfile
+import types
 import unittest
 from collections.abc import Iterable
 from collections.abc import Sequence
@@ -27,11 +31,16 @@ from typing import Optional
 from typing import Union
 
 import pytest
+from parameterized import parameterized
 
+import apache_beam as beam
+from apache_beam.ml.inference import huggingface_inference
 from apache_beam.ml.inference import utils
 from apache_beam.ml.inference.base import PredictionResult
-from apache_beam.ml.inference.tensorflow_inference_test import 
FakeTFTensorModel
-from apache_beam.ml.inference.tensorflow_inference_test import 
_compare_tensor_prediction_result
+from apache_beam.ml.inference.base import RunInference
+from apache_beam.ml.inference.huggingface_inference import 
HuggingFaceModelHandlerTensor
+from apache_beam.ml.inference.huggingface_inference import 
HuggingFacePipelineModelHandler
+from apache_beam.testing import test_utils
 
 # pylint: disable=ungrouped-imports
 try:
@@ -40,14 +49,18 @@ try:
   from transformers import AutoModel
   from transformers import TFAutoModel
 
-  from apache_beam.ml.inference.huggingface_inference import 
HuggingFaceModelHandlerTensor
-except ImportError:
-  raise unittest.SkipTest('Transformers dependencies are not installed.')
+  from apache_beam.ml.inference.tensorflow_inference_test import 
FakeTFTensorModel
+  from apache_beam.ml.inference.tensorflow_inference_test import 
_compare_tensor_prediction_result
+except (ImportError, unittest.SkipTest):
+  # tensorflow_inference_test raises SkipTest if its dependencies are missing.
+  tf = None
+
+_ML_FRAMEWORKS = ('tensorflow', 'torch', 'transformers')
 
 
 def fake_inference_fn_tensor(
-    batch: Sequence[Union[tf.Tensor, torch.Tensor]],
-    model: Union[AutoModel, TFAutoModel],
+    batch: Sequence[Union['tf.Tensor', 'torch.Tensor']],
+    model: Union['AutoModel', 'TFAutoModel'],
     device,
     inference_args: dict[str, Any],
     model_id: Optional[str] = None) -> Iterable[PredictionResult]:
@@ -56,11 +69,12 @@ def fake_inference_fn_tensor(
 
 
 class FakeTorchModel:
-  def predict(self, input: torch.Tensor):
+  def predict(self, input: 'torch.Tensor'):
     return input
 
 
 @pytest.mark.uses_transformers
[email protected](tf is None, 'Transformers dependencies are not installed.')
 class HuggingFaceInferenceTest(unittest.TestCase):
   def setUp(self) -> None:
     self.tmpdir = tempfile.mkdtemp()
@@ -152,5 +166,132 @@ class HuggingFaceInferenceTest(unittest.TestCase):
     self.assertEqual(results[1].inference, {"translation_text": "output2"})
 
 
+class _FakePipeline:
+  """A stand-in for a transformers Pipeline, created by a fake pipeline()."""
+  def __init__(self, **kwargs):
+    self.kwargs = kwargs
+
+
+def _fake_transformers_module():
+  module = types.ModuleType('transformers')
+  module.pipeline = _FakePipeline
+  return module
+
+
+def _fake_torch_module(cuda_available):
+  module = types.ModuleType('torch')
+  module.cuda = types.SimpleNamespace(is_available=lambda: cuda_available)
+  return module
+
+
[email protected]
+def _fake_modules(**fake_modules):
+  """Replaces the given modules with fake ones."""
+  # Also hides the real modules, if already imported, and restores them
+  # afterwards.
+  with test_utils.block_imports(*fake_modules):
+    sys.modules.update(fake_modules)
+    try:
+      yield
+    finally:
+      for name in fake_modules:
+        del sys.modules[name]
+
+
+class HuggingFaceJobSubmissionTest(unittest.TestCase):
+  """Tests that ML frameworks are only used when executing pipelines.
+
+  tensorflow, torch and transformers should only be needed for loading models
+  and running inference when the pipeline is executed (for example, on the
+  workers), but not for constructing and submitting the pipeline. These tests
+  don't require any of them to be installed.
+  """
+  def test_importing_module_does_not_import_ml_frameworks(self):
+    # Executes the module in a new module object, i.e. without replacing the
+    # already imported module.
+    spec = importlib.util.find_spec(huggingface_inference.__name__)
+    with test_utils.block_imports(*_ML_FRAMEWORKS) as attempted_imports:
+      spec.loader.exec_module(importlib.util.module_from_spec(spec))
+    self.assertEqual(attempted_imports, [])
+
+  @parameterized.expand([('default', None), ('gpu', 'GPU'), ('cpu', 'CPU')])
+  def test_pipeline_job_submission_does_not_use_ml_frameworks(
+      self, unused_name, device):
+    with test_utils.block_imports(*_ML_FRAMEWORKS) as attempted_imports:
+      model_handler = HuggingFacePipelineModelHandler(
+          task='text-classification',
+          model='distilbert-base-uncased-finetuned-sst-2-english',
+          device=device)
+      pipeline = beam.Pipeline()
+      _ = (
+          pipeline
+          | beam.Create(['I love Apache Beam!'])
+          | RunInference(model_handler))
+      # Serializes the pipeline, as done during job submission, and
+      # deserializes it again. Deserializing fails if the pipeline references
+      # (i.e. requires) a module that can't be imported.
+      beam.Pipeline.from_runner_api(
+          pipeline.to_runner_api(), pipeline.runner, pipeline.options)
+    self.assertEqual(attempted_imports, [])
+
+  @parameterized.expand([
+      ('default_with_gpu', None, True, 'cuda:0'),
+      ('default_without_gpu', None, False, 'cpu'),
+      ('gpu_with_gpu', 'GPU', True, 'cuda:0'),
+      ('gpu_without_gpu', 'GPU', False, 'cpu'),
+      ('cpu_with_gpu', 'CPU', True, 'cpu'),
+  ])
+  def test_pipeline_device_is_determined_when_loading_model(
+      self, unused_name, device, cuda_available, expected_device):
+    # Whether a GPU is available is determined on the workers that load the
+    # model, not on the machine that constructs the model handler (and submits
+    # the pipeline). So the GPU availability is inverted for the latter.
+    with _fake_modules(torch=_fake_torch_module(not cuda_available)):
+      model_handler = HuggingFacePipelineModelHandler(
+          task='text-classification', device=device)
+    with _fake_modules(transformers=_fake_transformers_module(),
+                       torch=_fake_torch_module(cuda_available)):
+      pipeline = model_handler.load_model()
+    self.assertEqual(
+        pipeline.kwargs, {
+            'task': 'text-classification',
+            'model': '',
+            'device': expected_device
+        })
+
+  def test_pipeline_load_pipeline_args_device_is_used(self):
+    model_handler = HuggingFacePipelineModelHandler(
+        task='text-classification', load_pipeline_args={'device': 'cuda:1'})
+    with _fake_modules(transformers=_fake_transformers_module()):
+      with test_utils.block_imports('torch') as attempted_imports:
+        pipeline = model_handler.load_model()
+    self.assertEqual(pipeline.kwargs['device'], 'cuda:1')
+    self.assertEqual(attempted_imports, [])
+
+  def test_pipeline_invalid_device(self):
+    with self.assertRaisesRegex(ValueError, 'Invalid device value'):
+      HuggingFacePipelineModelHandler(task='text-classification', device='TPU')
+
+  def test_pipeline_device_specified_twice(self):
+    with self.assertRaisesRegex(ValueError,
+                                'specified in `load_pipeline_args`'):
+      HuggingFacePipelineModelHandler(
+          task='text-classification',
+          device='GPU',
+          load_pipeline_args={'device': 'cuda:0'})
+
+  def test_pipeline_loading_model_requires_transformers(self):
+    model_handler = HuggingFacePipelineModelHandler(
+        task='text-classification', device='CPU')
+    with test_utils.block_imports('transformers'):
+      with self.assertRaisesRegex(ImportError, 'transformers is required'):
+        model_handler.load_model()
+
+  def test_framework_detection_does_not_import_tensorflow(self):
+    with test_utils.block_imports('tensorflow') as attempted_imports:
+      self.assertFalse(huggingface_inference._is_tensorflow_tensor([1]))
+    self.assertEqual(attempted_imports, [])
+
+
 if __name__ == '__main__':
   unittest.main()
diff --git a/sdks/python/apache_beam/yaml/yaml_ml_test.py 
b/sdks/python/apache_beam/yaml/yaml_ml_test.py
index c4fc179b9a3..0b6be61a5d0 100644
--- a/sdks/python/apache_beam/yaml/yaml_ml_test.py
+++ b/sdks/python/apache_beam/yaml/yaml_ml_test.py
@@ -274,6 +274,45 @@ class MLTransformJobSubmissionTest(unittest.TestCase):
     self.assertEqual(attempted_imports, [])
 
 
+class RunInferenceJobSubmissionTest(unittest.TestCase):
+  def test_huggingface_pipeline_model_handler_does_not_use_ml_frameworks(self):
+    # tensorflow, torch and transformers should only be needed when executing
+    # the pipeline (for example, on the workers), not for constructing and
+    # submitting it.
+    # Imports the YAML ML module before blocking imports, since importing it
+    # loads all available MLTransform transforms, and some of these (e.g. the
+    # TFT based ones) do require tensorflow.
+    from apache_beam.yaml import yaml_ml  # pylint: disable=unused-import
+    ml_opts = beam.options.pipeline_options.PipelineOptions(
+        pickle_library='cloudpickle', yaml_experimental_features=['ML'])
+    with test_utils.block_imports('tensorflow', 'torch',
+                                  'transformers') as attempted_imports:
+      pipeline = beam.Pipeline(options=ml_opts)
+      _ = pipeline | YamlTransform(
+          '''
+          type: chain
+          transforms:
+            - type: Create
+              config:
+                elements:
+                  - {text: "I love Apache Beam!"}
+            - type: RunInference
+              config:
+                model_handler:
+                  type: HuggingFacePipelineModelHandler
+                  config:
+                    task: text-classification
+                    preprocess:
+                      callable: 'lambda row: row.text'
+          ''')
+      # Serializes the pipeline, as done during job submission, and
+      # deserializes it again. Deserializing fails if the pipeline references
+      # (i.e. requires) a module that can't be imported.
+      beam.Pipeline.from_runner_api(
+          pipeline.to_runner_api(), pipeline.runner, pipeline.options)
+    self.assertEqual(attempted_imports, [])
+
+
 if __name__ == '__main__':
   logging.getLogger().setLevel(logging.INFO)
   unittest.main()

Reply via email to