Lockstep is a data-oriented systems programming language for high-throughput, deterministic compute pipelines — bridging the productivity of C and the execution model of GPU compute shaders.
By enforcing Straight-Line SIMD execution and a Static Memory Topology, Lockstep lets the compiler generate machine code built to saturate CPU vector units without branch misprediction or cache contention.
- Data-oriented by design. Programs are modeled as physical circuits (pipelines), not sequences of instructions.
- Zero branching.
if/for/whileare banned inside kernels; branching is replaced by hardware-native masking and stream-splitting. - Predictable performance. No
malloc, no hidden threads, no GC. Memory is a static arena provided by the host. - Deterministic parallelism. Race conditions are impossible by construction:
state updates are isolated to
outstreams or linearaccumulatortypes.
A Lockstep program is a Directed Acyclic Graph (DAG) of compute nodes:
shader— 1-to-1: one input element produces one output element.filter— 1-to-0/1: conditionally passes data downstream.pure— a side-effect-free transform, strictly inlined.pipeline— the "circuit board" binding streams and uniforms to kernels.
Lockstep uses a host-owned static arena; the compiler computes every member's byte offset at compile time.
- SoA by default. Structs are decomposed into parallel primitive arrays (one contiguous array per field) to maximize cache-line and SIMD utilization.
- Saturated writes. Stream indices use saturation arithmetic instead of bounds checks: past capacity, the final element acts as a "trash can" that absorbs writes without corruption or branching.
- Live row counts. A filter keeps a data-dependent number of rows, packed at
the front of its output stream. Each stream whose row count is only known at
run time (a filter's output, and any stage fed only by such streams) has a
uint32_tcount slot atLOCKSTEP_OFFSET_COUNT_<STREAM>, which the tick writes. Later stages and folds run over only those rows. A fold over zero rows yields its operator's identity (avgyields 0).
With if/else banned, conditionals use branchless intrinsics —
step, mix, select, clamp, min, max, abs, sign, smoothstep:
shader ApplyPhysics(in Entity ent, out Entity updated, uniform float dt) {
float fall_vy = ent.vy - (9.81 * dt);
float bounce_vy = -ent.vy * 0.8;
// step returns 1.0 if ent.y <= 0.0, else 0.0
float is_grounded = step(0.0, -ent.y);
// mix(a, b, t) acts as a hardware-level selector
updated.vy = mix(fall_vy, bounce_vy, is_grounded);
updated.y = max(ent.y + (updated.vy * dt), 0.0);
}select(cond, a, b) is a branchless typed mux (a bool condition and matching
branch types), complementing the float-interpolating mix.
Global reductions (total energy, bounds, …) use linear types. An accumulator
must be consumed by a fold, which the compiler lowers into a parallel reduction
tree:
pipeline Simulation {
stream<Entity, 10000> particles;
accumulator<float> energy_sum;
bind {
particles = Calculate(particles, energy_sum);
// fold consumes the linear type and produces a global scalar
uniform float total_e = fold sum(energy_sum);
}
}The semantic validator enforces a strict type system with no implicit coercions.
- Primitives:
int,uint,float,double,bool,string.uinthas unsigned semantics;doubleis 64-bit float. Unknown types produceLCK310. - Composites: struct members may be primitives, previously declared structs,
or array suffixes (
T[4]). Generic-wrapper spellings (vector<float,4>, nestedmatrix<vector<Particle,4>,4>) are accepted for type checking and arena/header layout, but are lowered as opaque pointers in IR — treat them as ABI/layout placeholders, not as kernel value types for arithmetic, field access, or SIMD. Type identity is name-based and exact; field chains (a.b.c) resolve only through concrete struct types. - Coercion: none implicit — no widening/narrowing, no
int⇄floatpromotion. Assignments, initializers,purearguments/returns, and bind arguments require exact type equality. Mixedint/floatarithmetic without a cast is rejected withLCK424. Use an explicit cast when conversion is wanted.
Lockstep targets LLVM IR directly.
- Single-arena ABI. Kernels receive a
struct Lockstep_Arena*and compute byte offsets into it.Lockstep_Tick's arena parameter is markednoalias nocapture(it is the sole pointer parameter and every access is derived from it, so it is provably non-aliasing — arestrict-like guarantee at the ABI boundary). Scoped alias metadata on the individual arena-derived stream/accumulator pointers inside the tick is not yet emitted, so do not assume full intra-loop alias disambiguation (see ROADMAP.md). - SSA locals. Scalar and concrete-struct locals are lowered through SSA-friendly values where possible; arena loads/stores stay byte-addressed for ABI stability.
- Manual SIMD lowering. The fused-vector pass strip-mines contiguous stream elements and emits vector loads, stores, arithmetic, and reductions directly, rather than relying on LLVM auto-vectorization.
- Fast-math reductions. Reduction loops carry
fastflags so LLVM can reassociate into horizontal SIMD shuffles.
The compiler emits a C-compatible header for the host (C/C++, Rust, Zig):
- Allocate a
struct Lockstep_Arena(or an aligned block of at leastLOCKSTEP_ARENA_BYTES). - Prime initial data into the SoA fields at the header's byte offsets.
- Tick by calling
Lockstep_Tick(arena). There is no separateLockstep_BindMemoryentry point. - Read back any folded uniform at its
LOCKSTEP_OFFSET_UNIFORM_<NAME>byte offset. Auniform float total = fold sum(acc);in thebindblock reserves a slot in the arena thatLockstep_Tickwrites the reduced scalar to. For a filtered output stream, read its row count atLOCKSTEP_OFFSET_COUNT_<STREAM>first. Rows past the count, and every row of an intermediate stream that fusion eliminated, are unspecified.
See examples/minimal_host.c for a complete end-to-end host app.
Install in editable mode to get the lockstepc entry point:
pip install -e .
lockstepc program.lock # compile
cat program.lock | lockstepc --dump # read stdin, dump entities as JSON
lockstepc program.lock --format # canonical straight-line formatting
lockstepc program.lock --emit-ir # emit LLVM IR
lockstepc program.lock --emit-header # emit C host header
lockstepc program.lock --simulate # validate wiring/cardinality
lockstepc --version--simulate validates pipeline wiring and cardinality before backend
generation. Provide inputs with --simulate-input path.json:
{
"streams": { "raw_positions": [{"id": 1}, {"id": 2, "_keep": false}] },
"accumulators": { "energy": [0.5, 1.5] }
}Output includes per-route input_count/output_count, updated stream
snapshots, accumulator contents, and folded uniforms. The simulator uses the same
numeric model as compiled code: float is IEEE single precision (every operation
rounds), int is wrapping 32-bit with C (truncating) / and %, and double
is double precision. Folds (sum/avg) run in
deterministic pure-Python mode by default (including mixed int/float/bool
accumulators). An opt-in LLVM-backed reduction runs when LOCKSTEP_SIM_USE_LLVM=1
(or use_llvm_runtime=True); it executes out of process under POSIX resource
limits and reports an explicit error if clang/lli is missing rather than
silently falling back.
from lockstep_compiler import LockstepCompileResult, compile_lockstep
result: LockstepCompileResult = compile_lockstep(source_code, verbose=True)
# result.parse_tree, result.entities, result.diagnosticsEach diagnostic carries severity (info/warning/error), a stable code
(e.g. LCK101), a message, line, column, and an optional hint.
- Non-fatal observations (empty
bindblocks, duplicate declarations, unreachable statements after areturn) are returned inLockstepCompileResult.diagnostics; compilation still succeeds. purereturn rules:LCK413(noreturn),LCK414(multiple returns),LCK415(statements after the first return),LCK418(return type mismatch).- Type mismatches:
LCK412(pure-arg),LCK416(initializer),LCK417(assignment),LCK424(mixedint/floatwithout a cast). - Fatal parse errors raise
LockstepCompileError(.errorsholds parse diagnostics;.diagnosticsmirrors pre-failure context).
Generated headers expose Lockstep_SaturatedWriteIndex(...) and per-stream
LOCKSTEP_CAPACITY_STREAM_<NAME> macros. Define
LOCKSTEP_DEBUG_SATURATED_WRITES to log saturated writes, and override
LOCKSTEP_SATURATED_WRITE_LOG(...) to route them to custom telemetry.
Pinned lockfiles are generated from pyproject.toml with
uv: requirements.lock (runtime),
requirements-test.lock (+ test), requirements-lsp.lock (+ lsp). Install
and refresh with:
python -m pip install --require-hashes -r requirements-test.lock
make lock-deps # regenerate after changing dependenciesCI enforces freshness (make check-lock-deps) and installs with
--require-hashes.
make verify # lint + tests + mypy
make test-cov # tests with a coverage floortests/test_golden_ir.py pins the exact LLVM IR and C header for a curated
corpus (tests/golden/programs/*.lock) covering shaders, folds, filters, and
fused pipelines; any codegen change surfaces as a reviewable diff. Regenerate
intentional changes with LOCKSTEP_UPDATE_GOLDEN=1 pytest tests/test_golden_ir.py
(or python tests/golden/regenerate.py).
tests/test_differential_oracle.py is a differential oracle between the
simulator and compiled code. It generates random valid programs (seeded, in
tests/differential/program_gen.py), compiles each with clang, and runs one
Lockstep_Tick. It then checks every sink-stream row and every folded uniform
against the simulator, at several SIMD widths. The default test run covers 80
seeds. Run a wide sweep, or reproduce a single seed, with:
make oracle ORACLE_SEEDS=0:5000 # needs clang, x86-64 Linux
PYTHONPATH=.:tests python -m differential.oracle 1234 --width 4Known semantic gaps between the two are pinned as strict xfails in that
module (see ROADMAP.md).
Measured results for every harness are published in
benchmarks/RESULTS.md. Absolute numbers are
host-dependent — treat them as regression signals.
| Target | Measures |
|---|---|
make bench |
Frontend (parse + Python simulate) latency; writes benchmark-results.json |
make bench-check |
Frontend results vs. benchmarks/baselines/default.json (10% threshold, advisory) |
make bench-native |
Real throughput of clang -O3 compiled code calling Lockstep_Tick |
make bench-soa |
SoA vs. AoS throughput for the same kernel |
make bench-fusion |
Fused vs. per-stage loop throughput |
make bench-vs-c |
Shipped Lockstep_Tick vs. an idiomatic single-pass hand-written C baseline |
On pull requests, CI gates the deterministic native invariants (arena ABI +
output checksum) against benchmarks/baselines/native.json — a drift is a real
codegen regression. Throughput is host-dependent and reported as an artifact, not
gated. See benchmarks/native/README.md for
details and baseline-update steps.
make generate-parser # emits generated/parser/, committed to the repoCI enforces freshness via make check-generated-parser.
An opt-in LSP server surfaces compiler diagnostics live and provides semantic assistance:
pip install -e .[lsp]
lockstep-lspCapabilities: live diagnostics (textDocument/publishDiagnostics), go-to-
definition for struct members, hover type info (variables, fields, shader/pure
names), and bind-route / symbol autocompletion. The server speaks stdio and
works with standard editor LSP clients.