Build configuration
The ESPER_WITH_* feature macros, what each one selects, and how a build reports what it contains.
This page describes the current SSA builder and backend implementation. Start with the new text walkthrough for esper 1 syntax and
the roadmap for the library-first migration boundary.
Which subsystems a build of Esper contains is a compile-time choice, because a backend nobody wants should not be linked. Which machine a compiled-in backend emits for is a run-time choice, so one binary cross-compiles for every target its backends know about. The two are deliberately not the same knob.
This page covers the compile-time half. For the run-time half, see the target queries at the end.
The macros
Every switch lives at the top of include/esper.h, under
MARK: Feature Selection. Each is resolved there and is always defined
afterwards, always to the literal 0 or 1, so it works both in #if and in
an ordinary expression such as a table initializer.
| Macro | Default | Selects |
|---|---|---|
ESPER_WITH_VM | 1 | The register-file interpreter, and compile-time evaluation with it |
ESPER_WITH_TEXT | 1 | The .esp text format: parser and printer |
ESPER_WITH_C | 1 | Portable C99 source output |
ESPER_WITH_LLVM | 1 | LLVM IR text output |
ESPER_WITH_WASM | 1 | WebAssembly output |
ESPER_WITH_SPIRV | 0 | SPIR-V assembly for Vulkan compute |
ESPER_WITH_GLSL | 0 | GLSL source |
ESPER_WITH_WGSL | 0 | WGSL source for WebGPU |
ESPER_WITH_NATIVE | 1 | The machine-code backend |
ESPER_WITH_TINYASM | autodetected | tinyasm as the machine-code implementation |
ESPER_WITH_TINYLINKER | autodetected | Object writing and linking, via tinylinker |
The shader backends default off because each carries a distinct memory and control-flow model that most embedders never ask for. Everything else with no external dependency defaults on.
Three ways to override:
-DESPER_WITH_LLVM=0 # turn one off despite the default
-DESPER_WITH_SPIRV=1 # turn one on
-DESPER_NO_DEFAULTS # start from nothing and opt in explicitly
ESPER_NO_DEFAULTS zeroes every macro, the two autodetected ones included, even
when tinyasm and tinylinker are on the include path. It sets the base that each
#ifndef falls back to, so an explicit -D still wins over it.
Autodetection
ESPER_WITH_TINYASM and ESPER_WITH_TINYLINKER resolve through
__has_include(<tinyasm.h>) and __has_include(<tinylinker.h>). Angle
brackets, so the answer is a function of the include path and nothing else. The
same source compiled with and without -I../tinyasm/include gets a different
answer, which is the intent: a checkout without the sibling projects still
builds everything else.
Autodetection is resolved in the header rather than deferred, because
__has_include is only legal in a preprocessor directive. That is also why the
macros are always defined to 0 or 1 rather than left undefined.
The two dependencies between them
Two combinations are refused outright, at preprocessing time, on any translation
unit that includes esper.h:
include/esper.h:113:2: error: "ESPER_WITH_TINYASM needs ESPER_WITH_NATIVE: it implements that backend"
include/esper.h:116:2: error: "ESPER_WITH_TINYLINKER needs ESPER_WITH_TINYASM: there is nothing to link without machine code"
These are the only combinations the system rejects. Every other assignment compiles.
What turning one off actually does
This is the part worth being precise about, because it is narrower than the word “compiled out” suggests.
Setting a backend macro to 0 changes two things:
esper_backend_availablereports false for that backend, so it disappears fromesper_backendsand fromesper_target_available’s reckoning.esper_codegenloses its dispatch arm for it, and refuses the call withcodegen.no_backendbefore reaching the switch.
It does not remove the backend’s code. Only src/backends/tinyasm/ is
wholly wrapped in #if ESPER_WITH_TINYASM; the C, LLVM, WebAssembly, SPIR-V,
interpreter and text-format sources carry no feature guard of their own. A
library built with -DESPER_WITH_LLVM=0 still compiles llvm.c, still archives
it, and still exports esper_emit_llvm, which a caller can still invoke
directly. What it refuses is the request routed through esper_codegen:
EsperConfig cfg = {0};
cfg.backend = ESP_BACKEND_LLVM;
esper_codegen(unit, &cfg, stdout);
// codegen.no_backend: backend 'llvm' is not available in this build
The same holds for the interpreter and the text format. ESPER_WITH_VM=0 and
ESPER_WITH_TEXT=0 remove nothing functional: esper_exec, esper_parse and
esper_print are unguarded and keep working. Those two macros only affect what
esper_backends lists and which backend ids esper_codegen accepts.
A build that genuinely needs the code gone has to exclude the source files as well as define the macro. The macros describe what the library offers, not what it contains.
The macros belong to the library, not the consumer
The values that matter are the ones the library was compiled with. A consumer
translation unit compiled with different -D flags sees its own values for the
macros, but esper_backend_available answers from the library’s build. Ask the
function, not the macro, when the question is what this build can do.
What each build refuses
| Macro at 0 | Refused | Still works |
|---|---|---|
VM | esper_codegen with ESP_BACKEND_VM | esper_exec, esper_exec_with |
TEXT | esper_codegen with ESP_BACKEND_TEXT | esper_parse*, esper_print |
C | esper_codegen with ESP_BACKEND_C | esper_emit_c, esper_emit_c_debug |
LLVM | esper_codegen with ESP_BACKEND_LLVM | esper_emit_llvm, esper_emit_llvm_debug |
WASM | esper_codegen with ESP_BACKEND_WASM | esper_emit_wasm |
SPIRV | esper_codegen with ESP_BACKEND_SPIRV | esper_emit_spirv |
NATIVE | esper_codegen with ESP_BACKEND_NATIVE | esper_emit_native directly |
TINYASM | Machine code: esper_emit_native and esper_emit_native_object report codegen.unimplemented | Everything else |
In every row, “refused” means a diagnostic is reported and nothing is written. Nothing traps, and nothing is silently a no-op: emitting nothing would look like an empty program, so the refusal is explicit.
Without tinyasm, esper_emit_native and esper_emit_native_object are replaced
by stubs that say so rather than being left undefined, on the principle that a
consumer compiled against the public header should link either way:
this build emits no machine code for 'arm64-macos': configure it with tinyasm
Backends that exist only as a name
Three of the enumerators do not have a working backend behind them, whatever the macros say.
ESP_BACKEND_WASM and ESP_BACKEND_SPIRV have source files, but both are
placeholders. They emit a comment saying so and, for WebAssembly, an empty
(module). The lowering is not written.
ESP_BACKEND_GLSL and ESP_BACKEND_WGSL have no source at all. There is no
esper_emit_glsl, and esper_codegen has no dispatch arm for either at any
macro setting. Setting ESPER_WITH_GLSL=1 therefore makes the backend claim to
be available and then fail at code generation with codegen.unimplemented,
which is a worse answer than the codegen.no_backend it gives at the default of
0. Leave them off.
The xmake build
xmake.lua does not mirror the feature macros. It defines three options,
none of which is a feature switch:
| Option | Default | Effect |
|---|---|---|
tinyasm | ../tinyasm | Path to a tinyasm checkout |
tinylinker | ../tinylinker | Path to a tinylinker checkout |
cli | true | Whether to build the esper driver |
xmake f --tinyasm=/path/to/tinyasm --tinylinker=/path/to/tinylinker
xmake build
The first two are paths, not booleans. For each, the build checks that the
header and a built libtinyasm.a / libtinylinker.a are present; if so, it adds
src/backends/tinyasm/*.c to the library, puts the sibling’s include/ on the
include path as a public dependency, and links the archive. Putting that header
on the include path is what makes __has_include(<tinyasm.h>) succeed, so the
build system reaches ESPER_WITH_TINYASM only indirectly, by arranging for
autodetection to find it.
tinylinker is consulted only when tinyasm was found, matching the #error
dependency in the header. Both also gate the esper-native-check and
esper-target-check test targets, which have nothing to compare against without
a machine-code backend.
The only add_defines in the file are DEBUG, NDEBUG, and a path string for
one test target. There is no build-system route to ESPER_WITH_C=0 or
ESPER_WITH_SPIRV=1. Those are -D flags on the compiler command line, or
nothing.
Asking a build what it contains
A build reports itself through the queries in esper.h, which is what an
embedder should use rather than reading the macros.
EsperBackend backends[ESP_BACKEND_COUNT];
uint32_t n = esper_backends(backends, ESP_BACKEND_COUNT);
for (uint32_t i = 0; i < n; i++)
printf(" %s", esper_backend_name(backends[i]));
EsperTarget targets[ESP_TARGET_COUNT];
uint32_t m = esper_targets(targets, ESP_TARGET_COUNT);
for (uint32_t i = 0; i < m; i++)
printf(" %s", esper_target_name(targets[i]));
printf("host = %s\n", esper_target_name(esper_target_native()));
On a default build with tinyasm and tinylinker present, that reports:
backends (6): text vm c llvm native wasm
targets (9): x86_64-linux x86_64-macos x86_64-windows arm64-linux arm64-macos
arm64-windows riscv64-linux wasm32-wasi spirv-vulkan
host = arm64-macos
Both listing functions return how many exist rather than how many were written,
so a return greater than max means the array was too small. Pass NULL and
0 to count first.
Targets are not backends
Target enumerators are stable across builds and are never renumbered by the
feature selection. A target this build cannot emit still has its enumerator, and
esper_target_available reports false for it. A caller can therefore ask about a
target without knowing how Esper was built.
esper_target_available is true when any compiled-in backend serves the
target, and the C backend serves every target. That is why the listing above
includes spirv-vulkan on a build with ESPER_WITH_SPIRV=0: no shader backend
is present, but the C backend will still emit for it. A caller that needs a
specific pairing should ask for the pairing:
if (!esper_backend_supports(ESP_BACKEND_NATIVE, ESP_TARGET_WASM32_WASI)) {
// the machine-code backend has no wasm encoder
}
esper_backend_supports answers what a backend can express, independent of
whether it is compiled in. Combine it with esper_backend_available for the
full question. esper_codegen checks both, in that order, and reports
codegen.no_backend or codegen.bad_target accordingly.
Names round-trip: esper_target_from_name accepts what esper_target_name
produces, and esper_target_from_triple accepts an LLVM-style triple, so
aarch64-apple-darwin and Esper’s own arm64-macos reach the same target.
Both return ESP_TARGET_UNKNOWN for a name that matches nothing, including a
real target this build cannot emit, so use esper_target_available to tell
those two cases apart.
Processor features
One more run-time choice sits alongside the target. EsperTargetFeature names
capabilities a target’s processor may or may not have: ESP_FEAT_BITCOUNT,
ESP_FEAT_FLOAT_ROUND, and ESP_FEAT_ATOMIC. An assembler knows whether an
architecture has an encoding for an operation; it cannot know whether the chip
that eventually runs the result implements it.
esper_target_baseline_features gives what the architecture’s own base level
guarantees, deliberately conservative. EsperConfig::features overrides it, and
esper_config_features resolves the two:
EsperConfig cfg = {0};
cfg.target = ESP_TARGET_X86_64_LINUX;
cfg.features = esper_target_baseline_features(cfg.target) | ESP_FEAT_BITCOUNT;
Leaving features zero means the baseline, which runs everywhere. Naming more
gets shorter code on a machine you know. Naming a feature the machine lacks is a
fault at run time with nothing to point at, which is why the default assumes
little.
Layering
Three headers, and only two are installed.
include/esper.h and include/esper_exec.h are the installed interface. They
must not expose the IR’s layout, and must not leak <stdlib.h> or
<string.h> into a consumer. They keep the standard C type names rather than
the short aliases used internally, because defining an unprefixed u32 in
someone else’s namespace would collide with theirs.
src/core/esp_internal.h holds the layout. Only the library’s own sources and
the white-box tests under tests/unit/ may include it. Holding the layout there
is what lets the storage change without breaking a consumer.
tests/api_smoke.c compiles against the installed header alone. If a change
makes it stop compiling, the public API has a hole in it.