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.

MacroDefaultSelects
ESPER_WITH_VM1The register-file interpreter, and compile-time evaluation with it
ESPER_WITH_TEXT1The .esp text format: parser and printer
ESPER_WITH_C1Portable C99 source output
ESPER_WITH_LLVM1LLVM IR text output
ESPER_WITH_WASM1WebAssembly output
ESPER_WITH_SPIRV0SPIR-V assembly for Vulkan compute
ESPER_WITH_GLSL0GLSL source
ESPER_WITH_WGSL0WGSL source for WebGPU
ESPER_WITH_NATIVE1The machine-code backend
ESPER_WITH_TINYASMautodetectedtinyasm as the machine-code implementation
ESPER_WITH_TINYLINKERautodetectedObject 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:

  1. esper_backend_available reports false for that backend, so it disappears from esper_backends and from esper_target_available’s reckoning.
  2. esper_codegen loses its dispatch arm for it, and refuses the call with codegen.no_backend before 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 0RefusedStill works
VMesper_codegen with ESP_BACKEND_VMesper_exec, esper_exec_with
TEXTesper_codegen with ESP_BACKEND_TEXTesper_parse*, esper_print
Cesper_codegen with ESP_BACKEND_Cesper_emit_c, esper_emit_c_debug
LLVMesper_codegen with ESP_BACKEND_LLVMesper_emit_llvm, esper_emit_llvm_debug
WASMesper_codegen with ESP_BACKEND_WASMesper_emit_wasm
SPIRVesper_codegen with ESP_BACKEND_SPIRVesper_emit_spirv
NATIVEesper_codegen with ESP_BACKEND_NATIVEesper_emit_native directly
TINYASMMachine code: esper_emit_native and esper_emit_native_object report codegen.unimplementedEverything 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:

OptionDefaultEffect
tinyasm../tinyasmPath to a tinyasm checkout
tinylinker../tinylinkerPath to a tinylinker checkout
clitrueWhether 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.