Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

gh-126835: Disable tuple folding in the AST optimizer #128802

Closed
wants to merge 23 commits into from
Closed
Show file tree
Hide file tree
Changes from 22 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 0 additions & 111 deletions Lib/test/test_ast/test_ast.py
Original file line number Diff line number Diff line change
Expand Up @@ -153,22 +153,6 @@ def test_optimization_levels__debug__(self):
self.assertIsInstance(res.body[0].value, ast.Name)
self.assertEqual(res.body[0].value.id, expected)

def test_optimization_levels_const_folding(self):
folded = ('Expr', (1, 0, 1, 6), ('Constant', (1, 0, 1, 6), (1, 2), None))
not_folded = ('Expr', (1, 0, 1, 6),
('Tuple', (1, 0, 1, 6),
[('Constant', (1, 1, 1, 2), 1, None),
('Constant', (1, 4, 1, 5), 2, None)], ('Load',)))

cases = [(-1, not_folded), (0, not_folded), (1, folded), (2, folded)]
for (optval, expected) in cases:
with self.subTest(optval=optval):
tree1 = ast.parse("(1, 2)", optimize=optval)
tree2 = ast.parse(ast.parse("(1, 2)"), optimize=optval)
for tree in [tree1, tree2]:
res = to_tuple(tree.body[0])
self.assertEqual(res, expected)

Comment on lines -156 to -171
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as with test_compile_ast. Maybe add test for __debug__ instead of removing test entirely?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, there is test_optimization_levels__debug__ right above this one, so this one can indeed be gone.

def test_invalid_position_information(self):
invalid_linenos = [
(10, 1), (-10, -11), (10, -11), (-5, -2), (-5, 1)
Expand Down Expand Up @@ -3138,101 +3122,6 @@ def test_folding_format(self):

self.assert_ast(code, non_optimized_target, optimized_target)


def test_folding_tuple(self):
code = "(1,)"

non_optimized_target = self.wrap_expr(ast.Tuple(elts=[ast.Constant(1)]))
optimized_target = self.wrap_expr(ast.Constant(value=(1,)))

self.assert_ast(code, non_optimized_target, optimized_target)

def test_folding_type_param_in_function_def(self):
code = "def foo[%s = (1, 2)](): pass"

unoptimized_tuple = ast.Tuple(elts=[ast.Constant(1), ast.Constant(2)])
unoptimized_type_params = [
("T", "T", ast.TypeVar),
("**P", "P", ast.ParamSpec),
("*Ts", "Ts", ast.TypeVarTuple),
]

for type, name, type_param in unoptimized_type_params:
result_code = code % type
optimized_target = self.wrap_statement(
ast.FunctionDef(
name='foo',
args=ast.arguments(),
body=[ast.Pass()],
type_params=[type_param(name=name, default_value=ast.Constant((1, 2)))]
)
)
non_optimized_target = self.wrap_statement(
ast.FunctionDef(
name='foo',
args=ast.arguments(),
body=[ast.Pass()],
type_params=[type_param(name=name, default_value=unoptimized_tuple)]
)
)
self.assert_ast(result_code, non_optimized_target, optimized_target)

def test_folding_type_param_in_class_def(self):
code = "class foo[%s = (1, 2)]: pass"

unoptimized_tuple = ast.Tuple(elts=[ast.Constant(1), ast.Constant(2)])
unoptimized_type_params = [
("T", "T", ast.TypeVar),
("**P", "P", ast.ParamSpec),
("*Ts", "Ts", ast.TypeVarTuple),
]

for type, name, type_param in unoptimized_type_params:
result_code = code % type
optimized_target = self.wrap_statement(
ast.ClassDef(
name='foo',
body=[ast.Pass()],
type_params=[type_param(name=name, default_value=ast.Constant((1, 2)))]
)
)
non_optimized_target = self.wrap_statement(
ast.ClassDef(
name='foo',
body=[ast.Pass()],
type_params=[type_param(name=name, default_value=unoptimized_tuple)]
)
)
self.assert_ast(result_code, non_optimized_target, optimized_target)

def test_folding_type_param_in_type_alias(self):
code = "type foo[%s = (1, 2)] = 1"

unoptimized_tuple = ast.Tuple(elts=[ast.Constant(1), ast.Constant(2)])
unoptimized_type_params = [
("T", "T", ast.TypeVar),
("**P", "P", ast.ParamSpec),
("*Ts", "Ts", ast.TypeVarTuple),
]

for type, name, type_param in unoptimized_type_params:
result_code = code % type
optimized_target = self.wrap_statement(
ast.TypeAlias(
name=ast.Name(id='foo', ctx=ast.Store()),
type_params=[type_param(name=name, default_value=ast.Constant((1, 2)))],
value=ast.Constant(value=1),
)
)
non_optimized_target = self.wrap_statement(
ast.TypeAlias(
name=ast.Name(id='foo', ctx=ast.Store()),
type_params=[type_param(name=name, default_value=unoptimized_tuple)],
value=ast.Constant(value=1),
)
)
self.assert_ast(result_code, non_optimized_target, optimized_target)
Comment on lines -3150 to -3234
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as with test_compile_ast. Maybe not just remove them, but add testing of __debug__ as it is still in ast optimizer?


def test_folding_match_case_allowed_expressions(self):
def get_match_case_values(node):
result = []
Expand Down
5 changes: 0 additions & 5 deletions Lib/test/test_builtin.py
Original file line number Diff line number Diff line change
Expand Up @@ -570,11 +570,6 @@ def test_compile_ast(self):
self.assertIsInstance(raw_right, ast.Tuple)
self.assertListEqual([elt.value for elt in raw_right.elts], [1, 2])

for opt in [opt1, opt2]:
opt_right = opt.value.right # expect Constant((1,2))
self.assertIsInstance(opt_right, ast.Constant)
self.assertEqual(opt_right.value, (1, 2))

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test tests whether we optimize ast when passed PyCF_ONLY_AST/PyCF_OPTIMIZED_AST flag, right? I guess it's not right to just remove lines testing optimized ast. I know there are no more foldings left in ast optimizer, but there is still __debug__ thing left there. So maybe rewrite test to include that?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's ok to modify this one, and remove others from test_ast. I don't think it's worth keeping it. I wrote them to make sure that under different circumstances nodes are still optimized. Since there are no actual foldings (except the __debug__ but it's a special one), I decided to remove them. Changing test_ast tests to use __debug__ seems overhelming to me, IMO

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe you're right. They are testing const folding which no longer is there and __debug__ is just special case (which I am not sure we can call const folding either).

def test_delattr(self):
sys.spam = 1
delattr(sys, 'spam')
Expand Down
8 changes: 0 additions & 8 deletions Lib/test/test_compile.py
Original file line number Diff line number Diff line change
Expand Up @@ -793,14 +793,6 @@ def check_same_constant(const):
self.check_constant(f1, Ellipsis)
self.assertEqual(repr(f1()), repr(Ellipsis))

# Merge constants in tuple or frozenset
f1, f2 = lambda: "not a name", lambda: ("not a name",)
f3 = lambda x: x in {("not a name",)}
self.assertIs(f1.__code__.co_consts[0],
f2.__code__.co_consts[0][0])
self.assertIs(next(iter(f3.__code__.co_consts[1])),
f2.__code__.co_consts[0])

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why are these tests removed? They are not related. I understand this is failing, but its enough to update co_consts index. Now that tuple is folded later in pipeline, it sits at different index. Right?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This also bring us back to #130016. We wouldn't need to touch this if we were to merge that. @iritkatriel are we planning to?

# {0} is converted to a constant frozenset({0}) by the peephole
# optimizer
f1, f2 = lambda x: x in {0}, lambda x: x in {0}
Expand Down
8 changes: 5 additions & 3 deletions Lib/test/test_opcache.py
Original file line number Diff line number Diff line change
Expand Up @@ -1665,8 +1665,10 @@ def to_bool_str():
@requires_specialization_ft
def test_unpack_sequence(self):
def unpack_sequence_two_tuple():

t = 1, 2
for _ in range(_testinternalcapi.SPECIALIZATION_THRESHOLD):
a, b = 1, 2
a, b = t
self.assertEqual(a, 1)
self.assertEqual(b, 2)

Expand All @@ -1677,8 +1679,8 @@ def unpack_sequence_two_tuple():

def unpack_sequence_tuple():
for _ in range(_testinternalcapi.SPECIALIZATION_THRESHOLD):
a, = 1,
self.assertEqual(a, 1)
a, b, c, d = 1, 2, 3, 4
self.assertEqual((a, b, c, d), (1, 2, 3, 4))

unpack_sequence_tuple()
self.assert_specialized(unpack_sequence_tuple, "UNPACK_SEQUENCE_TUPLE")
Expand Down
29 changes: 26 additions & 3 deletions Lib/test/test_peepholer.py
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ def test_folding_of_tuples_of_constants(self):
for line, elem in (
('a = 1,2,3', (1, 2, 3)),
('("a","b","c")', ('a', 'b', 'c')),
('a,b,c = 1,2,3', (1, 2, 3)),
('a,b,c,d = 1,2,3,4', (1, 2, 3, 4)),
('(None, 1, None)', (None, 1, None)),
('((1, 2), 3, 4)', ((1, 2), 3, 4)),
):
Expand All @@ -164,8 +164,9 @@ def test_folding_of_tuples_of_constants(self):
self.assertNotInBytecode(code, 'BUILD_TUPLE')
self.check_lnotab(code)

# Long tuples should be folded too.
code = compile(repr(tuple(range(10000))),'','single')
# Long tuples should be folded too, but their length should not
# exceed the `STACK_USE_GUIDELINE`
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we perhaps add a test that tuples longer than STACK_USE_GUIDELINE are in fact not folded?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure about it. At the moment, for constant tuples which are longer than STACK_USE_GUIDELINE will be generated following bytecode:

BUILD_LIST 0
Pairs of LOAD_CONST + LIST_APPEND
CALL_INTRINSIC_1 (INTRINSIC_LIST_TO_TUPLE)

Shall we assert that this intrinsic is presented in bytecode?

code = compile(repr(tuple(range(30))),'','single')
Comment on lines +181 to +183
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How much of a concern is that we can no longer create constant tuples beyond length of STACK_USE_GUIDELINE? @iritkatriel

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's probably not ideal in case you have some kind of large constant lookup table for instance. As Kirill pointed out, this would get compiled to a bunch of LOAD_CONST + LIST_APPEND which is probably much slower

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is not a hard pattern to detect, right? Maybe we could fold it anyway? @Eclips4

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems quite predictable:

>>> def foo():                                                                                                                                                                                                                                                
...     return (1,2,3, ... ,31)                                                                                                                                                          
...  
>>> dis.dis(foo)                                                                                                                                                                                                                                              
  1           RESUME                   0                                                                                                                                                                                                                      
                                                                                                                                                                                                                                                              
  2           BUILD_LIST               0                                                                                                                                                                                                                      
              LOAD_SMALL_INT           1                                                                                                                                                                                                                      
              LIST_APPEND              1                                                                                                                                                                                                                      
              LOAD_SMALL_INT           2                                                                                                                                                                                                                      
              LIST_APPEND              1                                                                                                                                                                                                                      
              ...
              LOAD_SMALL_INT          31
              LIST_APPEND              1
              CALL_INTRINSIC_1         6 (INTRINSIC_LIST_TO_TUPLE)

(Could also be LOAD_CONST instead of LOAD_SMALL_INT here I suppose)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Exactly. I think we can easily fold it.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we could check with is_const_tuple if the tuple is constant and if it is, ignore STACK_USE_GUIDELINE because we know it'll be folded by flowgraph anyway.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think it's a good idea. We would be creating dependency between both.

Copy link
Contributor

@WolframAlph WolframAlph Feb 24, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So if we are going to fold constant tuple beyond STACK_USE_GUIDELINE, maybe we could also add this optimization to literal lists & sets? It would be consistent with previous Python version as we are not folding these cases anymore after we migrated them to CFG.

self.assertNotInBytecode(code, 'BUILD_TUPLE')
# One LOAD_CONST for the tuple, one for the None return value
load_consts = [instr for instr in dis.get_instructions(code)
Expand Down Expand Up @@ -345,6 +346,28 @@ def negzero():
self.assertInBytecode(code, opname)
self.check_lnotab(code)

def test_folding_of_tuples_on_constants(self):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
def test_folding_of_tuples_on_constants(self):
def test_folding_of_tuples_of_constants(self):

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And we actually, already have test_folding_of_tuples_of_constants, maybe we could add more to those instead?

tests =[
('()', True, 0),
('(1, 2, 3)', True, 3),
('("a", "b", "c")', True, 3),
('(1, a)', False, 2),
('(a, b, c)', False, 3),
('(1, (2, 3))', True, 2),
('(a, (b, c))', False, 2),
('(1, [], {})', False, 3),
(repr(tuple(range(30))), True, 30),
('(1, (2, (3, (4, (5)))))', True, 2)
]
for expr, is_const, length in tests:
with self.subTest(expr=expr, is_const=is_const, length=length):
code = compile(expr, '', 'eval')
if is_const:
self.assertNotInBytecode(code, 'BUILD_TUPLE', length)
self.assertInBytecode(code, 'LOAD_CONST', eval(expr))
else:
self.assertInBytecode(code, 'BUILD_TUPLE', length)

def test_elim_extra_return(self):
# RETURN LOAD_CONST None RETURN --> RETURN
def f(x):
Expand Down
18 changes: 9 additions & 9 deletions Programs/test_frozenmain.h

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

35 changes: 0 additions & 35 deletions Python/ast_opt.c
Original file line number Diff line number Diff line change
Expand Up @@ -278,40 +278,6 @@ fold_binop(expr_ty node, PyArena *arena, _PyASTOptimizeState *state)
return 1;
}

static PyObject*
make_const_tuple(asdl_expr_seq *elts)
{
for (Py_ssize_t i = 0; i < asdl_seq_LEN(elts); i++) {
expr_ty e = (expr_ty)asdl_seq_GET(elts, i);
if (e->kind != Constant_kind) {
return NULL;
}
}

PyObject *newval = PyTuple_New(asdl_seq_LEN(elts));
if (newval == NULL) {
return NULL;
}

for (Py_ssize_t i = 0; i < asdl_seq_LEN(elts); i++) {
expr_ty e = (expr_ty)asdl_seq_GET(elts, i);
PyObject *v = e->v.Constant.value;
PyTuple_SET_ITEM(newval, i, Py_NewRef(v));
}
return newval;
}

static int
fold_tuple(expr_ty node, PyArena *arena, _PyASTOptimizeState *state)
{
PyObject *newval;

if (node->v.Tuple.ctx != Load)
return 1;

newval = make_const_tuple(node->v.Tuple.elts);
return make_const(node, newval, arena);
}

static int astfold_mod(mod_ty node_, PyArena *ctx_, _PyASTOptimizeState *state);
static int astfold_stmt(stmt_ty node_, PyArena *ctx_, _PyASTOptimizeState *state);
Expand Down Expand Up @@ -505,7 +471,6 @@ astfold_expr(expr_ty node_, PyArena *ctx_, _PyASTOptimizeState *state)
break;
case Tuple_kind:
CALL_SEQ(astfold_expr, expr, node_->v.Tuple.elts);
CALL(fold_tuple, expr_ty, node_);
break;
case Name_kind:
if (node_->v.Name.ctx == Load &&
Expand Down
15 changes: 15 additions & 0 deletions Python/codegen.c
Original file line number Diff line number Diff line change
Expand Up @@ -1693,11 +1693,26 @@ codegen_typealias(compiler *c, stmt_ty s)
return SUCCESS;
}

static bool
is_const_tuple(asdl_expr_seq *elts)
{
for (Py_ssize_t i = 0; i < asdl_seq_LEN(elts); i++) {
expr_ty e = (expr_ty)asdl_seq_GET(elts, i);
if (e->kind != Constant_kind) {
return false;
}
}
return true;
}

/* Return false if the expression is a constant value except named singletons.
Return true otherwise. */
static bool
check_is_arg(expr_ty e)
{
if (e->kind == Tuple_kind) {
return !is_const_tuple(e->v.Tuple.elts);
}
if (e->kind != Constant_kind) {
return true;
}
Expand Down
Loading