Buffers on the Edge: Python and Rust
alexgaynor.net
alexgaynor.net
Is this really true when you’re directly modifying the underlying data structure of a python object? That can’t really be considered “pure python” anymore, and it seems like there would LOTS of ways to use it to cause shenanigans.
Python hooks into C at some points. I have not verified every single one of those points.
Iterating a data structure while modifying it can get some very weird behaviour in Python.
I think it qualifies as undefined behaviour. Python recommends not to do this, without any safeguards in place.
What this articles says is that the buffer protocol allows bugs that are qualitatively different.
I thought the behaviour was simply whatever cpython does but it seems it is documented (so not undefined, indeed as you say). Copy pasted below with source.
Note
There is a subtlety when the sequence is being modified by the loop (this can only occur for mutable sequences, e.g. lists). An internal counter is used to keep track of which item is used next, and this is incremented on each iteration. When this counter has reached the length of the sequence the loop terminates. This means that if the suite deletes the current (or a previous) item from the sequence, the next item will be skipped (since it gets the index of the current item which has already been treated). Likewise, if the suite inserts an item in the sequence before the current item, the current item will be treated again the next time through the loop. This can lead to nasty bugs that can be avoided by making a temporary copy using a slice of the whole sequence, e.g.,
https://docs.python.org/3.7/reference/compound_stmts.html#th...
import ctypes
ptr = ctypes.c_char_p(0x1234) # integer to pointer cast
ptr.value # deref dangling pointer
And similar operations are available through numpy and the buffer API, just a bit harder to jump into those, but once you do, the same kind of mistakes can happen.The big difference is that most of these mistakes are opt-in, while in C every line of code is a possible source for problems.
Therefore there's not really any issue with having a Python API that you have to be a bit careful with to use safely - as long as it is documented and relatively obvious, it's no different to ctypes.
For example, you can trivially reintroduce C-style undefined behaviour on buffer overflow by writing a Rust type which implements Index (and optionally IndexMut) using unsafe to just not bother with a bounds check. Rust says this type is wrong, it should not do this, that's a bug -- even if your program using it has no buffer mistakes, the type is wrong anyway.
This is a fact of Rust's culture rather than a technical implication of the Rust language design, albeit they're closely related.
C++ culture was also a bit like that on the early days when we discussed about safer abstractions to C code on Usenet, somehow that got lost along the way as more C folks migrated into it.
It is Rust today, it was Ada, Object Pascal and Modula-2 in the early 80's, and anything in between since then
Edit: or as part of the webapp Armageddon wasm and wasm runtimes all get written in rust and the os running all those webapps and wasm services atrophies to a tiny thing that eventually gets rewritten itself as something(s) non linuxlike.
https://www.misra.org.uk/publications/
validated with QA tooling like
https://www.axivion.com/en/products/coding-guidelines/misra-...
But this is a case of undefined behaviour even for C extensions that do obey the usual contract. It even includes numpy for example. The two relevant elements are:
* When C extension uses the buffer protocol, is should operate directly on the buffer - the whole point of the buffer protocol is to avoid copying and allow speed almost of native code.
* When C extension is is doing CPU intensive work, release the Python GIL (but don't use any CPython APIs e.g. to instantiate a new object, iterate a list, etc.)
A major selling point of numpy, for example, is that you can use it to do CPU intensive work without the GIL locked, allowing true parallelism. I don't think it's a deal breaker that you can use this to get proper C-level undefined behaviour but it's definitely interesting, and I don't consider it a bug in numpy or Python.
But if even if you were able to modify a buffer-protocol buffer directly using native Python, that would keep the GIL locked, which would prevent simultaneous access from multiple threads.
I've done this against my own code. I got to a point where I needed shared memory due to multiprocessing but couldn't get anything to work quite right.
https://docs.python.org/3/library/multiprocessing.html#modul...
Whatever idiocy that got me there need not be mentioned but I infact was able to code a buffer overflow, and i only needed to bypass dep. aslr wasn't important even though it was win 10 with aslr on.
In hindsight, not a python problem. Definitely a me problem.
All I took away was the age-old adage of: try to avoid passing objects back and forth from native code and also that I write inefficient code that copies buffers more often than I thought.
If you’re interested in python performance I saw a really good talk from strangeloop this year titled "Python Performance Matters": https://youtu.be/vVUnCXKuNOg ; Emery Berger has been talking about python performance for quite some time.
[0]: https://doc.rust-lang.org/std/sync/mpsc/fn.channel.html
https://mailchi.mp/thestrangeloop/strange-loop-2021-cfp-5896...
(I’m in no way an interested party, other than someone who was lucky enough to get my job to send me once.)
Idk, to me the natural answer for anything crossing "ffi" border would be *mut u8. Afaik rustc makes fewer assumptions about raw pointers and it might be closer to sound interface?
It sounds to me like the Python buffer protocol is under-specified, which can lead to data races in native code.
Here's an example of how this could occur without Rust being involved:
- Two python threads with a shared buffer object
- One thread instructs a C extension to write into the buffer
- The other thread instructs a C extension to read from the buffer
- The buffer is implemented to return the same pointer for reads and writes (not forbidden by the spec https://docs.python.org/3/c-api/buffer.html#buffer-request-t...)
- The C extension releases the GIL while accessing the buffer (explicitly allowed by the spec)
In this case, the C extension is reading and writing to the same buffer at the same time without the use of atomics, which is the definition of a data race, and therefore triggers UB.
IMO, the best fix would be for the buffer implementation to expose a "concurrency mode", and require that users of the buffer API use the concurrency mode specified by the buffer when accessing it. In one mode, no synchronization is necessary because the buffer only ever hands out one writable pointer or multiple readable pointers but never both. In another mode, you might be required to have the GIL, in another mode there might be a buffer-specific RWLock you have to hold while accessing it.
The problem is exactly that there is no safe way to access the data, according to Rust's definition of safety. The entire point of Rust is to exclude the C-esque "well, I have a pointer, I guess I have the right to do whatever I want with it, I hope, maybe?" access model, but that's what Python is operating under. No amount of Rust hackery can add Rust's promises onto the Python code. I'd argue that the "correct" answer is to hand back an opaque pointer value into the Rust, and obligate all Rust access to that opaque pointer to be through "unsafe". To put things back into Rust's safe space would require an unsafe copy, but at least the copy would be back in "true" Rust.
The problem here isn't that that is incorrect, the problem is that it's uselessly unergonomic, both for the programmer, and because I may not want to pay the price of making a copy. At this point we're just down to figuring out what promise of Rust we want to break. (The only reason we're not discussing which promises of Python we want to break is that on this dimension, it doesn't make any. There are other conceivable situations where we might have compromise in both directions, e.g., Haskell's immutability.) We have the freedom of acknowledging there isn't a perfect solution, and then pursuing the "good enough"... and the "good enough" is probably to just hand Rust the buffer wrapped in whatever Rust type you feel like/is most appropriate from Rust's point of view, and then just saying "all this type can do, and therefore all it does, is constrain my Rust code; I can do nothing about the Python at all on this level". Since there is no solution, spinning about trying to find one is not terribly productive.
Seems a bit odd to use python at all if one cares about this level of speed optimisation?
This of course creates the somewhat strange outcome that writing really fast Python code is all about writing your code in anything but Python but the syntax and ecosystem are obviously worth it to people.
I really have to wonder why people are so insistent on keeping up the “Python charade”, like, surely at some point we recognise that all the effort trying to back-port performance into something that really isn’t having it, isn’t really worth it, and we’re better off just porting the bits of Python that work out to other languages that are fast/ergonomic/correct/etc and everyone involved can get some time back.
The interpreters got famous on the 8-bit machines because there wasn't space for either JIT or AOT compilers.
The latest talk I saw regarding Python performance actually addresses this charade, with a profiler that helps locate which parts of Python code should be rewritten in C, some people really love their "Python".
"Python Performance Matters"
And what language is really the alternative? There is no clear answer. Many languages might somewhat be ok but I'm not sure. Python has grown so much because:
- It's very simple.
- Its syntax almost reflects algorithmic pseudo code.
- It's a managed language with garbage collection, you will usually not see segfaults or other memory corruption, and also not the simple kind of memleaks.
- Debugging is easy.
Those are features which are really nice for newcomers from other scientific fields, but also for professionals when Python is just the gluing language which orchestrates some heavy workers, e.g. TensorFlow or PyTorch. Or when it's just used as a better shell scripting language.
I'm myself using Python mostly for machine learning (TensorFlow, PyTorch), and as a shell script language alternative, and for other small utilities. And I really don't know what alternative language there is, which would be a better fit for this, even ignoring the state of the tooling and library support.
Some people might say Julia. But Julia is more complicated and more difficult to understand, esp for newcomers. And Julia has slow startup times. Slow startup times already disqualifies Julia for these applications, or at least it's a very huge disadvantage.
I suppose the standard library can be considered complex, but is hardly worse than C++.
I've seen plenty of non-programmers become proficient enough in Python to be productive, but maybe one guy -- a statistician maintaining R libraries -- learn enough C++ to be dangerous.
Questions would include changes across minor versions, meta programming, old and new classes, ABC, slots, magical methods, async, standard library, famous libraries like stackless, ...
Having trained many newly-graduated data scientists and engineers to a level of being reasonably productive in Python, my assessment is that it is simply not that difficult to get a handle on. But if I'm expected to take non-career-programmers to comparable C++ fluency, that would be a considerably longer process.
Things they'd have to worry about in C++ that they don't have to worry about in Python: memory leaks, memory debugging, complex templates, libraries written in the lowest-level way possible, move semantics, ...
Having a REPL and things like notebooks is great. Having pip install. Having a syntax that is very beginner friendly, especially for reading. Just making it easy to glue code together without worrying about types.
Half of these things are hard to impossible to port, and many others are actively undesirable to port.
If you consider python as a sort of bash-scripting language, that is explicitly meant to call 'real' code and very easily combine it into something new that is more usable, that might make more sense. Sadly the extra ergonomics of python vs bash mean that making code that can easily be called by python is a lot more work.
Firstly, faster languages don't have the ergonomics.
Secondly, there's a tonne of Python code out there already that isn't going away any time soon, and any effort to make the runtime faster benefits all that code.
Thirdly, there's little reason Python can't be as fast as the likes of JavaScript beyond the issues imposed by the C extension API. The reality is that not a lot of effort has historically been put into making the CPython runtime particularly fast. It's only recently that anyone's put any concerted effort into it.
CPython devs will only accept proper JIT when the language really starts getting a betting from the likes of Julia, Chapel, Go, JVM and .NET bindings to the same underylying C/C++/Fortran libs.
Rust has a good shot at taking over Python's role in a couple years: it is fast, cargo is even simpler to use than pip, multithreading is mostly idiot-proof, and there's a growing ecosystem of data science and ML libraries that bring the benefits of Rust's more expressive type system to the table. But it will take years to grow that ecosystem to a size that makes it really competitive with Python.
Still, Python does have so much momentum that it's almost certainly going to continue its rise. But other languages can still succeed, the ecosystem doesn't need to end up as winner-take-all.
Mutable NAND shared.