faster-cpython / ideas

1.67k stars 49 forks source link

Do we need the _PyFrame_OpAlreadyRan check in frameobject.c? #623

Closed iritkatriel closed 1 month ago

iritkatriel commented 10 months ago

There are two places in frameobject.c where there is a check like:

if (PyCell_Check(value) && _PyFrame_OpAlreadyRan(f, MAKE_CELL, i))

(added by @ericsnowcurrently in https://github.com/python/cpython/commit/631f9938b1604d4f893417ec339b9e0fa9196fb1).

No tests fail if I replace _PyFrame_OpAlreadyRan(...) by true, and preceded by

    if (PyCell_Check(value)) {
        assert(_PyFrame_OpAlreadyRan(frame, MAKE_CELL, i));
    }

From inspecting the code, there are only two places where a PyCell can be created: in MAKE_CELL, and from Python via types.CellType(). Is the check intended to catch cells created from Python in this way? If so, can be write a test for it? Otherwise, can we just check PyCell_Check(value) and remove the _PyFrame_OpAlreadyRan function?

ericsnowcurrently commented 10 months ago

tl;dr We probably could drop _PyFrame_OpAlreadyRan(), but it would be regression in correctness, though the faulty corner cases seem quite unlikely. I'd tend toward leaving things as they are.

As to tests, at the bottom here I've outlined the cases where things would get weird. I don't think it would crash though. I do expect we have a test coverage gap relative to _PyFrame_OpAlreadyRan().

Context

Relevant public API:

The following are changes are relevant:

There are some essential points to keep in mind here:

(expand for details about how `_PyFrame_OpAlreadyRan()` was/is used) * `PyFrame_FastToLocals()` (used by the call trampoline and for `INTRINSIC_IMPORT_STAR`) * when fastlocals array entry kind is `CO_FAST_CELL` * if entry value is non-NULL * if the value is a cell object * if the `MAKE_CELL` instruction already ran * use the value held by the cell (ignore it if NULL) * else use the value as-is (passed in as an arg or set via `PyFrame_LocalsToFast()`) * else use the value as-is (passed in as an arg or set via `PyFrame_LocalsToFast()`) * else ignore the entry * `PyFrame_LocalsToFast()` (used by the call trampoline and for `INTRINSIC_IMPORT_STAR`) * when fastlocals array entry kind is `CO_FAST_CELL` * if entry value is non-NULL * if that value is a cell object * if the `MAKE_CELL` instruction already ran * replace the value in the cell * else replace the value in the fastlocals entry (passed in as an arg or set via `PyFrame_LocalsToFast()`) * else replace the value in the fastlocals entry (passed in as an arg or set via `PyFrame_LocalsToFast()`) * else ignore the entry * no-arg `super()` calls (no longer happens, as of [gh-26587](https://github.com/python/cpython/pull/26587)) * if the first fastlocals array entry is a cell * if the `MAKE_CELL` instruction already ran * use the value held by the cell for `self` (raise if NULL) * else raise * else raise

Note that, since gh-26396, we have changed the machinery and data structures for frames, and refactored the code related to PyFrame_FastToLocals(). We've also added a few new related C-API functions, which involved factoring out the code using _PyFrame_OpAlreadyRan() into a helper function.

Why _PyFrame_OpAlreadyRan()?

With all of that in mind, we needed a way to tell if MAKE_CELL had run for a fastlocals array entry yet, but really only when the current value of the entry was a cell object. That's the exceptional case--where someone passed a cell to a function where that arg was itself was meant to be a cell (or likewise set a cell using PyFrame_LocalsToFast() before MAKE_CELL ran).

To deal with this, we would need to know in PyFrame_FastToLocals() and no-arg super() if we should use the cell itself or use the value it's holding. Likewise we would need know in PyFrame_LocalsToFast() if we should replace the entry's current value (a cell) with the new value or put the new value into the cell.

_PyFrame_OpAlreadyRan() was more or less a solution for dealing with this exceptional case.
https://github.com/python/cpython/pull/26396/commits/362088d6f208c99305f5e7693e975c4158e3bd47 (from gh-26396) demonstrates the connection.

(FYI, gh-26396 left the code in a situation where every (?) cell var would have two entries in the fastlocals array (the second of them was never used). This was fixed a week later in gh-26587. That situation and the fix are certainly related to the fundamental motivation to _PyFrame_OpAlreadyRan(). However, it was a separate matter and the fix did not impact the state of things relative to _PyFrame_OpAlreadyRan().)

(I'll also note that, since I added _PyFrame_OpAlreadyRan(), we have changed the conditions under which the "eval breaker" gets checked, which is a mechanism by which the eval loop can be interrupted separately from tracing/debugging. Currently there isn't any way for the eval breaker to lead to the exceptional situation.)

Drop _PyFrame_OpAlreadyRan()?

The question at hand is, do we still need _PyFrame_OpAlreadyRan()?

In retrospect, we didn't really need _PyFrame_OpAlreadyRan() for no-arg super(). It's highly unlikely that anyone would pass a cell in as the self argument, especially since it is passed implicitly for instance methods. It's also fairly unlikely that anyone would replace self via PyFrame_LocalsToFast() while debugging/tracing before the first instruction (MAKE_CELL for the self entry, which is always the first one). Consequently, we actually stopped using _PyFrame_OpAlreadyRan() for no-arg super() in gh-26587 (https://github.com/python/cpython/pull/26587/commits/0e1cbc48c38becd8b13ad26fc3e956d64729e186).

(@markshannon pointed out in a review comment that we should drop _PyFrame_OpAlreadyRan(), but I'm pretty sure at this point that he was referring to no longer exporting the symbol, which I did fix in that PR.)

As to our use of _PyFrame_OpAlreadyRan() in PyFrame_LocalsToFast() and PyFrame_FastToLocals() (and now PyEval_GetLocals(), PyFrame_GetVar(), etc.), I'm still not sure. 🤪 It depends on the likelihood of the following happening:

The likelihood of the PyFrame_LocalsToFast() case seems fairly remote, but I'm not certain about the arg case (though it doesn't seem super likely that people are writing functions that take a cell object as an arg which is then used in an inner function).

iritkatriel commented 10 months ago

Thanks @ericsnowcurrently , that's helpful. I think we need to add tests covering those edge cases. I'll create an issue on cpython repo for that.

I am also wondering if there is a more direct way to check whether the Cell came from a MAKE_CELL, than to look at the instruction pointer. We could, for instance, have a field on the cell to save the version of the function whose MAKE_CELL created it.

I would also consider renaming this function to makecell_already_ran, and make it specific to MAKE_CELL. I don't think we can trust it in general, for other opcodes, given the instruction pointer can be moved around in the debugger almost arbitrarily (I don't think it can be moved to before the MAKE_CELLs though).

markshannon commented 10 months ago

I'm fairly sure we can just remove _PyFrame_OpAlreadyRan().

It is always true if the frame is complete and _PyFrame_OpAlreadyRan() cannot be called if the frame is incomplete.

ericsnowcurrently commented 10 months ago

The following directly or indirectly depend on _PyFrame_OpAlreadyRan() to know if a cell object in the fastlocals array was an arg (or set externally[^1]) vs. set by MAKE_CELL:

I could only think of two realistic ways that the eval loop could be interrupted to run external code, such that one of the above might be invoked:

The former won't happen before all the MAKE_CELL instructions execute, due to the limited places we check (loops, calls). It sounds like the latter won't be a problem because tracing doesn't start until RESUME, which always follows all the MAKE_CELL instructions. It may be worth having asserts, to replace _PyFrame_OpAlreadyRan(), that verify the frame is already "complete" in each case.

[^1]: A cell object could be set externally in the fastlocals array with PyFrame_LocalsToFast(), etc. FWIU, there really isn't any other way.

gvanrossum commented 9 months ago

Thanks, Eric! It makes sense that we should just check whether the frame is complete. I'm not sure an assert would be wise -- in a no-GIL world C code inspecting another thread's frame stack using internal APIs (like some debugging tool) could potentially trip such an assert. It might be safer to just pretend a cell variable is not set at all when we ask for its value while the frame is incomplete. "Not set" should always be a valid outcome for these APIs.

ericsnowcurrently commented 9 months ago

So before MAKE_CELL runs, the corresponding fastlocals entry is considered "Not set", even if populated with an initial value? I'm fine with that.

gvanrossum commented 9 months ago

So before MAKE_CELL runs, the corresponding fastlocals entry is considered "Not set", even if populated with an initial value? I'm fine with that.

Only if it's "problematic", i.e. if the kind is CO_FAST_CELL and the value is indeed a cell (since that's the only case where _PyFrame_OpAlreadyRan() is needed to disambiguate).

A lesser-known line of the Zen of Python applies: "In the face of ambiguity, refuse the temptation to guess."