Skip to content

Add a FurutaSwingupJuliaC analysis: deploy the swing-up controller as a trimmed JuliaC binary - #8

Open
baggepinnen wants to merge 2 commits into
mainfrom
juliac-export-analysis
Open

Add a FurutaSwingupJuliaC analysis: deploy the swing-up controller as a trimmed JuliaC binary#8
baggepinnen wants to merge 2 commits into
mainfrom
juliac-export-analysis

Conversation

@baggepinnen

@baggepinnen baggepinnen commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator

Adds a third target next to running a program in this process and exporting it as C: JuliaC compiles it into a standalone executable with no Julia installation behind it. FurutaSwingupJuliaC is the analysis; export_swingup_juliac the plain entry point.

(Rebased onto the consolidated program layer — the earlier version of this branch predated it and is preserved in the juliac-v1-backup tag. The port turned out smaller than what it replaced: resolve_tunables and JuliaComputing/SynchToolkit.jl#150's generated constructors do what it used to do by hand, and since the node does its own I/O and logging, the application is only a timing loop.)

Shape

compile_program_source is compile_program's sibling — both go through the new program_signature, so the model and node signature are built once — and returns the generated declarations as Julia source instead of evaluating them. That is what --trim needs: the node must be defined in a package, so precompiling it compiles the node and serializes the executable into the package image (JuliaComputing/SynchJulia.jl#203). A module eval'd into SynchToolkit at runtime has no image to be serialized into.

export_program_juliac writes that package and builds it, mirroring export_program_c:

<output_dir>/<app>/Project.toml       # + SynchJulia's dynamic_execution = false
                  src/controller.jl   # the code-generated node and its parameter structs
                  src/hardware_ffi.jl # the operators the node calls, over the copied csrc/
                  src/<app>.jl        # baked-in parameters, the timing loop, @main
                  csrc/, deps/        # the C the node calls into, built next to the app
<output_dir>/bundle/bin/<app>         # 3.3 MB standalone binary

The application is a Julia transcription of csrc/run_hardware.c and reads none of the node's outputs, for the same reason that file does not: the program logs what it wants logged. Like the C export it carries the csrc/ implementations along and names them by absolute path, so an application belongs to the directory it was written into — deploying elsewhere means running the analysis there, which is what the C target's make amounts to too.

Verification

37 assertions, including the gated end-to-end path: --trim=safe builds with zero verifier errors, no clang* anywhere in the bundle (as JuliaComputing/SynchJulia.jl's own test/caching/trim.jl checks), and the binary starts, resolves its own libraries and reports the device it cannot open — this host has no HIL SDK, so qube_hw_open fails by design. The remaining step is a run on the rig.

Two things worth knowing

Operator references had to be localized. The generated node refers to our I/O operators through spliced function objects, which print qualified (QuanserComponents.hw_measure); emitted verbatim the application would depend on this package and pull the modelling stack into the binary. They are rewritten to bare names and defined locally as ccalls over the copied csrc/. Documented at length in a comment below, since the mapping table it needs looks derivable rather than independent.

step!/reset! need two upstream pieces, both now available: SynchJulia ≥ 0.4.3 (JuliaComputing/SynchJulia.jl#226) and its dynamic_execution = false preference, which the emitted Project.toml sets — legitimate here precisely because the executable is built during precompilation and no definition can change afterwards. Without them the trim verifier rejects six call sites; this was JuliaComputing/SynchJulia.jl#211.

Analysis structure

A separate analysis rather than another target on FurutaSwingupExperiment, but it extends FurutaSwingupBase, so the parameter set every hardware run shares is declared once; what it adds is app_name, julia_channel, trim, build. The fan-out in analysis_base.jl routes the generated entry point to its own spec and run_analysis. The C and deploy parameters come along with the shared root and have no effect here, which the partial's docstring says.

Environment note

This needs SynchToolkit from git main, not registry 0.4.9: main's own generate_swingup_controller fails on 0.4.9 with Untranslatable/unhandled term encountered: ModelingToolkitBase.SampleTime(nothing), which JuliaComputing/SynchToolkit.jl#172 fixed and no release carries yet. (Both copies can sit in the depot at once, so pathof(QuanserComponents.SynchToolkit) is worth checking when that error appears.) Unrelated: the current dyad CLI wants to drop a module qualification in generated/QubePendulum_definition.jl (MultibodyComponents.RootedFrame.FrameA()RootedFrame.FrameA()); that churn is left out of this branch.

🤖 Generated with Claude Code

https://claude.ai/code/session_01PA77Eh76Yyro9eZXN9JuR1

baggepinnen and others added 2 commits August 7, 2026 09:39
A third target next to running in this process and exporting C: JuliaC compiles
a program into a standalone executable with no Julia installation behind it.

`compile_program_source` is `compile_program`'s sibling — both go through the new
`program_signature`, so the model and the node signature are built once — and
returns the generated declarations as Julia source instead of evaluating them.
That is what `--trim` needs: the node has to be *defined in a package*, so that
precompiling it compiles the node and serializes the executable into the package
image (JuliaComputing/SynchJulia.jl#203). A module `eval`'d into SynchToolkit at
runtime has no image to be serialized into.

`export_program_juliac` writes that package and builds it. It mirrors
`export_program_c` closely, because the deployed thing is the same: the node does
its own I/O and its own logging, so the application around it is only timing —
a Julia transcription of csrc/run_hardware.c, reading none of the node's outputs.
Like the C export it carries the `csrc/` implementations along, builds them into
its own `deps/`, and names them by absolute path, so an application belongs to
the directory it was written into.

Two things had to be handled that the C path does not have to:

- The generated node refers to this library's operators through spliced function
  objects, which print qualified (`QuanserComponents.hw_measure`). Emitted
  verbatim, the application would depend on this package and pull the whole
  modelling stack into the binary. `compile_program_source` rewrites them to bare
  names and reports which it rewrote; `emit_program_ffi` then defines exactly
  those as `ccall`s over the copied `csrc/`. Base functions the node also calls
  print unqualified and resolve anywhere, hence a parent-module test rather than
  a name list.
- `step!`/`reset!` are only statically resolvable with SynchJulia >= 0.4.3
  (JuliaComputing/SynchJulia.jl#226) and its `dynamic_execution = false`
  preference, which the emitted Project.toml sets: the executable is built during
  precompilation and no definition can change afterwards, which is exactly the
  condition that preference documents.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PA77Eh76Yyro9eZXN9JuR1
The swing-up controller deployed as a statically compiled binary: same program,
same designed gains and same log as `FurutaSwingupExperiment`, compiled by JuliaC
instead of `make`. `export_swingup_juliac` is the plain entry point,
`FurutaSwingupJuliaC` the analysis, with the emitted application, the JuliaC
build log and the hardware run log as its artifacts.

It is a separate analysis rather than another target on the existing one, but it
still extends `FurutaSwingupBase`, so the parameter set every hardware run shares
is declared once: what it adds is where the application goes and how it is
compiled (`app_name`, `julia_channel`, `trim`, `build`). The fan-out in
analysis_base.jl routes the generated entry point to its spec, which is what keeps
one typed `run_analysis` per analysis; `app_name` and friends are what identify
it there. The C and deploy parameters come along with the shared root and have no
effect here, which the partial's docstring says.

The test suite covers the emission unconditionally — including that the node's
operator references really are local to the application, since a qualified one
would quietly pull the modelling stack into the binary — and gates the build on
`juliac_available()`: where Julia 1.13 and JuliaC are installed it builds with
`--trim=safe`, checks the log for verifier errors, checks no C toolchain leaked
into the bundle, and runs the binary. Verified here: 37 assertions, zero verifier
errors, a 3.3 MB binary that starts, resolves its own libraries and reports the
device this host has no SDK for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PA77Eh76Yyro9eZXN9JuR1
@baggepinnen
baggepinnen force-pushed the juliac-export-analysis branch from 20f0173 to 99a3683 Compare August 7, 2026 09:40
@baggepinnen

Copy link
Copy Markdown
Collaborator Author

The generated node refers to our operators qualified, and what this branch does about it

Worth recording, because it is the one thing in this branch that is a workaround rather than a
design, and it looks automatable.

What happens

compile_program_source gets the same declarations SynchToolkit would have eval'd, and writes
them into the application package as source. The node body, though, does not name our I/O
operators — codegen splices the function objects themselves into the expression tree, and
printing one qualifies it:

julia> node = last(gen.decls);
julia> # every leaf that is one of ours, by type:
       typeof(QuanserComponents.hw_measure)
       typeof(QuanserComponents.hw_write)
       typeof(QuanserComponents.log_row)      # …and hw_shoulder, hw_elbow, hw_time,
                                              #    hw_dt, hw_exec, hw_count_shoulder,
                                              #    hw_count_elbow

So string-ing the tree yields QuanserComponents.hw_measure(…) inside the emitted
controller.jl. That is fine for a module eval'd back into this session, and wrong for a
package meant to stand alone: the application would have to depend on QuanserComponents, which
means the entire modelling stack (MTK, Symbolics, the Dyad libraries) enters an artefact whose
whole point is to contain nothing but the controller. Under --trim it is not even a size
question — the dependency simply defeats the exercise.

Note this is invisible on the C path: export_c emits extern double qube_hw_measure(double);
plus a named call site, so the C target has always resolved these by symbol name and never had
to care where the Julia function lived.

The fix here

Two halves, both in this branch:

  • _localize_operators (src/program.jl) walks the declarations and rewrites any leaf whose
    parentmodule is QuanserComponents to its bare nameof, collecting the names as it goes.
    The parent-module test rather than a name list is deliberate: the node also calls sin,
    clamp, mod and friends, which print unqualified and resolve in any module, so a
    name-driven rewrite would have to enumerate ours and stay in sync with them. GlobalRefs are
    handled too, in case a later SynchToolkit emits those instead of function objects.
  • emit_program_ffi (src/juliac.jl) then defines exactly the collected names in the emitted
    package, as one-line ccalls into the app's own copies of csrc/qube_hw.c and
    csrc/qube_log.c. The mapping from operator name to C symbol and arity is the OPERATOR_FFI
    table; an operator the node calls that is missing from it is an error at export time rather
    than an undefined name at the app's precompilation.

Net effect: the emitted application depends on SynchJulia, SynchCompiler, StaticArrays and
FunctionWrappers, and calls the same C the other two targets call. The test suite asserts both
directions — no QuanserComponents.hw_*/log_* survives in controller.jl, and every operator
the node calls is defined in hardware_ffi.jl.

Why it wants automating

OPERATOR_FFI is a third place the operator set is written down, after the @register_symbolic
declarations in src/hardware_io.jl / src/data_log.jl / src/traj_source.jl and the C
prototypes in csrc/*.h. All three already have to agree; this branch adds a fourth
correspondence (Julia operator name → C symbol → library → arity) that is derivable from the
first two rather than independent information. The C target derives its half automatically —
export_c gets the symbol name from the function — so the Julia target should be able to as
well, and the ccall in each operator's body already states the symbol, the library and the
signature we transcribe by hand.

Left as is for now, since it is small, checked by tests, and changes only when an operator is
added.

@baggepinnen baggepinnen changed the title Add a FurutaExportJuliaC analysis: export the swing-up controller as a trimmed JuliaC binary Add a FurutaSwingupJuliaC analysis: deploy the swing-up controller as a trimmed JuliaC binary Aug 7, 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.

1 participant