Interactive evaluation: one TranslationUnit per cell¶
This page describes how LFortran represents an interactive session — the REPL,
the Jupyter kernel and the JupyterLite lab — in ASR, and what that means for
code that walks symbol tables. Ordinary (ahead-of-time) compilation is not
affected by any of it: there is one TranslationUnit, no chaining and no
copying.
The shape¶
Every cell is compiled into its own TranslationUnit. Their symbol tables are
chained through SymbolTable::parent, oldest first:
cell 1 TranslationUnit symtab(parent = nullptr)
cell 2 TranslationUnit symtab(parent = cell 1's symtab)
cell 3 TranslationUnit symtab(parent = cell 2's symtab)
Two properties follow directly from that shape:
Cells are additive. A cell sees everything declared before it, and nothing can reach forward into a later cell.
Redeclaring shadows. A name declared again in a later cell does not overwrite the earlier one; it hides it, the way a nested scope does. Code compiled earlier keeps using what it resolved to then, and later cells resolve to the new declaration. This is what a Python notebook does when you re-run a cell: the name is rebound, and objects created earlier keep the binding they were made with.
So this session prints 1, then 2, then 1 again:
integer function f()
f = 1
end function
f() ! 1
integer function f()
f = 2
end function
f() ! 2
The first f is still there and still returns 1 — Fortran simply gives you no
way to name it once it is shadowed. The same holds for variables and modules;
a module redefined in a later cell leaves procedures compiled against the
earlier one using the earlier one.
Walking the chain¶
Before cells were chained, a scope chain ended at the one and only
TranslationUnit, so „walk to the root“ and „find the translation unit“ were
the same thing. They are no longer. A walk looking for the translation unit has
to stop at the nearest one, which is the cell being compiled — otherwise
symbols generated during compilation land in the session’s first cell, which
was compiled long ago and cannot be added to any more.
ASRUtils::is_tu_scope() is the test, and the walks that use it are
get_tu_symtab(), get_sym_module(), get_sym_module0(),
get_scope_names() and SymbolTable::get_tu_scope(). With a single
TranslationUnit the nearest one is also the root, so ordinary compilation
sees no change. New code that walks up a scope chain looking for the
translation unit should stop at is_tu_scope(), not at parent == nullptr.
Qualifying symbols by cell¶
Two live declarations of the same name need two distinct symbols in the
generated code. Symbols declared at cell scope are therefore qualified by the
cell they belong to, the way symbols in a module are qualified by the module:
ASRUtils::cell_prefix() returns __cell<N>_ for the Nth cell.
The first cell is deliberately unqualified, so that it and ordinary compilation
produce identical symbols. The wrapper each cell is run through
(__lfortran_evaluate_<N>) is never qualified — the evaluator looks it up by
that name.
Codegen derives the prefix from the scope that declares a symbol, not from
the cell being compiled (tu_symbol_prefix() in asr_to_llvm.cpp), which is
what makes a reference from a later cell resolve to the earlier cell’s symbol.
The snapshot¶
The ASR passes rewrite what they are given: pass_array_by_data replaces a
procedure taking an assumed-shape array with a specialisation under a mangled
name, for instance. A cell is compiled from the tree semantic analysis
produced, and the passes may do as they like to it — it is thrown away
afterwards. What the next cell is parented to is a copy of this cell’s
symbols taken beforehand (FortranEvaluator::snapshot_cell_scope()), so that
later cells resolve names against the signatures the user wrote rather than
the lowered ones.
Only symbols are copied. Later cells resolve names and read signatures; they never re-run an earlier cell’s statements.
This has a consequence worth knowing: because a later cell resolves to the
unspecialised procedure, a pass that changes signatures has to specialise
earlier cells‘ procedures too, so that the call reaches the procedure the JIT
actually holds. The generated names are derived from the signature, so they
come out the same as they did when that cell was compiled, and the call links
to the definition already in the JIT. pass_array_by_data does this by
visiting ancestor cell scopes.
Codegen and the JIT¶
When a cell is compiled, earlier cells‘ symbols are declared, not defined:
their definitions are already in the JIT, and this cell holds them in their
pre-pass form anyway. Compiler-generated helpers are recreated by the passes on
every evaluation, and redefining a symbol is an error for the JIT, so
FortranEvaluator::drop_redefinitions() turns definitions the JIT already
holds into declarations.
A cell containing a program unit is compiled into
__lfortran_evaluate_<N>_program and called from the evaluator; without that,
the program would be compiled and never run, and the cell would silently
produce no output.
Tests¶
src/lfortran/tests/test_llvm.cpp holds the interactive tests. The shadowing
ones (FortranEvaluator shadow a variable/function/module across cells) are
the executable description of the semantics above: each checks that the new
binding is visible and that the old one is still alive.
One cell is one evaluate2() call, so a test is written as a sequence of them.
See LFortran in the Browser (JupyterLite) for reproducing a lab bug in the browser and reducing it
to such a test.