Skip to content

jit: daslang -lib emits a C-ABI library - #3927

Draft
aleksisch wants to merge 3 commits into
masterfrom
aleksisch/standalone-jit
Draft

jit: daslang -lib emits a C-ABI library#3927
aleksisch wants to merge 3 commits into
masterfrom
aleksisch/standalone-jit

Conversation

@aleksisch

@aleksisch aleksisch commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator

A host that only needs to call one daslang script had two options, and both asked
too much. The C++ API embeds the compiler. Standalone AOT emits C++ source the host
has to compile itself, and the result is a C++ class. Neither works when the host
is C, or when it wants a plain ABI boundary.

daslang -lib script.das -output build/script now writes a native library and the
C header a host calls it through. --jit-lib-static gives an archive instead. Which
functions cross is your choice: [export_c] marks them one at a time, and
-lib-export-all offers every public function of the entry module whose signature C
can spell, naming the ones it skips. Selection reads one bit, Function.flags.exports,
so the interpreter, AOT and the header emitter cannot drift apart. Scalars, string,
pointers and enums cross by value; structures and the float2..uint4 / range
families cross as const T * and return through a trailing out pointer. Every
declared structure carries a size assert and one offset assert per field, so a host
built for another target fails to compile rather than misreading memory.

A library is called by code that cannot catch anything, which shapes the entry
points. Every thunk runs its body under the context's catch boundary: a das panic
returns zero, leaves the out param untouched, and reports through
<P>_last_error(ctx). <P>_create works on any thread, because daslang's
environment is thread-local while the registration behind it happens once. A second
daslang runtime in one process declines with a null instance instead of aborting.
And <P>_destroy emits the program's [finalize] calls itself, because a jitted
SimFunction carries no shutdown flag for the runtime's own drain to find.

Where to look: modules/dasLLVM/daslib/llvm_lib.das is the entry and thunk emitter,
daslib/c_api_header.das decides what can cross and writes the header, and the four
emit_standalone_* helpers in llvm_exe.das are the exe startup both artifacts now
share. The riskiest spot is the thunk ABI: a structure result is built in an alloca
of its own alignment, never in a frame field, because an in-struct [N x i8] slot
aligns to 1 while the body stores through it at the structure's real alignment.

@aleksisch
aleksisch force-pushed the aleksisch/standalone-jit branch 6 times, most recently from 80f61f0 to da90e75 Compare September 8, 2026 22:28
A standalone context spoke C++ only, through methods on its generated class, and
its header was written in the same interleaved pass as its source - two nested
build_string blocks, every emitter writing into both writers, nothing able to
produce a header on its own. A C host had nothing to include.

One module writes the header now, and the C API is the only calling path:

    daslib/c_api_header.das   the whole generated header, C first
    CppApi                    the C++ half, appended under #ifdef __cplusplus

Each C++ function is an `inline` call to the C entry point declared above it, so
a C host and a C++ host include the same file, neither sees the other's half, and
one implementation carries both. That is also why the module needs no aot_cpp: a
proxy only ever spells a signature C can spell, and for those the C++ form is
mechanical - a struct or enum by its das name, everything else by its C one.
aot_standalone emits the matching extern "C" bodies from the same describer, so
the header and the source cannot disagree about what crosses.

An export was reachable two ways - as a method, and as the AOT function under it
taking a Context *. The method form is gone, so a host has one calling shape, and
it is the one the C half already uses: the context is a parameter, never a
receiver. Names carry a das_ prefix, and that is load-bearing rather than
decoration - class scope had been hiding a collision, since 06_full_runtime
exports http_status while its header also declares dasHV's enum class
http_status, and C++ has no separate tag namespace.
set_standalone_function_prefix changes it for an emission.

Selection is one bit, Function.flags.exports, which [export] and the new
[export_c] both set - so the C header takes what the C++ one takes, which is what
makes the proxy total. An export C cannot spell is skipped with a warning naming
the type; one that ASKED for C with [export_c] and cannot cross is an error. Its
name = "..." override renames both APIs at once, so a host calls one name.

Two things link found rather than reading: the extern "C" block sits inside
namespace das, because describeCppType spells a type relative to it and C linkage
survives a namespace anyway; and the C surface spells an enum as its base integer
and an opaque pointee as void *, which C++ converts back from neither on its own -
so every such argument is cast at the boundary.

CTypeSet.structs is borrowed from the program, so it is @do_not_delete - the set's
teardown was freeing the AST's own Structures.
`daslang -aot script.das out.cpp -aot-macros` core dumped whenever the script's
module graph carried a dasbind [extern], which covers every dasLLVM binding and
any user's own:

    Should be unreachable. We handled it in transformCall. Failed on: c_strlen.

-aot-macros sets export_all beside the aot_module it already sets, which takes
the force-marking branch in parseDaScript: every function of every module is
marked used and removeUnusedSymbols is skipped. That reaches the [extern]
declarations, which have no body of their own, and simulating one hit a
DAS_FATAL_ERROR.

The annotation claimed a stronger invariant than it needs. transformCall
rewrites every CALL to a bound extern, which is what matters; the function
itself being simulated is harmless, because nothing calls it. The base
FunctionAnnotation::simulate already returns nullptr, so the override - and the
35 commented-out lines under it - just go.
The JIT could already produce an exe and an AOT object; a host that only calls
one script had neither. -lib writes a native shared library (or a static archive
under --jit-lib-static) plus a C header, and links no daslang API into the host.

Nothing about the C surface is new here: daslib/c_api_header already decides what
may cross and writes the header for a standalone context, so -lib reuses that
describer whole and only emits the other side. A JIT library and an AOT
standalone context therefore declare the same C API, name the same functions and
skip the same signatures, and the C++ half a standalone context appends stays a
proxy over C entry points either backend can supply.

Which functions cross is one bit, Function.flags.exports, read three ways. The
default accepts only what carries [export_c]; --jit-lib-export-marked accepts the
bit however it was set, so a script already marking its host API with [export]
needs no second annotation; -lib-export-all is that same acceptance plus
policies.export_public_functions, which has to be a policy because MarkSymbolUse
consumes it before infer, where no option can reach. Only export-all may skip an
unspellable signature with a warning - the other two name a function on purpose.

[export_c] reaches a library source with no require because daslib/export_c is
!inscope and daslib/just_in_time requires it, so -lib, -jit and -exe all carry
the annotation; a compile with no JIT - the linter, the AOT pass - asks for it by
name.

Codegen stays in LlvmJitMode.EXE, which is what makes the exe startup shareable:
this hoists it into emitters -exe and -lib both call, and only the entry shape and
the artifact differ. Each export gets an extern "C" thunk that packs its arguments
into a frame and hands it to a trampoline through jit_lib_invoke_guarded, so a das
panic reaches the caller as a zero return with its text in <P>_last_error, never as
an unwind through the C boundary. A structure result is built in an alloca of its
own alignment, not in a frame field: an in-struct [N x i8] slot aligns to 1, and
the body then stores through it at the structure's real alignment. Symbols carry
the prefix of the OUTPUT name, so they always match the header they are declared
in. The context reaches a library's init as a parameter rather than as the create
call's result, so the sealed name lookups are adopted at the entry block's head -
still before every registration call.

Three facts about a library's runtime shape the entry points, and
ARCHITECTURE.md#lib-runtime-scope records them. The environment is thread-local
while the registration behind it happens once, so every later thread binds the
first one's environment. One daslang runtime fits in a process and every artifact
shares it: registrations go through jit_register_module_once, and a library that
finds a populated environment is a guest - it neither re-initializes the runtime
nor drains what it did not create, so a library loads into a daslang host and
beside another library. And nothing finds a jitted SimFunction's shutdown
functions, so <P>_destroy emits them itself.

-lib carries no CodeOfPolicies field of its own: llvm_jit declares jit_lib and
jit_lib_export_marked as module options, run_jit reads them off prog._options the
way it already reads jit_split_modules, and main.cpp pushes jit_lib between
compile and simulate.

examples/c_api_library builds one library each way and drives all three at once
through dasbind, which is also the coexistence proof. Its host.c includes nothing
but the generated header and links nothing but the library.
tests/jit_tests/jit_lib runs that example, so the -jit sweep covers it.
@aleksisch
aleksisch force-pushed the aleksisch/standalone-jit branch from da90e75 to 00ace70 Compare September 9, 2026 07:22
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