Achieving high Python performance with code generation (2022)
medium.com
medium.com
In practice it's stable-ish, but I'd be very afraid of putting it in a critical path like this.
Further, contrary to what you might assume CPython is not memory-safe. Most of the memory-unsafety is difficult to accidentally reach from regular Python code, but it's a distinct possibility if you're emitting bytecode directly. For example, LOAD_CONST is not bounds-checked.
Can you give an example of Python (source) code that isn't memory safe?
I assumed Python would be similar to Lua in this respect - the interpreter is memory safe no matter what source code you give it, but when it comes to custom bytecode, all bets are off.
eval((lambda:0).__code__.replace(co_consts=()))
This one is slightly cheat-y because it does modify the code object, just not the bytecode itself."Purer" unsafety issues are treated as bugs and patched, but that doesn't stop them from existing periodically. I don't have a specific example on-hand but I bet you can find some in here: https://github.com/search?q=repo%3Apython%2Fcpython+segmenta...
Edit: This one is a decent example (and hasn't been fixed for a long time) https://github.com/python/cpython/issues/74957
Yeah, the same is true for Lua (search for "segfault" here: https://www.lua.org/bugs.html). The difference is that Lua tends to have far fewer bugs overall than Python, mostly because it's so much simpler.
But my initial point was, if you're writing raw bytecode, you're much more likely to hit memory unsafety issues relative to normal Python code.
If you need to process data fast, just write the component in C that runs as a separate process, and send data to it with pipes.
I'm not saying it's necessarily a reason to write off the approach, but definitely something people should be aware of.
But, for some of the basic stuff in Python, you do get that with external C-based modules.
This is a quote from the article. 2x is not even in the ballpark of a single order of magnitude (~ 10x), let alone more than that.
Instead it's more like death by a million cuts with the end result being a performance nightmare.
Honestly I think the "avoid premature optimization" quote has done more harm than good. Too many people think it means "don't think about performance at all until it's a problem," but by then it's too late and actually a lot of problems combined.
[0] -- https://github.com/PyO3/pyo3
Apart from Cython and other C/C++ bindings one might also want to look at JIT compilers like Numba (or Jax and Pytorch for number crunching).
I don’t find Python ergonomic enough to justify the performance penalty.
I did read the rest of your comment; I was just not responding to that part.
Elsewhere iirc, V8, which is written in C++, can use runtime code generation for regexes.
> Mypyc compiles Python modules to C extensions. It uses standard Python type hints to generate fast code. Mypyc uses mypy to perform type checking and type inference.
>
> Mypyc can compile anything from one module to an entire codebase. The mypy project has been using mypyc to compile mypy since 2019, giving it a 4x performance boost over regular Python.
He jumps straight from the problem to codegen-based solutions, but I wonder if a simpler loop specialization would yield a big enough chunk of the performance he got. If you hoist the comparator handling higher up into a single branch per query, then have a separate loop for each, you avoid unnecessary branching in your hot loop. You'd have to duplicate logic for every comparator, which is a little ugly, but there were only a few comparators, and that seems a lot more palatable if the alternative is an elaborate codegen solution.
My needs were all static, so I gave up and wrote a codegen that generated Python, with markers in the relevant files to indicate where the codegen output should go. That would not work here.
In my case I had a lot of config dictionaries like:
default_output_args = {
"tsv": {"sep": "tab", "quoting": "always"},
"csv": {"sep": "comma", "quoting": "always"},
"json": {"indent": None, "allow_nan": True},
...
}
and a generic API like: def open_writer(destination, format="csv", output_args=None):
...
which would let 'format' specify the output format, with the corresponding default arguments, and with the output_args able to override the defaults.I found the generic API too annoying when I knew I wanted a given format, so I added format-specific APIs with kwargs set to the default values, like:
def open_csv_writer(destination, sep="tab", quoting="always"):
...
def open_json_writer(destination, indent=None, allow_nan=True):
...
And I wanted to keep the generic API and format-specific APIs synchronized, because the generic API is useful too.My original version processed default_output_args to generate the byte code for the different writer functions, setting up the function definitions correctly.
This is not something which can be done with ctypes.
Right, I was (in pre W^X days; compare Windows' BitBlt) compiling machine code onto the stack and executing it.
You're spot on, the number one issue is that it becomes harder to debug (and there will be bugs no matter how many eyeballs are on the parser and IR and code generator) and harder to communicate. I'm sure the system was dismantled not long after I and a few others left the team. I did my best to document everything as I went, but I probably could have worked faster without that (and it surely would have been dismantled faster, too).
The question comes down to how much you want that performance, and how much tooling you're willing to build around it. You can make some very custom debugging tools if you're already in there generating byte sequences around chunks of semantics. The mature languages have a lot of similar tools, too, though.
Compared to what?
Compared to doing the logic that picks which specific comparison function to call in every iteration of the inner loop? Closures will certainly make things faster compared to that, for the same reason that the method described in the article does.
Compared to generating bytecode by hand? Closures might not make things any faster, but they won't make them any slower either. Generating bytecode by hand is just doing by hand what the Python interpreter does internally when you define a closure using normal Python code. The def statement means the interpreter generates bytecode from the contents of the def block, wraps it in a code object, and attaches that code object to the function that gets returned.
In other words, what the article is doing is generating a closure--just doing it in a way that's much less common than just writing a Python function that returns a function.
> It would be necessary to look up the closed-over value in the nonlocal environment
No, it wouldn't. A Python closure includes cells that store the values of the closed over variables, so as far as the closure function is concerned, they're local variables.
But a more reasonable thing to do would be to look down the road that JIT's and other similar technologies are coming to mainline CPython, see e.g. https://tonybaloney.github.io/posts/python-gets-a-jit.html - and thus it might make sense to have some of the unique semantics of Python opcodes be adopted by specifications like ARM, similar to how ARM created the FJCVTZS opcode to exactly match Javascript's ToInt32 specification:
https://stackoverflow.com/questions/50966676/why-do-arm-chip... -> https://tc39.es/ecma262/#sec-toint32
But I could see a hardware VM that was a combination of a regular CPU and some of the hot code paths in interpreter as well as operations on primitives of arrays being sped up. The Java processors had the advantage of static types.
Seems like this is the crux of the problem. Migrating and/or replicating data to an underlying data layer that supports the required query logic probably makes more sense?
I'd at least be waiting for the next feature request of "can we just add one more operation to match()" before saying we needed to move our whole storage layer to something else.
I'm not criticizing the article, which humorously draws Dr.Strangelove analogies to these techniques. What I don't understand is that this sort thing has been popular for over 20 years in the Python space.
"Common Lisp using LLVM and C++ for Molecular Metaprogramming"
https://www.amazon.com/Paradigms-Artificial-Intelligence-Pro...
in Python without doing anything too out of the ordinary. I just finished
https://www.amazon.com/Lisp-Advanced-Techniques-Common/dp/01...
and came to almost the same conclusion in that most of the macro use in that book is in the syntactic sugar or performance optimization category. If I didn't have a lot of projects in the queue and people demanding my time I'd try coding up the ATN and Prolog examples in Python (sorta kinda did the "CLOS" example in that I built a strange kind of meta-object facility that made "objects" backed by RDF triples)
In Java I did enough hacking on this project
https://github.com/paulhoule/ferocity/blob/main/ferocity0/sr...
to conclude I could create something that people who think "Java Sux" and "Common Lisp Sux" would really hate. If I went forward on that it would be to try "code golf not counting POM file size" by dividing ferocity into layers (like that ferocity0 which is enough to write a code generator that can stub the whole stdlib) so I could use metaprogramming to fight back against the bulkification you get from writing Java as typed S-expressions. (I'm pretty sure type erasure can be dealt with if you aren't interested in JDK 10+ features like var and switch expressions, on the other hand a system like that doesn't have to be able to code generate syntactic sugar sorts of structures because ferocity is all about being able to write syntatic sugar)
Some days I wonder if the popularity of Lisp around MIT was really an attempt to stick Noam Chomsky for biting the hand that feeds them.
And it wasn't his choice or fault that they are aren't there.
Rather than go to all this hassle...
Just Program in C
Folks, take your dismissiveness elsewhere. It's not the HN spirit. The post is meant to be pedagogical, to teach the concept of codegen if you aren't already familiar with it. It already includes disclaimers about how this technique may be used on any language with a VM, not just Python. It explains why it was the right choice at the employer where the author used it.
So your dismissive comments add nothing.
To anyone who hasn't read the article yet: it's worth reading if you're not already familiar with code gen and want to learn a new tool to add to your programming toolbox. Take note of the caveats the author duly mentions.
What I'd like is to understand some set of circumstances such that, if somebody linked me this article and asked me "should I consider doing this in my code?", I could in good conscience say yes.
If he had presented it as a fun challenge or something and said "but actually just write performance sensitive code in a fast language" then I don't think you'd see these comments.
The claim in this article is not 2x, it’s “50% of CPU was consumed by this loop, now it’s ~0%”
I’d love to see a series of improvements and how they impact the machine behavior / speedup....
EDIT: By that I meant, if you are trying to make something fast, wouldn't it make more sense to rewrite the critical path in a faster language rather than trying to improve Python's speed?
My use case is MILP optimization. Constructing the model representation(the step necessary before calling some optimizer binary) is very slow in CPython, and Pypy makes development much less painful, since you often need to construct many models to get it right.
You'll likely get much less of a speedup if your program is already optimized around CPython's slowness, e.g., by calling out to libraries like numpy as much as possible. But PyPy lets my simple scripts punch above their weight without any extra circumlocutions.
Why rewrite when you can achieve same without any code changes? PyPy performance rivals golang .
In OP Case this could increase performance by 20x easily.