Repository: SeldonIO/MLServer (language: python)

Issue:
Add support for raw_input_contents field
Add support for top-level `raw_input_contents` in gRPC spec. This field was initially introduced to work-around some performance issues within gRPC.

You can find more details on these issues: 

- https://github.com/triton-inference-server/server/issues/1821
- https://github.com/kubeflow/kfserving/pull/998


Discussion / hints:
Any updates ? Meet same problem
Hey @LuBingtan , this is prioritised on our internal roadmap but haven't gotten around it yet. 

Is this currently a blocker for you? Would be good to learn more about how it's affecting you. 
Hi @adriangonz 
Can we pass a raw input json to the ML server instead of the predefoned inference request format via seldon core deployment similar to the predict_raw method in seldon core that accepts a raw json?
Hey @divyadilip91, it's possible to define custom endpoints within a custom inference runtime. This is not currently documented, but you can see [an example in the MLflow runtime](https://github.com/SeldonIO/MLServer/blob/aa1fd2ab59dc3bbfdafa662e9b8c0485aea7c838/runtimes/mlflow/mlserver_mlflow/runtime.py#L44-L55).

Just for extra context though, this issue is not about custom payloads, but allowing the user to send a _compact_ binary representation of their V2 payload (which can reduce latency in some use cases). 
Thankyou @adriangonz 

Fix patch:
diff --git a/mlserver/codecs/base64.py b/mlserver/codecs/base64.py
index f4ec4b339..86094c16e 100644
--- a/mlserver/codecs/base64.py
+++ b/mlserver/codecs/base64.py
@@ -5,21 +5,21 @@
 from functools import partial
 
 from ..types import RequestInput, ResponseOutput
-from .utils import is_list_of
+from .lists import is_list_of
 from .base import InputCodec, register_input_codec
-from .pack import unpack, PackElement
+from .lists import as_list, ListElement
 
 _Base64StrCodec = "ascii"
 
 
-def _ensure_bytes(elem: PackElement) -> bytes:
+def _ensure_bytes(elem: ListElement) -> bytes:
     if isinstance(elem, str):
         return elem.encode(_Base64StrCodec)
 
     return elem
 
 
-def _encode_base64(elem: PackElement, use_bytes: bool) -> Union[bytes, str]:
+def _encode_base64(elem: ListElement, use_bytes: bool) -> Union[bytes, str]:
     as_bytes = _ensure_bytes(elem)
     b64_encoded = base64.b64encode(as_bytes)
     if use_bytes:
@@ -28,7 +28,7 @@ def _encode_base64(elem: PackElement, use_bytes: bool) -> Union[bytes, str]:
     return b64_encoded.decode(_Base64StrCodec)
 
 
-def _decode_base64(elem: PackElement) -> bytes:
+def _decode_base64(elem: ListElement) -> bytes:
     as_bytes = _ensure_bytes(elem)
 
     # Check that the input is valid base64.
@@ -69,7 +69,7 @@ def encode_output(
     @classmethod
     def decode_output(cls, response_output: ResponseOutput) -> List[bytes]:
         packed = response_output.data.__root__
-        return list(map(_decode_base64, unpack(packed)))
+        return list(map(_decode_base64, as_list(packed)))
 
     @classmethod
     def encode_input(
@@ -88,4 +88,4 @@ def encode_input(
     def decode_input(cls, request_input: RequestInput) -> List[bytes]:
         packed = request_input.data.__root__
 
-        return list(map(_decode_base64, unpack(packed)))
+        return list(map(_decode_base64, as_list(packed)))
diff --git a/mlserver/codecs/datetime.py b/mlserver/codecs/datetime.py
index b6f9ce701..354c607b0 100644
--- a/mlserver/codecs/datetime.py
+++ b/mlserver/codecs/datetime.py
@@ -3,9 +3,8 @@
 from functools import partial
 
 from ..types import RequestInput, ResponseOutput
-from .utils import is_list_of
+from .lists import is_list_of, as_list, ListElement
 from .base import InputCodec, register_input_codec
-from .pack import unpack, PackElement
 
 _Datetime = Union[str, datetime]
 _DatetimeStrCodec = "ascii"
@@ -27,14 +26,14 @@ def _encode_datetime(elem: _Datetime, use_bytes: bool) -> Union[bytes, str]:
     return iso_date.encode(_DatetimeStrCodec)
 
 
-def _ensure_str(elem: PackElement) -> str:
+def _ensure_str(elem: ListElement) -> str:
     if isinstance(elem, str):
         return elem
 
     return elem.decode(_DatetimeStrCodec)
 
 
-def _decode_datetime(elem: PackElement) -> datetime:
+def _decode_datetime(elem: ListElement) -> datetime:
     iso_format = _ensure_str(elem)
     return datetime.fromisoformat(iso_format)
 
@@ -69,7 +68,7 @@ def encode_output(
     def decode_output(cls, response_output: ResponseOutput) -> List[datetime]:
         packed = response_output.data.__root__
 
-        return list(map(_decode_datetime, unpack(packed)))
+        return list(map(_decode_datetime, as_list(packed)))
 
     @classmethod
     def encode_input(
@@ -87,4 +86,4 @@ def encode_input(
     def decode_input(cls, request_input: RequestInput) -> List[datetime]:
         packed = request_input.data.__root__
 
-        return list(map(_decode_datetime, unpack(packed)))
+        return list(map(_decode_datetime, as_list(packed)))
diff --git a/mlserver/codecs/lists.py b/mlserver/codecs/lists.py
new file mode 100644
index 000000000..4bdc2af8a
--- /dev/null
+++ b/mlserver/codecs/lists.py
@@ -0,0 +1,27 @@
+"""
+Utils to work transparently with either lists of strings, or single strings.
+"""
+
+from typing import Any, Type, Union, List, Iterator
+
+ListElement = Union[bytes, str]
+ListPayload = Union[ListElement, List[ListElement]]
+
+
+def is_list_of(payload: Any, instance_type: Type):
+    if not isinstance(payload, list):
+        return False
+
+    def isinstance_of_type(payload: Any) -> bool:
+        return isinstance(payload, instance_type)
+
+    return all(map(isinstance_of_type, payload))
+
+
+def as_list(payload: ListPayload) -> Iterator[ListElement]:
+    if isinstance(payload, list):
+        # If it's a list, assume list of strings
+        yield from payload
+    else:
+        # If not a list, assume that it's a single element
+        yield payload
diff --git a/mlserver/codecs/numpy.py b/mlserver/codecs/numpy.py
index e0e12ce13..36164b556 100644
--- a/mlserver/codecs/numpy.py
+++ b/mlserver/codecs/numpy.py
@@ -5,7 +5,8 @@
 from ..types import RequestInput, ResponseOutput, Parameters
 
 from .base import InputCodec, register_input_codec, register_request_codec
-from .utils import SingleInputRequestCodec, is_list_of, InputOrOutput
+from .utils import SingleInputRequestCodec, InputOrOutput
+from .lists import is_list_of
 from .string import encode_str
 
 _DatatypeToNumpy = {
diff --git a/mlserver/codecs/pack.py b/mlserver/codecs/pack.py
deleted file mode 100644
index 874f7f4af..000000000
--- a/mlserver/codecs/pack.py
+++ /dev/null
@@ -1,14 +0,0 @@
-from typing import Generator, Union, List
-
-
-PackElement = Union[bytes, str]
-PackedPayload = Union[PackElement, List[PackElement]]
-
-
-def unpack(packed: PackedPayload) -> Generator[PackElement, None, None]:
-    if isinstance(packed, list):
-        # If it's a list, assume list of strings
-        yield from packed
-    else:
-        # If there is no shape, assume that it's a single element
-        yield packed
diff --git a/mlserver/codecs/pandas.py b/mlserver/codecs/pandas.py
index fceebbec3..9f637dcbc 100644
--- a/mlserver/codecs/pandas.py
+++ b/mlserver/codecs/pandas.py
@@ -7,7 +7,7 @@
 from .numpy import to_datatype, to_dtype
 from .string import encode_str
 from .utils import get_decoded_or_raw, InputOrOutput
-from .pack import PackElement
+from .lists import ListElement
 from ..types import InferenceRequest, InferenceResponse, RequestInput, ResponseOutput
 
 
@@ -44,7 +44,7 @@ def _to_response_output(series: pd.Series, use_bytes: bool = True) -> ResponseOu
     )
 
 
-def _ensure_bytes(elem: PackElement) -> bytes:
+def _ensure_bytes(elem: ListElement) -> bytes:
     if isinstance(elem, str):
         return encode_str(elem)
 
diff --git a/mlserver/codecs/string.py b/mlserver/codecs/string.py
index 66be9442f..031fd49eb 100644
--- a/mlserver/codecs/string.py
+++ b/mlserver/codecs/string.py
@@ -2,9 +2,9 @@
 
 from ..types import RequestInput, ResponseOutput, Parameters
 
-from .utils import SingleInputRequestCodec, is_list_of, InputOrOutput
+from .utils import SingleInputRequestCodec, InputOrOutput
 from .base import InputCodec, register_input_codec, register_request_codec
-from .pack import unpack, PackElement
+from .lists import as_list, is_list_of, ListElement
 
 _DefaultStrCodec = "utf-8"
 
@@ -13,9 +13,10 @@ def encode_str(elem: str) -> bytes:
     return elem.encode(_DefaultStrCodec)
 
 
-def decode_str(encoded: PackElement, str_codec=_DefaultStrCodec) -> str:
+def decode_str(encoded: ListElement, str_codec=_DefaultStrCodec) -> str:
     if encoded is None:
         return None
+
     if isinstance(encoded, bytes):
         return encoded.decode(str_codec)
 
@@ -29,7 +30,7 @@ def decode_str(encoded: PackElement, str_codec=_DefaultStrCodec) -> str:
 
 def _decode_input_or_output(input_or_output: InputOrOutput) -> List[str]:
     packed = input_or_output.data.__root__
-    unpacked = map(decode_str, unpack(packed))
+    unpacked = map(decode_str, as_list(packed))
     return list(unpacked)
 
 
diff --git a/mlserver/codecs/utils.py b/mlserver/codecs/utils.py
index 72966ec2d..8d5f1a3fd 100644
--- a/mlserver/codecs/utils.py
+++ b/mlserver/codecs/utils.py
@@ -1,4 +1,4 @@
-from typing import Any, Union, Dict, Optional, Type
+from typing import Any, Union, Dict, Optional
 
 from ..types import (
     InferenceRequest,
@@ -34,16 +34,6 @@
 DecodedParameterName = "_decoded_payload"
 
 
-def is_list_of(payload: Any, instance_type: Type):
-    if not isinstance(payload, list):
-        return False
-
-    def isinstance_of_type(payload: Any) -> bool:
-        return isinstance(payload, instance_type)
-
-    return all(map(isinstance_of_type, payload))
-
-
 def _get_content_type(
     request: Parametrised, metadata: Optional[Tagged] = None
 ) -> Optional[str]:
diff --git a/mlserver/grpc/converters.py b/mlserver/grpc/converters.py
index 4827f6623..cb418faca 100644
--- a/mlserver/grpc/converters.py
+++ b/mlserver/grpc/converters.py
@@ -4,6 +4,7 @@
 from . import model_repository_pb2 as mr_pb
 
 from .. import types
+from ..raw import extract_raw, inject_raw
 
 _FIELDS = {
     "BOOL": "bool_contents",
@@ -135,6 +136,12 @@ def to_types(cls, pb_object: pb.ModelInferRequest) -> types.InferenceRequest:
                 for out in pb_object.outputs
             ]
 
+        if pb_object.raw_input_contents:
+            # Unpack and inject raw contents into `data` fields if present
+            inject_raw(
+                inference_request.inputs, pb_object.raw_input_contents  # type: ignore
+            )
+
         return inference_request
 
     @classmethod
@@ -301,7 +308,14 @@ def to_types(cls, pb_object: pb.ModelInferResponse) -> types.InferenceResponse:
         pass
 
     @classmethod
-    def from_types(cls, type_object: types.InferenceResponse) -> pb.ModelInferResponse:
+    def from_types(
+        cls, type_object: types.InferenceResponse, use_raw: bool = False
+    ) -> pb.ModelInferResponse:
+        if use_raw:
+            # Extract the raw data in advance, to ensure the `data` field of
+            # the output objects is empty
+            type_object.outputs, raw = extract_raw(type_object.outputs)  # type: ignore
+
         model_infer_response = pb.ModelInferResponse(
             model_name=type_object.model_name,
             outputs=[
@@ -310,6 +324,10 @@ def from_types(cls, type_object: types.InferenceResponse) -> pb.ModelInferRespon
             ],
         )
 
+        if use_raw:
+            # If using raw outputs, ensure it's set on the final object
+            model_infer_response.raw_output_contents.extend(raw)
+
         if type_object.model_version is not None:
             model_infer_response.model_version = type_object.model_version
 
@@ -330,7 +348,13 @@ class InferOutputTensorConverter:
     def to_types(
         cls, pb_object: pb.ModelInferResponse.InferOutputTensor
     ) -> types.ResponseOutput:
-        pass
+        return types.ResponseOutput(
+            name=pb_object.name,
+            shape=list(pb_object.shape),
+            datatype=pb_object.datatype,
+            parameters=ParametersConverter.to_types(pb_object.parameters),
+            data=InferTensorContentsConverter.to_types(pb_object.contents),
+        )
 
     @classmethod
     def from_types(
diff --git a/mlserver/grpc/dataplane_pb2.py b/mlserver/grpc/dataplane_pb2.py
index cb0a34044..ff1d35b99 100644
--- a/mlserver/grpc/dataplane_pb2.py
+++ b/mlserver/grpc/dataplane_pb2.py
@@ -18,7 +18,7 @@
     syntax="proto3",
     serialized_options=None,
     create_key=_descriptor._internal_create_key,
-    serialized_pb=b'\n\x0f\x64\x61taplane.proto\x12\tinference"\x13\n\x11ServerLiveRequest""\n\x12ServerLiveResponse\x12\x0c\n\x04live\x18\x01 \x01(\x08"\x14\n\x12ServerReadyRequest"$\n\x13ServerReadyResponse\x12\r\n\x05ready\x18\x01 \x01(\x08"2\n\x11ModelReadyRequest\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x0f\n\x07version\x18\x02 \x01(\t"#\n\x12ModelReadyResponse\x12\r\n\x05ready\x18\x01 \x01(\x08"\x17\n\x15ServerMetadataRequest"K\n\x16ServerMetadataResponse\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x0f\n\x07version\x18\x02 \x01(\t\x12\x12\n\nextensions\x18\x03 \x03(\t"5\n\x14ModelMetadataRequest\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x0f\n\x07version\x18\x02 \x01(\t"\xc5\x04\n\x15ModelMetadataResponse\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x10\n\x08versions\x18\x02 \x03(\t\x12\x10\n\x08platform\x18\x03 \x01(\t\x12?\n\x06inputs\x18\x04 \x03(\x0b\x32/.inference.ModelMetadataResponse.TensorMetadata\x12@\n\x07outputs\x18\x05 \x03(\x0b\x32/.inference.ModelMetadataResponse.TensorMetadata\x12\x44\n\nparameters\x18\x06 \x03(\x0b\x32\x30.inference.ModelMetadataResponse.ParametersEntry\x1a\xe2\x01\n\x0eTensorMetadata\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x10\n\x08\x64\x61tatype\x18\x02 \x01(\t\x12\r\n\x05shape\x18\x03 \x03(\x03\x12S\n\nparameters\x18\x04 \x03(\x0b\x32?.inference.ModelMetadataResponse.TensorMetadata.ParametersEntry\x1aL\n\x0fParametersEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12(\n\x05value\x18\x02 \x01(\x0b\x32\x19.inference.InferParameter:\x02\x38\x01\x1aL\n\x0fParametersEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12(\n\x05value\x18\x02 \x01(\x0b\x32\x19.inference.InferParameter:\x02\x38\x01"\xd2\x06\n\x11ModelInferRequest\x12\x12\n\nmodel_name\x18\x01 \x01(\t\x12\x15\n\rmodel_version\x18\x02 \x01(\t\x12\n\n\x02id\x18\x03 \x01(\t\x12@\n\nparameters\x18\x04 \x03(\x0b\x32,.inference.ModelInferRequest.ParametersEntry\x12=\n\x06inputs\x18\x05 \x03(\x0b\x32-.inference.ModelInferRequest.InferInputTensor\x12H\n\x07outputs\x18\x06 \x03(\x0b\x32\x37.inference.ModelInferRequest.InferRequestedOutputTensor\x1a\x94\x02\n\x10InferInputTensor\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x10\n\x08\x64\x61tatype\x18\x02 \x01(\t\x12\r\n\x05shape\x18\x03 \x03(\x03\x12Q\n\nparameters\x18\x04 \x03(\x0b\x32=.inference.ModelInferRequest.InferInputTensor.ParametersEntry\x12\x30\n\x08\x63ontents\x18\x05 \x01(\x0b\x32\x1e.inference.InferTensorContents\x1aL\n\x0fParametersEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12(\n\x05value\x18\x02 \x01(\x0b\x32\x19.inference.InferParameter:\x02\x38\x01\x1a\xd5\x01\n\x1aInferRequestedOutputTensor\x12\x0c\n\x04name\x18\x01 \x01(\t\x12[\n\nparameters\x18\x02 \x03(\x0b\x32G.inference.ModelInferRequest.InferRequestedOutputTensor.ParametersEntry\x1aL\n\x0fParametersEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12(\n\x05value\x18\x02 \x01(\x0b\x32\x19.inference.InferParameter:\x02\x38\x01\x1aL\n\x0fParametersEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12(\n\x05value\x18\x02 \x01(\x0b\x32\x19.inference.InferParameter:\x02\x38\x01"\xb8\x04\n\x12ModelInferResponse\x12\x12\n\nmodel_name\x18\x01 \x01(\t\x12\x15\n\rmodel_version\x18\x02 \x01(\t\x12\n\n\x02id\x18\x03 \x01(\t\x12\x41\n\nparameters\x18\x04 \x03(\x0b\x32-.inference.ModelInferResponse.ParametersEntry\x12@\n\x07outputs\x18\x05 \x03(\x0b\x32/.inference.ModelInferResponse.InferOutputTensor\x1a\x97\x02\n\x11InferOutputTensor\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x10\n\x08\x64\x61tatype\x18\x02 \x01(\t\x12\r\n\x05shape\x18\x03 \x03(\x03\x12S\n\nparameters\x18\x04 \x03(\x0b\x32?.inference.ModelInferResponse.InferOutputTensor.ParametersEntry\x12\x30\n\x08\x63ontents\x18\x05 \x01(\x0b\x32\x1e.inference.InferTensorContents\x1aL\n\x0fParametersEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12(\n\x05value\x18\x02 \x01(\x0b\x32\x19.inference.InferParameter:\x02\x38\x01\x1aL\n\x0fParametersEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12(\n\x05value\x18\x02 \x01(\x0b\x32\x19.inference.InferParameter:\x02\x38\x01"i\n\x0eInferParameter\x12\x14\n\nbool_param\x18\x01 \x01(\x08H\x00\x12\x15\n\x0bint64_param\x18\x02 \x01(\x03H\x00\x12\x16\n\x0cstring_param\x18\x03 \x01(\tH\x00\x42\x12\n\x10parameter_choice"\xd0\x01\n\x13InferTensorContents\x12\x15\n\rbool_contents\x18\x01 \x03(\x08\x12\x14\n\x0cint_contents\x18\x02 \x03(\x05\x12\x16\n\x0eint64_contents\x18\x03 \x03(\x03\x12\x15\n\ruint_contents\x18\x04 \x03(\r\x12\x17\n\x0fuint64_contents\x18\x05 \x03(\x04\x12\x15\n\rfp32_contents\x18\x06 \x03(\x02\x12\x15\n\rfp64_contents\x18\x07 \x03(\x01\x12\x16\n\x0e\x62ytes_contents\x18\x08 \x03(\x0c"\x8a\x01\n\x18ModelRepositoryParameter\x12\x14\n\nbool_param\x18\x01 \x01(\x08H\x00\x12\x15\n\x0bint64_param\x18\x02 \x01(\x03H\x00\x12\x16\n\x0cstring_param\x18\x03 \x01(\tH\x00\x12\x15\n\x0b\x62ytes_param\x18\x04 \x01(\x0cH\x00\x42\x12\n\x10parameter_choice"@\n\x16RepositoryIndexRequest\x12\x17\n\x0frepository_name\x18\x01 \x01(\t\x12\r\n\x05ready\x18\x02 \x01(\x08"\xa4\x01\n\x17RepositoryIndexResponse\x12=\n\x06models\x18\x01 \x03(\x0b\x32-.inference.RepositoryIndexResponse.ModelIndex\x1aJ\n\nModelIndex\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x0f\n\x07version\x18\x02 \x01(\t\x12\r\n\x05state\x18\x03 \x01(\t\x12\x0e\n\x06reason\x18\x04 \x01(\t"\xec\x01\n\x1aRepositoryModelLoadRequest\x12\x17\n\x0frepository_name\x18\x01 \x01(\t\x12\x12\n\nmodel_name\x18\x02 \x01(\t\x12I\n\nparameters\x18\x03 \x03(\x0b\x32\x35.inference.RepositoryModelLoadRequest.ParametersEntry\x1aV\n\x0fParametersEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\x32\n\x05value\x18\x02 \x01(\x0b\x32#.inference.ModelRepositoryParameter:\x02\x38\x01"\x1d\n\x1bRepositoryModelLoadResponse"\xf0\x01\n\x1cRepositoryModelUnloadRequest\x12\x17\n\x0frepository_name\x18\x01 \x01(\t\x12\x12\n\nmodel_name\x18\x02 \x01(\t\x12K\n\nparameters\x18\x03 \x03(\x0b\x32\x37.inference.RepositoryModelUnloadRequest.ParametersEntry\x1aV\n\x0fParametersEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\x32\n\x05value\x18\x02 \x01(\x0b\x32#.inference.ModelRepositoryParameter:\x02\x38\x01"\x1f\n\x1dRepositoryModelUnloadResponse2\xae\x06\n\x14GRPCInferenceService\x12K\n\nServerLive\x12\x1c.inference.ServerLiveRequest\x1a\x1d.inference.ServerLiveResponse"\x00\x12N\n\x0bServerReady\x12\x1d.inference.ServerReadyRequest\x1a\x1e.inference.ServerReadyResponse"\x00\x12K\n\nModelReady\x12\x1c.inference.ModelReadyRequest\x1a\x1d.inference.ModelReadyResponse"\x00\x12W\n\x0eServerMetadata\x12 .inference.ServerMetadataRequest\x1a!.inference.ServerMetadataResponse"\x00\x12T\n\rModelMetadata\x12\x1f.inference.ModelMetadataRequest\x1a .inference.ModelMetadataResponse"\x00\x12K\n\nModelInfer\x12\x1c.inference.ModelInferRequest\x1a\x1d.inference.ModelInferResponse"\x00\x12Z\n\x0fRepositoryIndex\x12!.inference.RepositoryIndexRequest\x1a".inference.RepositoryIndexResponse"\x00\x12\x66\n\x13RepositoryModelLoad\x12%.inference.RepositoryModelLoadRequest\x1a&.inference.RepositoryModelLoadResponse"\x00\x12l\n\x15RepositoryModelUnload\x12\'.inference.RepositoryModelUnloadRequest\x1a(.inference.RepositoryModelUnloadResponse"\x00\x62\x06proto3',
+    serialized_pb=b'\n\x0f\x64\x61taplane.proto\x12\tinference"\x13\n\x11ServerLiveRequest""\n\x12ServerLiveResponse\x12\x0c\n\x04live\x18\x01 \x01(\x08"\x14\n\x12ServerReadyRequest"$\n\x13ServerReadyResponse\x12\r\n\x05ready\x18\x01 \x01(\x08"2\n\x11ModelReadyRequest\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x0f\n\x07version\x18\x02 \x01(\t"#\n\x12ModelReadyResponse\x12\r\n\x05ready\x18\x01 \x01(\x08"\x17\n\x15ServerMetadataRequest"K\n\x16ServerMetadataResponse\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x0f\n\x07version\x18\x02 \x01(\t\x12\x12\n\nextensions\x18\x03 \x03(\t"5\n\x14ModelMetadataRequest\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x0f\n\x07version\x18\x02 \x01(\t"\xc5\x04\n\x15ModelMetadataResponse\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x10\n\x08versions\x18\x02 \x03(\t\x12\x10\n\x08platform\x18\x03 \x01(\t\x12?\n\x06inputs\x18\x04 \x03(\x0b\x32/.inference.ModelMetadataResponse.TensorMetadata\x12@\n\x07outputs\x18\x05 \x03(\x0b\x32/.inference.ModelMetadataResponse.TensorMetadata\x12\x44\n\nparameters\x18\x06 \x03(\x0b\x32\x30.inference.ModelMetadataResponse.ParametersEntry\x1a\xe2\x01\n\x0eTensorMetadata\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x10\n\x08\x64\x61tatype\x18\x02 \x01(\t\x12\r\n\x05shape\x18\x03 \x03(\x03\x12S\n\nparameters\x18\x04 \x03(\x0b\x32?.inference.ModelMetadataResponse.TensorMetadata.ParametersEntry\x1aL\n\x0fParametersEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12(\n\x05value\x18\x02 \x01(\x0b\x32\x19.inference.InferParameter:\x02\x38\x01\x1aL\n\x0fParametersEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12(\n\x05value\x18\x02 \x01(\x0b\x32\x19.inference.InferParameter:\x02\x38\x01"\xee\x06\n\x11ModelInferRequest\x12\x12\n\nmodel_name\x18\x01 \x01(\t\x12\x15\n\rmodel_version\x18\x02 \x01(\t\x12\n\n\x02id\x18\x03 \x01(\t\x12@\n\nparameters\x18\x04 \x03(\x0b\x32,.inference.ModelInferRequest.ParametersEntry\x12=\n\x06inputs\x18\x05 \x03(\x0b\x32-.inference.ModelInferRequest.InferInputTensor\x12H\n\x07outputs\x18\x06 \x03(\x0b\x32\x37.inference.ModelInferRequest.InferRequestedOutputTensor\x12\x1a\n\x12raw_input_contents\x18\x07 \x03(\x0c\x1a\x94\x02\n\x10InferInputTensor\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x10\n\x08\x64\x61tatype\x18\x02 \x01(\t\x12\r\n\x05shape\x18\x03 \x03(\x03\x12Q\n\nparameters\x18\x04 \x03(\x0b\x32=.inference.ModelInferRequest.InferInputTensor.ParametersEntry\x12\x30\n\x08\x63ontents\x18\x05 \x01(\x0b\x32\x1e.inference.InferTensorContents\x1aL\n\x0fParametersEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12(\n\x05value\x18\x02 \x01(\x0b\x32\x19.inference.InferParameter:\x02\x38\x01\x1a\xd5\x01\n\x1aInferRequestedOutputTensor\x12\x0c\n\x04name\x18\x01 \x01(\t\x12[\n\nparameters\x18\x02 \x03(\x0b\x32G.inference.ModelInferRequest.InferRequestedOutputTensor.ParametersEntry\x1aL\n\x0fParametersEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12(\n\x05value\x18\x02 \x01(\x0b\x32\x19.inference.InferParameter:\x02\x38\x01\x1aL\n\x0fParametersEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12(\n\x05value\x18\x02 \x01(\x0b\x32\x19.inference.InferParameter:\x02\x38\x01"\xd5\x04\n\x12ModelInferResponse\x12\x12\n\nmodel_name\x18\x01 \x01(\t\x12\x15\n\rmodel_version\x18\x02 \x01(\t\x12\n\n\x02id\x18\x03 \x01(\t\x12\x41\n\nparameters\x18\x04 \x03(\x0b\x32-.inference.ModelInferResponse.ParametersEntry\x12@\n\x07outputs\x18\x05 \x03(\x0b\x32/.inference.ModelInferResponse.InferOutputTensor\x12\x1b\n\x13raw_output_contents\x18\x06 \x03(\x0c\x1a\x97\x02\n\x11InferOutputTensor\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x10\n\x08\x64\x61tatype\x18\x02 \x01(\t\x12\r\n\x05shape\x18\x03 \x03(\x03\x12S\n\nparameters\x18\x04 \x03(\x0b\x32?.inference.ModelInferResponse.InferOutputTensor.ParametersEntry\x12\x30\n\x08\x63ontents\x18\x05 \x01(\x0b\x32\x1e.inference.InferTensorContents\x1aL\n\x0fParametersEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12(\n\x05value\x18\x02 \x01(\x0b\x32\x19.inference.InferParameter:\x02\x38\x01\x1aL\n\x0fParametersEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12(\n\x05value\x18\x02 \x01(\x0b\x32\x19.inference.InferParameter:\x02\x38\x01"i\n\x0eInferParameter\x12\x14\n\nbool_param\x18\x01 \x01(\x08H\x00\x12\x15\n\x0bint64_param\x18\x02 \x01(\x03H\x00\x12\x16\n\x0cstring_param\x18\x03 \x01(\tH\x00\x42\x12\n\x10parameter_choice"\xd0\x01\n\x13InferTensorContents\x12\x15\n\rbool_contents\x18\x01 \x03(\x08\x12\x14\n\x0cint_contents\x18\x02 \x03(\x05\x12\x16\n\x0eint64_contents\x18\x03 \x03(\x03\x12\x15\n\ruint_contents\x18\x04 \x03(\r\x12\x17\n\x0fuint64_contents\x18\x05 \x03(\x04\x12\x15\n\rfp32_contents\x18\x06 \x03(\x02\x12\x15\n\rfp64_contents\x18\x07 \x03(\x01\x12\x16\n\x0e\x62ytes_contents\x18\x08 \x03(\x0c"\x8a\x01\n\x18ModelRepositoryParameter\x12\x14\n\nbool_param\x18\x01 \x01(\x08H\x00\x12\x15\n\x0bint64_param\x18\x02 \x01(\x03H\x00\x12\x16\n\x0cstring_param\x18\x03 \x01(\tH\x00\x12\x15\n\x0b\x62ytes_param\x18\x04 \x01(\x0cH\x00\x42\x12\n\x10parameter_choice"@\n\x16RepositoryIndexRequest\x12\x17\n\x0frepository_name\x18\x01 \x01(\t\x12\r\n\x05ready\x18\x02 \x01(\x08"\xa4\x01\n\x17RepositoryIndexResponse\x12=\n\x06models\x18\x01 \x03(\x0b\x32-.inference.RepositoryIndexResponse.ModelIndex\x1aJ\n\nModelIndex\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x0f\n\x07version\x18\x02 \x01(\t\x12\r\n\x05state\x18\x03 \x01(\t\x12\x0e\n\x06reason\x18\x04 \x01(\t"\xec\x01\n\x1aRepositoryModelLoadRequest\x12\x17\n\x0frepository_name\x18\x01 \x01(\t\x12\x12\n\nmodel_name\x18\x02 \x01(\t\x12I\n\nparameters\x18\x03 \x03(\x0b\x32\x35.inference.RepositoryModelLoadRequest.ParametersEntry\x1aV\n\x0fParametersEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\x32\n\x05value\x18\x02 \x01(\x0b\x32#.inference.ModelRepositoryParameter:\x02\x38\x01"\x1d\n\x1bRepositoryModelLoadResponse"\xf0\x01\n\x1cRepositoryModelUnloadRequest\x12\x17\n\x0frepository_name\x18\x01 \x01(\t\x12\x12\n\nmodel_name\x18\x02 \x01(\t\x12K\n\nparameters\x18\x03 \x03(\x0b\x32\x37.inference.RepositoryModelUnloadRequest.ParametersEntry\x1aV\n\x0fParametersEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\x32\n\x05value\x18\x02 \x01(\x0b\x32#.inference.ModelRepositoryParameter:\x02\x38\x01"\x1f\n\x1dRepositoryModelUnloadResponse2\xae\x06\n\x14GRPCInferenceService\x12K\n\nServerLive\x12\x1c.inference.ServerLiveRequest\x1a\x1d.inference.ServerLiveResponse"\x00\x12N\n\x0bServerReady\x12\x1d.inference.ServerReadyRequest\x1a\x1e.inference.ServerReadyResponse"\x00\x12K\n\nModelReady\x12\x1c.inference.ModelReadyRequest\x1a\x1d.inference.ModelReadyResponse"\x00\x12W\n\x0eServerMetadata\x12 .inference.ServerMetadataRequest\x1a!.inference.ServerMetadataResponse"\x00\x12T\n\rModelMetadata\x12\x1f.inference.ModelMetadataRequest\x1a .inference.ModelMetadataResponse"\x00\x12K\n\nModelInfer\x12\x1c.inference.ModelInferRequest\x1a\x1d.inference.ModelInferResponse"\x00\x12Z\n\x0fRepositoryIndex\x12!.inference.RepositoryIndexRequest\x1a".inference.RepositoryIndexResponse"\x00\x12\x66\n\x13RepositoryModelLoad\x12%.inference.RepositoryModelLoadRequest\x1a&.inference.RepositoryModelLoadResponse"\x00\x12l\n\x15RepositoryModelUnload\x12\'.inference.RepositoryModelUnloadRequest\x1a(.inference.RepositoryModelUnloadResponse"\x00\x62\x06proto3',
 )
 
 
@@ -936,8 +936,8 @@
     syntax="proto3",
     extension_ranges=[],
     oneofs=[],
-    serialized_start=1258,
-    serialized_end=1534,
+    serialized_start=1286,
+    serialized_end=1562,
 )
 
 _MODELINFERREQUEST_INFERREQUESTEDOUTPUTTENSOR_PARAMETERSENTRY = _descriptor.Descriptor(
@@ -1056,8 +1056,8 @@
     syntax="proto3",
     extension_ranges=[],
     oneofs=[],
-    serialized_start=1537,
-    serialized_end=1750,
+    serialized_start=1565,
+    serialized_end=1778,
 )
 
 _MODELINFERREQUEST_PARAMETERSENTRY = _descriptor.Descriptor(
@@ -1241,6 +1241,25 @@
             file=DESCRIPTOR,
             create_key=_descriptor._internal_create_key,
         ),
+        _descriptor.FieldDescriptor(
+            name="raw_input_contents",
+            full_name="inference.ModelInferRequest.raw_input_contents",
+            index=6,
+            number=7,
+            type=12,
+            cpp_type=9,
+            label=3,
+            has_default_value=False,
+            default_value=[],
+            message_type=None,
+            enum_type=None,
+            containing_type=None,
+            is_extension=False,
+            extension_scope=None,
+            serialized_options=None,
+            file=DESCRIPTOR,
+            create_key=_descriptor._internal_create_key,
+        ),
     ],
     extensions=[],
     nested_types=[
@@ -1255,7 +1274,7 @@
     extension_ranges=[],
     oneofs=[],
     serialized_start=978,
-    serialized_end=1828,
+    serialized_end=1856,
 )
 
 
@@ -1432,8 +1451,8 @@
     syntax="proto3",
     extension_ranges=[],
     oneofs=[],
-    serialized_start=2042,
-    serialized_end=2321,
+    serialized_start=2099,
+    serialized_end=2378,
 )
 
 _MODELINFERRESPONSE_PARAMETERSENTRY = _descriptor.Descriptor(
@@ -1598,6 +1617,25 @@
             file=DESCRIPTOR,
             create_key=_descriptor._internal_create_key,
         ),
+        _descriptor.FieldDescriptor(
+            name="raw_output_contents",
+            full_name="inference.ModelInferResponse.raw_output_contents",
+            index=5,
+            number=6,
+            type=12,
+            cpp_type=9,
+            label=3,
+            has_default_value=False,
+            default_value=[],
+            message_type=None,
+            enum_type=None,
+            containing_type=None,
+            is_extension=False,
+            extension_scope=None,
+            serialized_options=None,
+            file=DESCRIPTOR,
+            create_key=_descriptor._internal_create_key,
+        ),
     ],
     extensions=[],
     nested_types=[
@@ -1610,8 +1648,8 @@
     syntax="proto3",
     extension_ranges=[],
     oneofs=[],
-    serialized_start=1831,
-    serialized_end=2399,
+    serialized_start=1859,
+    serialized_end=2456,
 )
 
 
@@ -1698,8 +1736,8 @@
             fields=[],
         ),
     ],
-    serialized_start=2401,
-    serialized_end=2506,
+    serialized_start=2458,
+    serialized_end=2563,
 )
 
 
@@ -1872,8 +1910,8 @@
     syntax="proto3",
     extension_ranges=[],
     oneofs=[],
-    serialized_start=2509,
-    serialized_end=2717,
+    serialized_start=2566,
+    serialized_end=2774,
 )
 
 
@@ -1979,8 +2017,8 @@
             fields=[],
         ),
     ],
-    serialized_start=2720,
-    serialized_end=2858,
+    serialized_start=2777,
+    serialized_end=2915,
 )
 
 
@@ -2039,8 +2077,8 @@
     syntax="proto3",
     extension_ranges=[],
     oneofs=[],
-    serialized_start=2860,
-    serialized_end=2924,
+    serialized_start=2917,
+    serialized_end=2981,
 )
 
 
@@ -2137,8 +2175,8 @@
     syntax="proto3",
     extension_ranges=[],
     oneofs=[],
-    serialized_start=3017,
-    serialized_end=3091,
+    serialized_start=3074,
+    serialized_end=3148,
 )
 
 _REPOSITORYINDEXRESPONSE = _descriptor.Descriptor(
@@ -2179,8 +2217,8 @@
     syntax="proto3",
     extension_ranges=[],
     oneofs=[],
-    serialized_start=2927,
-    serialized_end=3091,
+    serialized_start=2984,
+    serialized_end=3148,
 )
 
 
@@ -2239,8 +2277,8 @@
     syntax="proto3",
     extension_ranges=[],
     oneofs=[],
-    serialized_start=3244,
-    serialized_end=3330,
+    serialized_start=3301,
+    serialized_end=3387,
 )
 
 _REPOSITORYMODELLOADREQUEST = _descriptor.Descriptor(
@@ -2319,8 +2357,8 @@
     syntax="proto3",
     extension_ranges=[],
     oneofs=[],
-    serialized_start=3094,
-    serialized_end=3330,
+    serialized_start=3151,
+    serialized_end=3387,
 )
 
 
@@ -2340,8 +2378,8 @@
     syntax="proto3",
     extension_ranges=[],
     oneofs=[],
-    serialized_start=3332,
-    serialized_end=3361,
+    serialized_start=3389,
+    serialized_end=3418,
 )
 
 
@@ -2400,8 +2438,8 @@
     syntax="proto3",
     extension_ranges=[],
     oneofs=[],
-    serialized_start=3244,
-    serialized_end=3330,
+    serialized_start=3301,
+    serialized_end=3387,
 )
 
 _REPOSITORYMODELUNLOADREQUEST = _descriptor.Descriptor(
@@ -2480,8 +2518,8 @@
     syntax="proto3",
     extension_ranges=[],
     oneofs=[],
-    serialized_start=3364,
-    serialized_end=3604,
+    serialized_start=3421,
+    serialized_end=3661,
 )
 
 
@@ -2501,8 +2539,8 @@
     syntax="proto3",
     extension_ranges=[],
     oneofs=[],
-    serialized_start=3606,
-    serialized_end=3637,
+    serialized_start=3663,
+    serialized_end=3694,
 )
 
 _MODELMETADATARESPONSE_TENSORMETADATA_PARAMETERSENTRY.fields_by_name[
@@ -3071,8 +3109,8 @@
     index=0,
     serialized_options=None,
     create_key=_descriptor._internal_create_key,
-    serialized_start=3640,
-    serialized_end=4454,
+    serialized_start=3697,
+    serialized_end=4511,
     methods=[
         _descriptor.MethodDescriptor(
             name="ServerLive",
diff --git a/mlserver/grpc/dataplane_pb2.pyi b/mlserver/grpc/dataplane_pb2.pyi
index 9ca17235d..12886bf41 100644
--- a/mlserver/grpc/dataplane_pb2.pyi
+++ b/mlserver/grpc/dataplane_pb2.pyi
@@ -435,7 +435,10 @@ class ModelInferRequest(google.protobuf.message.Message):
             pass
         @property
         def contents(self) -> global___InferTensorContents:
-            """The input tensor data."""
+            """The input tensor data.
+            This field must not be specified if tensor contents are being specified
+            in ModelInferRequest.raw_input_contents.
+            """
             pass
         def __init__(
             self,
@@ -548,6 +551,7 @@ class ModelInferRequest(google.protobuf.message.Message):
     PARAMETERS_FIELD_NUMBER: builtins.int
     INPUTS_FIELD_NUMBER: builtins.int
     OUTPUTS_FIELD_NUMBER: builtins.int
+    RAW_INPUT_CONTENTS_FIELD_NUMBER: builtins.int
     model_name: typing.Text = ...
     """The name of the model to use for inferencing."""
 
@@ -587,6 +591,31 @@ class ModelInferRequest(google.protobuf.message.Message):
         specified all outputs produced by the model will be returned.
         """
         pass
+    @property
+    def raw_input_contents(
+        self,
+    ) -> google.protobuf.internal.containers.RepeatedScalarFieldContainer[
+        builtins.bytes
+    ]:
+        """The data contained in an input tensor can be represented in "raw" bytes
+        form or in the repeated type that matches the tensor's data type. Using
+        the "raw" bytes form will typically allow higher performance due to the
+        way protobuf allocation and reuse interacts with GRPC. For example, see
+        https://github.com/grpc/grpc/issues/23231.
+
+        To use the raw representation 'raw_input_contents' must be initialized
+        with data for each tensor in the same order as 'inputs'. For each tensor,
+        the size of this content must match what is expected by the tensor's shape
+        and data type. The raw data must be the flattened, one-dimensional,
+        row-major order of the tensor elements without any stride or padding
+        between the elements. Note that the FP16 and BF16 data types must be
+        represented as raw content as there is no specific data type for a 16-bit
+        float type.
+
+        If this field is specified then InferInputTensor::contents must not be
+        specified for any input tensor.
+        """
+        pass
     def __init__(
         self,
         *,
@@ -602,6 +631,7 @@ class ModelInferRequest(google.protobuf.message.Message):
         outputs: typing.Optional[
             typing.Iterable[global___ModelInferRequest.InferRequestedOutputTensor]
         ] = ...,
+        raw_input_contents: typing.Optional[typing.Iterable[builtins.bytes]] = ...,
     ) -> None: ...
     def ClearField(
         self,
@@ -618,6 +648,8 @@ class ModelInferRequest(google.protobuf.message.Message):
             b"outputs",
             "parameters",
             b"parameters",
+            "raw_input_contents",
+            b"raw_input_contents",
         ],
     ) -> None: ...
 
@@ -681,7 +713,10 @@ class ModelInferResponse(google.protobuf.message.Message):
             pass
         @property
         def contents(self) -> global___InferTensorContents:
-            """The output tensor data."""
+            """The output tensor data.
+            This field must not be specified if tensor contents are being specified
+            in ModelInferResponse.raw_output_contents.
+            """
             pass
         def __init__(
             self,
@@ -739,6 +774,7 @@ class ModelInferResponse(google.protobuf.message.Message):
     ID_FIELD_NUMBER: builtins.int
     PARAMETERS_FIELD_NUMBER: builtins.int
     OUTPUTS_FIELD_NUMBER: builtins.int
+    RAW_OUTPUT_CONTENTS_FIELD_NUMBER: builtins.int
     model_name: typing.Text = ...
     """The name of the model used for inference."""
 
@@ -764,6 +800,31 @@ class ModelInferResponse(google.protobuf.message.Message):
     ]:
         """The output tensors holding inference results."""
         pass
+    @property
+    def raw_output_contents(
+        self,
+    ) -> google.protobuf.internal.containers.RepeatedScalarFieldContainer[
+        builtins.bytes
+    ]:
+        """The data contained in an output tensor can be represented in "raw" bytes
+        form or in the repeated type that matches the tensor's data type. Using
+        the "raw" bytes form will typically allow higher performance due to the
+        way protobuf allocation and reuse interacts with GRPC. For example, see
+        https://github.com/grpc/grpc/issues/23231.
+
+        To use the raw representation 'raw_output_contents' must be initialized
+        with data for each tensor in the same order as 'outputs'. For each tensor,
+        the size of this content must match what is expected by the tensor's shape
+        and data type. The raw data must be the flattened, one-dimensional,
+        row-major order of the tensor elements without any stride or padding
+        between the elements. Note that the FP16 and BF16 data types must be
+        represented as raw content as there is no specific data type for a 16-bit
+        float type.
+
+        If this field is specified then InferOutputTensor::contents must not be
+        specified for any output tensor.
+        """
+        pass
     def __init__(
         self,
         *,
@@ -776,6 +837,7 @@ class ModelInferResponse(google.protobuf.message.Message):
         outputs: typing.Optional[
             typing.Iterable[global___ModelInferResponse.InferOutputTensor]
         ] = ...,
+        raw_output_contents: typing.Optional[typing.Iterable[builtins.bytes]] = ...,
     ) -> None: ...
     def ClearField(
         self,
@@ -790,6 +852,8 @@ class ModelInferResponse(google.protobuf.message.Message):
             b"outputs",
             "parameters",
             b"parameters",
+            "raw_output_contents",
+            b"raw_output_contents",
         ],
     ) -> None: ...
 
diff --git a/mlserver/grpc/servicers.py b/mlserver/grpc/servicers.py
index 62d6d2958..4e376d9e6 100644
--- a/mlserver/grpc/servicers.py
+++ b/mlserver/grpc/servicers.py
@@ -63,6 +63,12 @@ async def ModelMetadata(
     async def ModelInfer(
         self, request: pb.ModelInferRequest, context: grpc.ServicerContext
     ) -> pb.ModelInferResponse:
+        return_raw = False
+        if request.raw_input_contents:
+            # If the request contains raw input contents, then use the same for
+            # the output
+            return_raw = True
+
         payload = ModelInferRequestConverter.to_types(request)
 
         request_headers = to_headers(context)
@@ -77,7 +83,7 @@ async def ModelInfer(
             response_metadata = to_metadata(response_headers)
             context.set_trailing_metadata(response_metadata)
 
-        response = ModelInferResponseConverter.from_types(result)
+        response = ModelInferResponseConverter.from_types(result, use_raw=return_raw)
         return response
 
     async def RepositoryIndex(
diff --git a/mlserver/raw.py b/mlserver/raw.py
new file mode 100644
index 000000000..d8f06330f
--- /dev/null
+++ b/mlserver/raw.py
@@ -0,0 +1,121 @@
+import struct
+
+from functools import reduce
+from operator import mul
+from typing import List, Tuple
+
+from .codecs.string import encode_str
+from .codecs.utils import InputOrOutput
+from .codecs.lists import ListElement
+
+_DatatypeToCtype = {
+    "BOOL": "?",
+    "UINT8": "B",
+    "UINT16": "H",
+    "UINT32": "I",
+    "UINT64": "L",
+    "INT8": "b",
+    "INT16": "h",
+    "INT32": "i",
+    "INT64": "l",
+    "FP16": "e",
+    "FP32": "f",
+    "FP64": "d",
+}
+
+_SizeFormat = "<I"
+
+
+def _tensor_length(shape: List[int]) -> int:
+    return reduce(mul, shape, 1)
+
+
+def _tensor_format(elem: InputOrOutput) -> str:
+    size = _tensor_length(elem.shape)
+    ctype = _DatatypeToCtype[elem.datatype]
+    return f"{size}{ctype}"
+
+
+def _unpack_bytes(raw: bytes) -> List[bytes]:
+    """
+    From Triton's implementation:
+        https://github.com/triton-inference-server/client/blob/6cc412c50ca4282cec6e9f62b3c2781be433dcc6/src/python/library/tritonclient/utils/__init__.py#L246-L273
+    """
+    offset = 0
+    length = len(raw)
+    elems = []
+    while offset < length:
+        [size] = struct.unpack_from(_SizeFormat, raw, offset)
+        offset += 4
+        [elem] = struct.unpack_from(f"<{size}s", raw, offset)
+        offset += size
+
+        elems.append(elem)
+
+    return elems
+
+
+def _pack_bytes(unpacked: List[ListElement]) -> bytes:
+    packed = []
+    for elem in unpacked:
+        as_bytes = _ensure_bytes(elem)
+        size = len(as_bytes)
+
+        packed_size = struct.pack(_SizeFormat, size)
+        packed.append(packed_size)
+
+        packed_elem = struct.pack(f"<{size}s", as_bytes)
+        packed.append(packed_elem)
+
+    return b"".join(packed)
+
+
+def _ensure_bytes(elem: ListElement) -> bytes:
+    if isinstance(elem, str):
+        return encode_str(elem)
+
+    return elem
+
+
+def _unpack_tensor(elem: InputOrOutput, raw: bytes) -> list:
+    tensor_format = _tensor_format(elem)
+    return list(struct.unpack(tensor_format, raw))
+
+
+def _pack_tensor(elem: InputOrOutput) -> bytes:
+    tensor_format = _tensor_format(elem)
+    return struct.pack(tensor_format, *elem.data)
+
+
+def unpack(elem: InputOrOutput, raw: bytes) -> list:
+    if elem.datatype == "BYTES":
+        return _unpack_bytes(raw)
+
+    return _unpack_tensor(elem, raw)
+
+
+def pack(elem: InputOrOutput) -> bytes:
+    if elem.datatype == "BYTES":
+        return _pack_bytes(elem.data)  # type: ignore
+
+    return _pack_tensor(elem)
+
+
+def inject_raw(
+    elems: List[InputOrOutput], raw_contents: List[bytes]
+) -> List[InputOrOutput]:
+    for elem, raw in zip(elems, raw_contents):
+        # TODO: Assert that `data` field is empty
+        elem.data = unpack(elem, raw)  # type: ignore
+
+    return elems
+
+
+def extract_raw(elems: List[InputOrOutput]) -> Tuple[List[InputOrOutput], List[bytes]]:
+    raw_contents = []
+    for elem in elems:
+        raw = pack(elem)
+        raw_contents.append(raw)
+        elem.data = []  # type: ignore
+
+    return elems, raw_contents
diff --git a/proto/dataplane.proto b/proto/dataplane.proto
index 9a5b6cfa8..ba11224ee 100644
--- a/proto/dataplane.proto
+++ b/proto/dataplane.proto
@@ -171,6 +171,8 @@ message ModelInferRequest
     map<string, InferParameter> parameters = 4;
 
     // The input tensor data.
+    // This field must not be specified if tensor contents are being specified
+    // in ModelInferRequest.raw_input_contents.
     InferTensorContents contents = 5;
   }
 
@@ -204,6 +206,25 @@ message ModelInferRequest
   // The requested output tensors for the inference. Optional, if not
   // specified all outputs produced by the model will be returned.
   repeated InferRequestedOutputTensor outputs = 6;
+
+  // The data contained in an input tensor can be represented in "raw" bytes
+  // form or in the repeated type that matches the tensor's data type. Using
+  // the "raw" bytes form will typically allow higher performance due to the
+  // way protobuf allocation and reuse interacts with GRPC. For example, see
+  // https://github.com/grpc/grpc/issues/23231.
+  //
+  // To use the raw representation 'raw_input_contents' must be initialized
+  // with data for each tensor in the same order as 'inputs'. For each tensor,
+  // the size of this content must match what is expected by the tensor's shape
+  // and data type. The raw data must be the flattened, one-dimensional,
+  // row-major order of the tensor elements without any stride or padding
+  // between the elements. Note that the FP16 and BF16 data types must be
+  // represented as raw content as there is no specific data type for a 16-bit
+  // float type.
+  //
+  // If this field is specified then InferInputTensor::contents must not be
+  // specified for any input tensor.
+  repeated bytes raw_input_contents = 7;
 }
 
 message ModelInferResponse
@@ -224,6 +245,8 @@ message ModelInferResponse
     map<string, InferParameter> parameters = 4;
 
     // The output tensor data.
+    // This field must not be specified if tensor contents are being specified
+    // in ModelInferResponse.raw_output_contents.
     InferTensorContents contents = 5;
   }
 
@@ -241,6 +264,25 @@ message ModelInferResponse
 
   // The output tensors holding inference results.
   repeated InferOutputTensor outputs = 5;
+
+  // The data contained in an output tensor can be represented in "raw" bytes
+  // form or in the repeated type that matches the tensor's data type. Using
+  // the "raw" bytes form will typically allow higher performance due to the
+  // way protobuf allocation and reuse interacts with GRPC. For example, see
+  // https://github.com/grpc/grpc/issues/23231.
+  //
+  // To use the raw representation 'raw_output_contents' must be initialized
+  // with data for each tensor in the same order as 'outputs'. For each tensor,
+  // the size of this content must match what is expected by the tensor's shape
+  // and data type. The raw data must be the flattened, one-dimensional,
+  // row-major order of the tensor elements without any stride or padding
+  // between the elements. Note that the FP16 and BF16 data types must be
+  // represented as raw content as there is no specific data type for a 16-bit
+  // float type.
+  //
+  // If this field is specified then InferOutputTensor::contents must not be
+  // specified for any output tensor.
+  repeated bytes raw_output_contents = 6;
 }
 
 
diff --git a/runtimes/mlflow/mlserver_mlflow/codecs.py b/runtimes/mlflow/mlserver_mlflow/codecs.py
index b74768df6..06f367e70 100644
--- a/runtimes/mlflow/mlserver_mlflow/codecs.py
+++ b/runtimes/mlflow/mlserver_mlflow/codecs.py
@@ -9,7 +9,7 @@
     register_request_codec,
     get_decoded_or_raw,
 )
-from mlserver.codecs.utils import is_list_of
+from mlserver.codecs.lists import is_list_of
 
 from typing import Any
 


Test patch:
diff --git a/benchmarking/testserver/models/sum-model/models.py b/benchmarking/testserver/models/sum-model/models.py
index 9fe911de3..bc260c39b 100644
--- a/benchmarking/testserver/models/sum-model/models.py
+++ b/benchmarking/testserver/models/sum-model/models.py
@@ -8,6 +8,6 @@ async def predict(self, payload: types.InferenceRequest) -> types.InferenceRespo
             total += sum(inp.data)
 
         output = types.ResponseOutput(
-            name="total", shape=[1], datatype="FP32", data=[total]
+            name="total", shape=[1, 1], datatype="FP32", data=[total]
         )
         return types.InferenceResponse(model_name=self.name, id="1", outputs=[output])
diff --git a/tests/conftest.py b/tests/conftest.py
index 3e3ff8293..ba826e9f1 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -117,11 +117,9 @@ def inference_request(request) -> types.InferenceRequest:
     return types.InferenceRequest.parse_file(payload_path)
 
 
-@pytest.fixture(
-    params=["inference-response.json", "inference-response-with-output.json"]
-)
-def inference_response(request) -> types.InferenceResponse:
-    payload_path = os.path.join(TESTDATA_PATH, request.param)
+@pytest.fixture
+def inference_response() -> types.InferenceResponse:
+    payload_path = os.path.join(TESTDATA_PATH, "inference-response.json")
     return types.InferenceResponse.parse_file(payload_path)
 
 
diff --git a/tests/grpc/test_converters.py b/tests/grpc/test_converters.py
index 7c2220096..fbedae7e3 100644
--- a/tests/grpc/test_converters.py
+++ b/tests/grpc/test_converters.py
@@ -42,6 +42,36 @@ def test_modelmetadataresponse_from_types(metadata_model_response):
         assert pb_obj.shape == ty_obj.shape
 
 
+@pytest.mark.parametrize(
+    "model_infer_request",
+    [
+        pb.ModelInferRequest(
+            id="",
+            inputs=[
+                pb.ModelInferRequest.InferInputTensor(
+                    name="input-0",
+                    datatype="INT32",
+                    shape=[1, 3],
+                    parameters={"content_type": pb.InferParameter(string_param="np")},
+                    contents=pb.InferTensorContents(int_contents=[1, 2, 3]),
+                )
+            ],
+        ),
+        pb.ModelInferRequest(
+            id="",
+            inputs=[
+                pb.ModelInferRequest.InferInputTensor(
+                    name="input-0",
+                    datatype="INT32",
+                    shape=[1, 3],
+                    parameters={"content_type": pb.InferParameter(string_param="np")},
+                    contents=pb.InferTensorContents(),
+                )
+            ],
+            raw_input_contents=[b"\x01\x00\x00\x00\x02\x00\x00\x00\x03\x00\x00\x00"],
+        ),
+    ],
+)
 def test_modelinferrequest_to_types(model_infer_request):
     inference_request = ModelInferRequestConverter.to_types(model_infer_request)
 
@@ -62,20 +92,45 @@ def test_modelinferrequest_to_types(model_infer_request):
     assert dict(inference_request) == dict(expected)
 
 
-def test_modelinferresponse_from_types(inference_response):
-    model_infer_response = ModelInferResponseConverter.from_types(inference_response)
-
-    expected = pb.ModelInferResponse(
-        model_name="sum-model",
-        id="123",
-        outputs=[
-            pb.ModelInferResponse.InferOutputTensor(
-                name=inference_response.outputs[0].name,
-                datatype="FP32",
-                shape=[1],
-                contents=pb.InferTensorContents(fp32_contents=[21.0]),
-            )
-        ],
+@pytest.mark.parametrize(
+    "use_raw, expected",
+    [
+        (
+            False,
+            pb.ModelInferResponse(
+                model_name="sum-model",
+                id="123",
+                outputs=[
+                    pb.ModelInferResponse.InferOutputTensor(
+                        name="output-0",
+                        datatype="FP32",
+                        shape=[1],
+                        contents=pb.InferTensorContents(fp32_contents=[21.0]),
+                    )
+                ],
+            ),
+        ),
+        (
+            True,
+            pb.ModelInferResponse(
+                model_name="sum-model",
+                id="123",
+                outputs=[
+                    pb.ModelInferResponse.InferOutputTensor(
+                        name="output-0",
+                        datatype="FP32",
+                        shape=[1],
+                        contents=pb.InferTensorContents(),
+                    )
+                ],
+                raw_output_contents=[b"\x00\x00\xa8A"],
+            ),
+        ),
+    ],
+)
+def test_modelinferresponse_from_types(inference_response, use_raw, expected):
+    model_infer_response = ModelInferResponseConverter.from_types(
+        inference_response, use_raw=use_raw
     )
 
     assert type(model_infer_response) is pb.ModelInferResponse
diff --git a/tests/grpc/test_servicers.py b/tests/grpc/test_servicers.py
index 72eae3496..5b5e82d84 100644
--- a/tests/grpc/test_servicers.py
+++ b/tests/grpc/test_servicers.py
@@ -6,6 +6,12 @@
     CLOUDEVENTS_HEADER_SPECVERSION,
 )
 from mlserver.grpc import dataplane_pb2 as pb
+from mlserver.grpc.converters import (
+    InferTensorContentsConverter,
+    InferInputTensorConverter,
+    InferOutputTensorConverter,
+)
+from mlserver.raw import pack, unpack
 from mlserver import __version__
 
 
@@ -70,6 +76,33 @@ async def test_model_infer(
     assert prediction.outputs[0].contents == expected
 
 
+async def test_model_infer_raw_contents(inference_service_stub, model_infer_request):
+    # Prepare request with raw contents
+    for input_tensor in model_infer_request.inputs:
+        request_input = InferInputTensorConverter.to_types(input_tensor)
+        packed = pack(request_input)
+        model_infer_request.raw_input_contents.append(packed)
+        input_tensor.ClearField("contents")
+
+    prediction = await inference_service_stub.ModelInfer(model_infer_request)
+
+    # Convert raw output into contents field
+    for output_tensor, raw_output in zip(
+        prediction.outputs, prediction.raw_output_contents
+    ):
+        response_output = InferOutputTensorConverter.to_types(output_tensor)
+        data = unpack(response_output, raw_output)
+        contents = InferTensorContentsConverter.from_types(
+            data, response_output.datatype
+        )
+        output_tensor.contents.CopyFrom(contents)
+
+    expected = pb.InferTensorContents(int64_contents=[6])
+
+    assert len(prediction.outputs) == 1
+    assert prediction.outputs[0].contents == expected
+
+
 async def test_model_infer_headers(
     inference_service_stub, model_infer_request, sum_model_settings
 ):
diff --git a/tests/test_raw.py b/tests/test_raw.py
new file mode 100644
index 000000000..7df861df4
--- /dev/null
+++ b/tests/test_raw.py
@@ -0,0 +1,157 @@
+import pytest
+import numpy as np
+
+from typing import List
+
+from mlserver.types import RequestInput, ResponseOutput
+from mlserver.raw import (
+    _pack_bytes,
+    _pack_tensor,
+    _unpack_bytes,
+    _unpack_tensor,
+    extract_raw,
+    inject_raw,
+)
+from mlserver.codecs.numpy import NumpyCodec
+
+
+def test_unpack_bytes():
+    raw = b"\x03\x00\x00\x00one\x03\x00\x00\x00two\x05\x00\x00\x00three"
+    expected = [b"one", b"two", b"three"]
+
+    unpacked = _unpack_bytes(raw)
+    assert unpacked == expected
+
+
+@pytest.mark.parametrize(
+    "unpacked",
+    [
+        [b"one", b"two", b"three"],
+        ["one", "two", "three"],
+    ],
+)
+def test_pack_bytes(unpacked: List[str]):
+    expected = b"\x03\x00\x00\x00one\x03\x00\x00\x00two\x05\x00\x00\x00three"
+
+    packed = _pack_bytes(unpacked)
+    assert packed == expected
+
+
+@pytest.mark.parametrize(
+    "tensor",
+    [
+        # bool
+        np.array([True, False, True]),
+        # uint8
+        np.array([[1, 2], [3, 4]], dtype=np.ubyte),
+        # uint16
+        np.array([[1, 2], [3, 4]], dtype=np.ushort),
+        # uint32
+        np.array([[1, 2], [3, 4]], dtype=np.uintc),
+        # uint64
+        np.array([[1, 2], [3, 4]], dtype=np.uint),
+        # int8
+        np.array([[1], [-2], [3]], dtype=np.byte),
+        # int16
+        np.array([[1], [-2], [3]], dtype=np.short),
+        # int32
+        np.array([[1], [-2], [3]], dtype=np.intc),
+        # int64
+        np.array([[1], [-2], [3]], dtype=np.int_),
+        # fp16
+        np.array([[1.2, 3.3]], dtype=np.half),
+        # fp32
+        np.array([[1.2, 3.3]], dtype=np.single),
+        # fp64
+        np.array([[1.2, 3.3]], dtype=np.double),
+    ],
+)
+def test_unpack_tensor(tensor: np.ndarray):
+    request_input = NumpyCodec.encode_input(name="foo", payload=tensor)
+    request_input.data = []
+    raw = tensor.tobytes()
+
+    unpacked = _unpack_tensor(request_input, raw)
+    request_input.data = unpacked
+    decoded = NumpyCodec.decode_input(request_input)
+
+    np.testing.assert_allclose(decoded, tensor)
+
+
+@pytest.mark.parametrize(
+    "tensor",
+    [
+        # bool
+        np.array([True, False, True]),
+        # uint8
+        np.array([[1, 2], [3, 4]], dtype=np.ubyte),
+        # uint16
+        np.array([[1, 2], [3, 4]], dtype=np.ushort),
+        # uint32
+        np.array([[1, 2], [3, 4]], dtype=np.uintc),
+        # uint64
+        np.array([[1, 2], [3, 4]], dtype=np.uint),
+        # int8
+        np.array([[1], [-2], [3]], dtype=np.byte),
+        # int16
+        np.array([[1], [-2], [3]], dtype=np.short),
+        # int32
+        np.array([[1], [-2], [3]], dtype=np.intc),
+        # int64
+        np.array([[1], [-2], [3]], dtype=np.int_),
+        # fp16
+        np.array([[1.2, 3.3]], dtype=np.half),
+        # fp32
+        np.array([[1.2, 3.3]], dtype=np.single),
+        # fp64
+        np.array([[1.2, 3.3]], dtype=np.double),
+    ],
+)
+def test_pack_tensor(tensor: np.ndarray):
+    request_input = NumpyCodec.encode_input(name="foo", payload=tensor)
+    packed = _pack_tensor(request_input)
+
+    expected = tensor.tobytes()
+
+    assert expected == packed
+
+
+def test_inject_raw():
+    inputs = [
+        RequestInput(name="foo", datatype="BYTES", shape=[3], data=[]),
+        RequestInput(name="bar", datatype="FP32", shape=[3, 2], data=[]),
+    ]
+    raw_contents = [
+        b"\x03\x00\x00\x00one\x03\x00\x00\x00two\x05\x00\x00\x00three",
+        np.array([1, 2, 3, 4, 5, 6], dtype=np.single).tobytes(),
+    ]
+
+    with_unpacked_raw = inject_raw(inputs, raw_contents)
+
+    expected = [[b"one", b"two", b"three"], [1, 2, 3, 4, 5, 6]]
+    assert len(with_unpacked_raw) == len(expected)
+    for request_input, expected in zip(with_unpacked_raw, expected):
+        assert request_input.data == expected
+
+
+def test_extract_raw():
+    inputs = [
+        ResponseOutput(
+            name="foo", datatype="BYTES", shape=[3], data=[b"one", b"two", b"three"]
+        ),
+        ResponseOutput(
+            name="bar", datatype="FP32", shape=[3, 2], data=[1, 2, 3, 4, 5, 6]
+        ),
+    ]
+
+    expected = [
+        b"\x03\x00\x00\x00one\x03\x00\x00\x00two\x05\x00\x00\x00three",
+        np.array([1, 2, 3, 4, 5, 6], dtype=np.single).tobytes(),
+    ]
+
+    without_data, raw_contents = extract_raw(inputs)
+
+    assert raw_contents == expected
+    assert len(expected) == len(without_data)
+    for response_output in without_data:
+        assert len(response_output.data) == 0
diff --git a/tests/testdata/inference-response-with-output.json b/tests/testdata/inference-response-with-output.json
deleted file mode 100644
index 2cb28b616..000000000
--- a/tests/testdata/inference-response-with-output.json
+++ /dev/null
@@ -1,12 +0,0 @@
-{
-  "model_name": "sum-model",
-  "id": "123",
-  "outputs": [
-    {
-      "name": "requested-output-0",
-      "shape": [1],
-      "datatype": "FP32",
-      "data": [21.0]
-    }
-  ]
-}