Skip to content

feat: lay large graphs out on the Java ELK server - #99

Merged
endrix merged 3 commits into
mainfrom
feat/elk-server
Oct 24, 2026
Merged

endrix merged 3 commits into
mainfrom
feat/elk-server

Conversation

@endrix

@endrix endrix commented Oct 23, 2026 •

Copy link
Copy Markdown
Owner

Summary

elkjs runs ELK, compiled to JavaScript, on the extension host's thread: while a large diagram lays out, the whole host waits. This lets a product lay large graphs out on the Java ELK server mlir-viewer already uses, in its own process.

Measured with the platform's own layout engine on NDF documents generated at size (warm JVM, identical ELK input to both engines):

Clauses Layout Diagram Free Ports Free Ports, default preset kept
200 0.67 → 0.27 s 1.09 → 0.41 s 2.1 → 0.28 s
500 1.20 → 0.36 s 2.06 → 0.62 s 10.2 → 1.2 s
1000 2.07 → 0.58 s 3.55 → 1.03 s 61 → 6.4 s
2000 2.83 → 0.95 s 8.2 → 2.6 s (fast preset forced)

On real HDL netlists (endrix/vhdl-ide#1, endrix/sv-ide#1, which turn this on there), Layout Diagram:

Netlist Nodes / edges elkjs ELK server
Dynamatic admm (VHDL) 471 / 1687 3.7 s 0.84 s
Dynamatic kernel_3mm_float (VHDL) 642 / 2266 4.4 s 0.92 s
Dynamatic lu (VHDL) 875 / 3263 2.7 s 0.89 s
picorv32_ac structure 1324 / 2676 3.1 s 0.86 s
picorv32, synthesis view 1222 / 2307 2.0 s 2.2 s

The picorv32 synthesis netlists are about 10 % slower on the server, inside ELK itself rather than on the socket, which points to the ELK 0.8.1 / 0.9 gap below.

What is in it

  • server/elk-server/, adapted from mlir-viewer's engine:
    • The process. It starts with the first layout that needs it, one per extension host. A free port goes in through ELK_SERVER_PORT, and it's ready once it prints "listening". It restarts with the next layout after dying or running out of heap, and stops when the extension deactivates.
    • The client and the writer that puts the server's answer back into the graph.
  • Framing. The client speaks only the server's framed mode (GZ10, length, gzip body, both ways), over one connection per layout. I reproduced two problems in mlir-viewer's approach against the jar:
    • The plain JSON mode finds the end of a message by counting braces, including inside strings. An id containing } hangs the layout until timeout, and Dialogram's ids carry labels.
    • A framed request whose body isn't gzip gets no answer at all. mlir-viewer's compressed mode sends raw bodies under 64 KB.
  • SwitchingElk. It stands where the elkjs instance stood (ElkFactory), so the engine, the Free Ports probe and every layout command use it unchanged.
    • auto (the default) sends a graph past 150 nodes or 400 edges to the server; that's where the cheaper presets start. Smaller graphs stay on elkjs and lay out exactly as before.
    • elk-server sends everything. elkjs never starts Java.
    • Anything the server can't do (not started, timed out, failed) falls back to elkjs for that layout.
  • Configuration.
    • The product names its jar with DiagramProfile.elkServer: { jar } (and SidecarProfileInput.elkServer).
    • The person chooses in <settingsNamespace>.layout.engine, .javaPath, .javaOpts (default -Xmx2g) and .serverTimeoutMs. These are read when the server starts.
  • The jar lives in packages/diagram-server/resources/elk-server/, with a README covering where it comes from, its SHA-256, licences and protocol notes. It's byte-identical to mlir-viewer's and ascpp-graph-sprotty's (af9cc65). It's a 7.6 MB binary in git, as in mlir-viewer.

Known gap

The jar bundles ELK 0.8.1 and elkjs is 0.9.3, so the two engines can lay the same graph out differently (seen on a small NDF document; the picorv32 pipeline was nearly identical). Under auto only large graphs reach the server, so small diagrams are unaffected. The fix is to rebuild the server on the ELK that elkjs carries, once its sources are located.

Not in this PR: an --engine option for scripts/render-svg. The renderer is in #91, so that option follows once both land.

Test plan

  • npm run typecheck, npm run check:neutrality
  • All four suites (diagram-server 377, diagram-client 262, extension-core 312, sidecar-toolkit 297)
  • elk-server.test.ts (15 tests):
    • The client against a stand-in framed server: braces in ids, the error answer, a timeout, and gzip even for {}.
    • The process with a fake spawn: one start shared by concurrent layouts, a restart after it dies, giving up with no jar or no "listening", staying stopped once disposed.
    • The switch: size rule, small graphs never start Java, large ones go to the server, fallback when it's down or failing.
    • The factory: plain elkjs without a server or with elkjs chosen, the switch otherwise, one shared process per jar and JVM options.
    • The shipped jar through WorkflowElkLayoutEngine: a chain whose ids all contain braces, laid out with elkjs never called. Skipped without Java.
  • profile-elk-server-options.test.ts, plus a sidecar profile pass-through test.
  • Before this PR, a harness swapping only the ELK object rendered the smoke and picorv32 NDF documents through the jar: frames, frame ports and routes all came out correctly.
  • Live in VS Code with ndf-ide (ships the jar and sets elkServer).

https://claude.ai/code/session_01E8WfqKknDNN6xpNVcJL5K8

elkjs runs ELK compiled to JavaScript on the extension host's thread. The
same ELK on a JVM, in its own process, is three times faster on the
platform's own layouts and seven to ten times faster with the default
preset kept on large graphs (NDF documents, warm JVM: Free Ports on 2000
clauses 8.2 s against 2.6 s; the default preset on 1000 clauses 61 s
against 6.4 s), and the host keeps running while it works.

From mlir-viewer's elk-server engine, adapted:

- `server/elk-server/`: the process (started by the first layout that
  needs it, one per extension host, restarted after it dies or runs out of
  heap, stopped with the extension), the client and the answer's writer.
- The client speaks only the server's framed mode (`GZ10`, length, gzip,
  both ways) over one connection per layout. The plain JSON mode mlir-viewer
  uses finds the end of a message by counting braces, string contents
  included, so an id with a `}` hangs it; the platform's ids carry labels.
  A framed request must be gzip however small, or the server never answers.
- `SwitchingElk` stands where elkjs stood (`ElkFactory`): `auto` sends a
  graph past 150 nodes or 400 edges (where the cheaper presets begin) to
  the server and keeps the rest on elkjs, so small diagrams lay out exactly
  as before; anything the server cannot do falls back to elkjs.
- `DiagramProfile.elkServer: { jar }` (and `SidecarProfileInput.elkServer`)
  names the jar a product ships; `<settingsNamespace>.layout.engine`,
  `.javaPath`, `.javaOpts` and `.serverTimeoutMs` are the person's.
- `packages/diagram-server/resources/elk-server/`: the jar (byte-identical
  to mlir-viewer's) and where it comes from. It carries ELK 0.8.1; elkjs is
  0.9.3, so the engines can differ on the same graph until it is rebuilt.

Claude-Session: https://claude.ai/code/session_01E8WfqKknDNN6xpNVcJL5K8
Plain elkjs without a server or with elkjs chosen, the switch otherwise,
and one shared process per jar and JVM options.

Claude-Session: https://claude.ai/code/session_01E8WfqKknDNN6xpNVcJL5K8
Review of #99: the threshold's measurements were described as an NDF
product's documents; they are a producer's document graphs.

Claude-Session: https://claude.ai/code/session_01E8WfqKknDNN6xpNVcJL5K8
@endrix
endrix merged commit b333b08 into main Oct 24, 2026
1 check passed
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.

1 participant