Aether Architecture
This document provides an overview of Aether's compiler pipeline, runtime design, and key architectural decisions.
System Overview
+--------------------------------------------------------+
| Aether Compiler |
| |
| .ae -> Lexer -> Parser -> ModuleOrch -> TypeCheck -+ |
| | |
| .c <- CodeGen <- Optimizer <--------------+ |
+--------------------------------------------------------+
|
+--------------------------------------------------------+
| GCC/Clang |
| .c source -> native binary |
+--------------------------------------------------------+
|
+--------------------------------------------------------+
| Aether Runtime |
| Scheduler -> Actors -> Memory Pools -> Message Passing|
+--------------------------------------------------------+
Compiler Pipeline
1. Lexer (compiler/parser/lexer.c)
Purpose: Convert source text into tokens
Input: Raw Aether source code (.ae file)
Output: Stream of tokens
Process:
- Read source character by character
- Identify token boundaries (whitespace, operators, keywords)
- Classify tokens (identifier, number, string, keyword, operator)
- Track line and column numbers for error reporting
Example:
x = 42 + y
Produces tokens:
IDENTIFIER("x") EQUALS NUMBER(42) PLUS IDENTIFIER("y")
Key Files:
compiler/parser/lexer.c- Implementationcompiler/parser/tokens.h- Token type definitions
2. Parser (compiler/parser/parser.c)
Purpose: Build Abstract Syntax Tree (AST) from tokens
Input: Token stream from lexer
Output: AST representing program structure
Process:
- Consume tokens in order
- Apply grammar rules (recursive descent parsing)
- Build tree structure with each node representing a language construct
- Report syntax errors with location information
Key Concepts:
- Recursive Descent: Each grammar rule is a function
- Precedence Climbing: Handles operator precedence
- Error Recovery: Attempts to continue parsing after errors
Key Files:
compiler/parser/parser.c- Implementationcompiler/ast.h- AST node definitions
2.5 Module Orchestrator (compiler/aether_module.c)
Purpose: Resolve all imports, parse module files, cache ASTs, detect circular dependencies
Input: AST from parser (scanned for AST_IMPORT_STATEMENT nodes)
Output: Populated global_module_registry with cached module ASTs
Process:
- Scan program AST for import statements
- Resolve each import to a file path (stdlib paths, lib/, src/)
- Parse each module file (lex → parse → AST) with lexer state save/restore
- Cache parsed ASTs in
global_module_registry - Recursively process each module's own imports
- Build dependency graph and detect circular imports via DFS
Key Files:
compiler/aether_module.c- Orchestration, resolution, parsing, dependency graphcompiler/aether_module.h- Module registry, dependency graph types
3. Type Checker (compiler/analysis/typechecker.c)
Purpose: Infer and verify types for local variables
Input: AST from parser
Output: Type-annotated AST
Process:
- Two-pass: first collects declarations, then checks all nodes
- Constraint-based type inference (Hindley-Milner style, max 100 iterations)
- Report type errors with location information
Key Algorithms:
- Type Inference: Automatic type deduction for local variables
- Two-Pass Checking: Declarations collected before use-sites are checked
Key Files:
compiler/analysis/typechecker.c- Type checking (two-pass)compiler/analysis/type_inference.c- Constraint-based inference
4. Optimizer (compiler/codegen/optimizer.c)
Purpose: Apply AST-level optimizations before code generation
Input: Type-checked AST
Output: Optimized AST
Optimizations (always on):
- Constant Folding,
2 + 3 * 4→14at compile time - Dead Code Elimination,
if false { ... }block removed - Tail Call Detection, identifies recursive tail calls (detection only; loop transformation is pending)
Optimizations (applied during codegen, compiler/codegen/codegen_stmt.c):
- Arithmetic Series Collapse,
while i < n { acc += C; i++ }→ O(1) closed form - Linear Counter Sum,
while i < n { total += i; i++ }→ triangular-number formula
Key Files:
compiler/codegen/optimizer.c- AST-level passescompiler/codegen/codegen_stmt.c- Series and linear loop collapse
5. Code Generator (compiler/codegen/codegen.c)
Purpose: Generate C code from AST
Input: Optimized AST
Output: C source code (.c file)
Process:
- Walk AST in depth-first order
- Generate equivalent C code for each node
- Emit actor runtime calls for actor constructs
- Generate computed goto dispatch tables for message handlers
- Detect single-int messages and emit inline fast paths
Key Features:
- Computed goto dispatch for message handlers
- Inline single-int message encoding (bypasses pool allocation)
scheduler_send_local/scheduler_send_remoterouting based oncurrent_core_id- Locality-aware actor placement via
scheduler_spawn_actor(caller's core, or core 0 for main thread) - NUMA-aware actor allocation with derived-struct size
Key Files:
compiler/codegen/codegen.c- Entry point and includescompiler/codegen/codegen_expr.c- Expression generationcompiler/codegen/codegen_stmt.c- Statement generation and loop optimizationscompiler/codegen/codegen_actor.c- Actor dispatch tablescompiler/codegen/codegen_func.c- Function definitionscompiler/codegen/codegen.h- API
Deterministic output:
The same compiler build given the same source emits byte-identical C, every time. Two invariants make this true, and both are load-bearing:
- Emission order tracks AST/source order. No codegen pass (or analysis pass that feeds codegen) iterates a hash-keyed container to emit declarations, helpers, string literals, or dedup tables. If a future pass needs keyed lookup, it must still emit in source order.
- No volatile metadata reaches emitted text. The compiler bakes no timestamps, hostnames, or absolute build paths into generated C. The version string flows through the build-generated
runtime/aether_version.h(numeric components only), not through codegen.
Scope boundary: the guarantee is per compiler build. A different aetherc build (new version, different optimization passes) may emit different C; the downstream C compiler's binary output is out of scope.
The property is enforced in CI by tests/integration/emit_c_determinism/test_emit_c_determinism.sh, which compiles a corpus twice and byte-compares the results, and rejects timestamp macros outright.
Runtime Architecture
Actor System
Design: Lightweight process model with per-core scheduling
Key Components:
- Actor Structure (
runtime/scheduler/multicore_scheduler.h) ``c typedef struct { atomic_int active; int id; Mailbox mailbox; void (*step)(void*); pthread_t thread; int auto_process; atomic_int assigned_core; atomic_int migrate_to; // Affinity hint: core to migrate to (-1 = none) atomic_int main_thread_only; // If set, scheduler threads skip this actor SPSCQueue* spsc_queue; // Lock-free same-core messaging (lazy alloc) _Atomic(ActorReplySlot*) reply_slot; // Non-NULL during ask/reply atomic_flag step_lock; // Prevents concurrent step() during work-steal } ActorBase;`` - Message Structure (
runtime/actors/actor_state_machine.h) ``c typedef struct { int type; int sender_id; intptr_t payload_int; void* payload_ptr; struct { void* data; int size; int owned; } zerocopy; } Message;`` - Message Queue (
runtime/scheduler/lockfree_queue.h)- Lock-free SPSC ring buffer with cache-line aligned head/tail
- Batch enqueue and dequeue to reduce atomic operations
- Power-of-2 sizing with bitwise AND masking
- Message Dispatch (generated code)
- Computed goto dispatch table for direct label jumps
- Message ID read from
msg.type(not pointer dereference)
Scheduler
Design: Partitioned multicore scheduler with work stealing and message-driven migration
Architecture:
+---------------------------------------------------------+
| Partitioned Scheduler |
| +------------+------------+------------+------------+ |
| | Core 0 | Core 1 | Core 2 | Core 3 | |
| | [A1, A2] | [A3, A4] | [A5, A6] | [A7] | |
| | (local) | (local) | (local) | (idle) | |
| | | | <- steal --------+ | |
| +------------+------------+------------+------------+ |
+---------------------------------------------------------+
Locality-Aware Placement:
- Actors placed on the caller's core at spawn time
- Main thread spawns default to core 0, keeping top-level actor groups co-located
- Actors spawned from within actor handlers inherit the parent's core
- Each core processes only its local actors in the fast path
- Same-core messages delivered directly to mailbox (no queue overhead)
- Cross-core messages enqueued to target core's lock-free incoming queue
Message-Driven Migration:
- Cross-core sender sets
migrate_tohint on target actor, requesting migration to the sender's core - Owning scheduler checks migration hints after coalesce-buffer processing (targeted, O(batch_size)) and during idle actor scans (full, O(actor_count))
- Actor relocated using ascending core-id lock ordering with non-blocking try-lock
- Migrated-actor messages forwarded with spin-retry to prevent drops
Work Stealing (idle cores):
- Core becomes idle after extended empty cycles
- Scans all cores to find busiest (most pending work)
- Non-blocking try-lock attempt using ascending core-id ordering
- Steals one actor if victim has more than 4 actors
- Stolen actor reassigned to idle core
NUMA-Aware Allocation:
- Detects NUMA topology at scheduler initialization
- Allocates actor structures on the NUMA node local to the assigned core
- Falls back to standard malloc on single-node systems
- Platform support: Linux (libnuma), Windows (VirtualAllocExNuma), macOS (fallback)
Key Files:
runtime/scheduler/multicore_scheduler.c- Multi-core implementationruntime/scheduler/aether_scheduler_coop.c- Cooperative single-threaded backendruntime/scheduler/multicore_scheduler.h- API and data structures (shared by both)runtime/scheduler/lockfree_queue.h- Cross-core message queueruntime/aether_numa.c- NUMA detection and allocation
Cooperative Scheduler (Threadless Platforms)
For platforms without pthreads (WebAssembly, embedded, bare-metal), the cooperative scheduler (aether_scheduler_coop.c) implements the same API as the multi-core scheduler but runs everything on a single thread.
Architecture:
+-----------------------------------+
| Cooperative Scheduler |
| +---------+---------+---------+ |
| | Actor 1 | Actor 2 | Actor 3 | |
| | (poll) | (poll) | (poll) | |
| +---------+---------+---------+ |
| All on core 0, round-robin poll |
+-----------------------------------+
Key Differences from Multi-Core:
scheduler_init()forcesnum_cores = 1andmain_thread_mode = truescheduler_start()/scheduler_stop()are no-ops (no threads to manage)scheduler_wait()drains viaaether_scheduler_poll()loop- All sends are local (no cross-core queues, no migration)
- Ask/reply is synchronous (poll until reply_ready)
aether_send_message_sync()heap-allocates message data (no zero-copy stack optimization, mailbox FIFO order means the sent message may not be the next one consumed)
Selection: The Makefile selects the scheduler based on PLATFORM or auto-detects AETHER_NO_THREADING in EXTRA_CFLAGS. Only one scheduler .c file is compiled into the binary. Users can also use ae build --target wasm for Emscripten builds.
Actor Timeouts
Actors with receive { ... } after N -> { ... } carry timeout_ns and last_activity_ns fields in ActorBase. The generated step function checks _aether_clock_ns() before mailbox_receive(). If the idle duration exceeds the threshold, the timeout handler fires (one-shot, cancelled when any message arrives). Both schedulers poll timeout-active actors even when their mailbox is empty.
Cooperative Preemption (Opt-In)
Two independent mechanisms prevent long-running handlers from starving other actors:
- Scheduler-side (
AETHER_PREEMPT=1): Time-based drain loop break after configurable threshold (default 1ms) - Codegen-side (
aetherc --preempt):sched_yield()inserted at loop back-edges with reduction counter (every 10000 iterations)
Both are zero-cost when disabled. This matches Go's cooperative preemption model (pre-1.14).
Platform Detection (Tier 0)
runtime/config/aether_optimization_config.h defines compile-time AETHER_HAS_* flags that gate all platform-dependent code. These are resolved before any other header is included.
| Flag | Default 1 when | Default 0 when |
|---|---|---|
AETHER_HAS_THREADS | Linux/macOS/Windows | __EMSCRIPTEN__, -DAETHER_NO_THREADING |
AETHER_HAS_ATOMICS | C11 stdatomic available | __STDC_NO_ATOMICS__, -DAETHER_NO_ATOMICS |
AETHER_HAS_FILESYSTEM | Hosted platforms | -DAETHER_NO_FILESYSTEM |
AETHER_HAS_NETWORKING | Linux/macOS/Windows | __EMSCRIPTEN__, -DAETHER_NO_NETWORKING |
AETHER_HAS_NUMA | Linux/Windows | macOS, __EMSCRIPTEN__, -DAETHER_NO_NUMA |
AETHER_HAS_SIMD | x86_64/aarch64 | -DAETHER_NO_SIMD, __EMSCRIPTEN__ |
AETHER_HAS_AFFINITY | Linux/macOS/Windows | -DAETHER_NO_AFFINITY, __EMSCRIPTEN__ |
AETHER_HAS_GETENV | Hosted platforms | -DAETHER_NO_GETENV, freestanding |
AETHER_HAS_MALLOC | Everywhere | -DAETHER_NO_MALLOC |
When AETHER_HAS_ATOMICS == 0, <stdatomic.h> is replaced with fallback typedefs (atomic_int → volatile int). When AETHER_HAS_THREADS == 0, aether_thread.h provides no-op pthread stubs via macro redirects.
Memory Management
Message Payloads:
- Thread-local pool of 256 pre-allocated buffers (256 bytes each)
- Pool acquisition and release use plain loads/stores (no atomics for TLS)
- Falls back to malloc for oversized messages or pool exhaustion
aether_free_messagereturns buffers to the pool or callsfree
Actor Allocation:
scheduler_spawn_actorallocates viaaether_numa_allocwith the full derived-struct size- NUMA-aware placement on the local node of the assigned core
- Falls back to standard allocation on non-NUMA systems
String Ownership (heap-string tracker, issue #405):
- Every string variable carries a compiler-emitted
_heap_<name>companion that flips between 0 (literal) and 1 (heap-allocated) on each reassignment - Trackers are emitted at function-entry scope by a dedicated codegen pre-pass (
hoist_heap_string_trackersincompiler/codegen/codegen_stmt.c), making cross-block reassignment safe - Reassignment goes through a unified wrapper handling all four heap/literal transitions:
{ const char* _tmp_old = s; s = <rhs>; if (_heap_s) free(_tmp_old); _heap_s = <rhs_is_heap>; } - Recognised heap sources: hardcoded stdlib
string.{concat,substring,to_upper,to_lower,trim}, string interpolation"foo ${x}", and user-defined-> stringfunctions whose body provably returns heap (recursive structural escape analysis with cycle detection, memoised on the function-definition AST node's annotation slot) - Runtime cost: zero, just
if (...) free(...)per assignment. Codegen-time cost: O(K) per call site for fn-def lookup where K is the number of top-level fn definitions; total well under a millisecond for typical programs
Performance Optimizations
The runtime implements these optimization strategies in the active code paths:
- Main Thread Actor Mode - Single-actor programs bypass scheduler entirely
- Thread-Local Message Pools - Eliminate malloc/free overhead
- Batch Dequeue - Reduce atomic operations from N to 1 per batch
- Adaptive Batching - Scale batch size with load (64 to 1024)
- Direct Mailbox Delivery - Same-core messages bypass the queue
- Computed Goto Dispatch - Direct label jumps for message handlers
- Inline Single-Int Messages - Bypass pool allocation for common messages
- Message-Driven Migration - Co-locate communicating actors
- Optimized Spinlock - PAUSE/YIELD hints during spin-wait
- Cache Line Alignment - Prevent false sharing on shared structures
- Relaxed Atomic Ordering - Avoid barriers on non-critical counters
See runtime-optimizations.md for implementation details.
Module System
Design: Static module resolution with circular import detection
Resolution Process:
- Parse import statement, e.g.
import std.collections. - Search paths:
std/collections/module.aefirst, then./collections/module.aerelative to the importer, thencontrib/<name>/module.aefor contrib modules. - Load and parse the module if it isn't already loaded.
- Build the dependency graph and detect circular imports via DFS with visited tracking.
- Merge the module's exported functions and constants into the program AST with namespace-prefixed names (
module.func→module_funcinternally). - Emit C declarations/definitions for the merged symbols at codegen.
Key Files:
compiler/aether_module.c- Module resolutioncompiler/aether_module.h- Module API
Design Decisions
Why Compile to C?
Advantages:
- Leverage mature C compilers (GCC, Clang)
- Access to optimizations (inlining, loop unrolling, auto-vectorization)
- Portable across platforms
- Direct FFI with C libraries
Disadvantages:
- Two-stage compilation adds latency
- Debugging requires looking at generated C
- Some optimizations harder to implement at the source level
Why Work Stealing?
Advantages:
- Automatic load balancing for uneven workloads
- Cache-friendly for local work
- Non-blocking: failed steals do not block progress
Alternatives Considered:
- Round-robin: poor load balancing
- Per-core task queue: no load balancing
- Global queue: high contention
Why Lock-Free Queues?
Advantages:
- No mutex contention in the message-passing hot path
- Scales with core count
- Batch operations amortize atomic overhead
Disadvantages:
- Complexity (memory ordering, ABA considerations)
- Requires careful attention to cache line alignment
Trade-Offs
Compilation Speed vs Runtime Performance
- Choice: Favor runtime performance
- Implication: Two-stage compilation with aggressive optimization flags
- Mitigation: Incremental builds, parallel compilation
Memory Safety vs Performance
- Choice: Arena-based memory management (bulk deallocation, no tracing GC)
- Implication: Deterministic cleanup, no GC pauses
- Mitigation: Valgrind, AddressSanitizer for leak detection during development
Portability vs Optimization
- Choice: Portable C11 code with platform-specific branches
- Implication: Platform-specific intrinsics guarded by
#ifdirectives - Mitigation: Fallback paths for all platform-specific code
References
- Type Inference: Pierce & Turner (2000)
- Michael-Scott Queue: Michael & Scott (1996)
- Work Stealing: Blumofe & Leiserson (1999)
- Actor Model: Hewitt, Bishop, Steiger (1973)
- Arena Allocation: Hanson (1990)