Now Reading
Some causes to keep away from Cython

Some causes to keep away from Cython

2023-09-21 01:12:18

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 sooner nanobind 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.

See Also

A performance timeline created by Sciagraph, showing both CPU and I/O as bottlenecks
A memory profile created by Sciagraph, showing a list comprehension is responsible for most memory usage

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.

Source Link

What's Your Reaction?
Excited
0
Happy
0
In Love
0
Not Sure
0
Silly
0
View Comments (0)

Leave a Reply

Your email address will not be published.

2022 Blinking Robots.
WordPress by Doejo

Scroll To Top