Skip to content

Link and package modules

loom-link combines text and bytecode modules, applies configuration bindings, and either merges primary inputs or retains the dependency closure of selected roots. Its inputs are explicit: it never searches a directory or pulls in modules that the invocation did not provide.

Inputs and libraries

Positional inputs are primary modules. Repeated --library=path options add library modules whose exported definitions and template implementations are available to the primary program. The root below declares @project_layer locally, while the invocation supplies the library that defines it:

root.loom
// The declaration states the exact callable contract required from a library.
func.decl @project_layer(%value: i32) -> (i32)

func.def public @entry(%value: i32) -> (i32) {
  %result = func.call @project_layer(%value) : (i32) -> (i32)
  func.return %result : i32
}
loom-link root.loom \
  --library=layer.loom \
  --library=kernels.loom \
  --root=@entry \
  --mode=link \
  --to=bc \
  --output=entry.loombc

Inputs may mix .loom text and .loombc bytecode. --from=auto, the default, detects their encoding independently. --to=text|bc controls the one linked module written to --output or standard output.

The mode determines whether library bodies enter that output. --mode=merge copies every non-stripped symbol from the positional inputs and leaves every --library module separate. Pass several modules positionally when they are deliberately being flattened into one reusable module. --mode=link starts from roots in the positional inputs and may pull reachable providers from the explicit library universe.

Paths are frontend inputs and diagnostic identifiers, not symbol namespaces. loom-link does not canonicalize a path into program identity or search for undeclared files. The LoomC API can construct the same source universe entirely in memory.

Primary inputs jointly form the direct source merge, so their private definitions may satisfy one another's declarations. At most one direct definition may own a global name. When no direct definition exists, a separate library must export the exact definition and exactly one library may provide it. A direct definition is never replaced merely because a supplied library contains the same name; competing definitions within the applicable ownership class are errors rather than input-order choices. These roles govern linkage, not reachability: a symbol becomes live because it is a root or reachable dependency, not because its source was supplied. Library order likewise does not override template matching; contracts, signatures, facts, requirements, and explicit template priority determine eligibility.

Inspect before linking

List indexed symbols without materializing an output module:

loom-link root.loom --library=providers.loom --list-symbols

Print the link plan for one root:

loom-link root.loom \
  --library=providers.loom \
  --root=@entry \
  --print-plan

The plan shows why each symbol is live before the linker streams the selected definitions. This is the first place to inspect an unexpected dependency, missing declaration target, or template candidate set. It is cheaper and more direct than debugging target lowering after the wrong source program was selected.

List the configuration schema visible to the input program:

loom-link root.loom --print-config-schema

This exposes the declared keys, types, and constraints an application or build must bind without scraping source text.

Link mode starts from explicit roots and retains their reachable program:

loom-link model.loom \
  --library=kernel.loom \
  --library=motif.loom \
  --mode=link \
  --root=@elementwise_transform \
  --to=text \
  --output=elementwise-transform.loom

This produces a closed program for the facts known at the link boundary. In a targetless link it selects the portable provider and discards the unresolved wave32 alternative. A deployment-product link can make its target facts available before provider selection:

loom-link model.loom \
  --library=kernel.loom \
  --library=motif.loom \
  --mode=link \
  --root=@elementwise_transform \
  --target=amdgpu:gfx11-generic \
  --to=bc \
  --output=elementwise-gfx11.loombc

The profile uses family:selector syntax. It specializes every reachable kernel entry in each selective-link analysis module, allowing target requirements to prune providers before their bodies enter the output. The result is still ordinary standalone Loom bytecode, now closed for that target profile. Use the same selector when emitting the device artifact. Omitting the profile remains the correct path for portable libraries, partial links, and JIT boundaries that will receive target facts later.

The source-to-artifacts walkthrough runs this target-aware link as a checked example. In either mode, unrelated private symbols can disappear as soon as the chosen boundary no longer needs them.

Repeated --root=@symbol options select several roots from one catalog. Add --include-input-exports when exported symbols from the requester inputs should join the explicit root set. Library exports remain resolution candidates and never become roots merely because they are public in their source module.

Output visibility follows root intent rather than provider visibility. A public library definition selected only to satisfy the requester becomes a private dependency in the linked output. Name it as another root or provide it through --root-library when that definition should independently remain an output. This prevents a reusable library's complete public catalog from being re-exported through every product that consumes one definition.

Some products close before every reachable implementation. Portable command construction, for example, follows a kernel's contract and pure configuration without pulling its device body into the command artifact. Split command and kernel compilation follows that boundary through mixed local, linked, bytecode, and external kernels and shows how child source requests remain ordinary Loom modules.

Configuration bindings are applied to the composed analysis module before each reachability and template-selection step. This lets newly reachable code expose additional demands while value and target predicates eliminate provider alternatives that are impossible for the selected configuration:

loom-link root.loom \
  --library=providers.loom \
  --root=@entry \
  --config=model.hidden_size=4096 \
  --require-resolved-config \
  --output=entry.loom

--require-resolved-config turns a remaining required configuration declaration into a link failure. Without it, a partial module may retain unresolved config for a later composition or JIT boundary.

Merge a reusable catalog

Merge mode preserves every non-stripped symbol from the positional inputs in deterministic input order and produces one flat module:

loom-link motif.loom kernel.loom \
  --mode=merge \
  --to=bc \
  --output=kernel-library.loombc

Use this mode for reusable catalogs that should retain several helpers, templates, kernels, or configurations for later root selection. The result is still one Loom bytecode module, not a container of separately named modules and not target-native code. Later link or compile invocations can select a much smaller reachable program from it.

Remove test- and benchmark-only symbols from a deployment module with --strip-check:

loom-link library.loom \
  --mode=merge \
  --strip-check \
  --to=bc \
  --output=runtime-library.loombc

Keeping checks is useful for an authoring or qualification package. Stripping them is useful when the embedding owns validation or when executable specifications should not ship in the runtime artifact. It does not change the production symbols' semantics.

An exact call or launch is legal in an independently verified module only when that module contains the compatible definition or declaration. Libraries may satisfy those declared dependencies when linked. They may not retroactively make an undeclared call valid.

Link mode considers compatible definitions from the explicitly supplied universe. A closed link rejects a reachable exact declaration that remains unresolved. Add --allow-unresolved to produce a reusable partial artifact instead.

The checked composition example has three independently verifiable modules:

root.loom  --@project_layer-->  layer.loom  --@scale-->  kernels.loom

The layer library introduces its own kernel demand:

layer.loom
// A selected library definition may introduce its own unresolved dependency.
// Partial linking preserves the declaration in the relocatable output.
func.decl pure @scale(%value: i32) -> (i32)

func.def public @project_layer(%value: i32) -> (i32) {
  %result = func.call @scale(%value) : (i32) -> (i32)
  func.return %result : i32
}

Supplying only the layer resolves the root's declaration and retains the new transitive declaration in standalone bytecode:

loom-link root.loom \
  --library=layer.loom \
  --root=@entry \
  --allow-unresolved \
  --to=bc \
  --output=partial.loombc

The generated partial module is ordinary Loom IR. It contains the selected layer body, the reachable root, and the unsatisfied kernel contract:

partial.loom
func.def @project_layer(%value: i32) -> (i32) {
  %result = func.call @scale(%value) : (i32) -> (i32)
  func.return %result : i32
}

func.def public retain @entry(%value: i32) -> (i32) {
  %result = func.call @project_layer(%value) : (i32) -> (i32)
  func.return %result : i32
}

// A selected library definition may introduce its own unresolved dependency.
// Partial linking preserves the declaration in the relocatable output.
func.decl pure @scale(%value: i32) -> (i32)

A later invocation can reload that artifact in a fresh process and supply only the remaining library:

loom-link partial.loombc \
  --library=kernels.loom \
  --root=@entry \
  --to=text \
  --output=linked.loom
linked.loom
func.def @project_layer(%value: i32) -> (i32) {
  %result = func.call @scale(%value) : (i32) -> (i32)
  func.return %result : i32
}

func.def public retain @entry(%value: i32) -> (i32) {
  %result = func.call @project_layer(%value) : (i32) -> (i32)
  func.return %result : i32
}

// This library satisfies the exact contract retained in the partial artifact.
func.def pure @scale(%value: i32) -> (i32) {
  %factor = scalar.constant 2 : i32
  %result = scalar.muli %value, %factor : i32
  func.return %result : i32
}

The final module contains each reachable definition once and no longer needs a declaration. The first invocation did not search for the missing kernel library, and the root did not need to name the layer's transitive dependencies. A diamond of callers reaching the same global declaration still selects one compatible definition by symbol identity; path reachability does not clone one copy per route.

Run the exact formatting, partial-link, bytecode-reload, and final-link sequence with:

loom/docs/examples/module-composition/run.sh build/module-composition

template.apply follows the parallel family-selection model. The using module declares the family with template.decl; each explicitly supplied library may repeat that declaration and contribute implementations. Specialization selects an eligible implementation from that explicit universe. Source path and library order never stand in for matching rules.

Choose the output for the next boundary

Output Use
Linked .loom Review the selected source, inspect provider reachability, or feed a text-oriented tool.
Linked .loombc Cache or distribute a compact closed program, optionally specialized for a deployment target.
Merged .loombc Package a reusable catalog whose roots will be selected later.
Stripped full merge Package production symbols without executable checks.

The next boundary decides when remaining facts become known. A build may invoke loom-compile immediately. A model loader may combine the bytecode with more libraries and configuration. A JIT embedding may keep one indexed library universe and select different roots and targets per workload.