You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
The symbolic value lattice used in the tier 2 optimizer needs documentation and tests. #115816
The behavior of the symbolic values used in the tier 2 optimizer have a solid theoretical basis, but there is some subtlety involved.
The symbolic values and the lattice they form needs to be well documented and tested.
What are the symbolic values?
Each symbolic value represents not a real object, but knowledge about the set of objects that could be present at a given point in the code. This is powerful, but not intuitive.
Example:
deffoo(x): # line 0ifxisNone: # line 1return"None"# line 2return"ok"# line 3
At line 3, the symbolic value for x is not None, whereas the symbolic value for x at line 0 is unknown.
However, any object referred to x is unchanged by foo. It is easy to think in terms of objects, but it is really a type system that we are representing.
Why we need a lattice
Example:
deffoo(x): # line 0ifxisNone: # line 1return"None"# line 2ifxisnotNone: # line 3return"not None"# line 4return"???"# line 5
and this trace:
exit_if(x is not None) # line 1
exit_if(x is None) # line 3
return "???" # line 5
The trace is a correct trace; it correctly represents one path through the function.
So what symbolic value does x have for line 5? It is both None and not None.
This is why the lattice needs a top value, even though it superficially makes no sense.
If a value is top for a location, then that location is unreachable.
If it were up to me, the API should be focused on how we use it:
A symbol can be in one of the following states:
Nothing is known (bottom, blank initial slate)
It's NULL
It's not NULL
Not NULL and we know its type
Not NULL, known type, and known value (constant)
Impossible (top)
Initializing a symbol in one of these states requires a few different APIs (Create unknown, create NULL, create non-NULL, create specific type, create specific constant)
As we gain more info about a symbol, it moves to another state, according to the lattice Mark posted
For example, if we know it's True and we learn it's False, it transitions to top (impossible)
We need various inquiries:
Is it NULL?
Is it non-NULL?
Is it a specific type?
Is it a constant, and if so, what is its value?
Is it top?
For construction, I'd propose:
sym_new_unknown(ctx)
sym_new_null(ctx)
sym_new_notnull(ctx)
sym_new_type(ctx, type)
sym_new_const(ctx, value)
For updates, we'd get:
sym_set_null(sym)
sym_set_notnull(sym)
sym_set_type(sym, type)
sym_set_const(sym, value)
Note that if a symbols is known to be NULL and then we learn it is NULL, it must become top.
For inquiries:
sym_is_null(sym)
sym_is_notnull(sym)
sym_has_type(sym, type)
sym_get_const(sym) -> NULL or value
An orthogonal question is whether a symbol owns the references to the type and constant value.
It doesn't need to own the type, since we only use well-known immortal built-in types
I believe it should own the constant, since the results of constant folding may not be owned by anything else
As a result the constants must be DECREF'ed when the abstract interpretation context is destroyed.
If symbols own their constant, do sym_new_const() and sym_set_const() "steal" a reference from the caller, or does the caller (if it owns a reference) have to DECREF the constant too? We must take into account the error case too. While sym_set_const() cannot fail, sym_new_const() can fail (it can run out of space for new symbols). Possible approaches:
(A) sym_new_const() only steals a reference when it succeeds
// value has a new referencesym=sym_new_const(ctx, value);
if (sym==NULL) {
DECREF(value);
goto error;
}
(B) sym_new_const() always steals the reference (even when it fails)
// value has a new referencesym=sym_new_const(ctx, value);
if (sym==NULL) {
goto error;
}
(C) sym_new_const() never steals the reference
// value has a new referencesym=sym_new_const(ctx, value);
Py_DECREF(value);
if (sym==NULL) {
goto error;
}
By comparison, for sym_set_const(), the call can never fail, so the equivalent cases are
(A), (B) sym_set_const() always steals the reference
// value has a new referencesym_set_const(sym, value);
(C) sym_set_const() never steals the reference
// value has a new referencesym_set_const(sym, value);
Py_DECREF(value);
Of these, (C) is perhaps the least surprising API design (it's how most C APIs work) but (B) requires writing slightly less code. (A) feels the most error-prone. I think it's important for sym_new_const() and sym_set_const() to be consistent, which also argues against (A). (It seems we currently don't need sym_set_const(), so maybe this argument is moot -- though ISTM that _GUARD_IS_TRUE_POP would need to call sym_set_const(sym, Py_True).
PS. Mark's lattice diagram has a node representing "not None". This might require a separate set of APIs (we currently apparently don't need to test for "not None" yet).
We should ensure that if we test for a property that's represented lower in the lattice (e.g. "not NULL") and the symbol has any value higher up in the lattice (e.g. bool or False), the tested property is still considered true (since bool and False imply "not NULL").
But what to do with top? Is top considered to have all properties, or none? It is highest in the diagram, so that would imply that it should have all properties. But isn't that confusing?
Also, according to Wikipedia, in type theory, Bottom (⊥) is the type that has no values, whereas Top (⊤) is the type that encompasses all values. Maybe we should switch terminology?
From Mark's list of tests I deduce that he wants the symbol representing the type without values to return false/NULL for all inquiries. IIRC I ran into this in the context of static type checking too. This feels pragmatic, as it is likely to cause the optimizer to make a conservative decision. It probably doesn't matter too much: if we ever encounter such a symbol we've reached an error condition or unreachable code, so the optimizer might as well insert _EXIT_TRACE and give up at this point.
So I've been thinking about how to export the symbols API in a way that we can easily write tests in Python.
My idea is to export _Py_UOpsAbstractInterpContext as a Python object (it already has a PyObject_HEAD, although it's curently not used) and make all the sym_* functions methods on that. I propose that the Python-level interface should represent symbols as their index in the t_arena.arena array -- the conversion back and forth is simple (check the range on int -> sym), and we don't have to make symbols themselves Python objects.
A test I don't see in Mark's list:
Setting a type when it's already a const of that type has no effect
If it were up to me, the API should be focused on how we use it
I agree. That should be true of all APIs.
But what to do with top? Is top considered to have all properties, or none? It is highest in the #114058 (comment), so that would imply that it should have all properties. But isn't that confusing?
Yes, it is confusing, which is why we need clear docs.
If top is present then the code must be unreachable, so any transformation we make is correct. Guaranteed bug free, but completely useless.
The optimizer could check for top and insert an unconditional exit However, if top is very rare, it is probably better to carry on regardless and occasionally emit unreachable code.
My idea is to export _Py_UOpsAbstractInterpContext as a Python object
We will want to reuse a memory buffer (probably per-thread) when optimizing, so the optimizer will need to initialize the context from a buffer. The test can take a bytesarray to use as a buffer, so we don't need to expose the context.
Regarding sym_new_const and sym_set_const they should both borrow references, but unlike most functions they should be allowed to store the borrowed reference.
The reason that this makes sense because the optimized trace will not be able to access any constants that are not guaranteed to outlive it.
If the constants must outlive the optimized code, they must outlive the optimizer pass.
This will need to be clearly documented.
Further thoughts on top, having added some basic tests:
It is useful to be able to query a symbol, extract a value and get a meaningful result.
In other words, if (sym_is_const(sym)) { PyObject *k = sym_get_const(sym) should put something meaningful in k.
But if sym_is_const(top) is true, that is impossible.
We probably need to choose names that exclude top for these cases.
E.g. sym_is_unique_type() and sym_is_unique_const().
Did you see my comment suggesting that we might have Top and Bottom mixed up?
I'm confused about the borrowing for constants. If we calculate the result of say 3.14 * 2.5 we have a new constant that is stored in the symbol. Who owns it?
I'm okay if sym_is_const() and sym_is_type() return false for both top and bottom.
I feel names like sym_is_unique_type() are unnecessary long.
Activity
Fidget-Spinner commentedon Feb 22, 2024
You might also want to add the docs to https://github.com/faster-cpython/ideas/blob/main/3.13/redundancy_eliminator.md.
markshannon commentedon Feb 23, 2024
EDITED -- Everything is true for
top, which is weird, buttopis weird.[NOTE -- top and bottom should be swapped, see https://github.com/faster-cpython/ideas/pull/658]
Regarding tests, we need to test that:
sym_is_functions returnfalsefor bottomsym_is_functions returntruefor topsym_get_functions returnNULLfor bottomAll. There is no correct value forsym_get_functions returnNULLfor top.top, so we merely need to test that it doesn't crash.sym_is_typeandsym_get_typefunctions behave the same for constants, as for their type.sym_get_constreturnNULLfor anything other than a simple constant (or top).sym_set_nullto any type or const produces topsym_set_typeto null produces topsys_get_typegvanrossum commentedon Feb 23, 2024
Before we can even start writing tests we'll need to agree on the proper API for the symbols.
gvanrossum commentedon Feb 25, 2024
If it were up to me, the API should be focused on how we use it:
Trueand we learn it'sFalse, it transitions to top (impossible)For construction, I'd propose:
For updates, we'd get:
Note that if a symbols is known to be NULL and then we learn it is NULL, it must become top.
For inquiries:
An orthogonal question is whether a symbol owns the references to the type and constant value.
As a result the constants must be DECREF'ed when the abstract interpretation context is destroyed.
If symbols own their constant, do sym_new_const() and sym_set_const() "steal" a reference from the caller, or does the caller (if it owns a reference) have to DECREF the constant too? We must take into account the error case too. While sym_set_const() cannot fail, sym_new_const() can fail (it can run out of space for new symbols). Possible approaches:
(A) sym_new_const() only steals a reference when it succeeds
(B) sym_new_const() always steals the reference (even when it fails)
(C) sym_new_const() never steals the reference
By comparison, for sym_set_const(), the call can never fail, so the equivalent cases are
(A), (B) sym_set_const() always steals the reference
(C) sym_set_const() never steals the reference
Of these, (C) is perhaps the least surprising API design (it's how most C APIs work) but (B) requires writing slightly less code. (A) feels the most error-prone. I think it's important for sym_new_const() and sym_set_const() to be consistent, which also argues against (A). (It seems we currently don't need sym_set_const(), so maybe this argument is moot -- though ISTM that
_GUARD_IS_TRUE_POPwould need to callsym_set_const(sym, Py_True).gvanrossum commentedon Feb 25, 2024
PS. Mark's lattice diagram has a node representing "not None". This might require a separate set of APIs (we currently apparently don't need to test for "not None" yet).
gvanrossum commentedon Feb 25, 2024
We should ensure that if we test for a property that's represented lower in the lattice (e.g. "not
NULL") and the symbol has any value higher up in the lattice (e.g.boolorFalse), the tested property is still considered true (sinceboolandFalseimply "notNULL").But what to do with
top? Istopconsidered to have all properties, or none? It is highest in the diagram, so that would imply that it should have all properties. But isn't that confusing?Also, according to Wikipedia, in type theory, Bottom (⊥) is the type that has no values, whereas Top (⊤) is the type that encompasses all values. Maybe we should switch terminology?
gvanrossum commentedon Feb 25, 2024
From Mark's list of tests I deduce that he wants the symbol representing the type without values to return false/NULL for all inquiries. IIRC I ran into this in the context of static type checking too. This feels pragmatic, as it is likely to cause the optimizer to make a conservative decision. It probably doesn't matter too much: if we ever encounter such a symbol we've reached an error condition or unreachable code, so the optimizer might as well insert
_EXIT_TRACEand give up at this point.gvanrossum commentedon Feb 26, 2024
So I've been thinking about how to export the symbols API in a way that we can easily write tests in Python.
My idea is to export
_Py_UOpsAbstractInterpContextas a Python object (it already has aPyObject_HEAD, although it's curently not used) and make all thesym_*functions methods on that. I propose that the Python-level interface should represent symbols as their index in thet_arena.arenaarray -- the conversion back and forth is simple (check the range on int -> sym), and we don't have to make symbols themselves Python objects.A test I don't see in Mark's list:
markshannon commentedon Feb 26, 2024
I agree. That should be true of all APIs.
Yes, it is confusing, which is why we need clear docs.
If
topis present then the code must be unreachable, so any transformation we make is correct. Guaranteed bug free, but completely useless.The optimizer could check for
topand insert an unconditional exit However, iftopis very rare, it is probably better to carry on regardless and occasionally emit unreachable code.markshannon commentedon Feb 26, 2024
We will want to reuse a memory buffer (probably per-thread) when optimizing, so the optimizer will need to initialize the context from a buffer. The test can take a
bytesarrayto use as a buffer, so we don't need to expose the context.markshannon commentedon Feb 26, 2024
Regarding
sym_new_constandsym_set_constthey should both borrow references, but unlike most functions they should be allowed to store the borrowed reference.The reason that this makes sense because the optimized trace will not be able to access any constants that are not guaranteed to outlive it.
If the constants must outlive the optimized code, they must outlive the optimizer pass.
This will need to be clearly documented.
markshannon commentedon Feb 26, 2024
Further thoughts on
top, having added some basic tests:It is useful to be able to query a symbol, extract a value and get a meaningful result.
In other words,
if (sym_is_const(sym)) { PyObject *k = sym_get_const(sym)should put something meaningful ink.But if
sym_is_const(top)is true, that is impossible.We probably need to choose names that exclude
topfor these cases.E.g.
sym_is_unique_type()andsym_is_unique_const().gvanrossum commentedon Feb 26, 2024
@markshannon
3.14 * 2.5we have a new constant that is stored in the symbol. Who owns it?sym_is_const()andsym_is_type()return false for both top and bottom.sym_is_unique_type()are unnecessary long.9 remaining items
gvanrossum commentedon Feb 28, 2024
We have tests now, but I'm leaving this open because the API isn't yet documented.
gh-115816: Generate calls to sym_new_const() etc. without _Py_uop pre…
pythonGH-115816: Make tier2 optimizer symbols testable, and add a few…
pythonGH-115816: Assorted naming and formatting changes to improve ma…
pythongh-115816: Improve internal symbols API in optimizer (python#11…
pythongh-115816: Generate calls to sym_new_const() etc. without _Py_u…
pythonGH-115816: Make tier2 optimizer symbols testable, and add a few…
pythonGH-115816: Assorted naming and formatting changes to improve ma…
pythongh-115816: Improve internal symbols API in optimizer (python#11…
pythongh-115816: Generate calls to sym_new_const() etc. without _Py_u…
pythonGH-115816: Make tier2 optimizer symbols testable, and add a few…
pythonGH-115816: Assorted naming and formatting changes to improve ma…
pythongh-115816: Improve internal symbols API in optimizer (python#11…
pythongh-115816: Generate calls to sym_new_const() etc. without _Py_u…
pythonGH-115816: Make tier2 optimizer symbols testable, and add a few…