S6 was a project that started within DeepMind in 2019 to accelerate CPython with just-in-time compilation (“JIT”). These features would be provided as a regular Python library, and no changes to the CPython interpreter itself would be necessary. S6 was aiming to do for Python what V8 did for Javascript (the name is an homage to V8). Work was based on CPython verison 3.7. Depending on the workload, we saw speedups as high as 9.5x on common benchmarks.
import s6
@s6.jit
def foo(x):
return x + 1
Python is slow, and a lot of researchers use Python as their primary interface to build models. This can create friction for researchers, and speeding up the model building process can improve iteration and development time. The network effect of Python has made moving to a different language very difficult. So with Python here to stay, we looked to improve upon it.
The requirements we set ourselves were:
- Increase the speed of common programs x-fold: not 10%, but 10x.
- Be fully compatible with CPython. Most tools, libraries, and infrastructure that researchers use only work with CPython.
- Not require any code changes. Users should be able to import S6, wrap their top level function in a JIT annotation, and that’s it.
We have stopped working on S6 internally. As such, this repository has been archived and we are not accepting pull requests or issues. We open-sourced the code and provided a design overview below to spur conversations within the Python community and inspire future work on improving Python.
We set up a Docker container and some Jupyter notebooks to allow you to build and experiment with S6. To do so, follow these steps.
Set up the S6 Docker container:
-
Build the S6 docker container:
docker build -t deepmind-s6:latest .
You can run the docker container in two different ways:
-
By running a notebook server:
docker run -p 8888:8888 deepmind-s6:latest jupyter lab --port 8888 --ip 0.0.0.0 --no-browser --allow-root --NotebookApp.token=SECRET_TOKEN
Then open
localhost:8888/lab?token=SECRET_TOKEN
in a web browser.Demo notebooks are found in the
notebooks
directory. -
By running an interactive shell session:
docker run -it deepmind-s6:latest /bin/bash
The test suite for S6 can be run from within a Docker shell session with:
(cd /open-s6/s6/build; ninja test)
Overall, S6 provides a speed of up to 10x over CPython for pure Python benchmarks. Workloads that heavily rely on C APIs, like those that use NumPy, are bottlenecked by the C API calltime. Future work could include targeted optimisations in the C code to improve this; for example, use the profiling metadata to skip all the type checks normally done by C APIs.
Most testing during development was against internal benchmarks, or private variants of external benchmarks. Here we report results on three public pyperformance benchmarks; their docstrings detail minor modifications.
Richards is a common benchmark that best represents pure Python code that researchers write. For the Richards benchmark located under the benchmarks directory, if you run with 100 iterations, depending on your hardware, you should see a 7x speedup over the default Python 3.7 interpreter.
- On our internal dev machines, we saw speedups of 9.5x on average.
Raytrace is a pure-Python mathematical benchmark.
- On our internal dev machines, we saw speedups of 3-4.5x on average.
Unpack sequence
is the worst performing benchmark we ran on S6. The cause is that S6 has its own
ceval.c
interpreter that is not as well optimized as the standard CPython one.
We had to write a separate ceval.c
-like interpreter in order to add hooks for
S6 to compile functions and collect type feedback. With the standard unpack
sequence benchmark you can see a 5x slowdown as the main do_unpacking
function
is called once and with that one call, we use our own, slower interpreter.
Even then, UNPACK_SEQUENCE
is a difficult bytecode to handle from a type
checking perspective. Even if the input to the function is an array/tuple, an
array/tuple can contain any different type and in different orderings. So we
need to type check every element to make sure that the JIT'd function is being
passed the correct input types. Because of all this type checking, if a function
either unpacks a giant array/tuple or does a lot of unpacking (like the
benchmark), our compiled code can end up being slower than interpreted CPython.
As such, if we detect there are many or a large unpack, we avoid compiling the
function and bail out to the optimized ceval.c
interpreter in CPython
everytime we come back to this function.
With the issues of UNPACK_SEQUENCE
in mind, we included two version of the
unpack sequence benchmark. The super slow one (0.2x speedup) is where the
for-loop is in do_unpacking
so we never bail out to ceval.c
and then a
faster one (0.97x speedup) where we took the for-loop out of do_unpacking
and
instead call do_unpacking
loops
number of times.
S6 exists as a standalone dependency and does not require any code changes in CPython. However, it was developed for an environment with two unusual features:
- Native code needs to store a frame pointer, which typically requires the
-fno-omit-frame-pointer
compiler flag when building CPython and native extensions. Without this change, jit compilation might crash in some cases. - In the CPython interpreter, the function
PyFrame_FastToLocalsWithError
needs to be intercepted by S6. We used to do this by linking Python and Python extensions with the linker flag--wrap=PyFrame_FastToLocalsWithError
, but this is not part of the current release, and jit compilation might misbehave in some cases.
As discussed in the unpack_sequence benchmark section, S6 does not run well on giant functions. To mitigate this, break up your function into smaller pieces.
S6 only supports CPython 3.7, but with some changes it could be made to support later CPython versions.
There is further work to be done optimising S6's strongjit. Further performance improvements would be expected from additional compiler optimisations, such as inlining.
S6 replaces the existing CPython interpreter/bytecode handler
ceval.c
with our
own interpreter.cc
. For every PyCodeObject
, S6 does the following:
-
Check to see if the function has already been compiled; if the code has, S6 looks up and runs the compiled code.
-
If the code is not compiled, S6 runs the S6 bytecode interpreter, which is very similar to the CPython interpreter.
Similarly to V8, we maintain a counter that counts how many interpreter instructions have been executed. When this reaches a threshold, the PyCodeObject at the top of the call stack is considered for compilation.
To determine what code to compile, we use the Oracle which asynchronously compiles the function after a certain number of bytecode instructions and function visits. When compiling code, S6 translates it to strongjit: an intermediate representation suitable for various optimization passes. CPython bytecode implementations can be complex, and translation may create many strongjit instructions per Python bytecode.
Given type feedback, we may choose to make assumptions about inputs to a
function in order to generate optimisation opportunities (for instance that an
input is always an integer). We refer to this as function specialization. Guards
are put in place to check that these assumptions hold. If they do not, then the
function must be de-optimized and the bytecode is interpreted as before. Because
checking the type of all arguments at every function call may be costly, and to
avoid producing huge numbers of specializations, we only specialize on the
common case of the type of the first argument (usually self
).
When deoptimizing, we convert the optimized function state into CPython state
(PyFrameObject) and continue with the normal interpreter. However in many cases
we are not at a bytecode boundary when we deoptimize. In this case we interpret
the strongjit IR until we get to a boundary point. After writing and optimizing
the strongjit IR, S6 lowers it to x86 machine code using the asmjit
library.
Hidden classes
In its initial design, S6 used inline caching keyed on an object’s Map. A Map comes from prototype-based languages (introduced in SELF). It is also termed a Shape (in V8). It attempts to describe the location of attributes within an object. All objects with the same Map have the same attributes in the same locations. An inline cache memoizes the last result of an operation based on the Map of the input.
Frequently we used the Map as a cache key, as any modification to the behavior of an object should result in a Map transition (Maps are immutable).
For example, if we have the following in Python:
c.x
The pseudo code of lookup would look like this:
if map(c) == M:
return c.__attributes__[0]
else:
return __getattr__(c, "x")
Or for example, as shown in the diagram below, to get the x
attribute we:
- Obtain the
tp_dictptr
field from the object’s type. - Use the
tp_dictptr
to get the__dict__
field’s address from the object. - Follow the
__dict__
to get thePyDictObject
itself. - Look at the
values
array. TheMap
from step 2 tells you how to map from key to value.
(below, the red arrows are direct pointers, while purple arrows are indirect using the offset int value)
However, there are at least three issues with this approach:
In almost all instances a cache keyed on a Map
is insufficient, as
Python’s type system allows modifications to an object’s Type
to modify
attribute lookup on the object, even if the object has overridden a given
attribute already. The example of this is data descriptors. A modification to a
Type
potentially invalidates all associated Map
s. A Map
could be
specialized on the type version of its type, but this loses all the benefits
of structural typing. So when we should have a monomorphic cache lookup since
two types look the same (duck typing), we still require a polymorphic lookup.
class C(object):
def __init__(self):
self.x = 2
class D(object):
def __init__(self):
self.x = 3
def f(a : Union[C or D]):
return a.x # Structurally monomorphic, nominally polymorphic.
Inline caches reference mutable data. This can be a problem when dealing with functions. Holding borrowed references to function objects is unsafe, and acquiring references to function objects can be costly.
def f(l, a):
def g(x): ...
for x in l:
a += g(x) # inline cache holds reference to "g". Unsafe if borrowed, costly
# if increffed every time, and changes the lifetime of any object
# captured by `g`.
return a
We needed to add an extra field to PyObject
to store the map pointer, but
this would have broken ABI guarantees and made the possibility of upstreaming S6
into CPython very challenging.
So instead we implemented hidden classes. Hidden classes are a parallel type system to Python’s types. They are a subcategorization of types - objects of a unique type may have different hidden classes, but objects of a unique hidden class always have the same type. In this way they are distinct from Maps or Shapes that provide uniqueness based only on structural typing.
Hidden classes provide:
- Behavioral stability; if an object’s hidden class has not changed, its behavior has not changed.
- A dense numbering; for efficient lookup in a global dispatch table.
- A compile-time-inspectable enumeration of members and methods.
- Type information about member variables and methods.
- Type information is provided as a lattice in which information becomes weaker but never stronger.
Given that hidden classes contain a lattice of type information, they are not immutable, but they do change in well-defined and observable ways.
CPython never uses hidden classes. They are invisible to CPython and no type queries, subclass queries will never return different information based on hidden classes.
We must be able to store a mapping between PyObjects and hidden classes. The mapping must be incredibly fast.
One of the goals of S6 is to be import-compatible with existing CPython. Modifications to the PyObject class were not allowed, and as PyObject only contains two members (ob_refcnt and ob_type), there is nowhere to hide a class ID. Instead, we hide the hidden class ID inside PyDictObjects and PyTypeObjects.
We observe that there are two categories of object - objects that behave the same as a newly created object of their type, and those that behave differently.
class A(object): pass
a = A()
b = A() # a and b behave the same, and are of the same type, so same class ID.
b.x = 2 # Now b behaves differently from A, they must have differing class IDs.
For objects that behave the same a a newly created object of the same type, we can store the class ID in the type object.
For objects that behave differently: in order to behave differently they must
have a __dict__
. This is the only means of changing behaviour. Therefore we
can store the class ID in the PyDictObject used as __dict__
.
Specifically: Class identifiers are dense integers <= 20 bits in size.
If the PyObject has a __dict__
, the class identifier uses the uppermost 20
bits of the dict
’s ma_version_tag
field.
Otherwise the class identifier uses the uppermost 20 bits of PyTypeObject
’s
tp_flags
field. This field is 64-bits long and is already zeroed.
This has implications:
- We don’t support hidden classes for objects with custom attribute getters or
setters, because modifications to an object’s attributes require a
dict
for us to store the class ID. - If an object’s
dict
is modified outside the visibility of S6, a newma_version_tag
will be set by CPython. This will have the upper 20 bits zeroed, setting the effective class ID to 0. This allowed us to notice if our assumptions have been broken behind our back. Note that wrap-around for the other 44 bits would take 5 years if dicts were modified at 100kHz. - We don’t support objects with
tp_dictoffset < 0
. This means thedict
is offset from the end of the object in memory, which is a more complex calculation. This is rare and only really used forstr
orlong
subclasses.
The ability to store class IDs inside the PyTypeObject
allows us to store a
hidden class for all objects even if they do not have a dict
. An example of
this is builtin types, which are generally immutable. Objects that have custom
attribute setters are still excluded however, because tracking whether an object
still has the behaviors of its base type or not is impossible.
There are two classes of behavioral change: modifications to the type or a supertype and modifications to an object.
Modification of a type potentially changes the behavior of all hidden classes indirectly derived from that type. Therefore, in order to use a hidden class with a type, we must ensure that we can detect all behavioral changes of the type and its supertypes.
This is achieved by wrapping the modifiable members of the type and all its
supertypes (tp_setattro
, __bases__
, __class__
) with a wrapper function
that informs S6. If the type has a custom setattro
, we do not optimize the
type.
When a type is modified, the world is stopped (easy because of the GIL), compilations are halted, all affected classes are modified and if any attribute of a class was changed that generated code relied upon, the generated code is thrown away.
If an attribute is added or deleted from an object, the object must take a class
transition and have a new class. This is so that all attribute loads and stores
can load and store without nullptr
or out of bounds guards.
If an existing attribute is modified, then it is possible the behavior of an object changes. Examples include modifying a method. The method table for the object is then incorrect; the object must undergo a class transition. Attributes for which modification requires a class transition are marked as ‘behavioral’.
class C(object):
def f(self): pass
a = C() # Has hidden class H
a.x = 2 # New hidden class, H -> H2 because new attribute `x`.
a.x = 3 # Same class H2 - attribute merely changed.
a.f = 4 # New hidden class, H2 -> H3, because `f` was a member function before
# and thus was marked "behavioral".
Attributes that do not change value across all object instances of a class are termed ‘cacheable’. These attributes have a stable value and the code generator may emit code that relies upon this value being stable. Simple cases include a boolean attribute. An attribute-write to an attribute that is currently marked ‘cacheable’ to a different value simply removes the ‘cacheable’ flag and any code generated that relied on the attribute is thrown away.
Python code contains a large number of lookups to the __globals__
dict. Even a
function call requires consulting the globals dict to obtain the
PyFunctionObject
for a symbol name.
We can consider this dict in the same way as we treat object __dict__
fields;
we store a class ID in the globals dict, and can use all of the fast attribute
tricks implemented for objects on global values.
In particular, the cacheable
property described above implicitly allows us to
optimize away code hidden behind global booleans that are never modified:
DEBUG_MODE = False # Set this to True to enable debug statements.
def f(a):
if DEBUG_MODE: # This is a load of globals.DEBUG_MODE, which is known to be
print(a) # always False at runtime.
return a + 1
Adoption of a type or object is where S6 determines the correct hidden class
and sets it. Adoption of an object involves ensuring that its type is adopted
and setting its hidden class. This may involve inspecting the dictionary to see
which attributes have already been set. Adoption of a type involves creating a
new hidden class, setting the Py_TPFLAGS_S6_Adopted
flag and overriding
tp_new
to a wrapper function that allows us to adopt all new instances of the
object.
When S6 is initialized, we eagerly scan the type hierarchy to adopt all types.
We override PyTypeObject::tp_new
to a wrapper that lets us detect and adopt
newly created types.
This should allow us to catch all new instances of all objects except some
extension objects (Extension types that call Py_Ready()
for the first time
after we have greedily scanned the type hierarchy). These will be small in
number and we can add mitigations if needed (like adopting in tactical places
like GetAttr
).
The Oracle
class determines which functions are worth compiling and manages
the compilation queue. For development/debugging, you can configure whether
functions should be compiled asynchronously/synchronously, how often functions
are profiled and compiled, and more.
Compilation in the Oracle is a two step process. We don’t immediately compile
the function we first step into. Every profile_bytecode_instructions_interval
instructions, the current PyCodeObject
is inspected and hotness_threshold
decremented; if hotness_threshold
reaches zero, the object will be optimized.
This two-stage process reduces the frequency of accessing code metadata.
Since bool
s/float
s/long
s in Python are all objects, they are boxed types.
Doing arithmetic operations on these boxed types can be very expensive compared
to a single assembly instruction on values in registers. And since we collect
type information, we can unbox the values from the CPython boxed types to do
much faster arithmetic operations. Refer to UnboxInst
and BoxInst
for more
information.
Decorator to mark the function as JITable to S6. When the callable is invoked, just-in-time compilation mode is enabled if it was not enabled already. All functions called transitively by the decorated function are considered for just-in-time compilation, but only one function is compiled at a time. Note that a function in the call stack is not necessarily compiled on the first call, but only after some number of calls. See the Oracle section for more details.
A number of internal APIs are exposed by calling s6.inspect(foo)
for any
function foo
that is decorated with @s6.jit
or have been marked for tracing
if the decorated function called the function. These are completely unnecessary
during normal operation, and are made accessible for users to inspect and
introspect the JIT process.
Returns a string version of the intermediate representation used by S6 of fn
,
which is named “strongjit”. The version returned here is the version after
optimizations before it is compiled to machine code. The strongjit can be
interpreted by using the _evaluate
method of the s6.jit object.
Returns True
if fn
is currently compiled, and False
otherwise.
Compiles fn
, if it has not already been compiled. Throws an exception if the
compilation fails.
Deoptimize fn
. This only deoptimizes the main specialization. Throws
NotCompiledError
if not compiled
Evaluates fn
with the S6 evaluator. "Evaluating" means interpreting the S6
strongjit of fn
.
The function fn
needs to be compiled, otherwise execution will fall back to
using the CPython interpreter and compilation will never be triggered. This
function has no purpose other than debugging. If calling a compiled function
with _evaluate
doesn't result in the same behavior as when calling it will the
normal call (of a jitted function), then S6 code generation has a bug.
This will apply to all functions called by fn
recursively except if another
explicit call to the S6 API changes it. If a called function wasn't compiled,
execution will revert to using the plain CPython interpreter.
Strongjit is a static single-assignment form intermediate representation (SSA IR) that uses block arguments instead of PHI instructions for control flow. To produce optimized code, Python bytecode is translated to strongjit. Successive optimisation passes improve the strongjit. The improved strongjit is then translated to assembly. The design goals of the IR were:
- Textually round-trippable for testing.
- Compact, cache efficient. This is a JIT compiler, and we manipulate existing instructions/ops much less than frameworks like MLIR. Strongjit is, however, designed to be easily translatable to MLIR.
- Amenable to standard operations and traversals found in optimizing compilers
- Certain operations should be cheap:
- Instruction insertion.
- Instruction deletion.
- Block insertion, deletion, splitting.
- RPO traversal.
- Traversing def-use chains (at least from use to def; traversing from a def to all uses is less common).
- Holding a handle to an instruction that is invariant to instruction insertion/deletion.
-
Value
s are the base of the hierarchy. Basic blocks, instructions and block arguments are all values. They can use otherValue
s and be used in turn.Value
s have an operand list. -
Instruction
s areValue
s that reside inside aBlock
. Their address is constant and is never reused. -
Block
s areValue
s.Instruction
s can refer toBlock
s as operands, for examplejmp &0
.Block
s contain a doubly linked list ofInstruction
s.Block
s do not own the storage forInstruction
s - this is owned byFunction
. -
Function
s are the outermost unit of the hierarchy. They maintain a list ofBlock
s and own the storage for allBlock
s andInstruction
s. The list ofBlock
s is again a doubly linked list. -
Offset
: a bytecode instruction offsets. Gives a location within interpreted code. Used for exception/tracing location information and for deoptimization out of generated code.
For cache locality, all instructions reside in a single deque-like container that uses tombstones when instructions are erased to keep all instruction addresses constant. This allows pointers to instructions to be stored.
There is a lightweight class hierarchy that uses the instruction opcode as a
discriminator (See: casts
) and allows rich, type safe accessors/mutators.
See implementation in instructions.h
.
-
ConstantInst
: Materializes anint64
immediate as aValue
. -
JmpInst
: An unconditional jump to a single successor. -
BrInst
: A conditional branch to two successors. -
CompareInst
: Compares two operands. -
ExceptInst
: Unconditional jump to the exception handler. -
DeoptimizeIfInst
: A conditional deoptimize. Thetrue_successor
is a deoptimized block. Maintains a set of values that are required in order to run the deoptimized code. -
ReturnInst
: Unconditional return. -
UnreachableInst
: Terminate the current control flow arc with an error. -
NegateInst
: Unary arithmetic negation. -
NotInst
: Unary booleannot
. -
AddInst
: Binary arithmetic addition. -
SubtractInst
: Binary arithmetic subtraction. -
MultiplyInst
: Binary arithmetic multiplication. -
DivideInst
: Binary arithmetic division. -
RemainderInst
: Binary arithmetic modulo operation. -
AndInst
: Binary booleanand
operation. -
OrInst
: Binary booleanor
operation. -
XorInst
: Binary booleanxor
operation. -
ShiftLeftInst
: Binary shift-left operation. -
ShiftRightSignedInst
: Binary signed shift-right operation. -
IntToFloatInst
: integer to float conversion. -
SextInst
: Sign-extension operation. -
IncrefInst
: Increase reference count. -
DecrefInst
: Decrease reference count. -
LoadInst
: Loads from a pointer location with optional extension. -
LoadGlobalInst
: Loads a global by name, given an index into thekNames
tuple. Lookup follows the Python convention, looking in the frame globals dict and then in builtins if not found in globals. The result is a borrowed reference to aPyObject*
or0
(with an appropriate exception set) if the lookup failed. -
StoreInst
: Stores to a pointer location with optional truncation. -
FrameVariableInst
: Obtains the address of a local variable. This instruction also takes an indexi
, used with thekConsts
,kNames
,kFastLocals
andkFreeVars
kinds. -
CallNativeInst
: Calls a C function by symbol. The callee must be in a knownallowlist
. -
RematerializeInst
: A lazily calledCallNativeInst
. It exists to allow eliding work on the fast path that needs to be done if we deoptimize. An example here is eliding an attribute load for a method call. If we are forced to deoptimize before the call, for example while computing arguments, we are forced to materialize the attribute load. ARematerializeInst
is only run during deoptimization, if the de-optimizer finds aRematerializeInst
in the value stack it will call the runtime function to materialize the true value. -
CallPythonInst
: Calls a Python value. -
CallAttributeInst
: Calls an attribute on a value. This is equivalent to(call_python (call_native s6::GetAttr $x, "attr"), ...)
. The attribute is stored as a string inside the function's string table. -
CallNativeIndirectInst
: Calls a native function through a function pointer. -
SafepointInst
: ABytecodeBeginInst
,YieldValueInst
or aDeoptimizeIfSafepointInst
. It contains a bytecode offset, value stack and try handler stack; enough information to construct an accurate interpreter frame. -
BytecodeBeginInst
: Marks the beginning of a bytecode instruction. This contains all the information required to reconstruct a CPython interpreter state: bytecode offset, value stack contents and try-block stack contents. -
YieldValueInst
: Yields within a generator function. The function's state is saved, and can be resumed after the yield.YieldValueInst
takes a single operand that is yielded to its caller, and its result is the value the caller sends back. Because generators may be deoptimized while paused,YieldValueInst
also holds enough information to reconstruct an interpreter frame (it is aSafepointInst
). -
DeoptimizeIfSafepointInst
: Deoptimizes if a condition istrue
or optionallyfalse
. This instruction has enough information to materialize an interpreter frame (it is aSafepointInst
), so it does not need to act as control flow - if deoptimization occurs then no more strongjit IR code is run. -
In this way it is distinguished from
DeoptimizeIfInst
, which deoptimizes but is not at aSafepoint
boundary and needs to run more strongjit code (in the evaluator, usually) to get to a boundary. -
AdvanceProfileCounterInst
: Adds amount to the profile counter. This indicates that approximately the amount of CPython bytecodes of work has been performed. -
IncrementEventCounterInst
: Increments a named event counter managed by theEventCounters
singleton. Each event counter counts the number of times that it has been incremented by reaching a trace point during the execution of compiled code. -
TraceBeginInst
: Starts a trace event with a named argument in the trace categoryTraceCategories::kFunctionExecution
. The name is stored in the global intern table. Deoptimization is handled implicitly. EachTraceBeginInst
must be balanced by aTraceEndInst
. This means that aTraceBeginInst
inserted at the beginning of a function must be balanced by aTraceEndInst
at all exit points, which include:ReturnInst
ExceptInst
, where the instruction has no successors.YieldInst
, with a matchingTraceBeginInst
after theYieldInst
to resume the event.
-
TraceEndInst
: Ends a trace event with a named argument in the trace categoryTraceCategories::kFunctionExecution
. The trace event must have been started by a correspondingTraceBeginInst
. -
BoxInst
: Constructs a PythonLong
/Float
/Boolean
from the operand. -
UnboxInst
: Extracts a nativeLong
/Float
/Boolean
from a Python object. If the object is not of the correct type (e.g.PyFloat
wherePyLong
is expected) or, in the case ofPyLong
, its value exceeds 64 bits, then theoverflow
flag is set. This can be detected usingoverflowed?
as the very next instruction. -
OverflowedInst
: Determines whether the operand overflowed. The operand must be an arithmetic or unbox instruction, and must occur immediately before this instruction in the same block. -
FloatZeroInst
: Determines whether the operand is floating point zero, either positive or negative. -
GetClassIdInst
: Given an object, returns its class ID. -
GetObjectDictInst
: Given an object, returns its dict or zero on failure.- If the
dictoffset
or type are known, this instruction can operate more efficiently.
- If the
-
GetInstanceClassIdInst
: Given an object's__dict__
, returns its class ID. -
CheckClassIdInst
: Checks if an object has a particular class ID. -
LoadFromDictInst
: Given an object's__dict__
, loads__dict__.ma_values[index]
. -
StoreToDictInst
: Given an object's__dict__
, stores to__dict__.ma_values[index]
and returns the value replaced. -
CallVectorcallInst
: Calls a native function with CPython's vectorcall calling convention. -
SetObjectClassInst
: Sets the class ID of an object. The object and its instance dictionary are passed, and thema_version_tag
of the instance dictionary is written.