Skip to content

Commit 0a254f1

Browse files
authored
Merge pull request #19 from audulus/f64-double-window
Add a full f64 "double window" to the Stack VM
2 parents 858926a + 0a96627 commit 0a254f1

15 files changed

Lines changed: 1796 additions & 150 deletions

docs/FP_CODEGEN_PLAN.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -487,6 +487,19 @@ supports `f64` but the hot-path benchmarks don't exercise it). Option:
487487
Recommendation: **Option B** for now. Add f64 F variants later if real
488488
f64 workloads emerge.
489489

490+
> **Resolved: Option A was implemented.** A real f64 DSP workload emerged
491+
> (`benchmark/biquad_f64.lyte`), and on the int path it ran ~2.9× slower
492+
> than the f32 biquad — every op paid a GPR↔FP crossing and nothing fused.
493+
> f64 now gets a full parallel `double` window (`d0..d3` + `dfsp`), the
494+
> exact analogue of the float window, with `D`-suffix StackOps for
495+
> arithmetic, comparisons, conversions, memory, and math, plus the mirrored
496+
> fused superinstructions (`get_get_dmul_sum*`, `get_set*D`,
497+
> `get_f64const_dgt_jiz`). The two FP windows use all 8 FP arg registers
498+
> (`v0..v7` / `xmm0..xmm7`). Result: f64 biquad dropped from ~0.36s to
499+
> ~0.16s on the VM-host reference (`benchmark/run.sh`), ~1.2× the f32
500+
> biquad — the residual gap is f64's 2× state bandwidth, not dispatch or
501+
> crossing overhead.
502+
490503
### 6.3 Function call arg passing
491504

492505
When calling a function with mixed int and float args, how are they

docs/Stack_VM.md

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -33,11 +33,13 @@ On bare-metal Apple M4, the Stack VM runs biquad at 0.104s, sort at
3333
It was deleted — see `docs/HOT_LOCALS.md` for the design exploration
3434
and why the cache never paid off on this VM.
3535
- **No per-op runtime type check.** The codegen tracks each stack slot's
36-
type statically, and int vs f32 ops pick the right window at emit
37-
time — `IAdd` reads `t0/t1`, `FAddF` reads `f0/f1`, and so on. f64
38-
values ride the int window as bit patterns (rare in hot code).
36+
type statically, and int / f32 / f64 ops pick the right window at emit
37+
time — `IAdd` reads `t0/t1`, `FAddF` reads `f0/f1`, `DAddD` reads
38+
`d0/d1`, and so on. f64 now has its own dedicated `double` window
39+
(`d0..d3`, spilling to `*dfsp`), the exact analogue of the f32 float
40+
window, so f64 arithmetic also stays in FP registers.
3941
- **Per-window stack-depth validation.** `src/stack_depth.rs` runs
40-
forward over each function tracking int and float stack depth
42+
forward over each function tracking int, float, and double stack depth
4143
independently; any jump target or call site that doesn't match
4244
between incoming edges is a codegen bug.
4345

@@ -247,8 +249,14 @@ Two non-obvious choices here, both load-bearing:
247249
register-indirect store. Commit `21f2949`.
248250

249251
f32 and f64 coexist on the logical operand stack. The codegen tracks
250-
each slot's type statically; f32 slots live in the float window, f64
251-
slots (rare) ride the int window as bit patterns.
252+
each slot's type statically; f32 slots live in the float window (`f0..f3`)
253+
and f64 slots live in a parallel double window (`d0..d3`). The two FP
254+
windows together use the full 8 FP argument registers (`v0..v7` on
255+
aarch64, `xmm0..xmm7` on x86-64). f64 gets the same fused superinstructions
256+
as f32 — the multiply-accumulate sum chain, variable-move chains, and the
257+
const-compare-branch — so f64 DSP loops reach near-parity with f32 (the
258+
biquad f64 benchmark runs ~1.2× the f32 time, the gap being f64's 2× state
259+
bandwidth, versus ~2.9× before the double window existed).
252260

253261
## Hot local cache (removed)
254262

@@ -477,8 +485,6 @@ VM is safe to call from a real-time audio thread.
477485

478486
### Limitations
479487

480-
- **f64 is second-class.** f64 values ride the int window, pay GPR↔FP
481-
crossings on every op. Fine for correctness tests, not hot-pathed.
482488
- **No SIMD vector types in the VM.** `f32x4` lowers to per-lane
483489
scalar f32 ops via fused loads/stores. A proper SIMD window would
484490
need another register tier.

fuzz/Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@ libfuzzer-sys = "0.4.12"
1818
lyte = { path = "../" }
1919
libc = "0.2.186"
2020

21+
[build-dependencies]
22+
cc = "1.2.62"
23+
2124
[[bin]]
2225
name = "lexer"
2326
path = "fuzz_targets/lexer.rs"

fuzz/build.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
fn main() {
2+
// Propagate has_stack_interp cfg if the C compiler is Clang.
3+
// Mirrors cli/build.rs so the differential fuzz target can compile and
4+
// run the stack backend (which depends on the Clang-only C interpreter).
5+
let compiler = cc::Build::new().try_get_compiler();
6+
let is_clang = compiler
7+
.as_ref()
8+
.map(|c| c.is_like_clang())
9+
.unwrap_or(false);
10+
if is_clang {
11+
println!("cargo:rustc-cfg=has_stack_interp");
12+
}
13+
println!("cargo:rustc-check-cfg=cfg(has_stack_interp)");
14+
}

fuzz/fuzz_targets/differential.rs

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,21 @@ impl<'a> Gen<'a> {
6060
));
6161
}
6262

63+
// Optionally emit an `a * b + c` helper for each float type. The call
64+
// exercises float argument/return-value bridging — i.e. saving and
65+
// restoring the relevant float window (f0..f3 / d0..d3) around a call.
66+
let mut has_fma = [false; FLOAT_KINDS.len()];
67+
for (k, kind) in FLOAT_KINDS.iter().enumerate() {
68+
if self.next() % 3 == 0 {
69+
has_fma[k] = true;
70+
decls.push(format!(
71+
"{}(a: {t}, b: {t}, c: {t}) -> {t} {{\n return a * b + c\n}}",
72+
kind.fma,
73+
t = kind.ty
74+
));
75+
}
76+
}
77+
6378
// 1-4 initial integer variables
6479
let n_vars = (self.next() % 4 + 1) as usize;
6580
for i in 0..n_vars {
@@ -106,6 +121,24 @@ impl<'a> Gen<'a> {
106121
vars.push((name, VarType::Enum(n_variants)));
107122
}
108123

124+
// Optionally declare 1-2 variables of each float type. f32 drives the
125+
// stack VM's single float window (f0..f3, spilling through fsp); f64
126+
// drives the double window (d0..d3, spilling through dfsp). Values are
127+
// kept small so downstream `as i32` casts stay well inside i32 range.
128+
let mut has_floats = [false; FLOAT_KINDS.len()];
129+
for (k, kind) in FLOAT_KINDS.iter().enumerate() {
130+
if self.next() % 2 == 0 {
131+
has_floats[k] = true;
132+
let n_fvars = (self.next() % 2 + 1) as usize; // 1-2
133+
for i in 0..n_fvars {
134+
let name = format!("{}{}", kind.var_prefix, i);
135+
main_lines
136+
.push(format!(" var {} = {}", name, self.gen_float_literal(kind.ty)));
137+
vars.push((name, VarType::Float(kind.ty)));
138+
}
139+
}
140+
}
141+
109142
// 1-5 computed values, each printed
110143
let n_stmts = (self.next() % 5 + 1) as usize;
111144
for i in 0..n_stmts {
@@ -116,6 +149,25 @@ impl<'a> Gen<'a> {
116149
vars.push((name, VarType::Int));
117150
}
118151

152+
// Float computations, each printed as i32. Printing the truncated
153+
// integer (rather than the raw float) keeps output comparable across
154+
// backends — Rust's float Display ("3") and the C interpreter's printf
155+
// ("3.0") format integral floats differently, so raw float prints
156+
// would diverge on formatting alone. The float arithmetic itself is
157+
// still fully exercised before the cast.
158+
for (k, kind) in FLOAT_KINDS.iter().enumerate() {
159+
if !has_floats[k] {
160+
continue;
161+
}
162+
let n_fstmts = (self.next() % 3 + 1) as usize; // 1-3
163+
for i in 0..n_fstmts {
164+
let name = format!("{}r{}", kind.var_prefix, i);
165+
let expr = self.gen_float_expr(&vars, kind, has_fma[k], 2);
166+
main_lines.push(format!(" let {} = {}", name, expr));
167+
main_lines.push(format!(" print({} as i32)", name));
168+
}
169+
}
170+
119171
main_lines.push("}".to_string());
120172

121173
let mut parts = decls;
@@ -289,11 +341,101 @@ impl<'a> Gen<'a> {
289341
let val = (self.next() % 200) as i32 - 100;
290342
self.format_int(val)
291343
}
344+
345+
/// A small float literal in [0.0, 9.9], typed as `ty` ("f32"/"f64").
346+
/// Bare float literals are already f32, so an `as f32` cast would be an
347+
/// unsupported identity conversion — emit the bare literal for f32 and an
348+
/// explicit `as f64` conversion for f64. Values are bounded so that
349+
/// products of a few of these stay far inside i32 range after the final
350+
/// `as i32` cast.
351+
fn gen_float_literal(&mut self, ty: &str) -> String {
352+
let whole = self.next() % 10;
353+
let frac = self.next() % 10;
354+
if ty == "f32" {
355+
format!("{}.{}", whole, frac)
356+
} else {
357+
format!("({}.{} as {})", whole, frac, ty)
358+
}
359+
}
360+
361+
fn gen_float_expr(
362+
&mut self,
363+
vars: &[(String, VarType)],
364+
kind: &FloatKind,
365+
has_fma: bool,
366+
depth: u8,
367+
) -> String {
368+
if depth == 0 {
369+
return self.gen_float_leaf(vars, kind);
370+
}
371+
let max_choice = if has_fma { 8 } else { 6 };
372+
match self.next() % max_choice {
373+
// Float literal
374+
0..=1 => self.gen_float_literal(kind.ty),
375+
// Float variable reference (of this type)
376+
2..=3 => self.gen_float_var(vars, kind.ty),
377+
// Binary arithmetic (no division — avoids safety errors)
378+
4..=5 => {
379+
let ops = ["+", "-", "*"];
380+
let op = ops[self.next() as usize % ops.len()];
381+
let l = self.gen_float_expr(vars, kind, has_fma, depth - 1);
382+
let r = self.gen_float_expr(vars, kind, has_fma, depth - 1);
383+
format!("({} {} {})", l, op, r)
384+
}
385+
// Helper call (only if its `a * b + c` helper was emitted) —
386+
// exercises float argument/return-value bridging across a call.
387+
6..=7 => {
388+
let a = self.gen_float_expr(vars, kind, has_fma, depth - 1);
389+
let b = self.gen_float_expr(vars, kind, has_fma, depth - 1);
390+
let c = self.gen_float_expr(vars, kind, has_fma, depth - 1);
391+
format!("{}({}, {}, {})", kind.fma, a, b, c)
392+
}
393+
_ => self.gen_float_literal(kind.ty),
394+
}
395+
}
396+
397+
fn gen_float_leaf(&mut self, vars: &[(String, VarType)], kind: &FloatKind) -> String {
398+
if self.next() % 2 == 0 {
399+
self.gen_float_var(vars, kind.ty)
400+
} else {
401+
self.gen_float_literal(kind.ty)
402+
}
403+
}
404+
405+
fn gen_float_var(&mut self, vars: &[(String, VarType)], ty: &str) -> String {
406+
let fvars: Vec<&str> = vars
407+
.iter()
408+
.filter_map(|(name, t)| match t {
409+
VarType::Float(vt) if *vt == ty => Some(name.as_str()),
410+
_ => None,
411+
})
412+
.collect();
413+
if fvars.is_empty() {
414+
self.gen_float_literal(ty)
415+
} else {
416+
let idx = self.next() as usize % fvars.len();
417+
fvars[idx].to_string()
418+
}
419+
}
420+
}
421+
422+
/// A float type the generator can emit, paired with the names it uses for
423+
/// that type's variables and `a * b + c` helper.
424+
struct FloatKind {
425+
ty: &'static str,
426+
var_prefix: &'static str,
427+
fma: &'static str,
292428
}
293429

430+
const FLOAT_KINDS: [FloatKind; 2] = [
431+
FloatKind { ty: "f32", var_prefix: "sv", fma: "sfma" },
432+
FloatKind { ty: "f64", var_prefix: "fv", fma: "ffma" },
433+
];
434+
294435
#[derive(Clone)]
295436
enum VarType {
296437
Int,
438+
Float(&'static str),
297439
Struct,
298440
Array(usize),
299441
Enum(usize),
@@ -321,6 +463,11 @@ fn capture_stdout<F: FnOnce()>(f: F) -> String {
321463
f();
322464

323465
std::io::stdout().flush().ok();
466+
// The stack backend's C interpreter prints via C stdio (printf), which
467+
// buffers independently of Rust's stdout. Flush all C streams before
468+
// restoring fd 1, or the buffered output is lost and the capture comes
469+
// back empty.
470+
unsafe { libc::fflush(std::ptr::null_mut()) };
324471

325472
unsafe { libc::dup2(saved_fd, 1) };
326473
unsafe { libc::close(saved_fd) };
@@ -356,6 +503,17 @@ fn run_backend(program: &str, backend: &str) -> Option<String> {
356503
});
357504
Some(output)
358505
}
506+
#[cfg(has_stack_interp)]
507+
"stack" => {
508+
// Compile to the stack VM and run it through the C interpreter
509+
// (the same path cli/src/main.rs uses for `--backend stack`).
510+
let output = capture_stdout(|| {
511+
if let Ok(program) = compiler.compile_stack() {
512+
let _ = lyte::stack_interp_bridge::run(&program);
513+
}
514+
});
515+
Some(output)
516+
}
359517
#[cfg(target_arch = "aarch64")]
360518
"asm" => {
361519
let output = capture_stdout(|| {
@@ -460,4 +618,10 @@ fuzz_target!(|data: &[u8]| {
460618
let llvm_output = run_backend(&program, "llvm");
461619
assert_same("VM", &vm_output, "LLVM", &llvm_output, &program);
462620
}
621+
622+
#[cfg(has_stack_interp)]
623+
{
624+
let stack_output = run_backend(&program, "stack");
625+
assert_same("VM", &vm_output, "STACK", &stack_output, &program);
626+
}
463627
});

src/compiler.rs

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1980,7 +1980,31 @@ mod tests {
19801980
// At Return/ReturnVoid the entry depth must be 0 (plus the op's
19811981
// own delta, which is 0 for both return ops).
19821982
fn assert_f_window_balanced(program: &crate::stack_ir::StackProgram, label: &str) {
1983-
use crate::stack_depth::float_stack_delta;
1983+
assert_window_balanced(
1984+
program,
1985+
label,
1986+
crate::stack_depth::float_stack_delta,
1987+
"f-window",
1988+
);
1989+
assert_window_balanced(
1990+
program,
1991+
label,
1992+
crate::stack_depth::double_stack_delta,
1993+
"d-window",
1994+
);
1995+
}
1996+
1997+
/// Verify a register-window (float or double) stays balanced across the
1998+
/// CFG: every merge agrees on depth, every Return leaves depth 0, and no
1999+
/// op underflows. `delta` selects which window to check.
2000+
#[cfg(test)]
2001+
fn assert_window_balanced(
2002+
program: &crate::stack_ir::StackProgram,
2003+
label: &str,
2004+
delta: fn(&crate::stack_ir::StackOp) -> i32,
2005+
window: &str,
2006+
) {
2007+
let float_stack_delta = delta;
19842008
use crate::stack_ir::StackOp;
19852009

19862010
for func in &program.functions {
@@ -2044,7 +2068,9 @@ mod tests {
20442068
}
20452069
}
20462070
StackOp::FusedF32ConstFGtJumpIfZeroF(_, off)
2047-
| StackOp::FusedGetF32ConstFGtJumpIfZeroF(_, _, off) => {
2071+
| StackOp::FusedGetF32ConstFGtJumpIfZeroF(_, _, off)
2072+
| StackOp::FusedF64ConstDGtJumpIfZeroD(_, off)
2073+
| StackOp::FusedGetF64ConstDGtJumpIfZeroD(_, _, off) => {
20482074
let t = (i as i64 + 1 + *off as i64) as usize;
20492075
if t < n {
20502076
succs.push(t);
@@ -2066,9 +2092,9 @@ mod tests {
20662092
worklist.push(s);
20672093
} else if in_depth[s] != d_out {
20682094
panic!(
2069-
"[{}] {}: f-window depth mismatch at op {} \
2095+
"[{}] {}: {} depth mismatch at op {} \
20702096
(from op {}): {} vs {}",
2071-
label, func.name, s, i, in_depth[s], d_out,
2097+
label, func.name, window, s, i, in_depth[s], d_out,
20722098
);
20732099
}
20742100
}
@@ -2081,19 +2107,21 @@ mod tests {
20812107
if matches!(op, StackOp::Return | StackOp::ReturnVoid) {
20822108
assert!(
20832109
in_depth[i] == 0,
2084-
"[{}] {}: f-window leaks {} slot(s) at return op {}",
2110+
"[{}] {}: {} leaks {} slot(s) at return op {}",
20852111
label,
20862112
func.name,
2113+
window,
20872114
in_depth[i],
20882115
i,
20892116
);
20902117
}
20912118
let d_out = in_depth[i] + float_stack_delta(op);
20922119
assert!(
20932120
d_out >= 0,
2094-
"[{}] {}: f-window underflow at op {} ({:?}): in={} delta={}",
2121+
"[{}] {}: {} underflow at op {} ({:?}): in={} delta={}",
20952122
label,
20962123
func.name,
2124+
window,
20972125
i,
20982126
op,
20992127
in_depth[i],

0 commit comments

Comments
 (0)