Faster CPython at PyCon, part one
lwn.net
lwn.net
[1] https://speed.python.org/comparison/?exe=12%2BL%2B3.11%2C12%...
I doubt that the Mojo developers have some sort of 'secret sauce' or 'special trick' that will get them there. And even if they have something, I don't see why the Python devs wouldn't just implement the same approach, considering they're currently trying to make Python faster.
I assume that (as long as Mojo wants to stick to its goal of being a strict superset of Python), there will be a lot of things that just cannot be 'fixed'. For example, I'd be surprised if Python's Global Interpreter Lock isn't entangled with the language in a few nasty ways that'd make it really difficult to replace or improve upon (while retaining compatibility).
Then again, the developer of Swift is working on it, right? I guess he's got the experience, at least.
A language that's designed to be compiled doesn't have these issues, since you can move most of the checking and resolution logic to compile-time. But doing that requires the language's semantics to be aligned to that goal, which Python's most certainly are not.
The achievements of Pypy are pretty incredible in the JIT space, but the immense effort that has gone in there definitely is a good reason to be skeptical of Mojo's claims of being both compiled and also a "superset" of Python.
I think that this kind of thing is in fact done with the high-performing Javascript engines used in major browsers. I imagine PyPy, being a JIT, is in a position to do this kind of thing. Perhaps Python is more difficult than Javascript, and/or perhaps a lot more effort has been put into the Javascript engines than into PyPy?
This is sometimes repeated but I don’t believe that is why Python is slow (nor do I think for most measures of “dynamic” it is even true). Which aspect of “dynamism” in particular are you concerned about that JavaScript lacks? The primary hindrance is keeping CPython extensions working while making Python fast. Add to that the hundreds of millions that went into V8 and other JavaScript implementation efforts.
And then there's descriptors: https://docs.python.org/3/howto/descriptor.html
Doing that, plus 100% compatibility with CPython extension API, while preserving some expectations of deterministic destruction in Python, are the challenges one would face.
Because they're annotations with bad checkers. They're mostly helpful as documentation.
TypeScript checking is on a whole different level.
Plenty of other dynamic languages have proven the point already.
Overall, pure Python seems to be about 100x slower than what you can reasonably get with a compiled language and some hard work. It's about 10x slower than what you can get from JITs like Pypy and Javascript, when such comparisons makes sense.
I agree that Mojo remind me of Cython, but with more marketing and less compatibility with Python. Cython aspired to be a nearly 99% superset of Python, at least that's exactly what I pushed my postdocs and graduate students to make it be back in 2009 (e.g., there were weeks of Robert Bradshaw and Craig Citro pushing each other to get closures to fully work). Mojo seems to be a similar modern idea for doing the same sort of thing. It could be great for the ecosystem; time will tell. Cython could still also improve a lot too -- there is a major new 3.0 release just around the corner!: https://pypi.org/project/Cython/#history
The fast CPython changes are still very much interpreter-centric, and are checking whether assumptions have changed on every small operation. It seems to me that if you are able to JIT large chunks of code, and then push all the JIT invalidation checks into the dynamic features that break your assumptions rather than in the happy path that is using the assumptions, you ought to be able to get much closer to to Javascript levels of performance when dynamic features aren't being used.
Then support for those dynamic features becomes a fallback way of having a huge ecosystem from day one, even if it is only modestly faster than CPython.
You have to use special syntax and refactoring to get the 1000x speed ups. The secret sauce is essential a whole new language within the language which likely helps skip the GIL issues.
The concept of compiling python has been tried again and again. It has its moments, but anything remotely important is glued in from compiled code anyways.
If Mojo succeeds it will be purely based on quality of implementation, not a spark of genius.
Cython has always been advertised as a potential alternative to writing straight Python, and there are probably a decent number of people who do this. I work in computational science and don't personally know anyone that does. I use it myself, but it's usually a big lift because of the rough edges of the language and the sparse and low quality documentation.
If Cython had 10x as many people working on it, it could be turned into something significantly more useful. I imagine there's a smarter way of approaching the problem these days rather than compiling to C. I hope the Mojo guys pull off "modern and actually good Cython"!
In the end the way the industry works guarantees a endless stream of these before some combination of boredom and rentiership result in each getting abandoned. It's just a question of whether Mojo lasts 1, 3, or god willing 5 years on top.
Ideally, government or industry would get behind these projects and back them up. Evidently Microsoft is doing that for Python. For whatever reason, Cython and Pyrex both failed to attract that kind of attention. Hopefully it will be different with Mojo.
Here's to 5 years!
Cython's main benefit is very deep integration with Python (compared to eg. Rust and PyO3).
I wouldn't want to bet on "lots of work for a chance to get incrementally better."
There's Numba, CuPy, Jax and torch.compile. Arguably they are more like DSLs, which happen to integrate into Python than regular Python
Of course I don't know what Mojo will actually bring to the table since their documentation doesn't mention anything GPU specific, but the idea isn't completely novel.
In no way is Python more dynamic than Smalltalk, SELF or Common Lisp, which can at any given time redefine any object across the whole execution image and were/are mostly bootstraped environments.
What if you stay in the realm of numpy?
What's the biggest offender that you see?
You mean, what if you're only doing matrix stuff? Then it's probably easier to let numpy do the heavy lifting. You'll probably take less than a 5x performance hit, if you're doing numpy right. And if you're doing matrix multiplication, numpy will end up faster because it's backed by a BLAS, which mortals such as myself know better than to compete with.
> What's the biggest offender that you see?
Umm... every line of Python? Member access. Function calls. Dictionaries that can fundamentally be mapped to int-indexed arrays. Reference counting. Tuple allocation.
One fun exercise is to take your vanilla python code, compile it in Cython with the -a flag to produce an HTML annotation. Click on the yellowest lines, and it shows you the gory details of what Cython does to emulate CPython. It's not exactly what CPython is doing (for example, Cython elides the virtual machine), but it's close enough to see where time is spent. Put the same code through the python disassembler "dis" to see what virtual machine operations are emitted, and paw through the main evaluation loop [1]; or take a guided walkthrough at [2].
[1] https://github.com/python/cpython/blob/v3.6.14/Python/ceval.... (note this is an old version, you can change that in the url)
Are you talking about combinations of operations that are used commonly enough to warrant Eigen methods that perform them at once in SIMD?
sometimes numpy can elide those (e.g. why a+=b is faster than a=a+b) but this it not possible in general. Sometimes people use monstrosities like einsum... but I find it more intuitive to just write in C or C++...
In addition to the time spent in allocation / gc / needless copying, the memory footprint can be higher by a factor of a few (or more...).
I'd like to dig a little here, for my own curiosity. How is this possible? Ie, beating C or Rust code using... arcane magic. It reminds me of React was touted as fast; I couldn't figure out how a Javascript lib could be faster than Javascript.
Weld: A Common Runtime for High Performance Data Analytics
https://dspace.mit.edu/bitstream/handle/1721.1/137425/cidr_w...
Anecdotally I recently rewrote a piece of Python code in Rust and got ~300x speedup, but let's be conservative and give it 100x. Now let's extrapolate from that. In native code you can use SIMD, and that can give you a 10x speedup, so now we're at 1000x. In native code you can also easily use multiple threads, so assuming a machine with a reasonably high number of cores, let's say 32 of them (because that's what I had for the last 4 years), we're now at 32000x speedup. So to me those are very realistic numbers, but of course assuming the problem you're solving can be sped up with SIMD and multiple threads, which is not always the case. So you're probably mostly right.
Multiprocessing on Python works great and isn’t even very hard if you use say async_apply with a Pool.
Comparing single-threaded Python with multiprocesssing in Language X is unfair if not disingenuous.
Multiprocessing works great if you don't really need a shared memory space for your task. If it's very loosely coupled, that's fine.
But if you use something that can benefit from real threading, Python clamps you to about 1.5-2.5 cores worth of throughput very often.
In this way we can justify rewriting stuff in rust to our bosses!
If we write decent python, and perhaps even replace 1 line to use pypy, the speedup won't be impressive and we won't get to play with rust!
Nothing preventing something like Mojo to also use those same 32 threads but with 10-100x the performance instead.
Note that I don't doubt the 35k speedup -- I've seen speedups into the millions -- I'm just saying there's no way that can be a representative speedup that users should expect to see.
The one difference was the Rust one used a parallel iterator (rayon + one liner change), whereas I have found Python to be more pain than it's worth, for most usecases.
Or use Jax or Taichi.
This is my passion project. (Less activity these days as I'm sick)
> Oh I think NIH syndrome.
Neither CPython nor PyPy was invented at Microsoft.
We'll see if this is a pattern and the same happens at Microsoft.
I don't use it myself, I just see the relatively small difference in investment it would be between a great project that's slightly languishing in obscurity despite incredible talent and effort, and a genuine full alternative to CPython.
Python really has earned its place as the language of choice for scientific, research, and ML. I've stolen quite a few algos from CPython.
Well, they are Python devs after all.
That'd be a very nice feature and I'm not sure it exists in other languages.
How can it do that? The `self` variable could be anything, or not?
Or are there multiple versions of the compiled function, and it modifies the bytecode per each call of the function?
Also consider that:
- this is more branch predictor friendly (a generic opcode encounters many different cases, while a specialized opcode is expected to almost never fail),
- the inline cache has limited space and is stateful (imagine it's a C union of cache structs for different fast paths). If your "generic" LOAD_ATTR found a simple case B, it'd still need to ensure that its inline cache was actually primed for simple case B. This is not the issue with specialized opcodes; the cache is set to correct state when the opcode gets specialized.
BTW from the PEP:
“Motivation
Python is widely acknowledged as slow”
There are also a non-trivial number of people out there who read about the constant stream of performance improvements and end up confusing that with absolute high performance; see also the people who think Javascript JITs mean Javascript is generally at C performance, because they've consumed too many microbenchmarks where it was competitive with C on this or that task, and got 500% faster on this and 800% faster on that, and don't know that in totality it's still on the order of 10x slower than C.
It is, unfortunately, something that still needs to be pointed out.
Maybe my comment was too mean, but every time that something related to Python core enhancements appears someone has to say that C is faster and I am not sure if someone interested on how the generated bytecode improves the performance needs to be reminded that C is faster.
It isn't just Python. I'm getting moved more into a PHP area right now, and there's a system in there that I need to analyze to see whether its performance problems are architectural, or if it's just not a task PHP should be doing and Go(/Rust/C#/Java/... Go is just the one of that set most in my and my team's toolbelt) would actually perform just fine. I don't know which it is yet, but based on what I know at the moment, both are plausible.
And I'm not a "hater". I just have a clear view of the tradeoffs involved. There are many tasks for which Python is more power than one knows what to do with. Computers have gotten pretty fast.
It's now at least 2x off its original perf target and only the trivial case is being handled correctly.
I think reminding people is good.
I'm not trying to give anyone a wedgy by saying that. I thought I was just giving some justification for the optimization that OP describes.
Most attempts thus far haven't failed because it isn't possible, rather because the community hasn't railed around them.
Smalltalk, SELF, CL, Dylan, Lua have shown how it is done, Julia, JS and Ruby are following along, only Python keeps resisting it.
I don't know if I'd call that on par.
https://benchmarksgame-team.pages.debian.net/benchmarksgame/...
All this PyObject nonsense does nothing but slowdown the runtime. V8 was built for performance from day 1. CPython is old, slow, and will always be outperformed by V8 regardless of the tweaks this team does.
I am not a compiler engineer, I can’t give you a specific, technical answer.
I've moved on to Go for things where I need to care about performance but like thinking about things in a similar way Python lets me think about them.
How would one do that? I thought python generated bytecode and interpreted that code? Where's the machine code? Do you mean dissembling the python interpreter itself? In which case it would be gcc/llvm spitting out machine code, no?
The unfortunate thing is that the vast majority of Python code in use today doesn't need all those super-dynamic features. So it would run just fine on a cut-down JITed interpreter. But there's always the corner cases.
In retrospect, Python 3 was a lost opportunity here: it could have broken more compatibility, enabled multi-core and JITs, and then the 10 year transition pain would actually have been worth it. But that's hindsight, of course.
This is something that seems like it should be true, but counter evidence exists that proves that it's not the case.
The first example would be V8, the JavaScript JIT compiler used in Chrome and NodeJs (and probably other things). V8 is many times faster than CPython in pretty much every situation.
The second, and even better example is SBCL, a Common Lisp compiler. SBCL is quite a bit faster than CPython and V8, it's closer to the JVM in terms of performance in benchmarks that I have saw.
The third example would be some of the Scheme compilers, like Chez and Gambit, which are not far off from SBCL.
Maybe you could argue that JavaScript is not as dynamic as Python. I don't know JavaScript at all so maybe that is the case.
I'm pretty sure that Common Lisp and Scheme are not less dynamic though. I think Common Lisp is actually more dynamic but I don't have any way to measure this, so it's just my opinion.
So assuming these languages are as or more dynamic than Python, this seems to be proof that Pythons dynamic-ness is not the reason for it's poor performance!
The Lisp compilers are also much less widely used and have much less engineering power available!
I think these counter examples are pretty interesting and don't know exactly what to make of it. Python has more funding and more users to contribute to it (except in the case of V8), I guess until now they just haven't put any of that into performance.
I don't think there's anything Python can do that Common Lisp can't in terms of dynamic-ness!
This is a quote from python.org:
>These languages are close to Python in their dynamic semantics, but so different in their approach to syntax that a comparison becomes almost a religious argument: is Lisp's lack of syntax an advantage or a disadvantage? It should be noted that Python has introspective capabilities similar to those of Lisp, and Python programs can construct and execute program fragments on the fly. Usually, real-world properties are decisive: Common Lisp is big (in every sense), and the Scheme world is fragmented between many incompatible versions, where Python has a single, free, compact implementation.
https://www.python.org/doc/essays/comparisons/
I believe there are dynamic things Common Lisp can do that python can't, like modifying and creating classes, inheritance and methods at runtime, even with effects propagating out to already existing class instances!
It's not. Common Lisp was designed to enable Lisp applications to be delivered with reasonable performance, first in 1984, when an expensive computer might have had 1 to 10 Megabytes (!) of memory and a CPU with 8 Mhz / 1 Million instructions per second. You'll then see a bunch of different implementations, sometimes within the same running Lisp and able to use different execution modes in the same program:
* source interpreted Lisp -> a Lisp Interpreter executes the code from traversing the s-expressions of the source code -> this is usually slow to execute, but there are also very convenient debug features available
* compiled Lisp code -> a Lisp compiler (often incremental) compiles Lisp code to faster code: byte code for a VM, C code for a C compiler or machine code for a CPU. -> often this keeps a lot of the dynamic features
* optimized compiled Lisp code -> like above, but the code may contain optimization hints (like type declarations or other annotations) -> the compiler uses this provided information or infers its own to create optimized code.
For "optimized compiled Lisp code" the compiler may remove all or some of dynamic features (like late binding of functions, allowing data of generic types to be passed, runtime type checks, runtime dispatch, runtime overflow detection, removal of debug information, tail call optimization, ...). It may also inline code. The portions where such optimizations are applied span from certain parts of functions to whole programs.
Common Lisp also has normal function calls and generic function calls (CLOS) -> the latter are usually a lot slower and people are experimenting with ways to make it fast (-> by removing dynamism where possible).
So, speed in Common Lisp is not one thing, but a continuum. Typically one would run compiled code, where possible, and run optimized code only where necessary (-> in parts of the code). For example one could run a user interface in unoptimized very dynamic compiled code and certain numeric routines in optimized compiled code.
CL-USER> (defun foo (a b)
(declare (optimize (speed 3) (safety 0))
(fixnum a b))
(the fixnum (+ a (the fixnum (* b 42)))))
CL-USER> (disassemble #'foo)
; disassembly for FOO
; Size: 28 bytes. Origin: #x70068A0918 ; FOO
; 18: 5C0580D2 MOVZ TMP, #42
; 1C: 6B7D1C9B MUL R1, R1, TMP
; 20: 4A010B8B ADD R0, R0, R1
; 24: FB031AAA MOV CSP, CFP
; 28: 5A7B40A9 LDP CFP, LR, [CFP]
; 2C: BF0300F1 CMP NULL, #0
; 30: C0035FD6 RET
NIL
As you can see, with optimization instructions and type hints, the code gets compiled to tight machine code (here ARM64). Without those, the compiled code looks very different, much larger, with runtime type checks and generic arithmetic.And in any case, there are the Smalltalk and SELF JITs as an example of highly dynamic environments, where anything goes.
Python could have a declaration which says, "this function/module doesn't participate in anything stupidly dynamic, like access to parent locals". If it calls some code which tries to access parent locals, the behavior is undefined.
That's kind of a bad thing because in Lisp I don't have to declare anything unsafe to a compiler just to have reasonably efficient local variables that can be optimized away and all that.
So, as of today, they’re useless for optimization. That could be changed, but hasn’t been so far.
When using SBCL for example, none of CLs dynamic features are restricted from the programmer in any way. So whether it's compiled to native code or not has no bearing at all on how dynamic the language is.
Can you explain to me why a Python compiler couldn't implement optimizations similar to SBCL?
>It's not.
Is Python more powerful than CL in some way that I am not aware of?
> When using SBCL for example, none of CLs dynamic features are restricted from the programmer in any way.
Sure, but it will be slower in benchmarks. The excellent benchmark numbers of SBCL is in part a result of being able to cleverly remove dynamic features.
I believe there are areas of CLOS which are stupid dynamic; but even there, the specification tries to tread carefully. Firstly, you don't have to use CLOS in a Lisp program; and if you need data structures with named slots, structs may suffice.
Importantly, Common Lisp keeps a kind of basic type versus class type separation in the language. You don't feel it because it's not obnoxious, like int versus Integer in Java. Built in basic object types like integers and strings all have a CLOS class in Common Lisp. But, the class of that class (the metaclass) is not the same as that of a class which the application defines with defclass. The Lisp compiler doesn't have to worry about silly monkey patching being perpetrated on a string or integer.
In some areas of the language, it's clear that the designers were trying to avoid bringing in dynamic behavior that would interfere with performance. For instance, conditions are defined in such a way that they are "class-like" objects, but without the actual requirement that they be CLOS instances.
The meta-object protocol (MOP) was also kept out of the language. I'm not sure whether the MOP is "stupid dynamic" because it also seems to hold the keys to avoiding "stupid dynamic" in that if you don't like some particular dynamism in a given class, maybe you can design your own meta-class which avoids it. It might be possible using MOP to, say, have an object system where the inherited slots of a derived class are at the same offset in the underlying vector storage as in the parent class, so accesses can be optimized. Maybe you can ban multiple inheritance in that meta-class.
Ongoing Ruby efforts as well.
So, in my opinion, it's clearly not as simple as just taking all of that existing knowledge and a good team and just doing it, or one of the many efforts to do so would almost certainly have succeeded by now.