jit: daslang -lib emits a C-ABI library - #3927
Draft
aleksisch wants to merge 3 commits into
Draft
Conversation
aleksisch
force-pushed
the
aleksisch/standalone-jit
branch
6 times, most recently
from
September 8, 2026 22:28
80f61f0 to
da90e75
Compare
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
force-pushed
the
aleksisch/standalone-jit
branch
from
September 9, 2026 07:22
da90e75 to
00ace70
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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/scriptnow writes a native library and theC header a host calls it through.
--jit-lib-staticgives an archive instead. Whichfunctions cross is your choice:
[export_c]marks them one at a time, and-lib-export-alloffers every public function of the entry module whose signature Ccan 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/rangefamilies cross as
const T *and return through a trailing out pointer. Everydeclared 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>_createworks on any thread, because daslang'senvironment 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>_destroyemits the program's[finalize]calls itself, because a jittedSimFunction carries no shutdown flag for the runtime's own drain to find.
Where to look:
modules/dasLLVM/daslib/llvm_lib.dasis the entry and thunk emitter,daslib/c_api_header.dasdecides what can cross and writes the header, and the fouremit_standalone_*helpers inllvm_exe.dasare the exe startup both artifacts nowshare. 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]slotaligns to 1 while the body stores through it at the structure's real alignment.