Skip to content

What if we get rid of NLR stuff? #1245

Description

@pfalcon

Exceptions are weird implementation-wise. Go and Rust folks hate them and go out of their way to force people to not use for a reason. The best architecture for handling exceptions is with single accumulator where all data is stored in memory (essentially, volatile). Alas, to get any performance, you need bunch of registers. But then you need to save and restore them properly on exceptions (also on interrupts, task switching, etc.), and that gets boring. With naive scheme like we have, it takes to save "all" registers, and cost to pay is bloated stack. With smarter runtime, the cost is less stack and them more complicated stack layouts, unwind procedure, and potentially performance.

Rust bans runtime exceptions, but at least tries to offer some macros to a similar effect (as they say, if someone trusts them). But with Go, it's just "if" ladders, or, well, exercises in creativity https://blog.golang.org/errors-are-values trying to emulate in runtime what decent exception-handling compiler does behind the scene.

So, what if, what if we rewrite uPy Go-style - with explicit, manual exception propagation instead of NLR? That would save us some stack. VM and bytecode already works like that, the crucial point lies in fun_bc_call() which converts status to an exception. Then we can skip raising an exception (note that stackless does that automagically), and NLR-wrap only calls to native functions. But next step is to question whether we need to raise exceptions in native functions either. Most cases are simple, and we can return status instead. Few other cases could be dealt with Go-style. So, @dpgeorge, are there any remaining cases where handling error propagation would be cumbersome?

Last issue with that is the fact that both Go and Rust requires return pair of values - error/success status and actual value. Well, we kinda do the same, but pass one of values by pointer, which is of course not ideal. Return a struct of 2 words or manually constructed int64_t would work with decent compiler but not ideal either. Well, taking into account that in Python only Exception subclasses can be raised, we can introduce a flag to differentiate exception-as-value and exception-as-raised, and then return just a single value, in register.

Activity

  1. danicampora commented on May 9, 2015

    @danicampora
    Member

    Can this GO-style exception handling be implemented without deviating from the Python syntax?

  2. pfalcon commented on May 9, 2015

    @pfalcon
    ContributorAuthor

    Sure, this all talks about C-level.

  3. dpgeorge commented on May 9, 2015

    @dpgeorge
    Member

    Well, well, you managed to dig up one of my most long-standing things that I wanted to reconsider; ie how exceptions are handled at the C level.

    In my dream world where uPy becomes meta-Python, the exception handling (at the C level) would be configurable, with at least the following 3 options: NLR mode (as it is now), compiler supported mode (eg using C++ exceptions by translating the meta-Python description to C++), and return value mode (what is described above).

    Note that "return value mode" is the way CPython does it. For a C function that could raise an exception, the return value is always checked and if it's NULL then that means an exception was raised and the exception is stored in some global variable (or something like that, probably there are messy details).

    It might save you (stack) RAM, but it's going to cost a lot of ROM to check the result of each and every call. Note that if you implement it by returning 2 values from each function (or returning a struct or larger-than-word-size value) then you need extra stack space for each function to hold this value.

    are there any remaining cases where handling error propagation would be cumbersome?

    There are many, and the call graph is pretty deep. Some examples:

    1. Allocating memory can raise an exception if out of memory, so anything that calls m_new* needs to have if statements everywhere.
    2. The parser needs to check because it can allocate qstr's which can fail.
    3. Anything that implements or calls obj->print needs to check because the print backend may be a dynamically growing vstr which can fail to grow if out of memory (and hence raise an exception). Eg the builtin str and repr functions do this.
    4. Almost all API functions in obj.h and runtime.h need to be changed because they can all raise exceptions in one way or another (eg mp_obj_new_* can fail memory allocation, mp_obj_get_int can fail if it's not an int, ...).
  4. pfalcon commented on May 9, 2015

    @pfalcon
    ContributorAuthor

    Yes, the more I think about it, the more it looks that it would be realistic only with this "meta-implementation" approach, because there're several different choices, and it's hard to tell which would work better (for a particular compiler/arch/whatever).

    It might save you (stack) RAM, but it's going to cost a lot of ROM to check the result of each and every call.

    Yes, good point.

    Note that if you implement it by returning 2 values from each function (or returning a struct or larger-than-word-size value) then you need extra stack space for each function to hold this value.

    Right, most ABIs guarantee (well, allow) return only a struct with a single scalar in register. Then the only choice would be to pack return value as int64_t, and hope that compiler can optimize it well. That's of course weak hope to rewrite all the code, and then the realistic choice is return exception value by pointer.

    Allocating memory can raise an exception if out of memory

    Oh, right. That's indeed a real exception, which can occur anywhere at any time. Well, such can be handled at the top level. I more talk "exceptions as invalid return value", like StopIteration, IndexError, etc.

    As more context, I'm taking look at tightening yield from implementation, and in few places it would require wrapping in NLR. I managed to handle the arguably most common one w/o introducing nlr_push, instead handling it on VM level, but that still require nlr_raise. And more corner of cases then remain unhandled, because nlr_push is just too expensive. If there was way of manually pass exceptions, it might help.

    So, what do about this - nothing, because it's too complex and benefits are not clear, or would it be ok to add exception passing to mp_call*(). Then those are many, and maybe storing exception value "globally" (in MP_STATE) might help (that doesn't feel right, but well, what to do otherwise, why not try?)

  5. pfalcon commented on May 9, 2015

    @pfalcon
    ContributorAuthor

    And more corner of cases then remain unhandled, because nlr_push is just too expensive.

    Here's one case: 4a60cac

  6. dpgeorge commented on May 10, 2015

    @dpgeorge
    Member

    would it be ok to add exception passing to mp_call*().

    By this I assume you mean that mp_call_function_n_kw (and friends) must never raise an exception. In this case, and if you don't want to wrap things in nlr later down the call chain, then the following functions must also never raise an exception:

    mp_call_function_n_kw
        malloc
        type->call
            mp_setup_code_state
                malloc
                mp_obj_new_dict
                mp_obj_new_tuple
                dict_store
                mp_obj_new_cell
    
    type_call
        type->make_new
    
    mp_obj_instance_call
        mp_obj_instance_get_call
            mp_obj_class_lookup
                mp_map_lookup
                    mp_obj_equal
                        type->binary_op
                    mp_obj_hash
                mp_load_method_maybe
                    type->attr
        mp_obj_new_bound_meth
            malloc
    

    That's a rough chain of possible calls starting from mp_call_function_n_kw. You can see that it can hit ->binary_op, ->attr, and ->make_new and I didn't even try to follow the chain further for those 3 functions for all possible types. Likely you hit almost all API functions.

    You also hit all native functions via fun_native_call. So either that function wraps each native call in an nlr buffer, or no native calls can raise exceptions.

    The problem is that everything is deeply connected (which is just a reflection of Python's semantics).

    [I found similar issues when trying to pass some global state (locals/globals dicts) as local context variables to some functions, to eliminate nlr push/pop just to save/restore the locals/globals context (eg for exec and eval). As soon as you try to change the C calling conventions in one spot it quickly spreads everywhere.]

  7. pfalcon commented on May 10, 2015

    @pfalcon
    ContributorAuthor

    By this I assume you mean that mp_call_function_n_kw (and friends) must never raise an exception.

    Perhaps, "try not to raise is possible" ;-).

    then the following functions must also never raise an exception:

    Well, putting it like that is complicated and frustrating ;-).

    You also hit all native functions via fun_native_call

    Yes, so the basic premise is that we already support "passing" (instead of raising) exceptions for bytecode functions, but where that feature blocked is at fun_bc_call (and all its wrappers). Then, for version 1, fun_native_call would need nlr-wrapping indeed. But version 2 says that for many cases, native functions also may return-pass exceptions, not raise. Not all, like your example with MemoryError.

    So, I'm not sure how really useful that will be. As usual, I err on side of generality - for example, propose to care about "all" exceptions (because that will allow to eliminate stack-hungry nlr-wrapping). Maybe more realistic aim is to get rid of raising StopIteration - that will add a bit of performance (but then again, only bit, so it itches to shoot for more).

  8. pfalcon commented on May 11, 2015

    @pfalcon
    ContributorAuthor

    Here's what work prompted these thoughts: #1247 . So, there's StopIteration, we try to optimize it to MP_OBJ_STOP_ITERATION if possible. But that doesn't support exception arguments, so we cannot use it for that. And so we convert it back and forth, as result of which on some places we don't expect one form or another, what that patches tries to fix, but there's no warranty there're no more places like that.

  9. pfalcon commented on May 11, 2015

    @pfalcon
    ContributorAuthor

    #402 is of course related and previous attempt at thinking to untangle, this ticket again tries to think how to optimize "almost all" exceptions, not just StopIteration.

  10. dpgeorge commented on Sep 13, 2018

    @dpgeorge
    Member

    See #4131 for a proof of concept to remove all of nlr and replace it with explicit error checking.

  11. added a commit that references this issue on Oct 9, 2018
  12. dpgeorge commented on Jun 9, 2020

    @dpgeorge
    Member

    I'll close this, since the discussion has moved to the proof-of-concept PR which removes NLR.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    rfcRequest for Comment

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions