Fastapi: [QUESTION] enhance serialization speed

Created on 2 Jul 2019  Â·  28Comments  Â·  Source: tiangolo/fastapi

Description

I have to return sometimes big objects, I'm constrained in the fact that chunking them is not an option

An example of such an object would be a dict {"key:: value} where value is a list of list, 20 list of 10k elements.

I wrote this simple test case that shows quite clearly the massive hit in several scenarios (run with pytest tests/test_serial_speed.py --log-cli-level=INFO)
Here's the output:

======================================================================================================================= 1 passed in 3.68 seconds =======================================================================================================================
(fastapi) ➜  fastapi git:(slow_serial) ✗ pytest tests/test_serial_speed.py --log-cli-level=INFO
========================================================================================================================= test session starts ==========================================================================================================================
platform linux -- Python 3.6.8, pytest-5.0.0, py-1.8.0, pluggy-0.12.0
rootdir: /home/lotso/PycharmProjects/fastapi
plugins: cov-2.7.1
collected 1 item                                                                                                                                                                                                                                                       

tests/test_serial_speed.py::test_routes 
---------------------------------------------------------------------------------------------------------------------------- live log call -----------------------------------------------------------------------------------------------------------------------------
INFO     tests.test_serial_speed:test_serial_speed.py:39 route1: 0.05402565002441406
INFO     tests.test_serial_speed:test_serial_speed.py:18 app.tests.test_serial_speed.route1, 9.395180225372314, ['http_status:200', 'http_method:GET', 'time:wall']
INFO     tests.test_serial_speed:test_serial_speed.py:18 app.tests.test_serial_speed.route1, 9.395131000000001, ['http_status:200', 'http_method:GET', 'time:cpu']
INFO     tests.test_serial_speed:test_serial_speed.py:52 route1: 0.049863576889038086
INFO     tests.test_serial_speed:test_serial_speed.py:18 app.tests.test_serial_speed.route2, 10.358616590499878, ['http_status:200', 'http_method:GET', 'time:wall']
INFO     tests.test_serial_speed:test_serial_speed.py:18 app.tests.test_serial_speed.route2, 10.358592000000002, ['http_status:200', 'http_method:GET', 'time:cpu']
INFO     tests.test_serial_speed:test_serial_speed.py:64 route1: 0.05589580535888672
INFO     tests.test_serial_speed:test_serial_speed.py:18 app.tests.test_serial_speed.route3, 11.318845272064209, ['http_status:200', 'http_method:GET', 'time:wall']
INFO     tests.test_serial_speed:test_serial_speed.py:18 app.tests.test_serial_speed.route3, 11.318446000000002, ['http_status:200', 'http_method:GET', 'time:cpu']
PASSED                                                                                                                                                                                                                                                           [100%]

====================================================================================================================== 1 passed in 31.60 seconds =======================================================================================================================

all routes do the same with slight variations:

  1. build a big object, it's a dict, one key and a list of list value with 3 sublists of 100k elements, a little bit extreme maybe but it's to show clearly the impact
  2. return the object

as you can see the time taken to build such an object is small, around 0.05s, but...

route1 just returns it, it takes 9s
route2 returns it but has the response_model=BigData in the signature, it takes 1s more
route3 is not intuitive to me, I thought that by already building a BigData object and returning it, there would be no penalty, but it's again slower

How can I [...]?
improve performance

edit: the tests are available at this branch, can PR should you want to https://github.com/euri10/fastapi/tree/slow_serial

question

Most helpful comment

Yeah, it's also easy enough to write a decorator that performs the conversion to a response for endpoints you know are safe. Something like:

def go_fast(f):
    @wraps(f)
    async def wrapped(*args, **kwargs):
        return UJSONResponse(await f(*args, **kwargs))
    return wrapped

(Might want to use inspect.iscoroutinefunction to also handle def endpoints.)

All 28 comments

Just to confirm, you do have ujson installed? What type of machine are you running this on?

Route 1 is going to be fastest as it is just returning the JSONResponse directly.

Route 2 is taking the dict, running field.validate and then dumping it back out as JSON, so will be slower than route 1.

_I could be wrong on this one, have never really looked at the response portion of the fastapi code, but from a quick glance_
Route 3 constructs the pydantic model (which validates it), then calls field.validate on it again and then dumps it back out as json, making it the slowest.

FWIW, with ujson on a MBP:
test_serial_speed.py::test_routes ------------------------------------------------------------------------------------------------ live log call ------------------------------------------------------------------------------------------------- test_serial_speed.py 41 INFO route1: 0.028443098068237305 test_serial_speed.py 19 INFO app.test_serial_speed.route1, 2.768159866333008, ['http_status:200', 'http_method:GET', 'time:wall'] test_serial_speed.py 19 INFO app.test_serial_speed.route1, 2.7664400000000002, ['http_status:200', 'http_method:GET', 'time:cpu'] test_serial_speed.py 53 INFO route2: 0.02795100212097168 test_serial_speed.py 19 INFO app.test_serial_speed.route2, 3.3919589519500732, ['http_status:200', 'http_method:GET', 'time:wall'] test_serial_speed.py 19 INFO app.test_serial_speed.route2, 3.386338999999999, ['http_status:200', 'http_method:GET', 'time:cpu'] test_serial_speed.py 64 INFO route3: 0.03644418716430664 test_serial_speed.py 19 INFO app.test_serial_speed.route3, 3.939689874649048, ['http_status:200', 'http_method:GET', 'time:wall'] test_serial_speed.py 19 INFO app.test_serial_speed.route3, 3.9318400000000002, ['http_status:200', 'http_method:GET', 'time:cpu']

I don't have ujson installed and my machine was a dual i5-2697v2 (until today when it seems I fried my mb bios...) well.
Will try ujson even if iirc it's kind of stalled project, isn't it?

I'm investigating it, a bit, with line_profiler.

Maybe calling fastapi.encoders.jsonable_encoder() part is main factor?, and too many calling this function (recursively).

File: /home/me/venvs/fastapi/lib/python3.7/site-packages/fastapi/encoders.py
Function: jsonable_encoder at line 8

Line #      Hits         Time  Per Hit   % Time  Line Contents
==============================================================
     8                                           @profile
     9                                           def jsonable_encoder(
    10                                               obj: Any,
    11                                               include: Set[str] = None,
    12                                               exclude: Set[str] = set(),
    13                                               by_alias: bool = True,
    14                                               skip_defaults: bool = False,
    15                                               include_none: bool = True,
    16                                               custom_encoder: dict = {},
    17                                               sqlalchemy_safe: bool = True,
    18                                           ) -> Any:
    19    130008      85523.0      0.7      3.5      if include is not None and not isinstance(include, set):
    20                                                   include = set(include)
    21    130008      97039.0      0.7      3.9      if exclude is not None and not isinstance(exclude, set):
    22                                                   exclude = set(exclude)
    23    130008     172094.0      1.3      6.9      if isinstance(obj, BaseModel):
    24         1          2.0      2.0      0.0          encoder = getattr(obj.Config, "json_encoders", custom_encoder)
    25         1          1.0      1.0      0.0          return jsonable_encoder(
    26         1          1.0      1.0      0.0              obj.dict(
    27         1          0.0      0.0      0.0                  include=include,
    28         1          1.0      1.0      0.0                  exclude=exclude,
    29         1          1.0      1.0      0.0                  by_alias=by_alias,
    30         1      22756.0  22756.0      0.9                  skip_defaults=skip_defaults,
    31                                                       ),
    32         1          1.0      1.0      0.0              include_none=include_none,
    33         1          1.0      1.0      0.0              custom_encoder=encoder,
    34         1          6.0      6.0      0.0              sqlalchemy_safe=sqlalchemy_safe,
    35                                                   )
    36    130007     116806.0      0.9      4.7      if isinstance(obj, Enum):
    37                                                   return obj.value
    38    130007     136911.0      1.1      5.5      if isinstance(obj, (str, int, float, type(None))):
    39     30001      18834.0      0.6      0.8          return obj
    40    100006      76767.0      0.8      3.1      if isinstance(obj, dict):
    41     50001      59332.0      1.2      2.4          encoded_dict = {}
    42     70002      77972.0      1.1      3.1          for key, value in obj.items():
    43                                                       if (
    44                                                           (
    45     20001      13416.0      0.7      0.5                      not sqlalchemy_safe
    46     20001      15464.0      0.8      0.6                      or (not isinstance(key, str))
    47     20001      18121.0      0.9      0.7                      or (not key.startswith("_sa"))
    48                                                           )
    49     20001      13778.0      0.7      0.6                  and (value is not None or include_none)
    50     20001      14021.0      0.7      0.6                  and ((include and key in include) or key not in exclude)
    51                                                       ):
    52     20001      13916.0      0.7      0.6                  encoded_key = jsonable_encoder(
    53     20001      12987.0      0.6      0.5                      key,
    54     20001      13242.0      0.7      0.5                      by_alias=by_alias,
    55     20001      13011.0      0.7      0.5                      skip_defaults=skip_defaults,
    56     20001      13224.0      0.7      0.5                      include_none=include_none,
    57     20001      12971.0      0.6      0.5                      custom_encoder=custom_encoder,
    58     20001      33429.0      1.7      1.3                      sqlalchemy_safe=sqlalchemy_safe,
    59                                                           )
    60     20001      14937.0      0.7      0.6                  encoded_value = jsonable_encoder(
    61     20001      13204.0      0.7      0.5                      value,
    62     20001      13382.0      0.7      0.5                      by_alias=by_alias,
    63     20001      13164.0      0.7      0.5                      skip_defaults=skip_defaults,
    64     20001      12975.0      0.6      0.5                      include_none=include_none,
    65     20001      13333.0      0.7      0.5                      custom_encoder=custom_encoder,
    66     20001      27140.0      1.4      1.1                      sqlalchemy_safe=sqlalchemy_safe,
    67                                                           )
    68     20001      15872.0      0.8      0.6                  encoded_dict[encoded_key] = encoded_value
    69     50001      32725.0      0.7      1.3          return encoded_dict
    70     50005      54024.0      1.1      2.2      if isinstance(obj, (list, set, frozenset, GeneratorType, tuple)):
    71         5          3.0      0.6      0.0          encoded_list = []
    72     40009      30858.0      0.8      1.2          for item in obj:
    73     40004      29258.0      0.7      1.2              encoded_list.append(
    74     40004      29604.0      0.7      1.2                  jsonable_encoder(
    75     40004      28551.0      0.7      1.2                      item,
    76     40004      26669.0      0.7      1.1                      include=include,
    77     40004      27325.0      0.7      1.1                      exclude=exclude,
    78     40004      26618.0      0.7      1.1                      by_alias=by_alias,
    79     40004      26593.0      0.7      1.1                      skip_defaults=skip_defaults,
    80     40004      26568.0      0.7      1.1                      include_none=include_none,
    81     40004      27065.0      0.7      1.1                      custom_encoder=custom_encoder,
    82     40004      60135.0      1.5      2.4                      sqlalchemy_safe=sqlalchemy_safe,
    83                                                           )
    84                                                       )
    85         5          1.0      0.2      0.0          return encoded_list
    86     50000      36219.0      0.7      1.5      errors: List[Exception] = []
    87     50000      37246.0      0.7      1.5      try:
    88     50000      36024.0      0.7      1.5          if custom_encoder and type(obj) in custom_encoder:
    89                                                       encoder = custom_encoder[type(obj)]
    90                                                   else:
    91     50000      66823.0      1.3      2.7              encoder = ENCODERS_BY_TYPE[type(obj)]
    92                                                   return encoder(obj)
    93     50000      38776.0      0.8      1.6      except KeyError as e:
    94     50000      43301.0      0.9      1.7          errors.append(e)
    95     50000      37506.0      0.8      1.5          try:
    96     50000     114909.0      2.3      4.6              data = dict(obj)
    97     50000      38964.0      0.8      1.6          except Exception as e:
    98     50000      41277.0      0.8      1.7              errors.append(e)
    99     50000      37823.0      0.8      1.5              try:
   100     50000      54317.0      1.1      2.2                  data = vars(obj)
   101                                                       except Exception as e:
   102                                                           errors.append(e)
   103                                                           raise ValueError(errors)
   104     50000      37823.0      0.8      1.5      return jsonable_encoder(
   105     50000      38198.0      0.8      1.5          data,
   106     50000      36241.0      0.7      1.5          by_alias=by_alias,
   107     50000      35441.0      0.7      1.4          skip_defaults=skip_defaults,
   108     50000      34936.0      0.7      1.4          include_none=include_none,
   109     50000      34819.0      0.7      1.4          custom_encoder=custom_encoder,
   110     50000      75581.0      1.5      3.1          sqlalchemy_safe=sqlalchemy_safe,
   111                                               )

Total time: 4.87313 s
File: /home/me/venvs/fastapi/lib/python3.7/site-packages/fastapi/routing.py
Function: serialize_response at line 37

Line #      Hits         Time  Per Hit   % Time  Line Contents
==============================================================
    37                                           @profile
    38                                           def serialize_response(
    39                                               *,
    40                                               field: Field = None,
    41                                               response: Response,
    42                                               include: Set[str] = None,
    43                                               exclude: Set[str] = set(),
    44                                               by_alias: bool = True,
    45                                               skip_defaults: bool = False,
    46                                           ) -> Any:
    47         1          1.0      1.0      0.0      if field:
    48         1          1.0      1.0      0.0          errors = []
    49         1       8834.0   8834.0      0.2          value, errors_ = field.validate(response, {}, loc=("response",))
    50         1          1.0      1.0      0.0          if isinstance(errors_, ErrorWrapper):
    51                                                       errors.append(errors_)
    52         1          0.0      0.0      0.0          elif isinstance(errors_, list):
    53                                                       errors.extend(errors_)
    54         1          0.0      0.0      0.0          if errors:
    55                                                       raise ValidationError(errors)
    56         1          0.0      0.0      0.0          r = jsonable_encoder(
    57         1          0.0      0.0      0.0              value,
    58         1          0.0      0.0      0.0              include=include,
    59         1          0.0      0.0      0.0              exclude=exclude,
    60         1          0.0      0.0      0.0              by_alias=by_alias,
    61         1    4864294.0 4864294.0     99.8              skip_defaults=skip_defaults,
    62                                                   )
    63         1          0.0      0.0      0.0          return r
    64                                               else:
    65                                                   return jsonable_encoder(response)

Maybe, it is good that running include = set(include) only once.
And the cost of calling isinstance() is not cheap.

And ujson's effect is limited, maybe. because calling response_class is not slow.

fastapi/routing.py:getapp()

Line #      Hits         Time  Per Hit   % Time  Line Contents
==============================================================
    83                                               @profile
    84                                               async def app(request: Request) -> Response:
    85         1          2.0      2.0      0.0          try:
    86         1          1.0      1.0      0.0              body = None
    87         1          1.0      1.0      0.0              if body_field:
    88                                                           if is_body_form:
    89                                                               body = await request.form()
    90                                                           else:
    91                                                               body_bytes = await request.body()
    92                                                               if body_bytes:
    93                                                                   body = await request.json()
    94                                                   except Exception as e:
    95                                                       logging.error(f"Error getting request body: {e}")
    96                                                       raise HTTPException(
    97                                                           status_code=400, detail="There was an error parsing the body"
    98                                                       ) from e
    99         1          1.0      1.0      0.0          solved_result = await solve_dependencies(
   100         1          1.0      1.0      0.0              request=request,
   101         1          1.0      1.0      0.0              dependant=dependant,
   102         1          0.0      0.0      0.0              body=body,
   103         1        102.0    102.0      0.0              dependency_overrides_provider=dependency_overrides_provider,
   104                                                   )
   105         1          1.0      1.0      0.0          values, errors, background_tasks, sub_response, _ = solved_result
   106         1          1.0      1.0      0.0          if errors:
   107                                                       raise RequestValidationError(errors)
   108                                                   else:
   109         1          1.0      1.0      0.0              assert dependant.call is not None, "dependant.call must be a function"
   110         1          1.0      1.0      0.0              if is_coroutine:
   111                                                           raw_response = await dependant.call(**values)
   112                                                       else:
   113         1       4045.0   4045.0      0.3                  raw_response = await run_in_threadpool(dependant.call, **values)
   114         1          1.0      1.0      0.0              if isinstance(raw_response, Response):
   115                                                           if raw_response.background is None:
   116                                                               raw_response.background = background_tasks
   117                                                           return raw_response
   118
   119         1          1.0      1.0      0.0              response_data = serialize_response(
   120         1          0.0      0.0      0.0                  field=response_field,
   121         1          1.0      1.0      0.0                  response=raw_response,
   122         1          0.0      0.0      0.0                  include=response_model_include,
   123         1          1.0      1.0      0.0                  exclude=response_model_exclude,
   124         1          0.0      0.0      0.0                  by_alias=response_model_by_alias,
   125         1    1345932.0 1345932.0     99.0                  skip_defaults=response_model_skip_defaults,
   126                                                       )
   127         1          2.0      2.0      0.0              response = response_class(
   128         1          1.0      1.0      0.0                  content=response_data,
   129         1          1.0      1.0      0.0                  status_code=status_code,
   130         1       8758.0   8758.0      0.6                  background=background_tasks,
   131                                                       )
   132         1         32.0     32.0      0.0              response.headers.raw.extend(sub_response.headers.raw)
   133         1          1.0      1.0      0.0              if sub_response.status_code:
   134                                                           response.status_code = sub_response.status_code
   135         1          0.0      0.0      0.0              return response

And this test case's input values are not valid encodable expression.
(e.g. fake.email is used instead of fake.email(), this is typo?)

So, the bottoms of each recursive call, exception is raised again and again, and, in generally, exception handling is high-cost, so this code is too slow.

yep it's a mistake, I corrected it and don't see same discrepancies, sorry for that !

it seems the original code I based this test case on might have same sort of bug that I didn't detect because of the Any type, which as you said raises exceptions

thanks for the hint, will dig more and see what's happening, Any is evil

I am also experiencing the same/related issue whereby a large nested payload takes ~10 mins to reach a point in the api whereby i can assign it to a variable. I have removed pydantic validation etc to just leave the bare endpoint and tried ujson but this doesnt seem to have helped significantly. for reference, the same payload is processed in ~1.8 seconds in flask.

@Charlie-iProov any chance you could put together a minimal example that is much faster in flask? It could be a good starting point for performance work.

Hi heres a quick example - not sure where to host the files so just made a public repo https://github.com/MarlieChiller/api-serialisation-comparison the test uses a payload that contains a nested base64 encoded image string as that was my initial use case where i discovered the difference. However, you can swap the image out for an array and i found the difference is still present (although the time difference was reduced). I think the larger the array length, the larger the time discrepancy though

for reference, in the base example in that repo i was getting approximately 44 seconds in fastapi to 1.4 seconds in flask. When i switched test cases to an array, i used a length of 10000 instead of the image string. Hope that helps

@MarlieChiller Something is definitely behaving strangely here. I'm getting similar results to you when I run your script. On the other hand, if I make use of the ASGI test client, I get performance in line with flask (maybe a little faster) -- ~1.3s of execution time on my machine (vs ~1.4 for the flask going through the server):

import base64
import json
import sys
from datetime import datetime

import requests as r
from fastapi import FastAPI
from starlette.testclient import TestClient

app = FastAPI(title="fast_api_speed_test")


@app.post("/test")
async def endpoint(payload: dict):
    if payload:
        print(type(payload))
        return 200


test_client = TestClient(app)


def main():
    iterations = 1000

    with open("black.png", "rb") as image_file:
        img = image_file.read()
        img = base64.b64encode(img)

    fast_api = send_request(iterations, img, 8000)
    print("fastapi speed >>> ", fast_api)


def send_request(iterations, encoded_string_img, port):
    payload = {"count": iterations, "payload": []}
    for i in range(iterations):
        payload["payload"].append(
            {"arbitrary_field": f"{i}", "image": encoded_string_img.decode("utf-8")}
        )

    print(sys.getsizeof(json.dumps(payload)))
    x = datetime.utcnow()
    response = test_client.post("/test", json=payload)
    y = datetime.utcnow() - x
    print(response.content)
    return y


if __name__ == "__main__":
    main()

Because of this, I don't think the performance issue is with fastapi, but maybe uvicorn instead? I'm looking into it some more...

out of the blue, can it be sync stuff ?

On Tue, Oct 1, 2019 at 11:48 AM dmontagu notifications@github.com wrote:

@MarlieChiller https://github.com/MarlieChiller Something is definitely
behaving strangely here. I'm getting similar results to you when I run your
script. On the other hand, if I make use of the ASGI test client, I get
performance in line with flask (maybe a little faster) -- ~1.3s of
execution time on my machine (vs ~1.4 for the flask going through the
server):

import base64import jsonimport sysfrom datetime import datetime
import requests as rfrom fastapi import FastAPIfrom starlette.testclient import TestClient

app = FastAPI(title="fast_api_speed_test")

@app.post("/test")async def endpoint(payload: dict):
if payload:
print(type(payload))
return 200

test_client = TestClient(app)

def main():
iterations = 1000

with open("black.png", "rb") as image_file:
    img = image_file.read()
    img = base64.b64encode(img)

fast_api = send_request(iterations, img, 8000)
print("fastapi speed >>> ", fast_api)

def send_request(iterations, encoded_string_img, port):
payload = {"count": iterations, "payload": []}
for i in range(iterations):
payload["payload"].append(
{"arbitrary_field": f"{i}", "image": encoded_string_img.decode("utf-8")}
)

print(sys.getsizeof(json.dumps(payload)))
x = datetime.utcnow()
response = test_client.post("/test", json=payload)
y = datetime.utcnow() - x
print(response.content)
return y

if __name__ == "__main__":
main()

Because of this, I don't think the performance issue is with fastapi, but
maybe uvicorn instead? I'm looking into it some more...

—
You are receiving this because you authored the thread.
Reply to this email directly, view it on GitHub
https://github.com/tiangolo/fastapi/issues/360?email_source=notifications&email_token=AAINSPWFAYZUTS6OUHVQQTTQMMMILA5CNFSM4H4YY6OKYY3PNVWWK3TUL52HS4DFVREXG43VMVBW63LNMVXHJKTDN5WW2ZLOORPWSZGOEAAVWBA#issuecomment-536959748,
or mute the thread
https://github.com/notifications/unsubscribe-auth/AAINSPWJKQOQ3KAWLPO656DQMMMILANCNFSM4H4YY6OA
.

--
benoit barthelet
http://pgp.mit.edu/pks/lookup?op=get&search=0xF150E01A72F6D2EE

@euri10 I don't think that should be the issue (again, the ASGI TestClient speed seems to indicate the problem is not with application-level stuff). But this is deeply disturbing.

To be fair, it is a ~175MB payload, but I still think it should be significantly faster to process.

I didnt see Flask code but printing stuff take lts of time

On Tue, Oct 1, 2019 at 11:54 AM dmontagu notifications@github.com wrote:

@euri10 https://github.com/euri10 I don't think that should be the
issue (again, the ASGI TestClient speed seems to indicate the problem is
not with application-level stuff). But this is deeply disturbing.

—
You are receiving this because you were mentioned.
Reply to this email directly, view it on GitHub
https://github.com/tiangolo/fastapi/issues/360?email_source=notifications&email_token=AAINSPXAKJNGNU2UOKI37TLQMMM33A5CNFSM4H4YY6OKYY3PNVWWK3TUL52HS4DFVREXG43VMVBW63LNMVXHJKTDN5WW2ZLOORPWSZGOEAAWFHY#issuecomment-536961695,
or mute the thread
https://github.com/notifications/unsubscribe-auth/AAINSPXKXEPKX33ZS5GCUFLQMMM33ANCNFSM4H4YY6OA
.

--
benoit barthelet
http://pgp.mit.edu/pks/lookup?op=get&search=0xF150E01A72F6D2EE

Okay, so if you change the annotation from dict to Any, the speed is still ridiculously slow. Also, the speed is relatively fast until the single request gets to be about 30+ MBs, at which point the response time starts scaling much-worse-than-linearly. So I think the problem is the server, not the fastapi / the validation. I'm trying to run a uvicorn in a profiler to find where it is slow now.

I think it may be worth trying to run the app using a different server (e.g., hypercorn) to see if that has any impact.

@MarlieChiller I simplified your script a bit, isolating the problem as specifically the payload size (and using just starlette, not even fastapi):

import sys
from datetime import datetime

import requests
import uvicorn
from requests import Session
from starlette.applications import Starlette
from starlette.requests import Request
from starlette.responses import Response
from starlette.testclient import TestClient

app = Starlette()


@app.route("/", methods=["POST"])
async def endpoint(request: Request):
    payload = await request.json()
    assert isinstance(payload, dict)
    return Response("success")


def _speed_test(session: Session, url: str):
    payload = {"payload": "a" * 100_000_000}
    start = datetime.utcnow()
    response = session.post(url=url, json=payload)
    elapsed = datetime.utcnow() - start
    assert response.status_code == 200
    assert response.content == b"success"
    print(elapsed)


def asgi_test():
    client = TestClient(app)
    _speed_test(client, "/")


def uvicorn_test():
    session = requests.Session()
    _speed_test(session, f"http://127.0.0.1:8000/")


def main():
    if "--asgi-test" in sys.argv:
        asgi_test()
        # 0:00:00.650825
    elif "--uvicorn-test" in sys.argv:
        uvicorn_test()
        # 0:00:17.502396
        # cProfile:
        # Name   Call Count   Time (ms)  Own Time (ms)
        # body      391         16670        16649
    else:
        uvicorn.run(app)


if __name__ == "__main__":
    main()

I'm going to post an issue on the starlette and the uvicorn repos about this.

sounds good, thanks for the help

I posted to starlette and uvicorn just now, I guess we'll see if there is any response there!

@MarlieChiller I got to the bottom of this -- it was due to how the request body was being built by starlette.

I opened a PR to fix it: https://github.com/encode/starlette/pull/653

Nice! Why was += causing a quadratic growth in T vs .join may i ask?

It has to do with the way strings work in python — a new string is created in memory out of the two inputs every time you call +=. So you are basically making a copy of everything you’ve seen every time the += is called. The list-joining approach doesn’t do any copying until it puts all the pieces together at the end.

Just ran into this as well.

The example below is taking about 30ms:

@app.get('/', response_class=UJSONResponse)
async def root():
  return {'results': list(range(10000))}

doing the ujson dumps in the body cuts that down to around 3-5ms:

@app.get('/', response_class=UJSONResponse)
async def root():
  return ujson.dumps({'results': list(range(10000))})

Here's some strange behavior I don't understand:

import time

from fastapi import FastAPI
from starlette.responses import UJSONResponse, Response
from starlette.testclient import TestClient

app = FastAPI()


@app.get('/a', response_class=UJSONResponse)
async def root():
    content = {'results': list(range(10000))}
    return content


@app.get('/b', response_class=Response)
async def root():
    content = {'results': list(range(10000))}
    return UJSONResponse.render(None, content)
    # return ujson.dumps(content, ensure_ascii=False).encode("utf-8")


client = TestClient(app)

t0 = time.time()
for _ in range(100):
    client.get("/a")
t1 = time.time()
print(t1 - t0)
# 1.7897768020629883

t0 = time.time()
for _ in range(100):
    client.get("/b")
t1 = time.time()
print(t1 - t0)
# 0.32788991928100586

Seems like it's not using UJSONResponse properly; might be a bug.

EDIT: It is using UJSONResponse; the problem is that it is also applying the relatively poorly performing jsonable_encoder to a thing that is already valid json -- not good.

I investigated -- the problem is that jsonable_encoder is very slow for objects like this since it has to make many isinstance calls for each value in the returned list.

This seems like a pretty substantial shortcoming -- I think there should be a way to override the use of jsonable_encoder with something faster in cases where you know you don't need its functionality. (Currently you can provide a custom_encoder, but it won't speed things up in cases where you are returning a list/dict since jsonable_encoder will still loop over each entry and perform lots of isinstance checks.)

A 6x overhead is not good!

@dmontagu was about to mention that it's mostly likely the jsonable_encoder and all of the validation in serialize_response.

that's why I stopped using response_class, but I recon people may want to
use the swagger niceness of having it documented

On Thu, Oct 3, 2019 at 3:52 AM Mike notifications@github.com wrote:

@dmontagu https://github.com/dmontagu was about to mention that it's
mostly likely the jsonable_encoder and all of the validation code.

—
You are receiving this because you were mentioned.
Reply to this email directly, view it on GitHub
https://github.com/tiangolo/fastapi/issues/360?email_source=notifications&email_token=AAINSPSGAXZTER7BXZNNBCTQMVF6ZA5CNFSM4H4YY6OKYY3PNVWWK3TUL52HS4DFVREXG43VMVBW63LNMVXHJKTDN5WW2ZLOORPWSZGOEAGXGCA#issuecomment-537752328,
or mute the thread
https://github.com/notifications/unsubscribe-auth/AAINSPQ2USMGKKEID7LF7IDQMVF6ZANCNFSM4H4YY6OA
.

--
benoit barthelet
http://pgp.mit.edu/pks/lookup?op=get&search=0xF150E01A72F6D2EE

Yeah, it's also easy enough to write a decorator that performs the conversion to a response for endpoints you know are safe. Something like:

def go_fast(f):
    @wraps(f)
    async def wrapped(*args, **kwargs):
        return UJSONResponse(await f(*args, **kwargs))
    return wrapped

(Might want to use inspect.iscoroutinefunction to also handle def endpoints.)

Was this page helpful?
0 / 5 - 0 ratings

Related issues

danieljfarrell picture danieljfarrell  Â·  27Comments

nishtha03 picture nishtha03  Â·  23Comments

somada141 picture somada141  Â·  21Comments

sm-Fifteen picture sm-Fifteen  Â·  22Comments

sandys picture sandys  Â·  23Comments