Usage¶
This is a high level description of how to use LiveHD.
The lhd driver¶
LiveHD is driven by a single command line binary called lhd. It replaces the
legacy interactive shell (lgshell) and its |> pipeline syntax, which were
removed from the tree. lhd is a stateless kernel: one invocation runs one
step — (declared inputs, config) -> (declared outputs, exit code) — which
makes it directly callable from build systems (Make, Ninja, Bazel) and by
coding agents.
Two invariants are properties of the binary, not flags:
- Deterministic: output bytes are a pure function of the inputs. The
reported
run_idis a content hash of (tool version + command + resolved config + input bytes), never wall-clock/random. If a timestamp must be embedded it comes from theSOURCE_DATE_EPOCHconvention. - Hermetic: a source file the frontend cannot find within its declared
inputs is a
missing_fileerror, never a silent reach into the filesystem.
To build and get started:
$ bazel build //lhd:lhd
$ ./bazel-bin/lhd/lhd help
$ ./bazel-bin/lhd/lhd help synth
The design rationale is documented in the LiveHD repo under
docs/contracts/future_cli.md.
Commands¶
The split rule: commands that ingest source are language-qualified; commands
that operate on IR are language-agnostic. The language word is optional — it
is inferred from the file extension (.prp vs .v/.sv).
| Command | Purpose |
|---|---|
lhd elaborate [verilog\|pyrope] SRCS... |
frontend: source → IR |
lhd synth IR... |
transform / optimize / codegen over IR inputs |
lhd compile [verilog\|pyrope] SRCS... |
fused elaborate + synth (one action, one exit code) |
lhd check |
logic equivalence check (LEC) between two designs |
lhd scan FILES.prp... |
report each Pyrope file's import strings (dependency discovery) |
lhd lsp |
Pyrope language server over stdio (JSON-RPC) |
lhd list steps\|recipes\|emit-kinds\|error-classes\|options [REGEX] |
discovery (JSON when piped; options prints human text on a terminal) |
lhd describe <command\|recipe:NAME\|emit-kind\|pass.flag> |
self-documentation (JSON output; pass.flag shows one option's full help) |
lhd version / lhd help [command] |
meta |
Shared arguments honored by the execution commands:
| Argument | Meaning |
|---|---|
--emit KIND:PATH |
declared single-file typed output (verilog: only) |
--emit-dir KIND:DIR/ |
declared directory output (ln:, lg:, pyrope:, lnast-dump:) |
--top <name> |
top module/function |
--config lhd.toml |
pass-flag defaults from a config file (see below) |
--result-json PATH |
structured result object (JSON) to a file (else stdout) |
--workdir DIR |
scratch + ephemeral lgdb; never a global cache |
-j/--jobs N |
intra-action parallelism |
-q/--quiet, --verbose |
stderr verbosity; never pollutes the stdout protocol |
--diag-fmt auto\|jsonl\|pretty |
rendering of the stdout result envelope and the stderr diagnostics; auto (default) = pretty on a terminal, jsonl when piped/captured (agents, CI) |
Typed inputs and outputs (kinds)¶
Every input/output is a typed slot KIND:PATH. The main IR kinds:
| Kind | Contents |
|---|---|
ln: |
the design's LNAST units — an hhds::Forest save directory (forest.txt + binary tree bodies) plus a manifest.json unit index. Alias: lnast: |
lg: |
the design's LGraphs — an hhds::GraphLibrary save directory (library.txt + binary graph bodies). Aliases: design:, lgraph: |
verilog: |
Verilog source. As --emit, a deterministic name-sorted concatenation of the per-module inou.cgen.verilog output |
pyrope: |
Pyrope source. As --emit-dir, a per-unit .prp re-emission via pass.prp_writer (needs ln:/pyrope inputs) |
lnast-dump: |
round-trippable textual LNAST dump (the Lnast::dump text form), one <unit>.lnast per unit. A debug/test observable; the binary interchange form is ln: |
Because a design always holds many units/graphs, ln:/lg:/pyrope: are
directory containers (--emit-dir only). --emit verilog:PATH is the one
single-file output. ln:/lg: inputs are given positionally.
Verilog compilation¶
One shot — elaborate, optimize, and emit Verilog:
$ lhd compile foo.v --top foo --recipe O2 --emit verilog:foo.gen.v
Or as separate steps with the lg: container in between:
$ lhd elaborate foo.v --top foo --emit-dir lg:foo_lgs/
$ lhd synth lg:foo_lgs/ --recipe O1 --emit verilog:foo.gen.v
The Verilog frontend has three readers, selected with
--reader slang|yosys-slang|yosys-verilog (default slang):
slang— the directinou.slangSV→LNAST front-end (the default); the design becomes LNAST and joins the Pyrope flow.yosys-verilog— Yosys' native Verilog frontend, into LGraphs.yosys-slang— Yosys with the slang.so plugin (SystemVerilog), into LGraphs.
Because Verilog `include + +incdir can read files that are not on the
command line, the Verilog frontend supports --depfile PATH to write a
Make-syntax dependency file for build systems.
Pyrope compilation¶
$ lhd compile bar.prp --emit verilog:bar.gen.v
A .prp file can define several functions; elaborate pyrope emits one LNAST
unit per elaborated unit into an ln: directory. Files related by import
ride along as pre-elaborated ln: inputs. The canonical per-file → top flow:
# per pyrope file, in parallel (no imports)
$ lhd elaborate f1.prp --emit-dir ln:f1_lns/ --emit-dir lg:f1_lgs/
# a file importing f1: its pre-elaborated ln: rides along
$ lhd elaborate f2.prp ln:f1_lns/ --emit-dir ln:f2_lns/ --emit-dir lg:f2_lgs/
# top target: aggregate ln: units into ONE library, then synth
$ lhd elaborate ln:f1_lns/ ln:f2_lns/ --top foo --emit-dir lg:top_lgs/
$ lhd synth lg:top_lgs/ --recipe O1 --emit-dir lg:top_opt_lgs/ --emit verilog:top.v
To discover the import relationships without elaborating (Pyrope import
arguments are comptime string literals, so the list is exact):
$ lhd scan f1.prp f2.prp # imports reported in the result's "scan" member
Source maps¶
Verilog source maps emitted by codegen are ECMA-426 compliant. Enable them with
cgen.srcmap=1; the generated .v and source-map sidecar can be loaded by
standard source-map tools.
# Generate Verilog with source maps.
$ lhd compile inou/prp/tests/equiv/mod_varargs_csa.prp --emit-dir verilog:tmp --set cgen.srcmap=1
# Visualize the mapping.
# Open https://evanw.github.io/source-map-visualization/
# Upload the tmp/mod_varargs_csa.blk_add__u8.v* files.
Linking libraries (Pyrope + a Verilog black box)¶
A design can mix leaves from different frontends. import("lg:NAME") pulls a
compiled LGraph (from a previous Pyrope or Verilog/yosys run) into a Pyrope
module as a black box — instantiated by name, body resolved at link time. The
elaborate command then assembles several lg:/ln: inputs, starting from
--top, into one new lg: library you can synthesize.
The lg: name is the full graph name: a Verilog module keeps its name (inv), a
Pyrope unit is file.entity (adder.adder).
Cut-and-paste from the LiveHD root (writes Verilog to ./tmp); this is exactly
the flow exercised by lhd/tests/lhd_usage_merge_test.sh:
$ bazel build //lhd:lhd
# 1. a Verilog leaf -> lg: (through yosys; the default reader is now slang, so
# request the yosys frontend explicitly for this black-box leaf)
$ ./bazel-bin/lhd/lhd elaborate lhd/tests/merge_demo/inv.v --top inv --reader yosys-verilog --emit-dir lg:tmp/inv_lg/
# 2. a Pyrope leaf -> lg:
$ ./bazel-bin/lhd/lhd elaborate lhd/tests/merge_demo/adder.prp --emit-dir lg:tmp/adder_lg/
# 3. a top Pyrope that imports BOTH (a yosys black box + a Pyrope module) -> ln:
# (elaborated only; the lg: imports stay unresolved until the link step)
$ ./bazel-bin/lhd/lhd elaborate lhd/tests/merge_demo/top.prp --emit-dir ln:tmp/top_ln/
# 4. LINK: merge the two lg: libraries and lower the top against them -> a new lg:
$ ./bazel-bin/lhd/lhd elaborate --top top lg:tmp/inv_lg/ lg:tmp/adder_lg/ ln:tmp/top_ln/ --emit-dir lg:tmp/merged_lg/
# 5. synthesize the assembled library -> Verilog
$ ./bazel-bin/lhd/lhd synth lg:tmp/merged_lg/ --emit verilog:tmp/top.v
tmp/top.v holds three modules — inv, adder.adder, and top.top — with
top instantiating the other two (y = (-x) + 1). Because gids are a
deterministic hash of the graph name, a name shared across libraries keeps the
same gid, so the merge is conflict-free.
Recipes¶
A recipe is the named pass chain between the frontend and the terminal
--emit — defined as data, not |> strings:
| Recipe | Passes | Description |
|---|---|---|
O0 |
(frontend lowering only) | no graph optimization; ln: inputs still run pass.upass + tolg |
O1 |
pass.cprop |
constant/copy propagation (default) |
O2 |
pass.cprop, pass.bitwidth |
cprop + bitwidth inference |
--set pass.flag=value overrides one knob without forking the recipe, e.g.
--set cgen.srcmap=1. A typo'd pass or flag is a usage error (never a silent
no-op). Introspect with:
$ lhd list recipes
$ lhd describe recipe:O2
$ lhd list options # every --set/--config pass.flag, with defaults
$ lhd list options 'cgen\..*' # regex-filtered
$ lhd describe upass.toln # one option, full help text
The result records the expanded recipe (the passes+flags that actually ran), so an artifact is self-describing even if a recipe is later redefined.
Configuration: lhd.toml¶
--config lhd.toml provides pass-flag defaults as a declared input file. It
is a strict TOML subset: # comments, pass tables ([upass], [cprop],
[bitwidth]), and key = value entries with quoted strings, booleans, or
integers. The top level takes only recipe. Anything else is a config
error — a typo'd pass table errors rather than silently no-oping.
recipe = "O2"
[upass]
constprop = true
verifier = false
File entries are defaults: explicit --set/--recipe always win, and the
recipe key is ignored by commands with no recipe slot, so one lhd.toml can
serve every step of a flow. The config is folded in before run_id hashing,
so a config file and the equivalent explicit flags hash identically.
$ lhd describe config # prints the lhd.toml schema
Equivalence checking (LEC)¶
lhd check runs a logic equivalence check (via inou/yosys/lgcheck) between
an implementation and a reference; each side can be a verilog: file or a
lg: directory:
$ lhd check --impl verilog:foo.gen.v --ref verilog:foo.v --impl-top foo --ref-top foo
A non-equivalent pair exits non-zero with error.class = equiv_fail.
Inspecting the IRs¶
Textual LNAST dump (round-trips through Lnast::read):
$ lhd elaborate bar.prp --emit-dir lnast-dump:dump_dir/
From elaborate the dump is post-parse; from synth/compile it is
post-upass. The ln:/lg: directories themselves are the binary interchange
forms (hhds::Forest::save / hhds::GraphLibrary::save).
Results, exit codes, and error classes¶
A step's success is only the process exit code: 0 = pass, non-zero = fail.
The structured result (one JSON object, to --result-json PATH or stdout)
carries the detail: command, status, run_id, inputs, outputs, the expanded
recipe steps, and on failure an error block:
| error.class | Meaning |
|---|---|
usage |
invalid CLI usage or unsupported argument |
syntax |
input file has syntax errors |
internal |
LiveHD bug or unhandled case |
equiv_fail |
equivalence check failed |
signal |
process killed by signal (segfault, abort) |
timeout |
step exceeded time limit |
missing_file |
required file or path does not exist |
config |
missing or invalid configuration |
dependency |
required external tool or prior artifact absent |
unsupported |
requested feature is known but not implemented |
lhd emits nothing on stdout except the selected protocol — no banners, no
echoed commands, no raw pass logs. Per-step raw logs land under
--workdir/logs/.
Diagnostics¶
Errors and warnings are also emitted as a finer JSONL stream — one line per
diagnostic — alongside the step result. Point it at a file with
--emit diagnostics:PATH. The stderr rendering follows --diag-fmt:
clang-style text in pretty mode (the default on a terminal), the same JSONL
records in jsonl mode (the default when stdout is piped/captured). The
machine stream is the source of truth; the human text is a rendering of it.
Diagnostics are designed to be triaged by a coding agent as much as read by a
human, which drives the schema below.
Each record is one JSON object:
| field | meaning |
|---|---|
severity |
error (compilation cannot proceed), warning (proceeds, likely issue), or note (a secondary location) |
code |
stable, greppable id (kebab-case, e.g. range-fit) — survives message rewording, so tooling branches on it instead of the prose |
category |
what kind of fix is needed (below) |
pass |
originating stage (e.g. inou.prp, upass.attributes) |
message |
one human-readable line, without the location |
span |
source location, or null when unknown |
hint |
one actionable suggestion (optional) |
category says who is wrong:
| category | meaning |
|---|---|
syntax / name / type / bitwidth |
the source — fix the Pyrope/Verilog |
unsupported |
valid input LiveHD cannot lower yet — rewrite around it, do not "fix" the source |
internal |
a LiveHD bug — reduce to a repro, do not change the source |
A run collects every diagnostic (not just the first), so all problems surface in
one compile; duplicates of the same diagnostic within a step are reported once.
The step result's error block summarizes the fatal diagnostic and points back
at the diagnostics file with per-severity counts. The renderer prints a
clang/rust-style block with a caret line when a span is available and degrades to
a single line — never a fabricated location — when it is not.
Pyrope language server¶
lhd lsp serves the Pyrope LSP (JSON-RPC over stdio) for .prp files:
real-time compile diagnostics (syntax, name, type, bit-width) in any editor.
The scripts/prplsp wrapper picks the right lhd binary (in-checkout build
when inside a livehd checkout, $PATH otherwise) — point your editor at that.
Bazel integration¶
A thin Starlark ruleset ships in the LiveHD repo (//tools:lhd.bzl) so a
BUILD file can run LiveHD as a hermetic action:
load("//tools:lhd.bzl", "lhd_verilog")
lhd_verilog(
name = "foo_net",
top = "foo",
srcs = ["foo.v", "bar.v"],
incdirs = ["rtl/inc"],
recipe = "O2",
out = "foo.gen.v", # also writes foo.d (depfile) + foo.result.json
)
Low level directed build¶
To compile an individual pass:
$ bazel build -c dbg //pass/cprop:pass_cprop
$ bazel build -c dbg //inou/yosys:all