Often while working with python I miss having a DI framework (sadly this is not very common in the python world). There are multiple blog posts on the benefits of DI, so I won't go into that here.
Either way, I was on the lookout for a DI framework for python and this one caught my eye: Dependency Injector. Mainly because of this listed feature:
"Performance. Fast. Written in Cython."
Which peaked my interest, how much overhead does a DI framework add to your code? So I wrote a small benchmark to test this.
For the benchmark we are requesting the containers to give us an instance of FooService, with the following dependency tree, with Cache, HTTPClient and Config being singletons and the rest being transient:
FooService
- Config
- Database
- Config
- Cache
- Config
- HttpClient
- Config
- BarService
- Database
- Config
- Cache
- Config
- HTTPClient
- Config
And these were the results:
$ python3.11 benchmark/run_benchmark.py
Direct (No framework) 563.216755 ns
Dependency Injector Container (Cython) 1183.705037 ns
Rodi Container (Pure Python) 1983.972198 ns
Ok, so compared to directly instantiating the objects ourselves it seems a pure python container is ~4x slower and the cython implementation is ~2x slower.
Which makes sense, since a normal container has some overhead to get a service:
- Check if service is a singleton or transient
- If its a singleton check if its already instantiated
- Find the dependencies for the service and get them (recursively)
Meta programming to the rescue
So how can we make this faster?
After a few failed attempts using closures and meta classes I had the idea to use meta programming and generate the code for the container at runtime. If we already know all services beforehand, we can generate a class with specialized methods for each service we need to instantiate.
Here's the optimizations we can perform on each methods:
- No need to check if the service is a singleton or transient
- No need to find the dependencies for the service at runtime
- Inlining the code to instantiate the dependencies
- This saves us the overhead of recursively calling the container to get the dependencies
- Preloading singletons also avoids the need to check if they are already instantiated
Here's a snippet of the generated code for the FooService method with all optimizations mentioned above:
def get(self, service_id): # to provide a clean interface we still have this method as overhead
return self._service_getter_map[service_id]()
def get_services_FooService(self):
return services.FooService(
config=self._singleton_instances[services.Config], # singleton instance is preloaded and inlined
database=services.Database( # transient service is inlined
config=self._singleton_instances[services.Config],
),
cache=self._singleton_instances[services.Cache],
http_client=self._singleton_instances[services.HTTPClient],
bar_service=services.BarService(
config=self._singleton_instances[services.Config],
database=services.Database(
config=self._singleton_instances[services.Config],
),
cache=self._singleton_instances[services.Cache],
http_client=self._singleton_instances[services.HTTPClient],
),
)
Results
So, did it work? Here's the results:
$ python3.11 benchmark/run_benchmark.py
Direct 563.216755 ns
MetaDI Container 792.128003 ns
Dependency Injector Container (Cython) 1183.705037 ns
Rodi Container 1983.972198 ns
Which are pretty good! We managed to be faster than the cython implementation while using pure python.
Conclusion
Even though I believe this performance gain is irrelevant for most applications, it was a fun experiment and I learned a lot about meta programming. I also think this is a good example of how meta programming can be used to optimize code, and I will definitely keep this in mind for future projects.
Source code can be found on Github
Latest comments (0)