Some causes to keep away from Cython

If it’s essential pace up Python, Cython is a really great tool.
It permits you to seamlessly merge Python syntax with calls into C or C++ code, making it simple to put in writing high-performance extensions with wealthy Python interfaces.
That being stated, Cython just isn’t the most effective device in all circumstances.
So on this article I’ll go over among the limitations and issues with Cython, and recommend some options.
A fast overview of Cython
In case you’re not accustomed to Cython, right here’s a fast instance; technically Cython has pre-defined malloc
and free
however I included them explicitly for readability:
cdef extern from "stdlib.h":
void *malloc(size_t measurement);
void free(void *ptr);
cdef struct Level:
double x, y
cdef class PointVec:
cdef Level* vec
cdef int size
def __init__(self, factors: record[tuple[float, float]]):
self.vec = <Level*>malloc(
sizeof(Level) * len(factors))
self.size = len(factors)
for i, (x, y) in enumerate(factors):
self.vec[i].x = x
self.vec[i].y = y
def __repr__(self):
consequence = []
for i in vary(self.size):
p = self.vec[i]
consequence.append("({}, {})".format(p.x, p.y))
return "PointVec([{}])".format(", ".be part of(consequence))
def __setitem__(
self, index, level: tuple[float, float]
):
x, y = level
if index > self.size - 1:
elevate IndexError("Index too giant")
self.vec[index].x = x
self.vec[index].y = y
def __getitem__(self, index):
cdef Level p
if index > self.size - 1:
elevate IndexError("Index too giant")
p = self.vec[index]
return (p.x, p.y)
def __dealloc__(self):
free(self.vec)
We’re writing Python—however any level we are able to simply name into C code, and work together with C variables, C pointers, and different C options.
If you’re not interacting with Python objects, it’s straight C code, with all of the corresponding pace.
Usually you’d add compilation to your setup.py
, however for testing functions we are able to simply use the cythonize
device:
$ cythonize -i pointvec.pyx
...
$ python
>>> from pointvec import PointVec
>>> pv = PointVec([(1, 2), (3.5, 4)])
>>> pv
PointVec([(1.0, 2.0), (3.5, 4.0)])
>>> pv[1] = (3, 5)
>>> pv[1]
(3.0, 5.0)
>>> pv
PointVec([(1.0, 2.0), (3.0, 5.0)])
How Cython works
Cython compiles the pyx
file to C, or C++, which then will get compiled usually to a Python extension.
On this case, it generates 197KB of C code!
As you possibly can think about, studying the ensuing C code just isn’t enjoyable; right here’s a tiny excerpt:
/* "pointvec.pyx":16
* self.size = len(factors)
*
* for i, (x, y) in enumerate(factors): # <<<<<<<<<<<<<<
* self.vec[i].x = x
* self.vec[i].y = y
*/
__Pyx_INCREF(__pyx_int_0);
__pyx_t_2 = __pyx_int_0;
if (probably(PyList_CheckExact(__pyx_v_points)) || PyTuple_CheckExact(__pyx_v_points)) {
__pyx_t_3 = __pyx_v_points; __Pyx_INCREF(__pyx_t_3); __pyx_t_1 = 0;
__pyx_t_4 = NULL;
} else {
__pyx_t_1 = -1; __pyx_t_3 = PyObject_GetIter(__pyx_v_points); if (unlikely(!__pyx_t_3)) __PYX_ERR(0, 16, __pyx_L1_error)
__Pyx_GOTREF(__pyx_t_3);
__pyx_t_4 = Py_TYPE(__pyx_t_3)->tp_iternext; if (unlikely(!__pyx_t_4)) __PYX_ERR(0, 16, __pyx_L1_error)
}
This half isn’t too unhealthy.
Later components of the code are a lot tougher to learn.
Why Cython is so enticing
As our instance reveals, making a small extension for Python may be very simple with Cython.
You get to make use of Python syntax to work together with Python, however you may also write code that compiles one-to-one with C or C++, so you possibly can have quick code simply interoperating with Python.
Some downsides to Cython
Sadly, since Cython is ultimately only a skinny layer over C or C++, it inherits all the issues that these languages endure from.
After which it provides some extra issues of its personal.
Downside #1: Reminiscence unsafety
Go look over the PointVec
instance above.
Can you notice the reminiscence security bug?
Click on right here to see the reply
Whereas the __setitem__
and __getitem__
strategies verify for indexes which might be too excessive, they don’t verify for adverse numbers.
We are able to subsequently write (and browse) to reminiscence addresses outdoors allotted reminiscence:
>>> pv[-200000] = (1, 2)
Segmentation fault (core dumped)
This could probably enable an attacker to take over the method if they may feed in the correct inputs.
Most safety bugs within the wild are attributable to reminiscence unsafety, and utilizing C and C++ makes it far too simple to introduce these bugs.
Cython inherits this drawback, which suggests it’s very tough to put in writing safe code with Cython.
And even when safety isn’t a priority, reminiscence corruption bugs are a ache to debug.
Bonus bug: The earlier bug was intentional, however Alex Gaynor pointed on the market’s one other bug I launched by chance.
Can you notice it?
Click on right here to see the reply
sizeof(Level) * len(factors)
can overflow.
That is most likely tougher to take advantage of, however the reality it’s really easy to introduce safety bugs is actually not good.
Downside #2: Two compiler passes
If you compile a Cython extension, it first will get compiled to C or C++, after which a second go of compilation occurs with a C or C++ compiler.
Some bugs will solely get caught within the second compilation go, after Cython has generated 1000’s of traces of hard-to-decipher code.
The ensuing errors may be annoying:
Right here’s information.h
:
#embrace <stdio.h>
struct X {
double* myvalue;
};
static inline void print_x(struct X x) {
printf("%fn", *x.myvalue);
}
Right here’s typo.pyx
, which has a typo (can you notice it?):
cdef extern from "information.h":
cdef struct X:
double myvalue
void print_x(X x)
def go():
x = X()
x.myvalue = 123
print_x(x)
After I compile typo.pyx
, I get the next error:
typo.c -o typo.o
typo.c: In perform ‘__pyx_convert__from_py_struct__X’:
typo.c:1517:28: error: incompatible sorts when assigning to sort ‘double *’ from sort ‘double’
1517 | __pyx_v_result.myvalue = __pyx_t_10;
| ^~~~~~~~~~
Discover there’s no reference to the unique location within the .pyx
supply code.
On this case it’s fairly clear what’s happening, since our instance solely has the one task.
With extra advanced code, you need to take a look at the generated C file, and discover what Cython code it’s referring to from the feedback.
With C++ this may get much more irritating, for the reason that language is extra advanced and subsequently has extra methods to fail.
For extra skilled builders, that is much less of a difficulty, however a major profit of excellent compiler errors helps new builders.
Downside #3: No standardized bundle or construct system for dependencies
As soon as your Cython code base will get large enough, you would possibly wish to add some performance with out having to put in writing it your self.
If you happen to’re utilizing C++, you might have entry to the C++ commonplace library, together with its information constructions.
Past that, you’re within the land of C and C++, which for sensible functions has no bundle supervisor for libraries.
With Python, you possibly can pip set up
a dependency, or add it to your dependency file with poetry
or pipenv
.
With Rust, you possibly can cargo add
a dependency.
With C and C++ you’ve received no language-specific tooling.
Meaning on Linux you may get your Linux distribution’s model of standard libraries… however there’s apt
and dnf
and extra.
macOS has Brew, Home windows has its personal, a lot smaller repositories like Choco.
However each platform is completely different, and lots of libraries merely received’t be packaged for you.
After which when you’ve gotten your C or C++ library downloaded, you is perhaps coping with a customized construct system.
In brief, except you’re simply wrapping an current library, all of the incentives push you to put in writing every thing from scratch in Cython, relatively than reuse preexisting libraries.
Downside #4: Lack of tooling
Due to the small consumer base, and the complexity of how Cython works, it doesn’t have as a lot tooling as different languages.
For instance, most editors as of late can use LSP language servers to get syntax checking and different IDE performance, however there isn’t any such language server for Cython so far as I do know.
Bounce to definition? Can’t do this.
Auto-complete? Nope.
Spotlight apparent typos?
Possibly: Emacs has a cython checker that basically simply compiles the code.
Nevertheless it doesn’t hassle with the second compiler go.
Even when it did, you’d simply know that the error existed except the device did relatively extra work to map to the Cython code.
To provide one other instance, so far as I do know there isn’t any Cython equal of the black
autoformatter.
Downside #5: Python-only
Utilizing Cython locks you in to a Python-only world: any code you write is simply actually useful to somebody writing Python.
It is a disgrace, as a result of individuals in different ecosystems would possibly profit from this code as effectively.
For instance, the Polars DataFrame library can be utilized from Python, but additionally from Rust (the language it’s written in), JavaScript, and work is in progress for R.
Options to Cython
So what can you employ as an alternative of Cython?
- If you happen to’re wrapping an current C library, Cython remains to be a sensible choice.
Largely you simply must interface C to Python, precisely what Cython excels at—and also you’re already coping with a memory-unsafe language. - If you happen to’re wrapping an current C++ library, a local C++/Python library like
pybind11
or the soonernanobind
could give a extra nice improvement expertise. - If you’re writing a small, standalone extension, and you’re sure safety won’t ever a be a priority, Cython should be an inexpensive selection in the event you already know find out how to use it.
If you happen to count on you’ll be writing intensive quantities of code, you’ll need one thing higher.
My suggestion: Rust.
Word: Whether or not or not any explicit device or approach will pace issues up depends upon the place the bottlenecks are in your software program.
Have to determine the efficiency and reminiscence bottlenecks in your individual Python information processing code? Strive the Sciagraph profiler, with help for profiling each in improvement and manufacturing on macOS and Linux, and with built-in Jupyter help.
Rust instead
Rust is a memory-safe, high-performance language, and lets you simply write Python extensions with PyO3.
For easy circumstances, packaging is further simple with Maturin, in any other case you need to use setuptools-rust
.
You too can simply work with NumPy arrays.
Moreover, Rust overcomes all the opposite Cython issues talked about above:
- Reminiscence security: Rust is designed to be memory-safe by default, whereas nonetheless having the identical efficiency as C or C++.
- One compiler go: In contrast to Cython, there’s simply the one compiler.
- Built-in bundle repository and construct system: Rust has a growing ecosystem of libraries, and a bundle and construct supervisor known as Cargo.
Including dependencies is fast, simple, and reproducible. - Plenty of tooling: Rust has a linter known as clippy, a wonderful LSP server, an autoformatter, and so forth.
- Cross-language: A Rust library may be wrapped in Python, however you may also interoperate with different languages.
The downsides, in comparison with Cython:
- You may’t use Python syntax inline, so interfacing with Python is extra work.
- It’s a a lot extra advanced language than C, so it takes for much longer to be taught, although it’s no worse than C++.
Some real-world examples
Polars: We’ve already talked about Polars is written in Rust; the Python model is only a wrapper round a generic Rust library.
Py-Spy: The py-spy profiler is written in Rust, and by its nature may be very Python-specific.
Nonetheless, it shares some generic dependencies with the rb-spy Ruby profiler, which makes use of the identical operating-system mechanisms.
And past that it makes use of many different pre-existing Rust libraries—that’s the advantage of utilizing a language with a bundle supervisor and an lively open supply ecosystem.
Transforming our instance in Rust
So what does Rust seem like in comparison with Cython?
I rewrote the PointVec
instance in Rust, sometimes utilizing barely much less idiomatic code for a bit extra readability.
When formatted with Rust’s autoformatter, the result’s 55 traces of code, in comparison with 42 for Cython:
use pyo3::exceptions::PyIndexError;
use pyo3::prelude::*;
struct Level {
x: f64,
y: f64,
}
#[pyclass]
struct PointVec {
vec: Vec<Level>,
}
#[pymethods]
impl PointVec {
#[new]
fn new(factors: Vec<(f64, f64)>) -> Self {
Self {
vec: factors.into_iter().map(
|(x, y)| Level { x, y }).accumulate(),
}
}
fn __getitem__(
&self, index: usize
) -> PyResult<(f64, f64)> {
if self.vec.len() <= index {
return Err(PyIndexError::new_err(
"Index out of bounds"));
}
return Okay((self.vec[index].x, self.vec[index].y));
}
fn __setitem__(
&mut self, index: usize, t: (f64, f64)
) -> PyResult<()> {
let (x, y) = t;
if self.vec.len() <= index {
return Err(PyIndexError::new_err(
"Index out of bounds"));
}
self.vec[index] = Level { x, y };
return Okay(());
}
fn __repr__(&self) -> String {
return format!(
"PointVec[{}]",
self.vec
.iter()
.map(|t| format!("({}, {})", t.x, t.y))
.accumulate::<Vec<String>>()
.be part of(", ")
);
}
}
#[pymodule]
fn rust_pointvec(_py: Python, m: &PyModule) -> PyResult<()> {
m.add_class::<PointVec>()?;
return Okay(())
}
Discover that Rust has a built-in vector class, in addition to iterators.
We additionally don’t must manually free the reminiscence, Rust will do this routinely.
The brand new model has the identical performance because the Cython one, however with out the reminiscence security bug; the requirement for express typing forces us to note that constructive integers are probably what we wish.
>>> from rust_pointvec import PointVec
>>> pv = PointVec([(1, 2), (3.5, 4)])
>>> pv
PointVec[(1, 2), (3.5, 4)]
>>> pv[0] = (17, 18)
>>> pv[0]
(17.0, 18.0)
>>> pv
PointVec[(17, 18), (3.5, 4)]
>>> pv[-200000] = (12, 15)
Traceback (most latest name final):
File "<stdin>", line 1, in <module>
OverflowError: can't convert adverse int to unsigned
What occurs if we omit the bounds verify, like this?
fn __setitem__(
&mut self, index: usize, t: (f64, f64)
) -> PyResult<()> {
let (x, y) = t;
// if self.vec.len() <= index {
// return Err(PyIndexError::new_err(
// "Index out of bounds"));
// }
self.vec[index] = Level { x, y };
return Okay(());
}
Rust nonetheless protects us:
>>> from rust_pointvec import PointVec
>>> pv = PointVec([(1, 2), (3.5, 4)])
>>> pv[200000] = (12, 15)
thread '<unnamed>' panicked at 'index out of bounds: the len is 2 however the index is 200000', src/lib.rs:35:9
word: run with `RUST_BACKTRACE=1` setting variable to show a backtrace
Traceback (most latest name final):
File "<stdin>", line 1, in <module>
pyo3_runtime.PanicException: index out of bounds: the len is 2 however the index is 200000
Don’t again your self right into a nook
If you happen to’re writing a small extension and safety just isn’t a priority, Cython could also be a superb selection.
Nonetheless, it’s price wanting forward and desirous about the scope of your challenge.
If you happen to count on your codebase to develop considerably, it’s most likely definitely worth the funding to start out with a greater language from the beginning.
You don’t wish to begin hitting the bounds of Cython after you’ve written an entire pile of code.
Studying Rust will take extra work.
However in return your code will probably be extra maintainable, as a result of you’ll have entry to all kinds of libraries, much better tooling, and much fewer safety issues.
Lastly, it’s price noting that the unique writer of Polars didn’t write the JavaScript bindings, another person did.
If you happen to’re writing an open supply library, utilizing a non-Python-specific language for the core implementation permits non-Python programmers entry to the code, with out essentially including further work in your half.