BT

Facilitating the Spread of Knowledge and Innovation in Professional Software Development

Write for InfoQ

Topics

Choose your language

InfoQ Homepage Presentations Accelerating Performance by Incrementally Integrating Rust Into Existing Codebase

Accelerating Performance by Incrementally Integrating Rust Into Existing Codebase

49:47

Summary

Lily Mara explains how to avoid high-risk software rewrites through incremental FFI refactoring. She shares how engineering teams can replace Python bottlenecks with Rust via PyO3, demonstrating how to achieve dramatic function-level speedups, seamless integration testing, and meaningful infrastructure cost savings without microservice overhead.

Bio

Lily Mara is a Staff Engineer at Discord on the Notifications Platform team. She builds distributed systems to deliver tens of billions of notifications every day to Discord's users. Previously she was an Engineering Manager and Software Engineer at OneSignal in San Mateo, CA. Lily is the author of Refactoring to Rust.

About the conference

Software is changing the world. QCon San Francisco empowers software development by facilitating the spread of knowledge and innovation in the developer community. A practitioner-driven conference, QCon is designed for technical team leads, architects, engineering directors, and project managers who influence innovation in their teams.

Transcript

Lily Mara: I'm going to talk to you all today about how to make an existing piece of software faster by gradually adding some Rust code to it. For a little bit of background about me. I am a staff engineer at Discord, currently. I've been using Rust for a little over a decade, which is crazy to think about. Not professionally for that entire time, a mix of side of desk stuff in the beginning, but then I have professionally used Rust since 2019. I've spoken about Rust at QCon and many other software conferences. I also wrote the book, "Refactoring to Rust," which is about what we're going to be talking about in this talk today as well.

I think it's pretty much impossible for you to be a plugged-in software engineer, as I imagine you all are, because you're at a software conference, and not to have heard about Rust a lot over the last several years. I'm sure you've heard people say that it's super-fast. It's way faster than the dynamic languages that all of our big software monoliths are written in, like Python, or Ruby, or Node, or whatever. There's this big impulse that people get when they see something that's fast. They say, ok, let's start over, let's throw the whole world away and rewrite everything in Rust. That's a really nice idea. It sounds really fun on paper, like we have this cool, fun new language, and it has all these nice idioms, and it's super-fast. Let's just rewrite everything in Rust. I think that this is an understandable impulse, but it runs into some problems in the real world.

I'm sure that somebody in here has attempted fully rewriting a piece of software at some point in their career. I can attest that this is an error-prone process. This is not something that goes off as smoothly as we would like to imagine that it would. Full rewrites often blow past deadlines. They're often so much more complicated than we initially imagined them to be. We often introduce either new bugs or reintroduce bugs that the old system established and fixed and got rid of and moved past. That's because there's a lot of institutional historical knowledge that's in our existing legacy software systems. Old code is not just bad because it's old. It's often complicated because there are complicated sets of real-world constraints that code has to operate in. I think a lot of full rewrite projects suffer from the lack of introspection that leads to regenerating existing bugs.

If you're trying to optimize for performance, if your goal is like, we're going to rewrite from scratch so that the thing will be faster, if you are only approaching that problem from the perspective of programming language. Obviously, some programming languages have a different per-line CPU cost to execute or whatever, the amount of instructions that it takes to do a math operation, for example. That will be faster. If you're just looking at the programming language, you might be ignoring things like architectural changes that need to happen, your database schema, your querying patterns, your caching patterns, your multiple layers of microservices throughout the stack. I think that these full rewrite projects are understandable in their impulse, but they often run into some trouble.

FFI Refactoring

You might also be thinking like, we're not going to rewrite the whole thing, but we've got this big monolith and we want to break it up into microservices, and we can make it a little bit faster at a time. I actually want to talk today about something that is even more fine-grained than a microservice. Something that is more deep in the stack than the service boundary. We are going to talk today about what I'm calling FFI refactoring, which is where at the individual function level, we rewrite a piece of functionality and move it into a new faster programming language, in this case Rust. We're going to rewrite a little bit of functionality today from Python into Rust, and we're going to connect these two things using the C Foreign Function Interface. Basically, every modern operating system, every programming language that is used in the mainstream knows how to call a C function because there are mountains and mountains of C code that's out there in the world that is doing lots of important work today.

It's just a requirement that everything has to know how to call a C function. This has become like a lingua franca for software. We're going to exploit this to connect some Python code to some Rust code in a really performant way today. This is not a new technique. This is definitely not something that I invented by any stretch of the imagination. If you have used software like this before, you have definitely used these cross-language bindings. NumPy and SciPy are both based on, I believe, Fortran code that's talking to Python code that you as an application developer write. TensorFlow, I believe, is written in C++, and similarly has bindings available for lots of languages. Of course, OpenSSL is written in C and has bindings available in basically every programming language that exists.

Feasibility

Let's talk about feasibility. What are the realities that you're going to have to face if you're doing a project like this? There's always tradeoffs to everything. I'm sure everybody knows that there are no silver bullets. There's no magic solutions. Some of the tradeoffs that you might have to make if you pursue something like this, you're going to be making your deployments more complicated. Because if you're deploying a bunch of dynamic code in Python or Ruby or whatever, your deployments might be as simple as FTPing some software to a server and reloading a systemd service. Probably not at this stage, but it could be. There's no reason it couldn't be. You are going to be making your deployments more complicated if you introduce custom native code to your dynamic programming language deploys. Similarly, you're going to be making your developer environments more complicated. You're either going to have to put Rust compilers on your dev machines.

You're going to have to ship native binaries to your developer machines, which have to match the OS and micro-architecture of your dev's computers, or you are going to have to maintain duplicate old dynamic implementation and new native Rust implementation. Maybe those are things that you do not want to do. Of course, with any rewrite, there's the possibility that you're going to be adding bugs to your system. I think that there's a less likelihood of doing this when you're operating at this smaller scale, when you're constrained, as compared with a full giant rewrite. There's always a possibility that you could add bugs and make things slightly worse for yourself.

What makes a good project here? What is a good candidate for this kind of work? What is a bad candidate for this kind of work? I think it's really all about aggregates. It's all about looking at the sum total of what your applications are spending the most of their time doing and how can you cut down on that. This could be operations that happen sometimes but are super expensive, or it could be operations that are fairly cheap but are happening constantly. This could be your request verification code that sits in front of every single one of your API handlers. It's not especially heavy. It's not especially eating up a lot of CPU per call, but it is called on every single request. It's called by all of your teams or whatever. If you're on a platform team and you see that, 10% of our server runtimes are spent running this custom internal verification logic, what if that were 1%?

What if that were half a percent? That's not a trivial reduction in spend in a large organization. Let's also look at like, what do you have today? What is the reality of the systems that you're dealing with today? I have this two-axis system right here. The vertical axis represents the relative performance of Rust versus some other popular programming languages, and the horizontal axis represents the tooling support on the Rust side for doing this kind of FFI refactoring work. The best candidate languages are the things on the top right here. Languages like Ruby, Python, Node.js, Lua have really good tooling support in Rust for doing this kind of work. It's really easy to write Rust code that turns into a Python library or a Ruby library or a Node.js library and is callable from those things. It's also significantly faster than those languages. On the left side, you have languages that Rust is generally faster than but maybe don't have great tooling support.

Go, for example, doesn't have great tooling support just because Go's support for the C FFI is hamstrung by its runtime, which makes a lot of assumptions about the way that the runtime can yield execution, and it has to put a lot of that on hold when it's calling a C function. There's also a heavy per call cost that's associated with C FFI and Go, because it has to inflate the stack size. Languages like C and C++, are going to have similar performance to Rust. They might be slightly faster in some cases. There is good tooling support for these languages however, because in addition to the performance versus dynamic languages, compared with these languages, Rust offers a lot of memory safety guarantees that people like OS developers, kernel developers, really core system developers like. You're seeing a lot of projects like Android, the Windows kernel, the Linux kernel start to adopt Rust because it has the potential to remove a lot of memory safety bugs, which are some of the most critical security vulnerabilities that exist in a lot of core pieces of software.

Example - Reimplementation from Python into Rust

Today we're going to be talking about doing this strictly for performance reasons. We're going to imagine for a moment that you are a Python developer working on a Flask application, and you have this simple endpoint right here that just does some statistical calculations over a list of numbers that you get from a JSON request body. Nothing revolutionary, certainly. We are going to take this and we are going to refactor parts of it into Rust. As an aside, if you would like any more information on some of the Rust concepts that I'm going over, you should definitely read "The Rust Programming Language" by Carol Nichols Goulding and Steve Klabnik. It is free on the Rust documentation website. We're going to create a new Rust crate, a new Rust library called rstats. The Rust build tool Cargo is going to give us a package metadata file called Cargo.toml, as well as a Rust source file where all of our code is going to live for now.

We're going to start out by including a couple of dependencies, first for a statistics library. In Python, we were using the built-in standard library stats module, but Rust has a much more constrained standard library that mostly just has OS primitives and basic synchronization stuff. We're also going to include PyO3, which is Rust's crate that allows us to do easy integration with Python. It's a joke in the Rust community that a lot of people will name their crates after something involving the chemical reaction of rusting. Python trioxide oxidized Python is PyO3. We're going to enable the extension module feature of PyO3, which will allow that crate to generate an extension module, which is Python's terminology for a C library that you can call from Rust. Features is the way that Rust talks about conditional compilation to enable or disable code at compile time. Not every use of PyO3 would be for creating an extension module like this.

You could also use it to call Python code from Rust if you were so inclined. It certainly won't be very performant, but there's a lot of Python code out there that does really useful things, and maybe you want to call some of it for some reason. We'll also need to tell the compiler that we want to build a C dynamic library or cdylib. Most of the time the compiler is generating just Rust libraries that are only callable by the same version of the Rust compiler on the same hardware, but we will tell it to generate a C dynamic library.

This is the basic structure of what we're going to be doing today. On the Python side, we will have Flask. It will have an HTTP handler inside of it. We will deserialize some JSON. We'll take the values that are deserialized from JSON, pass them to a statistics computation function in Rust, pass those results back to Python, serialize them into JSON, and then send them back along their way on the HTTP stream. We're going to start out in our Rust file by importing some types from PyO3. Another convention in the Rust world is to have a module inside of a crate called prelude. If a crate has a lot of types that are required to really make good use of it, it's just a convenience so that you can import a bunch of things at once that are necessary. We're going to create a module inside of our Rust thing called rstats.

This needs to be the same name as the name of our crate. It will coincidentally also be the name that is importable from Python. As you might imagine, this attribute, pymodule, is going to turn this Rust module into an equivalent Python module. Or at the very least, it's going to take all of the things that are defined in this Rust module and generate wrappers on the Python side that can be called just like their normal Python types. We're also going to create a function annotated with pyfunction. Very similarly, you might be able to imagine what this does. It will generate a wrapper function called compute_stats on the Python side that just ultimately calls our Rust function. This is something, it's not especially useful at the moment. From our Python code, we can import our new rstats module. If we compile our Rust code using a tool called Maturin from the PyO3 developers, we are going to be able to boot up our Python code, our Flask code, and it will start. It won't give us an import error. It's not going to do anything particularly interesting, but the fact that there is just this small amount of code that you need in order to create a Rust module that you can import from Python, I always thought was pretty cool.

Let's get to the actual meat of our reimplementation work. On the Python side, we have this return line, which is returning these four stat properties of the list of numbers that we passed in. We're getting back the range, the quartiles, the mean, and the standard deviation, which I could never remember the purpose of. Let's get to it. On the Rust side, we are going to create a struct, which is going to hold all of the stat properties that we care about so that we can pass them back to Python. It's semi common on the Python side to have unstructured data that's just in a big dictionary with a bunch of string keys and values. On the Rust side, definitely more common to define structs that can do really efficient memory layout instead. We're going to have space for all those fields that we care about, and we are going to set the return type of our compute_stats function to that new stats response type.

We're going to import a bunch of items from the statrs library that we added to our Cargo.toml file. We are going to start writing the inside of our compute_stats function. We have to put the numbers that are coming in from Python into a statrs data type, which is just a wrapper that can hold lots of different types of sequences of values. Notice that this data type is marked as mutable. A lot of the stats computations that we're going to be doing actually require a sorted list of numbers, and statrs is going to do that sorting for us automatically. Many other libraries in other languages might do something like this by creating a defensive copy, because it's not really easy or obvious to encode mutability or immutability in the type system in other languages. If you're used to like Python, for example, in order to sort a list of numbers, you might call the sorted function, which gives you back a copied list of numbers. This is obviously not efficient if you have larger datasets. Rust makes mutability and immutability like first class components of the type system. It's really obvious what is and isn't a mutable binding, as well as where something is owned, what has responsibility for a particular value.

Inside of our stats response that we're going to return, we can easily calculate the range, which is the minimum and maximum, and the quartiles, which are coming from the low, mid, and upper quartiles. The mean and the standard deviation, though, they have this extra function call at the end. They have this little unwrap here at the end. What is that about? If we jump to the definition of these functions, we will see that both of these functions return an optional value. It's not the case that every dataset has a mean or a standard deviation. An empty set of numbers has no average because you cannot divide by zero. There's a possibility that a dataset might not have a median, might not have a standard deviation. To encode this possibility, to encode the case where there is no mean, no standard deviation, these functions return an optional value.

Optionals act as Rust's equivalent to nullability in other languages. I'm sure you have dealt with many bugs resulting from not doing a null check, resulting from assuming that something is initialized when it is actually not initialized. In many programming languages, null acts as, I think it's called the bottom type, where it can be assigned to variables of any type. This means that you need checks throughout your code that verify that a given value, a given variable does not have null stored into it. You need to repeat these checks because there's unfortunately no way to encode in most type systems that something is definitely not null. You can often encode that something might be null. Python's type checking, as well as newer versions of Java, have optional types which give you a hint that something might be null, like this function logically could return nothing. It's not really possible in most type systems to encode, this is definitely initialized to something. A function that takes a string as its input and returns a string, for example, you might need to check both the input argument as well as the caller might need to check the return value.

Rust takes a different approach. Many other functional languages take similar approaches of having a type which represents something that may or may not be initialized. We have an enum that has two branches. It either holds something and a value, or it holds nothing. This might seem like it's similar to null. It is different in the sense that it is not possible to avoid doing the null check. It is not possible to avoid forgetting to deal with the possibility that this thing is not initialized, because in order to get the value out of the thing, you have to say what happens if it's not initialized, if there's nothing there. Compared to null, option is strongly typed. You get really good guarantees about when a thing could or could not be initialized. You're able to centralize your checks throughout your code. If you have an entry point to your library that might take a value, you can check for the value being there at the edge of your library and then provide a default that's threaded all the way through the rest of your code.

The rest of your code does not have to do repeated checks. It sounds like a simple thing, but once you get used to knowing that things are always initialized, it becomes really handy and a nice way to think about code. In these cases, we are cheating a little bit. We are just throwing our hands up and saying, if it's not there, just throw an error. This is obviously not how you would write production quality code, but that's what we're doing on this slide. Before we can get our data back to Python, we have to tell PyO3 how our Rust data are going to transform into Python-compatible data. This pyclass attribute from PyO3 is going to generate a Python class wrapper so that on the Python side, when we return our StatisticsResponse, it is going to look just like a Python class to Python. This is required because this type is being returned from a function annotated with pyfunction.

If we left this off, we would get an error at compile time saying, Python isn't going to know how to deal with this value when you give it to it. We also need to add getters for our different fields. Not every Rust type is easily representable in Python, and we, therefore, need to specify which ones are going to be available to Python and which ones are not. You could also add setters so that you could have data readability and writability, both ways.

We're almost there. We're so close. We reimplemented our Python stats functionality in Rust. We generated a Python wrapper function for it. We generated a Python class that will hold our returned data. Let's call this brand-new exciting function. We can call the compute_stats function in our Python file, and that will give us back a StatisticsResponse. Then we can walk all of the fields of the StatisticsResponse on the Python side and turn that into JSON that will go back over the wire. If we start this up and we run it through curl, we will see that we get some pretty reasonable looking statistics back. That looks pretty promising. If we compare the two results, the Python and the Rust, however, we will see that they do not exactly line up. This is an unfortunate reality that you might run into with a rewrite, a refactor of any kind.

If you're swapping out an entire library, an entire ecosystem for another one, it's entirely possible that you might run into things that don't exactly line up on the output side. In this case, in addition to the very slight rounding error on some of the floating-point numbers, you can see that the quartiles are actually quite different. That's because one of these stats libraries does exact calculations, and the other one does estimations, which is really good for extremely large datasets. It's not so good if you have five numbers, though. It's pretty obvious that these are very different. This is something that is not unlikely to happen in a project where you're swapping out ecosystems. I've left this in so that we can talk about like, what do you do? What is the solution when you're faced with something like this? It's really simple. It depends, which is something that I'm sure no one has ever heard before in here.

It depends. There's a lot of different things that you could do. There's no silver bullet or always right solution to something like this. There's a couple of different things we could do. We could try to maintain the behavior exactly the same. You might say, if these quartiles are off, it's going to throw off these financial calculations, or it's going to throw off some really important core business functionality for us, and we can't accept that. Like, it has to stay the same. You also might decide that the numbers are off by a reasonable margin, and you can deal with it. Maybe the quartiles were fine. Maybe the only problem that you noticed was the floating-point rounding, and maybe that's not the end of the world for you.

If you want to maintain the behavior, if you want it to work exactly the same in Rust as it did in Python, then there's a couple of options available to you. You might be able to search for a different library that does the same thing that you're looking for. You might not be able to find it, and it might still be really important to you that it works exactly the same way. An expensive but workable option is you might have to reimplement the code from scratch. You might have to find out how the Python stats library calculates quartiles, and you might have to move that into Rust yourself. If something is business critical and you really want it to be as fast as possible, you might find yourself doing that. You also might decide that you just want to keep the quartile calculation in Python so that the results match exactly.

This is something that I think is a strength of this kind of work in that because we are doing this optimization, this rewriting at the individual function level, and the overhead of calling between Rust and Python is so low because they're operating in the same process and the same memory space, it's maybe reasonable to have parts of the functionality exist in one language and parts exist in another as needs dictate. If we were doing a rewrite project by using microservices, for example, and we discovered this incompatibility a few months down the line into a microservices rewrite, it would be much more complicated to have a totally new service maintain the same behavior because it wouldn't really be possible to have this cross-language communication that falls back to the old implementation. You could do it by having cross-service communication, but now you're introducing more and more network hops, which is expensive.

Testing

Let's talk testing. Testing, super important, as I'm sure we're all very well aware. We're going to add just a quick and simple little test to our Rust code. We'll create a test module in our Rust code. This little cfg test on here is just going to do some conditional compilation for us. It will disable all of our tests from production builds so that we don't bloat the size of our binaries. We're going to add a test function called test_9_numbers. We'll import that function that we defined earlier. As you might imagine, for a function called test_9_numbers, we're going to create a list of 9 numbers, run it through our compute_stats function, and then make some assertions about the results that come back from it. Fairly easy for us to precalculate and know what these numbers are going to be. If we compile and run this using Cargo's test tool, it will build that for us and it will tell us that our nine numbers test straightforwardly passed.

You obviously need more than one test over nine numbers in order to productionalize a system like this. Because this is something that integrates so deeply with the existing language, it's pretty straightforward for you to integrate this with your existing test framework. If you already have pytests that are running your Flask HTTP handler and making assertions about the JSON results that come back from it, those are just going to tie in really nicely with this new implementation. Because from the perspective of a user of your HTTP handler, the contract is really not going to change at all. There's not even additional services that someone would need to start up. You can also use dependency injection to swap out the old and the new implementations in the scope of your tests and make assertions about the results of the two implementations matching up, or do randomized property testing and make assertions that a random set of numbers gets the same results back from Python and Rust.

Performance

Let's talk about performance. This is the reason ostensibly that we did all of this work. We want to make the thing faster. We want to make the results better so we can save on our CPU costs. We can use Python's timeit module to run both the Python and the Rust implementation a few thousand times and see how fast they each go. We can see that the Rust code is a bit over a hundred times faster than the Python. Remember that this is a benchmark that is running through Python. Both of these examples are running a Python interpreter. Both of these examples are calling a Python function. There's not trickery that's happening here by virtue of no runtime at all existing on the Rust side. If you ran a benchmark that was calling that function entirely within Rust, I would expect it to be another order of magnitude faster.

However, these are total times coming from timeit, unfortunately. If we divide by the number of iterations, we will see that the average time to run this function once obviously still a hundred times faster, but the absolute time required to run the Python version of this code, the previous version, was only 86 microseconds, which is not an especially high CPU cost. However, if this is something, like I said previously, that is being run over and over again, a piece of verification code that's running at the edge of all of your API handlers, it might add up to a significant CPU cost that you could cut out. It's also important to do macrobenchmarks in addition to microbenchmarks whenever you're trying to understand the performance of something. Remember that these are HTTP handlers. They are running the Flask HTTP stack in Python. They are deserializing and serializing JSON in Python, which is not especially fast.

If we run a macrobenchmark using the work tool, we can see that we did make things about 15% faster, but this is obviously a far cry from the 100x that we saw at the individual function level. A 15% haircut to CPU costs is nothing to shake your head at. I'm sure if you told your boss you could save 15% on your infrastructure bills, they'd be pretty happy with you.

What if we wanted more? What if we wanted to squeeze a little bit extra out of this while we're working in this area? We're going to add JSON serializing to the mix here. It's theoretically also possible to do deserializing. You could monkey patch the way that Flask does request body deserializing, but it's automatic and in the internals of the library, so it's not quite as straightforward to do as the serializing bit. We are going to include a couple of additional dependencies. The Serde and serde_json crates are used for serializing and deserializing in the Rust world. Serde is going to get its derive feature activated, which is going to let us generate really fast and optimal serialization and deserialization code at compile time, which will save on a bunch of runtime costs that many dynamic serialization, deserialization frameworks have. We are going to add a little attribute to our StatisticsResponse struct to automatically do that serialization generation at compile time.

This is going to, like I said, generate a quite efficient way for us to serialize a StatisticsResponse into JSON, or TOML, or lots of other formats at compile time. We are going to create a new method on our StatisticsResponse struct that we can call from Python by using the pymethods decorator from PyO3. We'll call this method JSON, and it's just going to use the serde_json library to serialize our StatisticsResponse out to a JSON string. It's pretty straightforward for us to take this and turn the return value of our JSON function into a Flask response by setting the appropriate MIME type. Notice here that we are calling a Rust function from Python, getting a structured value back into Python, and then calling a Rust function on that structured value that came back. At the point that you can do this, you can hand your application developers something that is really opaque and looks to them just like a native type in whatever language they happen to be writing.

The fact that they are interacting with code that's written in Rust isn't necessarily salient besides from the performance implications. If we look at the macrobenchmarking results that we get once we move JSON serializing into Rust, we can see that it gets about another 5% faster versus the existing previous full Python implementation. It's about a 20% haircut to the runtime of this particular endpoint.

This is not something that just exists in the world of conference talks and theoreticals and wouldn't-it-be-nices. This is something that real teams do in production to save real amounts of CPU and real amounts of infrastructure costs. A few years ago, Discord's real-time engineering team put out a blog post about how they did this for some of their most important systems that deal with handling user state in production and saved a bunch and made the whole things quite a bit more efficient and able to scale for much larger guilds.

Recap

Today, we talked about FFI refactoring. We talked about the feasibility and risks that you might face with a project like this. We talked about the PyO3 crate and how you can use it to talk between Rust and Python. We talked about testing, how you can test something like this, how you might benchmark something like this, and realistic results that you might expect to see at the micro and macro level. I hope you check out my book, "Refactoring to Rust," if you'd like some more information on this, or check out my blog, lilymara.xyz.

Questions and Answers

Participant 1: I wanted to talk about that bridge between Python and Rust. Dissecting it to what goes from Python to Rust and what comes back. On this side, you have data structures going in. I'd like to know what kinds of restrictions we have. Is there zero copy possible there, for example, if I'm passing a lot of bytes or big data structures. That question goes both ways. I think in your example you had an object that was passed in, returned back, and then passed in again. How do we avoid copies there? Second question is more about things that could be impedance mismatches between languages, like logging, exception handling, error handling, anything that happens inside the Rust code that you would want to tell the outside world.

Lily Mara: The copying one, definitely, I would say depends on the ecosystem that you're working in. PyO3 does a really good job of making this pretty straightforward to avoid. Often the interaction points between Rust code and Python using PyO3 will get pointers on the other side. If you have a big byte array within Python, you will get handed a pointer to that. That will be sitting in Python's memory space that is allocated with the Python allocator. It will be deallocated by the Python allocator and all this stuff. You generally can avoid copies by just working with those pointer types. They do come with some limitations though, because of course, if you're working with Python code, especially if you're working not within Python, on Python code, you have to interact with Python's global interpreter lock, the GIL, which single threads all the operations that talk to Python.

This is of course becoming less true with newer versions of Python that are free threading. Generally speaking, you have to interact with the GIL. If you do have a large data structure, you can process it within the same thread without doing a copy. If you wanted to take a big data structure from Python and then spawn a new thread within Rust and work on that, you would have to copy it in order to effectively work on it from that background thread. Otherwise, you'd have to interact with the GIL.

Then your second question was like logging, exception handling, for sure. On the exception handling side, I think Rust is actually really well suited for this kind of thing, because it doesn't have an exception system. There's not like a magical control flow interruption that exists within Rust the same way as it does in other languages. It's pretty straightforward to use Rust standard error handling practices, which involve errors as values, and have those bubble up to the point where you have that Python interaction. Then at that edge point, you would convert that into a Python exception or you would log it or whatever. Logging and tracing, again, depending on the environment that you're in, could be more or less of a challenge. A lot of the logging and tracing interactions within Rust happen using facade crates. You would call the logging.info or tracing.info or whatever, within your libraries.

Then, at some point in the life cycle of your application, you would set up a handler that says, ok, when somebody generates a logging event, this is how you handle it. Actually, within your Rust library that's called from Python, you can set up a logging handler that forwards all messages into Python's logger or whatever third-party error handling provider you're using. Both of these are definitely possible to do.

Participant 2: First of all, thanks for convincing me definitely that Rust is a really good fit for progressive refactoring. I saw that it was extremely good in taking an existing product and improving its performance in a progressive way. That really convinced me. Also, regarding the comparison with the Go programming language, you are perfectly right. The FFI interface regarding Go is an entire mess. You have to enable a flag. I think it's called the CGO_ENABLED flag. Also, in defense of the Go world, I know I always thought that using FFI could cause portability issues. I wanted to hear your opinion about this. I always wondered why in the Rust ecosystem, the asynchronous engine was always left out from the standard library and we still have the Tokio component outside the standard library.

Lily Mara: It's definitely true that there are downsides to using FFI as an interaction point. I tried to touch on some of these earlier in the talk. You do have to make sure that if you're shipping around compiled assets, that those match up with the instruction set and the operating system that you're using on your end devices that are operating the software. It's definitely possible that you could have that as a potential point of interoperability issues if you don't manage it carefully. Of course, it's easy to make assumptions about the things that happen in your program if one compiler is responsible for looking at every single line of code that's being compiled. I do happen to think Go has a really cool execution model. The fact that the Go compiler is able to have all of this knowledge about where I/O operations are happening, where things that could block are happening, it's able to reduce a lot of cognitive overhead on behalf of the developer. It does also have some tradeoffs in the realm of interacting with FFI or some other things. I do also think that Go is similar enough to Rust in terms of performance that it's probably not even worth pursuing a project like this if you want to optimize your Go code. They're too close. It's like splitting hairs.

Then the other part of your question was like, why does Rust not have an async runtime just as a part of the standard library? I think this is just a difference in philosophies between the Rust people and the Go people. The Go people wanted to be able to have a language that was super easy for building web apps basically to support new hires doing whatever they wanted at Google. Rust was designed by people who wanted to take over C and C++'s place in the market, wanted to do OS development, kernel development, like really low-level things where having a runtime sidecar that was able to always do these yield operations wouldn't really be appropriate or desired. I think that both of these philosophies have merits. I think that they just have different goals associated with them at least in the very beginning. Rust did actually have green threads built directly into the language. When it first started, it had a similar execution model to Go, but then at some point they drew a line in the sand and said, "No, this is going to have too much of a cost associated with it. It'll have to be included by the people who want it and left out by the people who don't."

Participant 3: As someone who's pretty new to FFI, I just wanted to ask a little bit about the testing. Let's say I had a Python function that does make calls to several Rust functions, how would I test it all at once as a integration test?

Lily Mara: How would you test it all at once as an integration test? I think the thing that's really powerful about this kind of work is that from the perspective of a Python developer looking at the code on the screen, it just looks like you're calling a normal Python function. I really don't think that you would have to adapt your testing strategy too drastically to take advantage of a refactoring project like this. You would test it pretty similarly, I would imagine, to how you would test a collection of Python classes or a collection of Python functions in an integration test.

 

See more presentations with transcripts

 

Recorded at:

Software is changing the world. QCon San Francisco empowers software development by facilitating the spread of knowledge and innovation in the developer community. A practitioner-driven conference, QCon is designed for technical team leads, architects, engineering directors, and project managers who influence innovation in their teams.

Sep 10, 2026

BT