Skip to content

Add TraceLens model architecture visualizer - #991

Open
gabeweisz wants to merge 185 commits into
mainfrom
feat/gw_model_visualizer
Open

gabeweisz wants to merge 185 commits into
mainfrom
feat/gw_model_visualizer

Conversation

@gabeweisz

@gabeweisz gabeweisz commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Add a Visualizer that parses Hugging Face modeling code into block trees and computation graphs, then exports a Model Explorer view (CLI extra Visualizer, visualize_model_in_explorer).
  • Infer symbolic tensor shapes (B, S, H, …) by default, keep nested diagrams in their tile slots, and label collapsed groups with boundary shapes so Model Explorer no longer shows ? on those edges.
  • Ship self-contained HTML exports, a fact sheet, kernel/MoE wiring, and Visualizer unit tests with ~95% patch coverage on the new code.

Test plan

  • cd Visualizer && PYTHONPATH=. pytest tests/ (install [Visualizer] extra if needed)
  • visualize_model_in_explorer zai-org/GLM-5.3-Flash and confirm shapes on edges, indexer nested in attention, hyperconnection collapse at B x S x 4096
  • Spot-check Mixtral/Qwen/Kimi/DeepSeek exports for regressions
  • Confirm lint (Black/Ruff on changed Python) and copyright headers on Visualizer files

Made with Cursor

gabeweisz and others added 30 commits August 10, 2026 18:57
Introduce a CPU-only Visualizer package that inspects Hugging Face configs and modeling code via AST to render architecture diagrams, including detailed block trees, hybrid layer variants, and fact sheets with loop-derived layer repeat info.
Derive attention and output-gate labels from AST kernel metadata so KDA shows delta-rule computation instead of QKᵀV, document g_proj’s gated-norm role, and add a measure-reflow-validate pipeline to prevent overlapping layout in detailed SVGs.

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Align shared tee routing, shrinkwrap, and layout compaction so input fan-out
leaves vertically with matched branch tees, and add runtime checks plus tests
for the routing, clamp, and per-leg bus behavior.

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
…xits

Feeders stacked in a target's own column competed for the same top-entry
port, so one leg cut across the merge bus of the block it passed. Ports are
now assigned deterministically by source column and proximity: the nearest
feeder keeps the center port, and the ones above it bypass on a port beside
the blocking tile, teeing below that tile's bus.

The old port order depended on dict iteration order, which under some hash
seeds gave the adjacent feeder the offset port and produced a horizontal jog
inside its exit stub. Exit-stub validation now inspects every segment rather
than just the path endpoints, and sub-epsilon horizontal offsets snap to
vertical so they no longer render as slanted.

Co-authored-by: Cursor <cursoragent@cursor.com>
Parse config-aware gate math instead of fabricated router stages, classify
methods like moe_infer from structure, and compact SparseMoeBlock names
without doubling an existing Moe stem.

Co-authored-by: Cursor <cursoragent@cursor.com>
…ing space

Detailed diagrams were losing content and drifting wider on every layout pass.
Kernels imported from a checkpoint's own directory never resolved, so blocks
like DeepSeek sparse_attn stayed opaque; norms named attn_norm/ffn_norm were
classified as attention/ffn, leaving the transformer block with no pairs to
render. Frames travelling with a cluster were also used to constrain it, and
side columns were averaged into frame centres, ratcheting widths up each pass.

Layout now re-hangs section content from its anchor after ports are re-docked,
so a vacated port row no longer opens dead space under the section title, and
spine connectors reach frame boundaries and merge glyph edges.

Co-authored-by: Cursor <cursoragent@cursor.com>
These fill uncovered branches in TraceLens Agent Analysis, PerfModel
extensions, and Reporting generators using fixtures and mocks.

Co-authored-by: Cursor <cursoragent@cursor.com>
Combine operations were special-cased as circular tiles that data could
enter from the side, and operation tiles could caption the learned
parameters feeding them. Both made diagrams inconsistent with the rest of
the graph, so operations are now ordinary named rectangles ("Add",
"Multiply") whose inputs always arrive at the top edge.

Dropping the symbolic node labels also removed the layout code that keyed
off them: the MoE router spine no longer matches the first "+" it finds,
which had been selecting an unrelated gate-frame Add and shifting the
sparse MoE section further right on every validation pass.

Co-authored-by: Cursor <cursoragent@cursor.com>
Modeling code binds its activation through a registry lookup
(`self.act_fn = ACT2FN[config.hidden_act]`) rather than constructing it, and
the init parser only derived class names from constructor calls. The
attribute therefore had no class at all, so Qwen3 drew a tile labelled
"act fn" and its dense MLP dropped the activation entirely. The checkpoint
config already names the activation, so the lookup now resolves through it.

A registry lookup is also the fallback arm of a config switch whose other
arm constructs a real module, as in Kimi's SituAndMul, so it never displaces
a constructed class. Because a registered activation is pointwise and
carries no gating, it no longer counts as a composite child that would stop
a forward from contributing its own tensor math.

Co-authored-by: Cursor <cursoragent@cursor.com>
Side-fed modules that expand into visible steps attached every extra call
argument to the frame head, so DeepSeek's MoE drew the router's gating
weights as a second input to the expert's first Linear. Operations now
record which forward parameter they read, letting each feed dock on the
step that actually consumes it, with a new route for entering a frame
member below the head.

Merge buses were also inferred from coincident coordinates, which turned
TopK's two independent operands into a shared trunk. Bus membership now
requires the link to own that source/target bus, and same-side merge legs
get channels reserved from the graph before routing.

Co-authored-by: Cursor <cursoragent@cursor.com>
…r real source

Connector anchors were widened to the enclosing inline frame, so the router placed
entry and exit ports across the frame rather than the tile. Ports landed in empty
space beside a tile and drew as connectors joined to nothing, which is what made the
MiniMax dense MLP and experts blocks look half-wired. An anchor is now the tile as
drawn; a frame is an obstacle, not a docking surface.

Operand resolution also missed calls that become their own node, so an operation
reading such a result resolved no source and fell back to a link from the previous
step in source order -- a dataflow edge the forward never performs. The indexer's
MatMul now reads the rope call it actually consumes instead of the pad amount, and
an operation whose operands come from a forward parameter no longer chains to the
step above it. Unanchored branches rank from their consumer so they sit beside the
step that reads them instead of floating up beside the input.

Finally, a submodule invoked on the untouched forward input now branches at the
input instead of running in series behind the previous call. The MoE block's experts
read the block input rather than the shared expert's output, and both paths reach
the output, which is the combine the forward performs.

Co-authored-by: Cursor <cursoragent@cursor.com>
…t_tracediff

The export gains a fact-sheet builder and merge wires it into the viewer bundle.

test_tracediff.py had merge conflict markers committed into it, so the module did
not parse at all. Both sides of every marker block were empty, so removing the
markers restores the file without choosing between them or losing anything.

Co-authored-by: Cursor <cursoragent@cursor.com>
Regenerate the bundled graph after the single-block fact sheet change, and
check in the CLI export output plus the Kimi operator introspection artifact.

Co-authored-by: Cursor <cursoragent@cursor.com>
Fix L2Norm q/k hidden-state inputs and kernel operand edges, use readable purple tiles, add the fact-sheet panel, rename the CLI entry point, and refresh the Kimi-K3 export JSON.

Co-authored-by: Cursor <cursoragent@cursor.com>
…odes.

Replace per-kernel label tables with generic AST-derived naming, wire multi-port pipeline inputs through labeled edges and inputsMetadata, and default the viewer to show port_label on edges.

Co-authored-by: Cursor <cursoragent@cursor.com>
gabeweisz and others added 30 commits September 11, 2026 12:21
…ibling chain)

Closes the two false cycles the vision-tower fidelity work exposed, keeping the
export acyclic while removing spurious RMSNorm input edges.

merge.py (_inject_group_outputs): a nested boundary (loop body whose @input
nodes live at an ancestor id-prefix) could derive an @output id already owned by
an ancestor section's real boundary. The viewer fuses same-id nodes, merging
their edge sets into a false cycle. Disambiguate the derived id by namespace
only when it is already taken by a node in another namespace (prefix != namespace
is pervasive/normal, so the guard is collision-based, not mismatch-based).

computation_graph.py:
- Scope a module's first-op resolution by node identity, not shared attr_name,
  so section-1b boundary args land on the right instance. Same source line
  (Glm5NextRMSNorm) is reused by post_layernorm and every block norm; the flat
  attr_last_index kept only the last, dumping a block's boundary args onto
  post_layernorm's Cast (5 inputs) and the decoder kv_a_layernorm Cast (2).
  Both now collect exactly one.
- Skip section-1b side-arg edges onto a module's first op when that op provably
  does not read the arg (_first_op_entry_params): the block-head norm takes only
  hidden_states; binding cu_seqlens/position_embeddings there both misrepresents
  the norm and, since the kernel producing cu_seqlens runs later, closed a cycle.
- Feed each inlined submodule from its real predecessor (_submodule_chain_input)
  instead of the previous sibling. Parallel siblings that share an upstream
  (attention q_norm/k_norm both read the qkv unbind) were chained sequentially,
  manufacturing a spurious q_norm->k_norm edge that, with the correct
  k_norm<-unbind edge, closed a cycle.

Export is acyclic (0 non-trivial SCCs, no duplicate ids); post_layernorm,
kv_a_layernorm and each vision norm Cast have one input; merger proj has one.
Full suite: 1018 passed, 3 skipped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The vision tower conflated its patch axis with the LLM sequence, stamping
the text B*S / B*S/4 symbols and text hidden 4096 onto every vision
activation. Introduce an "active activation geometry" on ShapeInferencer:
while a vision-scoped section is inferred, the (B,S,H) fallback stamps and
the -1 reshape flatten switch to the single patch axis (Pv,) + vision
hidden 1024. Seed the tower @input with [Pv, C*T*P*P] so inner boundaries
inherit the vision axis by conservation; the text/decoder path is
untouched (active geometry defaults to (B,S) + text hidden).

Result: patch-embed [Pv,1176]->[Pv,3,2,14,14]->Conv3d [Pv,1024,1,1,1]->
[Pv,1024]; merger @output [Pv/4,4096]; zero B*S in the visual namespace;
combine node stays [B,S,4096]. Graph remains acyclic.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Issue C — restore the rotary path: propagate param aliases through
tuple/name unpacks (cos, sin = position_embeddings) so _param_refs
recognises the unpacked names and apply_rotary_pos_emb is no longer
dropped.

Issue B — vision @input / patch-embed hidden_states now [Pv, 1176]:
guard infer_model_graph's subgraph merge so a parent-seeded concrete
vision spec is not clobbered by a child's root-less default, and stamp
the synthetic @vision_input boundary node with the real patch-flat shape.

Issue E — a collapsed module's output_shape line shows the slot name
only when the namespace has more than one output port (two-pass,
arity-based group_boundary_shapes), not for single-output modules.

Issue A — enforce concrete-vs-concrete edge equality: after endpoint
reconciliation, fail the export when both ends of a wire have
fully-concrete but disagreeing shapes, naming both nodes/ports/shapes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…A–G)

Improve the fidelity of the Model Explorer graph export so every computation is
visible and correctly shaped, with no cyclic graph. All fixes are general graph
machinery, not model-specific hacks.

- A: enforce concrete-vs-concrete equality on both ends of a wire; a real dim
  mismatch now fails the export instead of shipping silently.
- B: vision @input / patch-embed hidden_states resolve to the raw flat patch
  dim [Pv,1176] instead of a clobbered root-less default.
- C: restore the rotary path — tuple-unpacked `cos, sin = position_embeddings`
  aliases now propagate so the op is no longer dropped.
- D: multi-output split/unbind. Tuple-unpacked `.split([4,4,16])` / `.unbind(0)`
  fan out into one output-port node per named slice, each carrying its real
  slice width (`[B,S,4]/[B,S,4]/[B,S,16]`). A multi-output op whose sole operand
  is a module parameter (`self.base.split(...)`, `self.scale.unbind(0)`) is no
  longer false-chained to the previous sibling, and its slices size from the
  parameter shape.
- E: a collapsed module shows an output slot name only when it has >1 output.
- F/G: expert loop flattens into Glm5NextTextMoE as a single count-labeled
  `Loop_288_iterations` block with one carried in/out pair; the spurious
  duplicate MoE-level loop and its mis-resolved carried shape are gone. All
  loops use the uniform `Loop_{N}_iterations` convention.

Adds a loop-carried-pair well-formedness regression test and updates the
expert-loop namespace assertions for the MoE flatten.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ging path

The --backend option (and its torch/ast split) was removed when the AST
pipeline became the sole graph builder; the README still documented a
torch default backend and non-existent --seq-len/--batch-size flags.

- Replace the two-backends framing with the actual static-analysis +
  meta-device-shape pipeline; document the two-stage model->JSON->HTML flow.
- Refresh the options table: drop --backend, add --from-payload,
  --no-inline-expansion, --meta-shapes/--no-meta-shapes, --torch-module,
  --input-shape; fix the stale '(ast only)' note on --github.
- Add a 'Debugging: standalone nn.Module introspection' subsection covering
  --torch-module (torch_introspect), kept intentionally as a debugging aid
  separate from the HF checkpoint pipeline.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A forward that dispatches attention through ALL_ATTENTION_FUNCTIONS[config._attn_implementation]
leaves the AST holding only the local variable name (attention_interface), and
attention_kernel_details() then suppresses that synthetic name so the exported
node shows no kernel at all. GLM-5.3-Flash ships _attn_implementation unset, so
the vision (and full text) attention rendered with no indication of which kernel runs.

Default the resolved implementation to "sdpa" (the transformers default when the
checkpoint leaves _attn_implementation unset) so a dispatched-attention step names
the kernel that actually runs. Surface it as a dedicated attn_implementation node
attr on AttentionOp nodes, in addition to the existing details line.

General: applies to any dispatched-attention model; custom kernels (e.g. GLM's
recurrent_kimi_delta_attention linear attention) are untouched since only steps
whose kernel is a dispatch variable get resolved.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…fect 2)

Glm5NextVisionAttention.forward branches on
`if is_flash_attention_requested(self.config):` between a fused flash kernel
and a per-chunk torch.cat fallback. is_flash_attention_requested is opaque to
_config_value, so the extractor walked BOTH branches: the export then carried a
duplicated attention kernel plus a phantom Concat from the branch that never
runs, an output reshape with two inputs, and a dangling Concat self-loop.

Resolve the predicate from the checkpoint config in the forward extractor. A
new _resolve_flash_request_predicate reads config._attn_implementation
(defaulting to the transformers "sdpa" when unset), maps it against the set of
flash implementation names, and honours a leading `not`. The undecidable-if
then collapses to the branch that actually runs (sdpa -> not flash -> the
per-chunk fallback), so exactly one attention kernel and one real Concat remain
and the output reshape reads a single producer.

Thread the class config through _forward_operations_from_forward into the
extractor at the three visit_ClassDef call sites via _config_for_class.

General: keys off the shared transformers predicate name
(is_flash_attention_requested), not any model or class, and falls back to the
existing _config_value path for every other conditional.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…sion defect 1)

A forward that unpacks `q, k, v = self.qkv(h)...unbind(0)` and then feeds two
submodule calls off distinct slots — `self.q_norm(query_states)` (ordinal 0),
`self.k_norm(key_states)` (ordinal 1) — lost the consumed output ordinal on the
module-call wiring path. The inline-op path already threads it
(operation_predecessor_ports), but forward_step_predecessors carried only the
producer, so both norms docked onto slot 0: key_states never appeared as its own
port and k_norm read query_states.

Capture the consumed output ordinal alongside the arg->producer map in the
forward extractor (step_predecessor_ordinals), reusing the existing
_read_output_ports slot resolver so it fires for any unpacked
unbind/split/chunk local, not any particular class. Thread it through
ForwardAnalysis -> ClassStructure -> BlockNode
(forward_step_predecessor_ordinals) and stamp
graph.link_output_ports in computation_graph section 1b, mirroring the inline-op
ordinal handling in section 1. The producer then fans out into one named
@split_out port per consumed slot.

Result for GLM-5.3-Flash vision attention: three distinct query_states /
key_states / value_states ports off the qkv unbind, q_norm->query_states,
k_norm->key_states, value_states on its own slot, no q_norm->k_norm edge.

General: keys off the already-recorded var_output_ordinal (Split/Chunk/Unbind),
threaded through the one wiring path that dropped it; no class-name checks.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ion defect 3)

Glm5NextVisionMLP computes gate = gate_proj(hidden_state) and
up = up_proj(hidden_state) — two leaf linears in parallel off the same block
input (clamp-based swiglu). _add_linear_pipeline_chain feeds each leaf step from
the previous sibling's tail by default, so up_proj rendered as up_proj <-
gate_proj: two linears in a row, misrepresenting the swiglu as a chain.

Consult the AST-recorded predecessor for leaf steps too. Generalize
_submodule_chain_input (previously composite-only, and it skipped the forward
input) to map a recorded FORWARD_METHOD_INPUT predecessor to the chain input
index when the caller supplies one, and call it from the leaf branch. up_proj
records reading @method_input, so it now sources the block input; a named-sibling
predecessor still resolves to that sibling; everything else falls back to the
previous-sibling chain, so genuine sequential pipelines are untouched. The
composite call site passes no chain_input_index, so its behavior is unchanged.

General: prefers the AST-recorded true predecessor over source-order position;
independent of fused-silu vs clamp-swiglu and leaves _situ_gated_mlp untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…roundwork)

Vision export dropped every module-level free-function call
(get_vision_position_ids, get_vision_attention_seqlens, apply_mask_to_
padding_states). Their results defaulted to whatever fed the call, so the
rotary/seqlen side-paths dangled or mis-docked to hidden_states.

- ast_analyze: emit unrecognized free-function calls as @fn_* synthetic
  nodes that bind their own output producer (skip_free_fn gates them off
  inside conditionals so a dropped attention branch can't resurrect dead
  ops); seed a secondary forward input as the method boundary only when a
  traced free-function actually consumes it, and only outside conditionals.
- block_tree: render @fn_* synthetics as FunctionOp leaf nodes.
- tests: absorb the resulting seq-index / split-ordinal shifts and assert a
  computation path (not a direct edge) where a newly-visible op now sits
  between the block input and the forget gate.

Fixes Bug 1 (get_vision_position_ids now feeds rotary_pos_emb). Bug 2
(get_vision_attention_seqlens) now renders as a node; its tuple-output
ordinals and role classification are addressed in follow-ups.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A dispatched-attention module declares packed-attention metadata by keyword
(cu_seqlens, max_seqlen) that only the flash code path reads; the default
sdpa/eager path ignores some of it. Once is_flash_attention_requested resolves
to the non-flash branch, such a parameter is a genuine part of the module's
interface yet dead in *this* implementation.

Rather than fabricate a live data edge for it, classify forward-signature
params against the resolved vs dropped branches: params referenced only in a
dropped branch (max_seqlen under sdpa) are removed from the wired kernel inputs
and surfaced as an `unused_interface_inputs` detail, while params the surviving
branch consumes (cu_seqlens) stay declared kernel inputs. General: keys off the
shared transformers flash-request predicate and the forward signature, no model
or parameter names baked in.

Also: give traced free-function synthetics (@fn_*) a neutral role so an
incidental "attention" token in the callee name no longer misclassifies them as
attention kernels (which the graph would fold away), and pass their recorded
output names through to their leaf nodes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The block-loop attention receives cu_seqlens by keyword from
get_vision_attention_seqlens(), which runs before the loop. That producer
(@fn_l1840) was emitted but stripped as a dangling leaf: section-1b's
module-call wiring skipped the cu_seqlens edge because the kernel had no
dedicated param entry, so the guard dumped nothing onto the block head norm
(correctly avoiding a cycle) and left the producer unconsumed.

Fix, in two general pieces:

- block_tree: a boundary attention input (empty provenance chain, e.g.
  cu_seqlens) becomes a param_inputs entry on the @attention kernel node.
  _build_module_param_entries then registers it, giving the caller's
  predecessor edge a dedicated docking point on the kernel instead of the
  head norm — so the edge survives the guard and the producer stays live.

- merge: name a group's input boundary after the @kernel_port_in label of
  the port it feeds, so the crossing renders as ``cu_seqlens`` rather than a
  generic ``hidden_states_2`` fallback.

Result: fn_l1840 -> @input_mirror:cu_seqlens -> @input:cu_seqlens ->
@kernel_in:cu_seqlens -> @attention, acyclic. max_seqlen (impl-dead) stays
correctly unwired and flagged. Full suite green (1027 passed, 3 skipped).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… loop (Bug 2C / C4c)

The vision rotary path severed at the block boundary: apply_rotary_pos_emb_vision
reads `cos, sin = position_embeddings`, but the positional synthetic declared no
forward-param input, so its operands defaulted to hidden_states and the pre-loop
`rotary_pos_emb` producer was orphaned.

Add a general `step_boundary_params` mechanism: an own-step synthetic records the
forward parameters it reads straight from the module boundary, resolving unpacked
locals (`cos`/`sin`) back to their caller-visible origin (`position_embeddings`)
via a param-alias origin map. The positional leaf then carries those as
`param_inputs`, so the cross-module predecessor pass docks the caller's producer
onto the deep consumer — the same routing `cu_seqlens` uses to reach the kernel.

Also fix an over-broad shape heuristic in `fill_missing_node_shapes`: the
`"embedding"` substring matched the `position_embeddings` boundary/mirror and
stamped it with the language (B, S, H) activation shape. A connected `@input`/
`@input_mirror` now stays pending so edge-propagation inherits the vision
producer's real (Pv, ...) shape instead.

Result: get_vision_position_ids -> rotary_pos_emb -> position_embeddings mirror
-> @input:position_embeddings -> apply_rotary, acyclic, carrying [Pv, 1176].

Adds test_glm53_vision_rotary_position_embeddings_wired_across_loop. Full suite
1028 passed, 3 skipped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
An inline op whose only operands are module parameters/buffers reads no
chain producer, so it rendered with no incoming edge and its values
appeared to come from nowhere. The canonical case is the mHC mapping's
`pre_b, post_b, comb_b = self.base.split(...)` and
`pre_scale, post_scale, comb_scale = self.scale.unbind(0)`: a Split/Unbind
over a raw learned parameter.

The AST already records the parameter as an `external_input`;
`_add_module_parameter_inputs` now materializes it as a local
`@tensor:external:<param>` operand node docked into the consuming op, so
the computation shows where its data originates. General and additive:
gated to ops that are otherwise sourceless (no incoming edge), so chain
steps that read a weight *and* continue the spine (`x = x * self.weight`)
are untouched — matching the guardrail in `_reads_only_a_side_parameter`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A tuple-unpacked split/chunk/unbind (mHC ``pre_w, post_w, comb_w =
F.linear(...).split([4, 4, 16])`` and vision ``q, k, v = qkv(h)...unbind(0)``)
previously fanned out into synthetic ``@split_out:`` port NODES — one per slice.
Those tiles rendered as gray "operation: unknown" boxes because ``@split_port_out``
was not a classifiable synthetic, even though they were correctly wired.

Per the owner's decision, drop the synthetic tiles entirely and give the split node
itself one real named output port per unpacked slice. Consumers were already wired to
the right ordinal (``link_output_ports`` from the AST unpack), so each ``Multiply`` /
``Add`` / ``View`` now reads the split's ordinal directly and shows its own operation.
Each port carries its per-slice shape — the edge feeding ``view`` reads ``[B, S, 16]``,
not the undivided ``[B, S, 24]``.

Mechanism (general, keyed off ``block.output_names``; no class-name checks):
- computation_graph.py: remove ``_add_split_output_port_nodes`` (and its
  ``@split_port_out`` constant) — the split node keeps its ordinal-threaded edges.
- shape_inference.py: ``infer_model_graph`` publishes one slice spec per ordinal under
  a reserved ``f"{id}{PORT_SPEC_SEP}{ordinal}"`` key (via ``_multi_output_slice_shape``);
  drop the dead ``@split_port_out`` inference branch.
- adapter.py: emit an ``output_names`` attr on multi-output nodes.
- shapes.py: ``annotate_nodes_with_shapes`` stamps per-ordinal ``outputsMetadata``
  (label + slice shape) when reserved port specs are present.

Full suite: 1029 passed, 3 skipped; export acyclic. Payload drops ~67 dummy tiles.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Constants and learned weights are never drawn: F.linear shows its
activation input and hides self.weight. Two mHC mapping ops slipped past
that rule because the weight was their only operand, fanned out:
  pre_b, post_b, comb_b = self.base.split(...)
  pre_scale, post_scale, comb_scale = self.scale.unbind(0)
self.base and self.scale are nn.Parameter (confirmed via ast.unparse of
the analyzed Glm5NextTextHyperConnection.__init__), so no activation flows
through these ops -- they are constants and must not appear.

Replace _add_module_parameter_inputs (which docked a visible
@tensor:external:<param> operand for such ops) with _prune_weight_only_ops:
roots are sourceless multi-output forward ops reading only a self.<attr>
external (mirroring the conservative multi-output guard in
_reads_only_a_side_parameter); weight-only-ness then propagates to any op
every operand of which is itself weight-only (e.g. comb_b.view(hc, hc)).
Pruned via the existing _prune_computation_nodes. Consumers that mix the
result with a real activation (pre_w * pre_scale + pre_b) stay and simply
lose the hidden operand edge.

Payload 982->952 nodes; 0 @tensor:external operands remain; acyclic.
Full suite 1029 passed, 3 skipped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ask #34)

A checkpoint records the transformers version it was exported with in
config.transformers_version. When that differs from the version installed
alongside TraceLens, the installed modeling file can be the wrong revision
for the checkpoint. Prefer the upstream modeling file at the declared
release tag; fall back to the installed file (then the pinned SHA) when no
version is declared, it matches installed, or the tag is not published.

source.py: new _installed_transformers_version, _fetch_versioned_transformers_file
(lru_cached incl. the negative result so a missing tag does not re-hit the
network on every load_model_spec), and _transformers_versioned_modeling_file;
wired into resolve_source_files ahead of the installed-file lookup for
transformers-native models. Tries both vX.Y.Z and bare X.Y.Z tag spellings.

For zai-org/GLM-5.3-Flash (declares 5.16.0, installed 5.16.1, and the 5.16.0
tag is absent upstream) this falls back to the installed file -> no graph or
op-id change. Adds coverage in test_source_kernel_coverage.py.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…iring (Task #33 defect A)

Expand the traced free-function apply_rotary_pos_emb_vision (and nested
rotate_half) into a visible op sub-pipeline in the Glm5NextVisionAttention
frame, and wire its cos/sin inputs to the correct position_embeddings tuple
ports.

Consumer side: the position_embeddings boundary now fans out one port per
tuple slot (cos = port 0, sin = port 1) to its two consuming unsqueeze ops,
via a new ordinal-aware module-param entry lookup in computation_graph
(_build_module_param_ordinal_entries / _lookup_ordinal_entries), replacing the
single-entry setdefault that dropped ordinals for tuple params.

Producer side: when several return slots trace to the same producer op
(cos, sin = self.recomposition_frequencies(...)), _inject_group_outputs now
disambiguates by source output ordinal so the rotary boundary emits distinct
@output:cos / @output:sin nodes instead of colliding both onto one id/label
(a hard Model Explorer validity bug). _resolve_slot_names_for_prefix returns
an ordered per-producer slot list; the consumer tolerates a scalar mapping too.

Verified on zai-org/GLM-5.3-Flash: 971 nodes, 0 duplicate ids, export acyclic.
Full suite green (1032 passed, 3 skipped). Updated vision-rotary topology
tests and added a shared-producer ordinal-slot coverage test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…il edge (Task #33 defect A)

A consumer reading a specific return slot of an inline-expanded tuple-returning
free function must dock onto that slot's internal producer, not the frame's last
op. ``q_embed, k_embed = apply_rotary_pos_emb_vision(...)``: q_embed is the
ordinal-0 internal cast, k_embed the frame's last op. The source-order chain in
_add_chain fed the q-path transpose from the frame tail (k_embed), so it read
both slots and q_embed was orphaned.

Thread the helper's own return_order/return_slots (ordinal -> internal producer
attr) as forward_step_return_producers through ClassStructure and BlockNode, and
in the section-1 predecessor wiring redirect the consumer to the matching
per-ordinal producer, removing the stale frame-tail chain edge. General: derived
from the helper's return metadata, no class-name checks.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Glm5NextTextLinearAttention calls q_proj/k_proj/v_proj/b_proj as parallel
projections on the shared apply_mask_to_padding_states(hidden_states, ...)
result. The SeqSegment sequential-source fallback in build_computation_graph
chained consecutive submodule calls in source order, fabricating a spurious
q_proj -> k_proj edge and rendering a "Linear" node with two tensor inputs.

Guard the fallback so a submodule call with AST-recorded predecessors
(forward_step_predecessors) is left to section-1b predecessor wiring rather
than spine-chained onto the previous call. Parallel siblings that read the
block input keep their single real producer; only genuinely sequential steps
still fall back to source order. General across models -- honors the recorded
true predecessors instead of "previous call in source order".

Verified: q/k/v/b_proj each in=1 from the masked-input op, no sibling edges;
graph-wide zero submodule-call Linears with >=2 inputs; export acyclic;
full suite 1034 passed / 3 skipped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
An expandable module (collapsed namespace) surfaces its boundary shapes as
layer attributes so edges ending on the group still read a shape. Those
attributes come from group_boundary_shapes, which reads each @output boundary
port's `shape` metadata field. annotate_nodes_with_shapes rebuilds that port
metadata from the producer feeding the port, but built the `shape` value with
format_shape (dims only) while every other shape-application path
(_apply_shape_attrs, _apply_multi_port_shape_attrs) uses format_shape_with_dtype.
Result: 15 collapsed text/vision modules (input_layernorm, LinearAttention, MoE,
MLP, post_attention_layernorm, visual) showed output shape without its type.

Use format_shape_with_dtype for the boundary port `shape` too, matching the
other paths. All 134 module boundary shape attrs now carry dtype (was 119/15);
full suite 1035 passed / 3 skipped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
F.linear(input, weight) records both operands, so hyper-connection, MoE-
experts and router linears each carried a second tensor input -- the learned
weight (a self.fn.float() cast, a per-expert gate_up_proj gather, a
self.weight.type(float32) cast). Like the absorbed weight of an nn.Linear
submodule, that operand must not be drawn: the owner's rule is a Linear has a
single input, the activation.

Add _prune_linear_weight_operands after _prune_weight_only_ops: for each
forward _linear op, drop the edge from any operation_predecessors[1:] producer
that reads a module parameter (non-empty external_inputs), then strip the now-
dangling weight producer. Removing the edge (not bridging across the producer)
keeps a gathered weight's shared routing-index predecessor from being rewired
into the linear.

All 55 Linear nodes in the GLM-5.3-Flash export now render with exactly one
input (was 12 with two); graph stays acyclic; the only remaining non-boundary
leaves are the Task #39 vision unsqueezes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
#39 defect B)

Glm5NextVisionAttention runs the eager/sdpa path as an else-branch
comprehension the provenance walk never descends into, and value_states
never passes through a submodule call. The kernel therefore wired only
query_states/cu_seqlens, dropping key_states/value_states and leaving
their unsqueeze ops as orphan leaves.

Add _ForwardOperationExtractor._reconstruct_attention_step: when
@attention is a forward call with no recorded predecessors, recover them
from the first attention-interface call in source order, resolving each
positional tensor operand through var_producer (which tracks inline ops
and non-module tensors alike) and naming each so the kernel port is
labelled query_states/key_states/value_states. General: no branch or
comprehension shape is assumed.

Kernel now reads query_states/key_states/value_states/cu_seqlens, each
labelled and sourced from its own producer, no orphaned unsqueezes,
acyclic. Full suite green (1037+1).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
#41 D2)

The inline-expanded rope-helper frame took its namespace segment from the raw
synthetic call attr (`@positional_l1615_apply_rotary_pos_emb_vision` ->
`_positional_l1615_apply_rotary_pos_emb_vision`). inline_block_frame_label now
returns the clean source function name for positional/function synthetic frames,
so the frame reads like any module call (`apply_rotary_pos_emb_vision`). Node
ids keep the raw attr, so the rename is id-stable;
_is_free_function_frame_namespace recognizes the renamed segment via the
recovered function name.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…41 D4)

A tensor produced in one child group and consumed in a sibling child under the
same parent rendered as two identically named mirror tiles adjacent in the parent
scope: an `@output_mirror` feeding an input-styled `@input_mirror`. In the MoE,
this left a `topk_weights` input node that went into no block. When an
`@input_mirror`'s incoming edges all come from a single same-namespace
`@output_mirror`, the two describe the same tensor at the same scope:
_collapse_mirror_boundary_passthroughs drops the `@input_mirror` and repoints its
consumers onto the `@output_mirror`. Dataflow is unchanged (experts still gather
topk_weights, one-hot topk_indices); one redundant tile per crossing disappears.
A multi-source tuple fan-in (vision position_embeddings gathering cos+sin) has >1
source and is left intact.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…on (Task #D5)

get_vision_position_ids and get_vision_attention_seqlens run host-side index
building (.tolist()/.item()). Detect this generally by AST-walking the traced
callable for host-materialisation idioms, following cross-file imports (via
importlib find_spec + ast.parse, no execution) and recursing into callees.
Flagged nodes render a device: cpu attr; pure-tensor rope helpers stay clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…vive (Task #42 D1)

Glm5NextVisionRotaryEmbedding.forward calls the same child twice
(cos = self.recomposition_frequencies(cos); sin = self.recomposition_frequencies(sin))
and returns (cos, sin). A submodule call's step key was the bare child attr, so
call #2 overwrote call #1: the cos branch was dead-code-eliminated, the real
Cosine op orphaned, and both return slots aliased onto the single sin producer.

Analysis: give a child that is called more than once in a forward an
@l{lineno} call-site suffix (submodule_callsite_attr / base_submodule_attr),
so the two calls become two distinct steps each keeping its own predecessor and
var_producer. Detection is path-aware: a child called once in each of two
mutually-exclusive if/else arms is NOT repeated, so single-call and
branch-exclusive forwards stay byte-identical.

Wiring: in _wire_all_predecessor_edges pass 1b, resolve each tuple-unpacked
side arg (position_embeddings -> cos/sin) to its own return-slot producer via
_resolve_return_slot_source instead of aliasing every ordinal onto the last
slot's tail. Genuine single-producer tuple ops still split by ordinal port.

Result: distinct live Cosine and Sine ops, two recomposition_frequencies
frames, @output:cos / @output:sin backed by different producers, acyclic.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…(Task #43 D3)

A traced free-function call (a rope helper apply_rotary_pos_emb_vision,
get_vision_position_ids) was special-cased to skip boundary injection: its ops
docked straight onto external producers, so the frame had no @input/@output tiles
and did not read like a module. Owner rule: a free-function call must render
exactly like any other module call, with real boundaries.

Drop the free-function branch from _skip_nested_inline_frame_input (and remove the
now-dead _is_free_function_frame_namespace helper + its unused imports). The tuple
fan-out that motivated the skip (cos/sin position_embeddings[0]/[1]) is preserved
by the same per-ordinal machinery a multi-return module already uses:
_group_entry_buckets keys a bucket by the producer node, so both ordinal ports
land on one @input tile that re-exposes them; _inject_group_outputs emits one
@output per tuple slot. _inject_group_outputs only gives an @output to namespaces
that already have an @input, so output_namespaces stays a subset of
input_namespaces.

Result: the apply_rotary frame renders @input:q / @input:k /
@input:position_embeddings (the tuple stays one logical input, fanning ports
0/1 = cos/sin to the two unsqueezes) and one @output per return slot feeding the
q/k transposes per ordinal; acyclic. Rewrote the two rotary tests that asserted the
absence of a boundary to assert the new module-like boundary, and added
test_glm53_vision_rotary_frame_has_module_like_boundaries.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The rotary block's (cos, sin) tuple arrived at each vision block as the
forward parameter position_embeddings and collapsed onto one @input tile
fanning ports 0/1 (the sin port had no shape, rendering `?`). q_norm and
k_norm likewise showed identical `hidden_states` @input tiles though they
read distinct qkv-unbind slices, and the unbind's per-ordinal port shapes
dropped a dim ([Pv, 1024] -> [1024]).

Prevent the merge at its source rather than merging then splitting:
- merge.py: name a boundary input after the producer slice it reads. A new
  _return_slot_label threads the child's forward_return_slots into
  _inject_group_inputs (symmetric to the output side's
  _resolve_slot_names_for_prefix), so cos/sin stay two tiles named after the
  rotary return slots. _source_slice_label additionally recognises named
  qkv-unbind slices (output_names) and sibling @input/@output slice tiles, so
  nested consumers and q_norm/k_norm boundaries keep the slice name. Fires only
  for genuinely multi-output producers (bounded blast radius).
- shape_inference.py: an unbind slice keeps every non-split axis. The node
  spec is already one materialised slice, so only drop the unbind axis when the
  source still carries it (axis size == output count); otherwise return the
  slice unchanged. Fixes [Pv, 1024] -> [1024].

Result: distinct @input:cos / @input:sin (shaped, no `?`) at every level;
q_norm/k_norm boundaries labelled query_states/key_states; unbind ports report
[Pv, 1024]. Acyclic; full suite 1044 passed + 3 skipped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… shape)

The rotary embedding returns (cos, sin) where each slot traces to a distinct
single-output op (cos, sin = self.recomposition_frequencies(...)). The
ordinal-1 (sin) slot's producer coincides with the frame tail, so the fan-out
tagged its edge with the tuple ordinal "1" -- but that op has only output port
"0". The dangling sourceNodeOutputId left the viewer unable to resolve the sin
shape, rendering it as "?". When slots trace to distinct producers, read port
"0" instead of the ordinal.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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