Skip to content

Commit ccf047f

Browse files
wthollidayclaude
andcommitted
Add freeverb FFI performance benchmark
Generic C harness (benchmark/freeverb_bench.c) that drives lyte_compiler_compile — the same codepath the xcframework uses, which the CLI does not touch. Auto-binds every [f32] slice port and extern fn, times init() once and process() N times (default 10 000). Runner script (benchmark/run-freeverb.sh) builds liblyte.dylib with --features llvm, builds the harness, and reports median compile + exec over multiple runs. Targets tests/cases/freeverb.lyte (no duplication). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 284860b commit ccf047f

2 files changed

Lines changed: 261 additions & 0 deletions

File tree

benchmark/freeverb_bench.c

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
// FFI performance harness for DSP scripts with init/process entry points.
2+
//
3+
// Drives lyte_compiler_compile (the codepath used by the xcframework, which
4+
// the CLI does NOT touch), binds every [f32] slice port to a host buffer,
5+
// times init() once and process() ITERATIONS times. Reports a single line:
6+
//
7+
// compile=<seconds>s exec=<seconds>s
8+
//
9+
// The wrapper benchmark/run-freeverb.sh builds liblyte.dylib + this harness
10+
// and averages over N runs.
11+
//
12+
// The whole .lyte file is added via lyte_compiler_add_prelude so that
13+
// `assume` statements (used to discharge bounds checks on slice ports) are
14+
// accepted, mirroring how the xcframework hosts trusted DSP code.
15+
16+
#include <stdio.h>
17+
#include <stdlib.h>
18+
#include <string.h>
19+
#include <stdint.h>
20+
#include <stdbool.h>
21+
#include <stddef.h>
22+
#include <time.h>
23+
#include "lyte.h"
24+
25+
#ifndef MAX_FRAMES
26+
#define MAX_FRAMES 256
27+
#endif
28+
#define MAX_SLICE_PORTS 64
29+
#define STORAGE_LEN 16
30+
31+
static bool send_stub(void* ctx, void* buf_data, int32_t buf_len) {
32+
(void)ctx; (void)buf_data; (void)buf_len;
33+
return false;
34+
}
35+
36+
static char* slurp(const char* path) {
37+
FILE* f = fopen(path, "rb");
38+
if (!f) { perror(path); return NULL; }
39+
fseek(f, 0, SEEK_END);
40+
long sz = ftell(f);
41+
fseek(f, 0, SEEK_SET);
42+
char* buf = (char*)malloc((size_t)sz + 1);
43+
if (!buf) { fclose(f); return NULL; }
44+
if (fread(buf, 1, (size_t)sz, f) != (size_t)sz) {
45+
perror("fread"); free(buf); fclose(f); return NULL;
46+
}
47+
buf[sz] = 0;
48+
fclose(f);
49+
return buf;
50+
}
51+
52+
static double now_seconds(void) {
53+
struct timespec ts;
54+
clock_gettime(CLOCK_MONOTONIC, &ts);
55+
return (double)ts.tv_sec + (double)ts.tv_nsec * 1e-9;
56+
}
57+
58+
int main(int argc, char** argv) {
59+
if (argc != 2) {
60+
fprintf(stderr, "usage: %s <file.lyte>\n", argv[0]);
61+
return 1;
62+
}
63+
const char* path = argv[1];
64+
65+
long iterations = 10000;
66+
const char* iter_env = getenv("FREEVERB_ITERATIONS");
67+
if (iter_env && *iter_env) {
68+
iterations = strtol(iter_env, NULL, 10);
69+
if (iterations <= 0) iterations = 10000;
70+
}
71+
72+
char* source = slurp(path);
73+
if (!source) return 1;
74+
75+
const char* entry_names[] = {"init", "process"};
76+
LyteCompiler* compiler = lyte_compiler_new(entry_names, 2);
77+
if (!compiler) { fprintf(stderr, "lyte_compiler_new failed\n"); return 1; }
78+
79+
if (!lyte_compiler_add_prelude(compiler, source)) {
80+
fprintf(stderr, "parse error: %s\n", lyte_compiler_get_error(compiler));
81+
return 1;
82+
}
83+
84+
double tc0 = now_seconds();
85+
LyteProgram* program = lyte_compiler_compile(compiler);
86+
double tc1 = now_seconds();
87+
if (!program) {
88+
fprintf(stderr, "compile error: %s\n", lyte_compiler_get_error(compiler));
89+
return 1;
90+
}
91+
92+
size_t globals_size = lyte_program_get_globals_size(program);
93+
uint8_t* globals = lyte_globals_alloc(program);
94+
if (!globals) { fprintf(stderr, "globals alloc failed\n"); return 1; }
95+
96+
static float port_bufs[MAX_SLICE_PORTS][MAX_FRAMES];
97+
static float storage_buf[STORAGE_LEN];
98+
int port_idx = 0;
99+
100+
int32_t frames_value = MAX_FRAMES;
101+
float sample_rate_value = 44100.0f;
102+
103+
size_t n = lyte_program_get_globals_count(program);
104+
for (size_t i = 0; i < n; i++) {
105+
const char* name = lyte_program_get_global_name(program, i);
106+
size_t off = lyte_program_get_global_offset(program, i);
107+
const char* ty = lyte_program_get_global_type(program, i);
108+
bool is_extern = lyte_program_get_global_is_extern(program, i);
109+
110+
if (is_extern) {
111+
// Host scripts in the wild declare extern hooks like `send`. We
112+
// bind a no-op stub for any extern so codegen finds a symbol;
113+
// performance scripts are not expected to actually call them.
114+
lyte_globals_bind_extern(globals, off, (const void*)send_stub, NULL);
115+
continue;
116+
}
117+
if (strcmp(name, "frames") == 0) {
118+
memcpy(globals + off, &frames_value, sizeof(int32_t));
119+
continue;
120+
}
121+
if (strcmp(name, "sampleRate") == 0) {
122+
memcpy(globals + off, &sample_rate_value, sizeof(float));
123+
continue;
124+
}
125+
if (strcmp(ty, "[f32]") == 0) {
126+
if (strcmp(name, "storage") == 0) {
127+
lyte_globals_bind_slice(globals, off, storage_buf, STORAGE_LEN);
128+
} else {
129+
if (port_idx >= MAX_SLICE_PORTS) {
130+
fprintf(stderr, "too many slice ports (>%d)\n", MAX_SLICE_PORTS);
131+
return 1;
132+
}
133+
lyte_globals_bind_slice(globals, off, port_bufs[port_idx++], MAX_FRAMES);
134+
}
135+
}
136+
}
137+
138+
// Drive any host gating ports so process() runs the full DSP body. Some
139+
// scripts early-out when active/CH_ON are zero; populate both if present.
140+
for (size_t i = 0; i < n; i++) {
141+
const char* name = lyte_program_get_global_name(program, i);
142+
if (strcmp(name, "active") == 0 || strcmp(name, "CH_ON") == 0) {
143+
void* slice_ptr;
144+
memcpy(&slice_ptr, globals + lyte_program_get_global_offset(program, i),
145+
sizeof(void*));
146+
if (slice_ptr) ((float*)slice_ptr)[0] = 1.0f;
147+
}
148+
}
149+
150+
if (!lyte_entry_point_call(program, 0, globals)) {
151+
fprintf(stderr, "init failed: %s\n", lyte_compiler_get_error(compiler));
152+
return 1;
153+
}
154+
155+
double t0 = now_seconds();
156+
for (long i = 0; i < iterations; i++) {
157+
if (!lyte_entry_point_call(program, 1, globals)) {
158+
fprintf(stderr, "process failed at iteration %ld: %s\n",
159+
i, lyte_compiler_get_error(compiler));
160+
return 1;
161+
}
162+
}
163+
double t1 = now_seconds();
164+
165+
printf("compile=%.3fs exec=%.3fs (%ld * process() at %d frames, globals=%zu bytes)\n",
166+
tc1 - tc0, t1 - t0, iterations, MAX_FRAMES, globals_size);
167+
168+
lyte_globals_free(globals, globals_size);
169+
lyte_program_free(program);
170+
lyte_compiler_free(compiler);
171+
free(source);
172+
return 0;
173+
}

benchmark/run-freeverb.sh

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
#!/bin/bash
2+
# Freeverb FFI performance benchmark.
3+
#
4+
# Builds liblyte.dylib with --features llvm, builds the FFI harness in
5+
# benchmark/freeverb_bench.c, and runs it against tests/cases/freeverb.lyte
6+
# RUNS times. Reports the median compile + exec time.
7+
#
8+
# This exercises lyte_compiler_compile (the codepath the xcframework hosts
9+
# use) — same path that hid the no_recursion-not-threaded regression. The
10+
# CLI does not touch this codepath, so this benchmark is the smallest thing
11+
# that would have caught that bug.
12+
#
13+
# Usage: ./benchmark/run-freeverb.sh [RUNS] [ITERATIONS]
14+
# RUNS number of harness runs to median over (default 5)
15+
# ITERATIONS process() calls per run (default 10000)
16+
17+
set -euo pipefail
18+
19+
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
20+
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
21+
cd "$REPO_ROOT"
22+
23+
RUNS="${1:-5}"
24+
ITERATIONS="${2:-10000}"
25+
26+
LYTE_FILE="tests/cases/freeverb.lyte"
27+
HARNESS_SRC="benchmark/freeverb_bench.c"
28+
29+
LLVM_PREFIX="$(brew --prefix llvm@18 2>/dev/null || true)"
30+
if [ -z "$LLVM_PREFIX" ] || [ ! -d "$LLVM_PREFIX" ]; then
31+
echo "Error: llvm@18 not found. Install with: brew install llvm@18" >&2
32+
exit 1
33+
fi
34+
export LLVM_SYS_180_PREFIX="$LLVM_PREFIX"
35+
36+
echo "Building liblyte.dylib (release, --features llvm)..."
37+
cargo build --release --features llvm --lib --quiet
38+
39+
# Resolve actual target dir — honors a `.cargo/config.toml` target-dir override.
40+
TARGET_DIR="$(cargo metadata --format-version=1 --no-deps 2>/dev/null \
41+
| sed -n 's/.*"target_directory":"\([^"]*\)".*/\1/p')"
42+
TARGET_DIR="${TARGET_DIR:-./target}"
43+
DYLIB_DIR="$TARGET_DIR/release"
44+
if [ ! -f "$DYLIB_DIR/liblyte.dylib" ]; then
45+
echo "Error: $DYLIB_DIR/liblyte.dylib not found after build" >&2
46+
exit 1
47+
fi
48+
49+
HEADER_DIR="Sources/CLyte/include"
50+
HARNESS_BIN="$DYLIB_DIR/freeverb_bench"
51+
52+
echo "Building FFI harness..."
53+
cc -O2 -Wall -I "$HEADER_DIR" \
54+
"$HARNESS_SRC" \
55+
-L "$DYLIB_DIR" -llyte \
56+
-Wl,-rpath,"$DYLIB_DIR" \
57+
-o "$HARNESS_BIN"
58+
59+
echo "Running freeverb FFI bench: $RUNS runs of $ITERATIONS process() calls"
60+
echo ""
61+
62+
declare -a compile_samples=()
63+
declare -a exec_samples=()
64+
for r in $(seq 1 "$RUNS"); do
65+
out=$(FREEVERB_ITERATIONS="$ITERATIONS" "$HARNESS_BIN" "$LYTE_FILE")
66+
echo " run $r: $out"
67+
c=$(printf '%s\n' "$out" | sed -n 's/.*compile=\([0-9.]*\)s.*/\1/p')
68+
e=$(printf '%s\n' "$out" | sed -n 's/.*exec=\([0-9.]*\)s.*/\1/p')
69+
if [ -z "$c" ] || [ -z "$e" ]; then
70+
echo "Error: failed to parse run $r output" >&2
71+
exit 1
72+
fi
73+
compile_samples+=("$c")
74+
exec_samples+=("$e")
75+
done
76+
77+
# Bash median (RUNS is small, sort is fine).
78+
median() {
79+
local sorted=($(printf '%s\n' "$@" | sort -n))
80+
local n=${#sorted[@]}
81+
printf '%s' "${sorted[$((n / 2))]}"
82+
}
83+
84+
cm=$(median "${compile_samples[@]}")
85+
em=$(median "${exec_samples[@]}")
86+
87+
echo ""
88+
printf 'median compile=%ss median exec=%ss\n' "$cm" "$em"

0 commit comments

Comments
 (0)