Advanced Topic: Manual Stack Space Management
The INVOKE instruction and call_sub method automatically manage the return address stack (_ret_addr_stack) for you: they push the current pointer before entering a subroutine, and the finally block pops it upon return. For most workflows, this is all you need.
However, AmritaSense also exposes the return address stack for manual control via PUSH_RET and RET. The pattern is:
- PUSH_RET — Push an alias or address onto
_ret_addr_stack - JMP — Jump somewhere else in the workflow
- RET — Pop the saved address and jump back
This lets you implement custom call/return schemes that don't follow the rigid INVOKE/call_sub discipline.
The Return Address Stack
_ret_addr_stack is a Stack[PointerVector] on the WorkflowInterpreter. INVOKE pushes the current pointer onto it; the finally block of call_sub pops and restores it. With PUSH_RET, you can push any alias target onto the stack directly from the composition chain without writing a custom node.
PUSH_RET and RET
PUSH_RET(alias_or_idata)— pushes the resolved address of a target alias (or a raw address list) onto_ret_addr_stack. The instruction returns aNodeType[None](an inline@Node-decorated callable), placed directly in the>>chain.RET()— pops the top entry from_ret_addr_stackand restores the pointer viarebase_ptr. Unlikejump_to/jump_far_ptr,rebase_ptrdoes not set the jump flag, so the interpreter naturally advances to the next instruction (return-address + 1) after the return. Callers should pushtarget - 1so that the advance step lands exactly on the target node.
Neither instruction should be return-ed from inside a @Node() function — place them directly in the >> chain.
Closed-interval jumps vs. open-interval returns
The two families of pointer rewrite differ in whether the target itself is included:
| Instruction | Address written to _pointer | Execution begins at | Interval |
|---|---|---|---|
JMP / jump_to | the target address | the target address | closed — target included |
RET / rebase_ptr | the saved address | the saved address + 1 (added by advance_pointer()) | open — saved address excluded |
JMP needs a jump target, so it lands exactly there. RET needs a resume point, and a resume point is the node after the call site — so the address it stores is the one the main loop advances from. The main loop guarantees the + 1 itself, which is why RET must never set the jump flag: doing so would suppress the advance and re-run the saved address.
That is why the None default of CALL pushes the CALL node's own address: RET rebases there and the advance lands on the node right after it.
Example: PUSH_RET + JMP + RET
from amrita_sense import ALIAS, NOP, Node, WorkflowInterpreter
from amrita_sense.instructions import JMP, PUSH_RET, RET
@Node()
async def start() -> None:
print("Start")
@Node()
async def doing_work() -> None:
"""The section we JMP into."""
print(" Doing work")
@Node()
async def after_return() -> None:
"""RET pops _ret_addr_stack and resumes here."""
print("Back here (via RET)")
comp = (
start
>> PUSH_RET("resume") # push the return address (NOP right before after_return)
>> JMP("work") # jump into the work section
>> ALIAS(NOP, "resume") # RET rebases here; advance lands on after_return
>> after_return
>> ALIAS(doing_work, "work")
>> RET()
)
await WorkflowInterpreter(comp.render()).run()Flow (new RET semantics):
PUSH_RET("resume")pushes the address of theNOPaliased"resume"— the node beforeafter_returnJMP("work")jumps to thedoing_worknode- After
doing_work,RETpops the saved address,rebase_ptrs there, and the interpreter advances ontoafter_return
Because
RETdoes not set the jump flag, the saved address must be the predecessor of the real target (target - 1). The"resume"NOP plays that role here.
CALL
CALL(to_adr, *, from_adr=None) is a convenience instruction that combines PUSH_RET + JMP into a single node. Internally it:
- Pushes the resolved return address onto
_ret_addr_stack(just likePUSH_RET) - Jumps to
to_adr(just likeJMP)
from_adr is keyword-only and defaults to None, which means "return to the node right after this CALL". When it is None, CALL always pushes its own address — the address of the CALL node itself — no matter whether it runs in the main run() flow or inside a call_sub. RET rebases the pointer to that address, and the interpreter then advances onto the node right after CALL.
Keeping the default independent of the nesting level is what makes the pushed address meaningful: every entry on _ret_addr_stack is the address of the instruction that pushed it, so a stack dump tells you who is waiting to resume, and where. A CALL nested inside a subroutine therefore returns to its own successor instead of escaping to the parent's return address.
Because None is the default, the common case is simply CALL("target").
from amrita_sense.instructions import CALL, RET
from amrita_sense.instructions.subprogram import ARCHIVED_SEGMENT
# Pattern A: explicit two-step (push predecessor + JMP)
comp_a = (
start
>> PUSH_RET("resume")
>> JMP("work")
>> ALIAS(NOP, "resume")
>> after_return
>> ALIAS(doing_work, "work")
>> RET()
)
# Pattern B: CALL convenience — from_adr defaults to None (the current pointer)
# RET rebases to CALL itself, then advance lands on after_return.
# The body is hidden in an ARCHIVED_SEGMENT so normal flow skips it.
comp_b = (
start
>> CALL("work")
>> after_return
>> ARCHIVED_SEGMENT(ALIAS(doing_work, "work") >> RET())
)CALL is semantically equivalent to the two-step pattern (with the from_adr=None default covering the common "return to the next node" case). Note that in Pattern B the body must be archived (ARCHIVED_SEGMENT) — otherwise the normal flow would re-enter it after after_return.
When to Use Manual Stack Management
| Scenario | Use |
|---|---|
| Simple subroutine call/return | INVOKE + natural call_sub return |
| Custom return destination | PUSH_RET + JMP + RET |
| Push-and-jump convenience | CALL + RET |
| Multi-level stack unwinding | Push multiple addresses, RET once per level |
| Non-linear control flow | Combine with JMP for arbitrary jump patterns |
Subroutine-like Pattern with FN
The modern way to write a self-contained "subroutine" is FN(entrypoint, block) — it embeds its own skip mechanism (_fn_escape) and auto-appends RET(). Call it with CALL(entrypoint); no manual PUSH_RET / JMP / RET plumbing is needed:
from amrita_sense import Node, WorkflowInterpreter
from amrita_sense.instructions import FN, CALL
@Node()
async def start() -> None:
print("Start")
@Node()
async def step1() -> None:
print(" Step 1")
@Node()
async def step2() -> None:
print(" Step 2")
@Node()
async def after_return() -> None:
print("Back here (via FN)")
# Self-contained subroutine: normal flow skips it (via _fn_escape),
# CALL enters it; FN auto-appends RET() at the end.
subroutine = FN("sub_entry", step1 >> step2)
comp = (
start
>> CALL("sub_entry") # from_adr=None -> return after this node
>> after_return # RET rebases to the call site -> advance lands here
>> subroutine
)
await WorkflowInterpreter(comp.render()).run()Flow (new RET semantics):
CALL("sub_entry")pushes the current pointer and jumps into the subroutinestep1 >> step2execute sequentially- The auto-appended
RET()pops the saved address,rebase_ptrs to the call site, and the interpreter advances ontoafter_return
FN vs manual stack ops:
PUSH_RET/JMP/RETremain available for fully manual stack control (non-linear flow, multi-level unwinding). For ordinary "call a routine and come back",FN+CALLis the recommended, less error-prone form.
Caution
- Stack integrity:
RETpops from_ret_addr_stackunconditionally. If the stack is empty, this raises anIndexError. Always push a corresponding address (viaINVOKEorPUSH_RET) before reachingRET. - Return-address + 1:
RETusesrebase_ptr(no jump flag), so execution resumes at the node after the saved address. Pushtarget - 1(or rely on theNonedefault ofCALL, which points at the instruction itself). - Not a subprogram instruction:
PUSH_RETandRETare standalone nodes in the composition chain. Do NOT call them from inside a@Node()function — place them directly in the>>chain.
