Skip to content

Commit 6fc38c0

Browse files
committed
Enable inference serving capabilities on sagemaker endpoint using tornado
1 parent 6fdda45 commit 6fc38c0

17 files changed

Lines changed: 504 additions & 5 deletions

File tree

‎src/utils.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ def get_dir_for_version(version: Version) -> str:
2121
# Clean versioning strings
2222
def post_handle_container_log(container_log_content):
2323
# Work with bytes directly
24-
return container_log_content.replace(b'%21', b'!')
24+
return container_log_content.replace(b"%21", b"!")
2525

2626

2727
def is_exists_dir_for_version(version: Version, file_name_to_verify_existence="Dockerfile") -> bool:

‎template/v3/Dockerfile‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -191,7 +191,7 @@ RUN mkdir -p $SAGEMAKER_LOGGING_DIR && \
191191
&& ${HOME_DIR}/oss_compliance/generate_oss_compliance.sh ${HOME_DIR} python \
192192
&& rm -rf ${HOME_DIR}/oss_compliance*
193193

194-
ENV PATH="/opt/conda/bin:/opt/conda/condabin:$PATH"
194+
ENV PATH="/etc/sagemaker-inference-server:/opt/conda/bin:/opt/conda/condabin:$PATH"
195195
WORKDIR "/home/${NB_USER}"
196196
ENV SHELL=/bin/bash
197197
ENV OPENSSL_MODULES=/opt/conda/lib64/ossl-modules/
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
from __future__ import absolute_import
2+
3+
import utils.logger
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
#!/bin/bash
2+
python /etc/sagemaker-inference-server/serve.py
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
from __future__ import absolute_import
2+
3+
"""
4+
TODO: when adding support for more serving frameworks, move the below logic into a condition statement.
5+
We also need to define the right environment variable for signify what serving framework to use.
6+
7+
Ex.
8+
9+
inference_server = None
10+
serving_framework = os.getenv("SAGEMAKER_INFERENCE_FRAMEWORK", None)
11+
12+
if serving_framework == "FastAPI":
13+
inference_server = FastApiServer()
14+
elif serving_framework == "Flask":
15+
inference_server = FlaskServer()
16+
else:
17+
inference_server = TornadoServer()
18+
19+
inference_server.serve()
20+
21+
"""
22+
from tornado_server.server import TornadoServer
23+
24+
inference_server = TornadoServer()
25+
inference_server.serve()
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
from __future__ import absolute_import
2+
3+
import pathlib
4+
import sys
5+
6+
# make the utils modules accessible to modules from within the tornado_server folder
7+
utils_path = pathlib.Path(__file__).parent.parent / "utils"
8+
sys.path.insert(0, str(utils_path.resolve()))
9+
10+
# make the tornado_server modules accessible to each other
11+
tornado_module_path = pathlib.Path(__file__).parent
12+
sys.path.insert(0, str(tornado_module_path.resolve()))
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
from __future__ import absolute_import
2+
3+
import asyncio
4+
import logging
5+
from typing import AsyncIterator, Iterator
6+
7+
import tornado.web
8+
from stream_handler import StreamHandler
9+
10+
from utils.environment import Environment
11+
from utils.exception import AsyncInvocationsException
12+
from utils.logger import SAGEMAKER_DISTRIBUTION_INFERENCE_LOGGER
13+
14+
logger = logging.getLogger(SAGEMAKER_DISTRIBUTION_INFERENCE_LOGGER)
15+
16+
17+
class InvocationsHandler(tornado.web.RequestHandler, StreamHandler):
18+
"""Handler mapped to the /invocations POST route.
19+
20+
This handler wraps the async handler retrieved from the inference script
21+
and encapsulates it behind the post() method. The post() method is done
22+
asynchronously.
23+
"""
24+
25+
def initialize(self, handler: callable, environment: Environment):
26+
"""Initializes the handler function and the serving environment."""
27+
28+
self._handler = handler
29+
self._environment = environment
30+
31+
async def post(self):
32+
"""POST method used to encapsulate and invoke the async handle method asynchronously"""
33+
34+
try:
35+
response = await self._handler(self.request)
36+
37+
if isinstance(response, Iterator):
38+
await self.stream(response)
39+
elif isinstance(response, AsyncIterator):
40+
await self.astream(response)
41+
else:
42+
self.write(response)
43+
except Exception as e:
44+
raise AsyncInvocationsException(e)
45+
46+
47+
class PingHandler(tornado.web.RequestHandler):
48+
"""Handler mapped to the /ping GET route.
49+
50+
Ping handler to monitor the health of the Tornados server.
51+
"""
52+
53+
def get(self):
54+
"""Simple GET method to assess the health of the server."""
55+
56+
self.write("")
57+
58+
59+
async def handle(handler: callable, environment: Environment):
60+
"""Serves the async handler function using Tornado.
61+
62+
Opens the /invocations and /ping routes used by a SageMaker Endpoint
63+
for inference serving capabilities.
64+
"""
65+
66+
logger.info("Starting inference server in asynchronous mode...")
67+
68+
app = tornado.web.Application(
69+
[
70+
(r"/invocations", InvocationsHandler, dict(handler=handler, environment=environment)),
71+
(r"/ping", PingHandler),
72+
]
73+
)
74+
app.listen(environment.port)
75+
logger.debug(f"Asynchronous inference server listening on port: `{environment.port}`")
76+
await asyncio.Event().wait()
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
from __future__ import absolute_import
2+
3+
import asyncio
4+
import importlib
5+
import logging
6+
import subprocess
7+
import sys
8+
from pathlib import Path
9+
10+
from utils.environment import Environment
11+
from utils.exception import (
12+
InferenceCodeLoadException,
13+
RequirementsInstallException,
14+
ServerStartException,
15+
)
16+
from utils.logger import SAGEMAKER_DISTRIBUTION_INFERENCE_LOGGER
17+
18+
logger = logging.getLogger(SAGEMAKER_DISTRIBUTION_INFERENCE_LOGGER)
19+
20+
21+
class TornadoServer:
22+
"""Holds serving logic using the Tornado framework.
23+
24+
The serve.py script will invoke TornadoServer.serve() to start the serving process.
25+
The TornadoServer will install the runtime requirements specified through a requirements file.
26+
It will then load an handler function within an inference script and then front it will an /invocations
27+
route using the Tornado framework.
28+
"""
29+
30+
def __init__(self):
31+
"""Initialize the serving behaviors.
32+
33+
Defines the serving behavior through Environment() and locate where
34+
the inference code is contained.
35+
"""
36+
37+
self._environment = Environment()
38+
logger.setLevel(self._environment.logging_level)
39+
logger.debug(f"Environment: {str(self._environment)}")
40+
41+
self._path_to_inference_code = (
42+
Path(self._environment.base_directory).joinpath(self._environment.code_directory)
43+
if self._environment.code_directory
44+
else Path(self._environment.base_directory)
45+
)
46+
logger.debug(f"Path to inference code: `{str(self._path_to_inference_code)}`")
47+
48+
def initialize(self):
49+
"""Initialize the serving artifacts and dependencies.
50+
51+
Install the runtime requirements and then locate the handler function from
52+
the inference script.
53+
"""
54+
55+
logger.info("Initializing inference server...")
56+
self._install_runtime_requirements()
57+
self._handler = self._load_inference_handler()
58+
59+
def serve(self):
60+
"""Orchestrate the initialization and server startup behavior.
61+
62+
Call the initalize() method, determine the right Tornado serving behavior (async or sync),
63+
and then start the Tornado server through asyncio
64+
"""
65+
66+
logger.info("Serving inference requests using Tornado...")
67+
self.initialize()
68+
69+
if asyncio.iscoroutinefunction(self._handler):
70+
import async_handler as inference_handler
71+
else:
72+
import sync_handler as inference_handler
73+
74+
try:
75+
asyncio.run(inference_handler.handle(self._handler, self._environment))
76+
except Exception as e:
77+
raise ServerStartException(e)
78+
79+
def _install_runtime_requirements(self):
80+
"""Install the runtime requirements."""
81+
82+
logger.info("Installing runtime requirements...")
83+
requirements_txt = self._path_to_inference_code.joinpath(self._environment.requirements)
84+
if requirements_txt.is_file():
85+
try:
86+
subprocess.check_call(["micromamba", "install", "--yes", "--file", str(requirements_txt)])
87+
except Exception as e:
88+
raise RequirementsInstallException(e)
89+
else:
90+
logger.debug(f"No requirements file was found at `{str(requirements_txt)}`")
91+
92+
def _load_inference_handler(self) -> callable:
93+
"""Load the handler function from the inference script."""
94+
95+
logger.info("Loading inference handler...")
96+
inference_module_name, handle_name = self._environment.code.split(".")
97+
if inference_module_name and handle_name:
98+
inference_module_file = f"{inference_module_name}.py"
99+
module_spec = importlib.util.spec_from_file_location(
100+
inference_module_file, str(self._path_to_inference_code.joinpath(inference_module_file))
101+
)
102+
if module_spec:
103+
sys.path.insert(0, str(self._path_to_inference_code.resolve()))
104+
module = importlib.util.module_from_spec(module_spec)
105+
module_spec.loader.exec_module(module)
106+
107+
if hasattr(module, handle_name):
108+
handler = getattr(module, handle_name)
109+
else:
110+
raise InferenceCodeLoadException(
111+
f"Handler `{handle_name}` could not be found in module `{inference_module_file}`"
112+
)
113+
logger.debug(f"Loaded handler `{handle_name}` from module `{inference_module_name}`")
114+
return handler
115+
else:
116+
raise InferenceCodeLoadException(
117+
f"Inference code could not be found at `{str(self._path_to_inference_code.joinpath(inference_module_file))}`"
118+
)
119+
raise InferenceCodeLoadException(
120+
f"Inference code expected in the format of `<module>.<handler>` but was provided as {self._environment.code}"
121+
)
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
from __future__ import absolute_import
2+
3+
import logging
4+
from typing import AsyncIterator, Iterator
5+
6+
from tornado.ioloop import IOLoop
7+
8+
from utils.logger import SAGEMAKER_DISTRIBUTION_INFERENCE_LOGGER
9+
10+
logger = logging.getLogger(SAGEMAKER_DISTRIBUTION_INFERENCE_LOGGER)
11+
12+
13+
class StreamHandler:
14+
"""Mixin that enables async and sync streaming capabilities to the async and sync handlers
15+
16+
stream() runs a provided iterator/generator fn in an async manner.
17+
astream() runs a provided async iterator/generator fn in an async manner.
18+
"""
19+
20+
async def stream(self, iterator: Iterator):
21+
"""Streams the response from a sync response iterator
22+
23+
A sync iterator must be manually iterated through asynchronously.
24+
In a loop, iterate through each next(iterator) call in an async execution.
25+
"""
26+
27+
self._set_stream_headers()
28+
29+
while True:
30+
try:
31+
chunk = await IOLoop.current().run_in_executor(None, next, iterator)
32+
# Some iterators do not throw a StopIteration upon exhaustion.
33+
# Instead, they return an empty response. Account for this case.
34+
if not chunk:
35+
raise StopIteration()
36+
37+
self.write(chunk)
38+
await self.flush()
39+
except StopIteration:
40+
break
41+
except Exception as e:
42+
logger.error("Unexpected exception occurred when streaming response...")
43+
break
44+
45+
async def astream(self, aiterator: AsyncIterator):
46+
"""Streams the response from an async response iterator"""
47+
48+
self._set_stream_headers()
49+
50+
async for chunk in aiterator:
51+
self.write(chunk)
52+
await self.flush()
53+
54+
def _set_stream_headers(self):
55+
"""Set the headers in preparation for the streamed response"""
56+
57+
self.set_header("Content-Type", "text/event-stream")
58+
self.set_header("Cache-Control", "no-cache")
59+
self.set_header("Connection", "keep-alive")

0 commit comments

Comments
 (0)