Skip to content

Resolve bare upward subroutine calls through the instance tree (§23.8.1, §23.11, §26.3) - #175

Closed
philipaxer wants to merge 10 commits into
aionhw:mainfrom
philipaxer:up/4c-upward
Closed

philipaxer wants to merge 10 commits into
aionhw:mainfrom
philipaxer:up/4c-upward

Conversation

@philipaxer

@philipaxer philipaxer commented Nov 8, 2026 •

Copy link
Copy Markdown
Contributor

Part 5 of the series for aionhw/xezim#952, which replaces name-string scoping in generate blocks with a scope tree and a binder.

Depends on #174: until that merges, this branch also shows its commits. New here:

  • 69dc4e2 Resolve bare upward subroutine calls through the instance tree (§23.8.1, §23.11, §26.3)
  • 1fa0f94 Test the keys bare upward calls are rewritten to (§23.8.1, §23.11, §26.3, §27.4)

Changes:

  • The binder records on each module instantiation the scope of its definition that holds it. Generate-for expansion records the loop indices on each instance.
  • The inliner keeps, per instance, the parent definition's scope tree, that scope and the indices.
  • A bare name the binder could not bind in its own definition, its compilation unit or its imports is looked up from the instantiation's scope, then from each enclosing instance's (§23.8.1 steps b and c). If the first declaration found is a function or task, the name becomes its key: g.f, u.f, u.g[1].f. If data is found first, or nothing is, the name stays as it was.
  • Imports of enclosing scopes are not searched: an imported name is not visible by hierarchical reference into the importing scope (§26.3).
  • A bound instance searches its target's definition scope (§23.11).
  • The bind audit prints each resolved call with its instance and key.

No xezim source changes. The runtime already finds the keys.

Programs

Verilator 5.055 rejects upward calls, so the expected values come from §23.8.1.

Program Before After Expected
child in a block calls the block's f (the issue program) a=0 a=1 1
same, top also declares f 1 2 2
child of two parameterized instances, top also declares f 100, 100 5, 3 5, 3
child of an instance, top declares f 100 3 3
child in a generate-for of an instance 0, 0 10, 11 10, 11
child in a nested block; child in an unlabelled block 100, 100 4, 8 4, 8
grandchild; bound instance in two parameterized targets 3; 3, 3 6; 5, 3 6; 5, 3
child's task enables t; t();, function body, continuous assign 0 1, n=0 9 10, n=2 9 10, n=2
two blocks each with a task and a child (p03) 0 0 1 2 1 2
$unit function and child's own import before the block 42 7 42 7 42 7
parent imports a package with f, top declares f 100 100 100
$root.top.g.f() and g.f() from the child 1 1 1 1 1 1

Before this PR, a child reached a parent instance's function only through one bare-name table entry. With one parent instance that gave the right value. With two, both children got the same one, and a top-level function of the same name won over the parent's.

Regressions

None found. Of the 177 corpus programs whose output changed, 105 state an expected value: all 105 reach it now, none did before. The other 72 have no expected value; I read them by group, and each now gives the §23.8.1 value.

Audit

List Files Upward lines Upward calls resolved, before after of those renamed
repo 872 206 0 1 1
svt 1,028 41 0 0 0
probes 280 65 0 41 35
earlier review corpus 15,734 6,820 0 217 215

The audit binds each definition on its own, so its Upward lines stay as they are. Counted per instance, the resolved calls are the ones the inliner now rewrites. The ones not renamed resolve to a top-level function and keep the bare name. All other audit counts are unchanged: repo and svt 0/0/0, probes A 0 B 10, the corpus A 154 B 778 other 11.

Artifacts against the previous PR: repo 821 the same and 1 different, svt 798 the same. 50 and 230 programs give no artifact on either side. The one difference is p23h.sv, where a child calls a function of its parent instance. That call is now spelled w.inst_x.helper, and the output is the same.

Corpus programs that changed

189 programs have a resolved upward call. All ran on both binaries, and 177 changed output. Verilator rejects 175 of those 177 at the upward call. The other 2 read a static local of a block task upward, and they match Verilator now but did not before.

Group Programs
reaches the program's expected value now, did not before 105
reached it before, does not now 0
neither 0
no expected value, read by group 72
output unchanged 12

Memory

stress_explicit.sv, median of three compiles against the previous PR: peak RSS +2.0% (+1.5% on an earlier build of the same source), artifact size the same.

Not covered

  • disable g.t by path (§9.6.2): the next PR.
  • A class method of a child module calling upward: the class body is rewritten without the instance prefix, so it keeps the runtime's walk from the object's instance.
  • A child instantiated inside a nested module (§23.4): the nested module's tree is keyed by the enclosing module and its definition by the bare name, so no record is made, and the child's call keeps its spelling (100, LRM 1). A nested module as the child is resolved.
  • Checker instances, which elaborate on a separate path.
  • A data name found upward before a function of the same name further up stops the search, and the call keeps its spelling. §23.8 continues past a variable, which is not a scope, so with function f in the parent module, int f in its block and the child in that block, the call should reach the parent's f (3); it prints 100, the same as before.
  • The audit location of a call in a deferred body is def@offset, because the source maps are released before those bodies are rewritten.

Not checked: UVM beyond the suite. Programs outside the changed set were compiled for the audit, not simulated.

Tests after the rebase onto main

Rebased onto core 5b0b02e and xezim 2e4e7f99. The results above were measured before the rebase, against the previous PR of the series.

  • core cargo test: 471 passed, 0 failed.
  • xezim cargo test --release --test hierarchy (built without LTO): 840 passed, 0 failed.
  • The full suites ran on part 7 of the series: xezim 6457 passed, 0 failed, 1 ignored (main: 6332, 0, 1). CI runs them on this PR.

The series

This PR is one part of a stacked series for aionhw/xezim#952. This section is the same in every part, so each PR can be read on its own.

Problem. A generate block is a scope (§23.9, §27.5, §27.6), but xezim resolved names in generate blocks by string. The elaborator renamed declarations textually (f → g.f) from per-block name lists, and the simulator retried a missed name by its last segment at run time. Each fix to that scheme, including the one for aionhw/xezim#897, broke other legal programs: a local hiding a block function was renamed anyway, items outside the copied list kept the bare name, two unlabelled blocks with the same variable collided, a child instance's call reached the wrong parent, and disable g.t matched by leaf.

Approach.

  • Core builds a scope tree for each definition: its generate blocks, all arms included, each with its declarations (scope_tree.rs).
  • A binder resolves every simple name against that tree and writes the result on the identifier: a declaration, a local or other hiding name, or "upward" (§23.8.1) (elaborate/bind.rs).
  • The generate rename then follows the binding instead of name lists. The names it produces (g.f, u.g[1].x, genblk2.t) are the keys the runtime already uses, so xezim needs almost no source change.
  • XEZIM_BIND_AUDIT=1 prints each binding next to what the rename did, so every disagreement can be listed.

Parts (core / xezim; each depends on the one before and repeats its commits until that merges):

Part Core xezim What it does
1 #171 aionhw/xezim#962 Scope tree; generate scope names (genblk<n>, §27.6 zero padding, §27.5 direct nesting) come from it
2 #172 aionhw/xezim#963 Binder and audit; no behaviour change
3 #173 aionhw/xezim#964 The rename follows the binding: a name a local, formal, class member or import hides keeps its spelling
4 #174 aionhw/xezim#965 The rename reaches final blocks, classes, gates, properties, sequences, let, covergroups, clocking and constraints
5 #175 aionhw/xezim#966 Bare upward calls from child instances resolve through the instance tree (§23.8.1)
6 #176 aionhw/xezim#967 disable by path and by label is bound like other names (§9.6.2)
7 #177 aionhw/xezim#968 Data of unlabelled generate blocks is scoped under genblk<n>
8 #180 aionhw/xezim#971 Data is renamed by binding at every position; the upward search continues past a variable
9 #181 aionhw/xezim#974 Typedefs of a block take the block's key
10 #182 aionhw/xezim#975 The binder covers the remaining legal bare names; no output change
11 #183 aionhw/xezim#981 Undeclared names are reported by binding: a bare name in an instance that nothing declares is an error, where it read a value found by its last segment
12 #184 aionhw/xezim#989 The generate rename takes its spelling from the binding; the name maps, the rename gate and the audit's comparison are removed (core -1,020/+454)
13 #185 aionhw/xezim#990 One generate walker instead of two; genvars are also substituted in properties, sequences, covergroups, gates and formal defaults of a loop (§27.4) (core -347/+101)

Independent of the series: aionhw/xezim#969 (time scope and clocking of a block in an instance) and aionhw/xezim#970 (a subroutine with no written lifetime stays static on the compiled tier).

Still to come.

  • Held until there is feedback: duplicate-declaration diagnostics.
  • Not planned: keys for classes in generate blocks. A class in a generate loop stays one class.

The run-time retries by leaf name stay. They serve shapes the binder cannot decide before the run (a class method calling its instance's task, g[k].f() with a variable index, virtual interfaces) and name resolution outside generate blocks (interface ports, $root, packages).

Size so far (parts 1 to 13 against main): core +4,476/-1,066 outside tests (net +3,410), +2,458 tests; xezim +198/-101 outside tests, mostly the core pin, +3,550 tests.

Testing. Every part passed the full xezim suite and the core suite before the rebase onto main. After the rebase, part 7 passed the full suites on main, and parts 8 to 13 passed the hierarchy and core suites. CI runs the rest. Each part's audit and artifact identity against the previous part are in its own text. Expected values come from IEEE 1800-2023 and are checked against Verilator 5.055 where it accepts the program.

Review order. Parts 1 and 2 are the base; the rest read from them. Reviewing those two first settles the direction.

…it (§23.9, §27.5, §27.6)

Each module, interface, program, nested module and checker gets a ScopeTree
before elaboration: the definition body and its generate blocks, all arms
included, with the names each scope declares and its imports. A library
module gets its tree when it is adopted.

Building the tree writes each construct's implicit name into the AST.
Elaboration and instance inlining read that name and hold no counter.

§27.5: the parser records a branch with no `begin` whose one item is a
conditional generate. Such a branch opens no scope: no rename, no scope stamp,
and its inner construct takes the outer construct's number and implicit name.
Its labels are declared in the enclosing scope.

§27.6: an implicit name gets leading zeros until no explicit name of its scope
conflicts: `genblk02` beside `parameter genblk2`, or beside a block labelled
`genblk2`, even in an arm that is not selected.
…rename (§23.9, §26.3, §23.8.1)

New elaborate/bind.rs walks every position of a definition after its scope
tree is built and writes `bound` on the head of each hierarchical identifier:
a declaration on the tree's scope chain (§23.9), Upward (§23.8.1), or no
target. No target means a subroutine, procedural block or class hides the name
(§13.5, §6.5, §9.3.4, §8), it resolves through an import or $unit (§26.3,
§3.12.1), or it is rooted or scoped. A class with an unknown base, or a
randomize-with on an object, leaves without a target any name some class
declares (§8.25, §18.7). `Bound` is 4 bytes and tells a position the binder did
not walk from these; it is not serialized and nothing reads it but the audit.

XEZIM_BIND_AUDIT prints each name with its binding and what
rename_decls_in_iter, run over a copy of the definition, made of it.

The tree keys a nested definition by its parent path (§23.4), takes enum
labels inside structs and on nets, assertion labels, and named blocks under an
unnamed block with no declaration (§9.3.4), and separates type parameters and
gate instances from the kinds the rename touches. Adopted library definitions
are bound and their trees kept.
…nvisited positions (§23.9, §26.3, §8, §13.5, §18.7)

tests/bind.rs asserts each name's binding, the rename's outcome and the kind
of any difference. The nested definitions in tests/scope_tree.rs are looked up
by parent path.
…rate rename (§23.9, §26.3, §18.7, §3.12.1)

The binder writes each subroutine declaration's own id on a new
`decl: Bound` field, not serialized. `rename_decls_in_iter` builds a gate
from the declarations it renames, and `rewrite_expr_impl` consults it before
taking a name from the port map: a name the rename maps to a subroutine of
the block is rewritten when its binding is that subroutine, and keeps its
spelling when it binds to a formal, local, member, import, label, `$unit` or
a deeper declaration.

The name-based rule remains for data names under either rename, an unbound
definition and an unanalysed reference.

The parser marks the name of a `$unit::f(...)` call as having no target, and
the binder keeps that outcome, so the call reaches the unit function from a
block that declares `f`.

`randomize() with` hides the members of the receiver's class when the receiver
is one name declared with a class's own name: a local, formal, loop variable,
block-level data or port. Any other object receiver hides every name some class
declares. A base class named through a package is looked up by its last segment.

The bind audit prints `target=<kind>`: what the tree declares under the name,
hiding aside.
…et kind and the randomize receiver (§23.9, §26.3, §6.19, §6.21, §14.3, §18.7, §18.12, §3.12.1)

Eight tests in `tests/bind.rs` now expect a hidden subroutine name to keep its
spelling and to show no disagreement. Their binding assertions are unchanged.
New tests cover the target kind, the declaration's id, nested data named like
a block's subroutine, `$unit::f()` and the receiver forms of `randomize() with`.
…§23.9, §9.2.3, §11.13, §14.3, §16.12, §19, §28, §8)

rename_item_decls rewrites final blocks, gates, properties, sequences, lets,
covergroups, clocking blocks, class bodies and out-of-class constraints through
the block's rename, limited to the block's subroutine names. The gate in
rewrite_expr_impl decides each reference by its binding, so a class member, a
formal or a local keeps its spelling. Data at these positions keeps its bare
name.

A class body gets the definition-relative key; the runtime adds the instance
from this.

The bind audit reports a subroutine the rename misses at these positions as
other, and data there as group B.
…e, let, covergroup, clocking and class positions (§23.9, §8.3, §13.5, §16.8)

Nine audit tests recorded these positions as missed. They now expect the
renamed key and no disagreement. Their binding assertions are unchanged.
….1, §23.11, §26.3)

The binder records on each module instantiation the scope of its
definition's tree that holds it, and generate-for expansion records the
loop indices on each instance. The inliner keeps, per instance, the
parent's tree, that scope and those indices.

A bare name the binder leaves to the hierarchy is looked up from the
instantiation's scope, then from each enclosing instance's. When the
first declaration found is a function or task, the name is rewritten to
its key: `g.f`, `u.f`, `u.g[1].f`. Data found first, or nothing found,
leaves the name as it was. Imports of enclosing scopes are not searched.
A bound instance searches its target's definition scope.

The bind audit prints each resolved call with its instance and key.
…6.3, §27.4)

A child in a block, two parameterized parents, nested generate loops, a
grandchild and a bound instance get the key of the subroutine found
upward. The compilation unit, the child's import and a parent's data
stop the search; a parent's import is not searched.
@philipaxer
philipaxer marked this pull request as ready for review November 8, 2026 19:27
@aionhw

aionhw commented Nov 9, 2026

Copy link
Copy Markdown
Owner

Integrated by cherry-pick with @philipaxer's authorship preserved, as the whole series: xezim-core 38f31c3…d802056 (core #171–#183) and xezim 163df958…554812e6 (#962–#981). Each layer has a code commit and a test commit, with #952 in the subjects. The per-layer "Sync core revision" commits were replaced by one sync to the final core revision.

Follow-ups for gaps found in review, each checked against the reference simulator:

  • VPI reads of an unlabelled generate block's variables (top.genblk1.x) read the block's value again: 1b5b8584, ddddd148.
  • disable g1.blk and disable r[0].w stop the named block, matched by full path, not one with the same label elsewhere: 2c0d0298, 786612c5.
  • A typedef that depends on the genvar of a generate-for inside an instance has each iteration's width: 6cb836c, e159ff0c.
  • $unit::x in a generate block that declares x reads the unit's x: 3ca0d09, 181e269b, 629050a8.
  • Implicit nets used in a generate block belong to that block, one per iteration, and g[0].n reaches them: df52996, 0f0c6ef, 38a8498c.
  • The binder's hidden-name search is indexed by name. It was a linear scan per name, quadratic in one scope's locals: 2a80522, a1e7912.

Two cases are left: a disable path naming a nested block (still matched by label), and classes declared in generate loops.

Published in xezim 603c9bf3 with xezim-core 212dde0.
Validation: the full xezim suite passes (6610 tests, 0 failed, 1 ignored), with xezim-core 514 and parser 233 tests, the JIT smoke and DPI fallback tests, and a 1.92 MSRV check.

@aionhw aionhw closed this Nov 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants