Image courtesy of https://undraw.co/

Imagine the following possibly hypothetical situation:

You work in an organization or team that is heavily Python-focused, primarily due to operating in application domains where scientific computing is important.

The Scientific Python ecosystem, of which machine learning is just one admittedly major theme, is brilliant for this.

However, you would like to evaluate development stacks outside of the Python world for the implementation of small and higher-performance services, which don’t need SciPy, to augment your architecture.

For this hypothetical situation, but probably more broadly, this alternative stack should satisfy the following quite subjective requirements, of which the first three are must-haves, but the final two can be discussed:

  1. Substantially faster and more memory efficient than Python.
  2. Language should be “safe”, in that it’s harder than average to produce bugs.
  3. Higher than average development velocity, through language expressivity, library ecosystem and tooling.
  4. Easy for new devs to become productive in, and produce some of that bug-free code.
  5. Straight-forward to deploy, ideally a single binary like Go.

With this hypothetical situation in mind, I decided to compare Go and C# with dotnet on the one hand with Python and FastAPI on the other, in the context of writing a really small PostgreSQL database writer service with the following RESTful(ish) API:

  • submit sensor measurement consisting of timestamp, sensor name and two optional floating point values.
  • list all submitted samples
  • list a single sample by ID
  • validation everywhere
  • browseable swagger / OpenAPI documentation and UI
Figure 1: Example of the OpenAPI / Swagger UI we would like to see.

Figure 1: Example of the OpenAPI / Swagger UI we would like to see.

I have close to no real-world experience with Go and C#, although I have read my fair share of tutorials and blog posts over the years.

I do enjoy keeping up with language development, from the conventional to the extra interesting. Both Go and C# have innovated quietly but consistently in their respective niches, although they are considered by most to be rather conventional languages.

In this case, my inexperience was a boon, as I could experience coming in as a new dev to setup this minimal API using these two stacks.

In both these cases, I wanted to try and select the most conventional and developer-friendly way to implement this API.

In the case of C#, I did opt to use the latest greatest ASP.NET Core 6’s new Minimal API approach, as it looks like this will soon become the standard for new projects.

In Python’s case I have more experience, and even some with FastAPI, but this was the first time I touched the new SQLModel and the SQLAlchemy’s recently introduced asynchronous database support.

In the following sections, you can find links to a github repo with the source to each example, and some of my thoughts on the experience.

Python with FastAPI and (asynchronous) SQLModel

The source code of this Python-based service is available at github.com/dbwriter_python.

Although I default to Django (even for microservices), I chose FastAPI with the recently released SQLModel package for this experiment, as it supports asynchronous database access (Django was planning to introduce this in the coming 4.0 release, but the schedule has slipped slightly) and since it has quickly proven itself as a great API service alternative in the scientific computing world.

It could also just be that I wanted to try out the new SQLModel for myself. :)

After altogether about 90 lines of code, I had the fully functioning dbwriter API done, in the conventional synchronous database mode, with fully documented and typed swagger docs out of the box.

Mostly because it’s so fresh, it took me an hour or two more of scratching around, and about 40 lines of additional code, to add switchable asynchronous database access to the implementation.

You can switch between the sync / async DB access using the ASYNC_DB variable. See the benchmarks section a bit further down to get a feel for the effect on performance.

Miscellaneous notes:

  • I really like the way FastAPI and SQLModel enables me to specify the shapes of the data using expressive Python.
  • The effort that tiangolo has gone to to make the libraries IDE friendly really shows while you work.
  • Docstrings of view functions and model classes are automatically shown in the Swagger UI, although in some cases you have to view the schema to see them.

Go with GIN, GORM and swaggo

You can find the source of this project at github.com/cpbotha/dbwriter_go.

Here I chose the Gin Web Framework, GORM for the database (although it does seem like a large part of the Go community prefers lower level abstractions) and finally swaggo for the OpenAPI / Swagger support.

It took me about 30 minutes and just over 100 lines to get the basic API up and running, but without swaggo at that point.

This process really felt very quick and easy.

I ascribe this to the language being relatively simple, the tooling in VSCode helping a lot, and of course go-fmt tidying up my code as I went.

After this easy first part, figuring out how to make the v1 and v2 parameters optional in the API took up a disproportionate amount of time. Go not being opinionated (enough) about sql.NullFloat64 vs the *float64 convention for optional parameters definitely slowed me down here.

Other than that, I did the initial work with the older github.com/jinzhu/gorm version of gorm before switching to the current gorm.io/gorm v1.21.15, but that’s mostly due to me not paying enough attention.

As small as this experiment was, it was a pleasant experience, and to my mind demonstrated some of the DX-related advantages of Go’s simplicity and consistency.

C# Minimal API style with dotnet 6 rc1

(You can find the source code of my attempt at a C# Minial API style database writer service at github.com/cpbotha/dbwriter_dotnet.)

Thanks to this blog post by Anuraj Parameswaran, the indomitable Scott Hanselman’s overview blog post on the matter and this “quickstart” guide by David Fowler, I was pretty excited to try out dotnet 6’s (at RC1 at the time of this writing) new “minimal API” style.

To give you an idea of how minimal, this is the Program.cs you get when invoking dotnet new web -o MyNewApi:

1
2
3
4
5
6
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

app.MapGet("/", () => "Hello World!");

app.Run();

I would have preferred to use JetBrains Rider for this (I did a substantial amount of the work on my MBA), but JetBrains has no support yet for this new Minimal API style, which is understandable but still disappointing.

VSCode with OmniSharp was more than fine for my purposes, although it seems that the internet does not think as highly of it than for example Ionide for F#.

I would have liked to try out the latest Visual Studio 2022 preview on Windows to get a taste of the fantastic C# tooling people always talk about, but could not fit it in this time.

Nonetheless, it took an hour or two, and a surprisingly compact 90 lines of code, to go from nothing to POST-ing the first timestamped measurements into the database via the Swagger UI.

As you will see on the github, I experimented with Swashbuckle’s mechanism for adding XML code comments to the Swagger docs, but I could only get it working for the serialization type, and not for the route handlers themselves.

This whole experience was quite pleasant, although I can see why some folks say that C# can be quite “big”. Unlike Go, there are many ways do do any one thing in C#, and this can complicate making the most suitable choice.

This is of course not a probem, but it’s a design choice to be aware of.

Finally, these days it’s possible to publish your dotnet service as a single, self-contained bundle that can be simply pushed to a Linux VM somewhere, much like Go.

Probably meaningless benchmarks: Don’t read

In order to get some idea of the relative performance of these three implementations, I used the wrk2 HTTP benchmarking tool and only hit the retrieve-one-sample endpoint repeatedly with the invocation wrk2 -t10 -c100 -d20s -R20000 http://localhost/samples/$ID.

Note that with -R20000, the wrk2 tool will attempt to sustain 20k requests per second.

I ran these on WSL2 Ubuntu 20.04 on my Ryzen 5900x (12 cores, 24 threads) with 64GB of DDR4-3600.

In the case of C#, I had to add some extra code to make sure that it would in fact hit the database and not cache the results.

This benchmark does not represent real-world use, but it does give an idea of the performance of the implementation over the pipeline of request handling, database record retrieval and JSON serialization.

Please also remember that the goal here was to write idiomatic, developer-friendly code, which is why I used ORMs, and JSON binding, and Swagger, and NOT to try and write the fastest service.

With all of that out of the way, here are some mostly meaningless benchmark results:

Implementation Requests / s Avg Latency (s) RSS / thread (MB)
FastAPI sync DB 5229 10.53 60
FastAPI async DB 8223 8.17 55
Go 13708 4.72 100
C# 19422 0.417 1200

Initially, when I was using sqlite, the Go implementation was about 10x faster than FastAPI. However, as soon as PostgreSQL was added, and all implementations had to make thet network connection roundtrip, the performance difference between FastAPI and Go narrowed significantly.

There’s an important lesson in that observation. The runtime might be super fast, but network and database delays can eclipse that.

More specifically, I’m quite impressed by FastAPI’s showing, especially in async database mode.

I was also impressed by C#’s great performance here.

MS has been paying specific and significant attention to improving EF Core’s performance in the Techempower benchmarks.

Please not that, while I’m fairly sure that the C# implementation is fully async down to the database, I am not sure how GORM and Go approach this.

Finally, as far as I understand it, although the C# implementation used a great deal more memory in my simple tests on my desktop machine, it will automatically scale down to cgroup memory limits as imposed for example by Docker. In this post we read that the dotnet techempower plaintext benchmarks were usually performed with a 150 MB memory limit.

Conclusion

When I started this, my goal was to evaluate Go and C# with dotnet as alternative stacks for a hypothetical Python-focused computational science organization.

My expectation was that Go would be the clear answer here.

However, although Go is certainly a strong contender, the fact that for this admittedly simple example FastAPI’s performance was pretty respectable, and the type-oriented code clean and quick to write, makes me think that a Python shop should probably only reach for hypothetical alternatives when they have really good reasons to do so.

Furthermore, the surprising simplicity of the new-style C# minimal API, its impressive performance and single-file deployment capability makes it a compelling candidate as well, if one is able to pay the memory cost, or if it later turns out that performance does indeed not suffer too much under constrained memory conditions.

Whatever the case may be, I had a great deal of fun and learning making these!