203 lines
7.9 KiB
Python
203 lines
7.9 KiB
Python
import contextlib
|
|
import multiprocessing
|
|
import os
|
|
import shutil
|
|
import tempfile
|
|
import zipfile
|
|
|
|
|
|
def _get_active_spark_session():
|
|
try:
|
|
from pyspark.sql import SparkSession
|
|
except ImportError:
|
|
# Return None if user doesn't have PySpark installed
|
|
return None
|
|
try:
|
|
# getActiveSession() only exists in Spark 3.0 and above
|
|
return SparkSession.getActiveSession()
|
|
except Exception:
|
|
# Fall back to this internal field for Spark 2.x and below.
|
|
return SparkSession._instantiatedSession
|
|
|
|
|
|
# Suppose we have a parent process already initiate a spark session that connected to a spark
|
|
# cluster, then the parent process spawns a child process, if child process directly creates
|
|
# a local spark session, it does not work correctly, because of PYSPARK_GATEWAY_PORT and
|
|
# PYSPARK_GATEWAY_SECRET are inherited from parent process and child process pyspark session
|
|
# will try to connect to the port and cause error.
|
|
# So the 2 lines here are to clear 'PYSPARK_GATEWAY_PORT' and 'PYSPARK_GATEWAY_SECRET' to
|
|
# enforce launching a new pyspark JVM gateway.
|
|
def _prepare_subprocess_environ_for_creating_local_spark_session():
|
|
from mlflow.utils.databricks_utils import is_in_databricks_runtime
|
|
|
|
if is_in_databricks_runtime():
|
|
os.environ["SPARK_DIST_CLASSPATH"] = "/databricks/jars/*"
|
|
|
|
os.environ.pop("PYSPARK_GATEWAY_PORT", None)
|
|
os.environ.pop("PYSPARK_GATEWAY_SECRET", None)
|
|
|
|
|
|
def _get_spark_scala_version_from_spark_session(spark):
|
|
version = spark._jvm.scala.util.Properties.versionNumberString().split(".", 2)
|
|
return f"{version[0]}.{version[1]}"
|
|
|
|
|
|
def _get_spark_scala_version_child_proc_target(result_queue):
|
|
from pyspark.sql import SparkSession
|
|
|
|
_prepare_subprocess_environ_for_creating_local_spark_session()
|
|
with SparkSession.builder.master("local[1]").getOrCreate() as spark_session:
|
|
scala_version = _get_spark_scala_version_from_spark_session(spark_session)
|
|
result_queue.put(scala_version)
|
|
|
|
|
|
def _get_spark_scala_version():
|
|
from mlflow.utils.databricks_utils import is_in_databricks_runtime
|
|
|
|
if is_in_databricks_runtime() and "SPARK_SCALA_VERSION" in os.environ:
|
|
return os.environ["SPARK_SCALA_VERSION"]
|
|
|
|
if spark := _get_active_spark_session():
|
|
return _get_spark_scala_version_from_spark_session(spark)
|
|
|
|
result_queue = multiprocessing.Queue()
|
|
|
|
# If we need to create a new spark local session for reading scala version,
|
|
# we have to create the temporal spark session in a child process,
|
|
# if we create the temporal spark session in current process,
|
|
# after terminating the temporal spark session, creating another spark session
|
|
# with "spark.jars.packages" configuration doesn't work.
|
|
proc = multiprocessing.Process(
|
|
target=_get_spark_scala_version_child_proc_target, args=(result_queue,)
|
|
)
|
|
proc.start()
|
|
proc.join()
|
|
if proc.exitcode != 0:
|
|
raise RuntimeError("Failed to read scala version.")
|
|
|
|
return result_queue.get()
|
|
|
|
|
|
def _create_local_spark_session_for_loading_spark_model():
|
|
from pyspark.sql import SparkSession
|
|
|
|
return (
|
|
SparkSession.builder
|
|
.config("spark.python.worker.reuse", "true")
|
|
# The config is a workaround for avoiding databricks delta cache issue when loading
|
|
# some specific model such as ALSModel.
|
|
.config("spark.databricks.io.cache.enabled", "false")
|
|
# In Spark 3.1 and above, we need to set this conf explicitly to enable creating
|
|
# a SparkSession on the workers
|
|
.config("spark.executor.allowSparkContext", "true")
|
|
# Binding "spark.driver.host" to 127.0.0.1 helps avoiding some local hostname
|
|
# related issues (e.g. https://github.com/mlflow/mlflow/issues/5733).
|
|
# Note that we should set "spark.driver.host" instead of "spark.driver.bindAddress",
|
|
# the latter one only set server binding host, but it doesn't set client side request
|
|
# destination host.
|
|
.config("spark.driver.host", "127.0.0.1")
|
|
.config("spark.executor.allowSparkContext", "true")
|
|
.config(
|
|
"spark.driver.extraJavaOptions",
|
|
"-Dlog4j.configuration=file:/usr/local/spark/conf/log4j.properties",
|
|
)
|
|
.master("local[1]")
|
|
.getOrCreate()
|
|
)
|
|
|
|
|
|
_NFS_PATH_PREFIX = "nfs:"
|
|
|
|
|
|
def _get_spark_distributor_nfs_cache_dir():
|
|
from mlflow.utils.nfs_on_spark import get_nfs_cache_root_dir # avoid circular import
|
|
|
|
if (nfs_root_dir := get_nfs_cache_root_dir()) is not None:
|
|
cache_dir = os.path.join(nfs_root_dir, "mlflow_distributor_cache_dir")
|
|
os.makedirs(cache_dir, exist_ok=True)
|
|
return cache_dir
|
|
return None
|
|
|
|
|
|
class _SparkDirectoryDistributor:
|
|
"""Distribute spark directory from driver to executors."""
|
|
|
|
_extracted_dir_paths = {}
|
|
|
|
def __init__(self):
|
|
pass
|
|
|
|
@staticmethod
|
|
def add_dir(spark, dir_path):
|
|
"""Given a SparkSession and a model_path which refers to a pyfunc directory locally,
|
|
we will zip the directory up, enable it to be distributed to executors, and return
|
|
the "archive_path", which should be used as the path in get_or_load().
|
|
"""
|
|
_, archive_basepath = tempfile.mkstemp()
|
|
# NB: We must archive the directory as Spark.addFile does not support non-DFS
|
|
# directories when recursive=True.
|
|
archive_path = shutil.make_archive(archive_basepath, "zip", dir_path)
|
|
|
|
if (nfs_cache_dir := _get_spark_distributor_nfs_cache_dir()) is not None:
|
|
# If NFS directory (shared by all spark nodes) is available, use NFS directory
|
|
# instead of `SparkContext.addFile` to distribute files.
|
|
# Because `SparkContext.addFile` is not secure, so it is not allowed to be called
|
|
# on a shared cluster.
|
|
dest_path = os.path.join(nfs_cache_dir, os.path.basename(archive_path))
|
|
shutil.copy(archive_path, dest_path)
|
|
return _NFS_PATH_PREFIX + dest_path
|
|
|
|
spark.sparkContext.addFile(archive_path)
|
|
return archive_path
|
|
|
|
@staticmethod
|
|
def get_or_extract(archive_path):
|
|
"""Given a path returned by add_local_model(), this method will return a tuple of
|
|
(loaded_model, local_model_path).
|
|
If this Python process ever loaded the model before, we will reuse that copy.
|
|
"""
|
|
from pyspark.files import SparkFiles
|
|
|
|
if archive_path in _SparkDirectoryDistributor._extracted_dir_paths:
|
|
return _SparkDirectoryDistributor._extracted_dir_paths[archive_path]
|
|
|
|
# BUG: Despite the documentation of SparkContext.addFile() and SparkFiles.get() in Scala
|
|
# and Python, it turns out that we actually need to use the basename as the input to
|
|
# SparkFiles.get(), as opposed to the (absolute) path.
|
|
if archive_path.startswith(_NFS_PATH_PREFIX):
|
|
local_path = archive_path[len(_NFS_PATH_PREFIX) :]
|
|
else:
|
|
archive_path_basename = os.path.basename(archive_path)
|
|
local_path = SparkFiles.get(archive_path_basename)
|
|
temp_dir = tempfile.mkdtemp()
|
|
zip_ref = zipfile.ZipFile(local_path, "r")
|
|
zip_ref.extractall(temp_dir)
|
|
zip_ref.close()
|
|
|
|
_SparkDirectoryDistributor._extracted_dir_paths[archive_path] = temp_dir
|
|
return _SparkDirectoryDistributor._extracted_dir_paths[archive_path]
|
|
|
|
|
|
@contextlib.contextmanager
|
|
def modified_environ(update):
|
|
"""Temporarily updates the ``os.environ`` dictionary in-place.
|
|
|
|
The ``os.environ`` dictionary is updated in-place so that the modification
|
|
is sure to work in all situations.
|
|
|
|
Args:
|
|
update: Dictionary of environment variables and values to add/update.
|
|
"""
|
|
update = update or {}
|
|
original_env = {k: os.environ.get(k) for k in update}
|
|
|
|
try:
|
|
os.environ.update(update)
|
|
yield
|
|
finally:
|
|
for k, v in original_env.items():
|
|
if v is None:
|
|
os.environ.pop(k, None)
|
|
else:
|
|
os.environ[k] = v
|