Skip to content

Commit fb61a88

Browse files
Path A.2: f64 support in scalar + dual-band lowerers
Floats live on the operand stack as bitcast IEEE-754 bit patterns inside i64-shaped slots. The bytecode-typed AddFloat / SubFloat / MulFloat ops bitcast back to f64 at their boundary, do the float math, and bitcast the result back to i64 for storage. The to_int and to_float intrinsics handle the int↔float conversion at the language boundary (sitofp / fptosi via bitcast). Why bitcast-via-i64 instead of typing the whole stack: the lowerer already uses Vec<IntValue> as the operand stack. Adding a tagged StackItem enum would have touched every op handler. The bitcast trick keeps the existing path unchanged and only the float-typed ops (Add/Sub/MulFloat) and the conversion intrinsics need to know about the encoding. Bytecode compiler enforces type discipline upstream — the JIT trusts the typed op. Implementation surface: - Const::Float in LoadConst → const_int(f.to_bits()) - Op::AddFloat/SubFloat/MulFloat in scalar lowerer via new bin_float helper (bitcast-i64-to-f64, op, bitcast back) - Op::AddFloat/SubFloat/MulFloat in dual-band lowerer via bin_vec_float helper (<2 x i64> bitcasts to <2 x f64>; both lanes get the parallel float op; bitcast back to <2 x i64>) - Op::Call("to_float", 1): pop i64, sitofp f64, bitcast to i64 - Op::Call("to_int", 1): pop i64, bitcast f64, fptosi i64 - Mirrored intrinsics in dual-band: operate on α lane only, splat result back as matched-band <r, r> Tests (3 new, all passing): - float_round_trip_to_int_and_back: to_int(to_float(x)) == x for any int x (proves the bitcast encoding) - float_arithmetic_via_to_float: area(r) = to_int(rf * rf) where rf = to_float(r) — exercises MulFloat - float_loop_accumulator: sum_squares(n) using float Add/Mul in a while loop. Verified against closed form n(n+1)(2n+1)/6 for n=1,2,3,10,100 (1, 5, 14, 385, 338,350) Out of scope for Path A.2 (deferred): - Float-typed Div / Mod: the OMC bytecode compiler doesn't yet emit DivFloat (Op::Div is always emitted, regardless of type), so the JIT would do integer division on float bit-patterns and produce garbage. Would need a compiler-side change to emit DivFloat when both operands are statically float, and then a matching JIT op. Documented in the test comment. - Float comparison ops (FloatPredicate::OEQ etc.) — comparisons happen on the int representation today, which gives wrong answers for negative zeros and NaNs. Same story as Div. - Float fn parameters or returns: signature stays scalar i64; callers convert at the boundary via to_float / to_int. - Tracking var-slot type so a variable can hold either int or float across stores: currently a single alloca i64 holds either kind via bitcast. That's correct as long as the compiler doesn't mix types into the same slot, which the type-tracking it does today should prevent. Workspace: 34 codegen tests pass (1 IR snapshot + 4 cross-fn + 5 dual-band + 5 dispatch + 3 harmony + 3 floats + 5 phi_shadow + 8 scalar). Smoke + harmonic-lib + 149 core tests still green. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 37fa0c5 commit fb61a88

3 files changed

Lines changed: 329 additions & 3 deletions

File tree

omnimcode-codegen/src/dual_band.rs

Lines changed: 111 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -269,14 +269,18 @@ impl<'ctx, 'a> DualBandLowerer<'ctx, 'a> {
269269
let alpha = match c {
270270
Const::Int(n) => i64_type.const_int(*n as u64, true),
271271
Const::Bool(b) => i64_type.const_int(*b as u64, false),
272+
// Path A.2: floats live on the i64 stack via
273+
// bitcast IEEE-754 bit pattern. Float-typed
274+
// ops bitcast back to f64 at the boundary.
275+
Const::Float(f) => i64_type.const_int(f.to_bits(), false),
272276
_ => {
273277
return Err(format!(
274-
"Session C only supports Const::Int/Const::Bool, got {:?} at op{}",
278+
"dual-band lowerer doesn't support {:?} at op{}",
275279
c, i
276280
));
277281
}
278282
};
279-
// Matched-band entry: β = α. (Session D will add
283+
// Matched-band entry: β = α. (Session F adds
280284
// explicit phi-shadow ops that diverge β.)
281285
let v = self.splat(alpha, &format!("const{}_v", idx))?;
282286
stack.push(v);
@@ -319,6 +323,15 @@ impl<'ctx, 'a> DualBandLowerer<'ctx, 'a> {
319323
}
320324

321325
Op::Add | Op::AddInt => self.bin_vec(&mut stack, i, |b, l, r| b.build_int_add(l, r, "add"))?,
326+
// Path A.2: float arithmetic in dual-band mode.
327+
// <2 x i64> bitcasts to <2 x f64> directly (same total
328+
// bit-width); both lanes get the float op in parallel.
329+
// β tracks α through float math the same way it does
330+
// through int math (matched-band semantics until an
331+
// explicit phi_shadow re-derives β).
332+
Op::AddFloat => self.bin_vec_float(&mut stack, i, |b, l, r| b.build_float_add(l, r, "fadd"))?,
333+
Op::SubFloat => self.bin_vec_float(&mut stack, i, |b, l, r| b.build_float_sub(l, r, "fsub"))?,
334+
Op::MulFloat => self.bin_vec_float(&mut stack, i, |b, l, r| b.build_float_mul(l, r, "fmul"))?,
322335
Op::Sub | Op::SubInt => self.bin_vec(&mut stack, i, |b, l, r| b.build_int_sub(l, r, "sub"))?,
323336
Op::Mul | Op::MulInt => self.bin_vec(&mut stack, i, |b, l, r| b.build_int_mul(l, r, "mul"))?,
324337
Op::Div => self.bin_vec(&mut stack, i, |b, l, r| b.build_int_signed_div(l, r, "div"))?,
@@ -486,6 +499,59 @@ impl<'ctx, 'a> DualBandLowerer<'ctx, 'a> {
486499
stack.push(h_v);
487500
continue;
488501
}
502+
// Path A.2: int↔float boundary intrinsics. The
503+
// dual-band carrier is <2 x i64>; we operate on
504+
// the α lane only (β is the harmonic shadow,
505+
// which doesn't follow the user-visible value
506+
// through int↔float conversions).
507+
if name == "to_float" && *argc == 1 {
508+
let v_v = self.pop(&mut stack, i, "to_float arg")?;
509+
let f64_type = self.ctx.f64_type();
510+
let alpha = self
511+
.builder
512+
.build_extract_element(v_v, i64_type.const_int(0, false), "tof_a")
513+
.map_err(|e| format!("hbit to_float extract at op{}: {}", i, e))?;
514+
let alpha_iv = match alpha {
515+
BasicValueEnum::IntValue(iv) => iv,
516+
_ => return Err(format!("hbit to_float not int at op{}", i)),
517+
};
518+
let f = self
519+
.builder
520+
.build_signed_int_to_float(alpha_iv, f64_type, "tof")
521+
.map_err(|e| format!("hbit to_float sitofp at op{}: {}", i, e))?;
522+
let ri = self
523+
.builder
524+
.build_bit_cast(f, i64_type, "tof_i")
525+
.map_err(|e| format!("hbit to_float bitcast at op{}: {}", i, e))?
526+
.into_int_value();
527+
let new_v = self.splat(ri, "tof_v")?;
528+
stack.push(new_v);
529+
continue;
530+
}
531+
if name == "to_int" && *argc == 1 {
532+
let v_v = self.pop(&mut stack, i, "to_int arg")?;
533+
let f64_type = self.ctx.f64_type();
534+
let alpha = self
535+
.builder
536+
.build_extract_element(v_v, i64_type.const_int(0, false), "toi_a")
537+
.map_err(|e| format!("hbit to_int extract at op{}: {}", i, e))?;
538+
let alpha_iv = match alpha {
539+
BasicValueEnum::IntValue(iv) => iv,
540+
_ => return Err(format!("hbit to_int not int at op{}", i)),
541+
};
542+
let v_f = self
543+
.builder
544+
.build_bit_cast(alpha_iv, f64_type, "toi_f")
545+
.map_err(|e| format!("hbit to_int bitcast at op{}: {}", i, e))?
546+
.into_float_value();
547+
let ri = self
548+
.builder
549+
.build_float_to_signed_int(v_f, i64_type, "toi")
550+
.map_err(|e| format!("hbit to_int fptosi at op{}: {}", i, e))?;
551+
let new_v = self.splat(ri, "toi_v")?;
552+
stack.push(new_v);
553+
continue;
554+
}
489555
// Resolve the call target. Self-recursion uses
490556
// self.function directly. Cross-fn calls (Session
491557
// H) look up `<name>_hbit` in the module's symbol
@@ -780,6 +846,49 @@ impl<'ctx, 'a> DualBandLowerer<'ctx, 'a> {
780846
Ok(())
781847
}
782848

849+
/// Path A.2: float-arithmetic binop on the dual-band vector.
850+
/// `<2 x i64>` bitcasts to `<2 x f64>` (same 128-bit width); both
851+
/// lanes get the float op in parallel; result bitcasts back to
852+
/// `<2 x i64>` for stack storage. Bytecode compiler enforces
853+
/// type discipline; the JIT just trusts the typed op.
854+
fn bin_vec_float<F>(
855+
&self,
856+
stack: &mut Vec<VectorValue<'ctx>>,
857+
op_idx: usize,
858+
f: F,
859+
) -> Result<(), CodegenError>
860+
where
861+
F: FnOnce(
862+
&Builder<'ctx>,
863+
VectorValue<'ctx>,
864+
VectorValue<'ctx>,
865+
) -> Result<VectorValue<'ctx>, inkwell::builder::BuilderError>,
866+
{
867+
let f64_type = self.ctx.f64_type();
868+
let v2f64 = f64_type.vec_type(2);
869+
let rhs = self.pop(stack, op_idx, "fbin rhs")?;
870+
let lhs = self.pop(stack, op_idx, "fbin lhs")?;
871+
let lhs_f = self
872+
.builder
873+
.build_bit_cast(lhs, v2f64, "fbin_lf")
874+
.map_err(|e| format!("hbit fbin lhs cast at op{}: {}", op_idx, e))?
875+
.into_vector_value();
876+
let rhs_f = self
877+
.builder
878+
.build_bit_cast(rhs, v2f64, "fbin_rf")
879+
.map_err(|e| format!("hbit fbin rhs cast at op{}: {}", op_idx, e))?
880+
.into_vector_value();
881+
let r_f = f(&self.builder, lhs_f, rhs_f)
882+
.map_err(|e| format!("hbit fbinop at op{}: {}", op_idx, e))?;
883+
let r_i = self
884+
.builder
885+
.build_bit_cast(r_f, self.v2i64, "fbin_ri")
886+
.map_err(|e| format!("hbit fbin ret cast at op{}: {}", op_idx, e))?
887+
.into_vector_value();
888+
stack.push(r_i);
889+
Ok(())
890+
}
891+
783892
fn cmp_vec(
784893
&self,
785894
stack: &mut Vec<VectorValue<'ctx>>,

omnimcode-codegen/src/lib.rs

Lines changed: 99 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -564,9 +564,17 @@ impl<'ctx, 'a> FunctionLowerer<'ctx, 'a> {
564564
let v = match c {
565565
Const::Int(n) => i64_type.const_int(*n as u64, true),
566566
Const::Bool(b) => i64_type.const_int(*b as u64, false),
567+
Const::Float(f) => {
568+
// Path A.2: floats live on the i64 stack as
569+
// bitcast-i64. const_int(bits) gives the
570+
// raw IEEE-754 bit pattern stored as i64;
571+
// float-typed ops bitcast it back via
572+
// bin_float when consuming.
573+
i64_type.const_int(f.to_bits(), false)
574+
}
567575
_ => {
568576
return Err(format!(
569-
"Session B only supports Const::Int and Const::Bool, got {:?} at op{}",
577+
"scalar lowerer doesn't support {:?} at op{}",
570578
c, i
571579
));
572580
}
@@ -612,6 +620,18 @@ impl<'ctx, 'a> FunctionLowerer<'ctx, 'a> {
612620
Op::Mul | Op::MulInt => self.bin_int(&mut stack, i, |b, l, r| b.build_int_mul(l, r, "mul"))?,
613621
Op::Div => self.bin_int(&mut stack, i, |b, l, r| b.build_int_signed_div(l, r, "div"))?,
614622
Op::Mod => self.bin_int(&mut stack, i, |b, l, r| b.build_int_signed_rem(l, r, "rem"))?,
623+
// Float arithmetic — Path A.2.
624+
//
625+
// Floats live on the stack as bitcast-i64 (the slot
626+
// type is uniform i64 throughout the lowerer; floats
627+
// are interpreted via bitcast at the float-op boundary
628+
// and bitcast back to i64 for storage). The bytecode
629+
// compiler only emits the Float-typed ops when it has
630+
// statically-typed-float operands, so the bitcast
631+
// assumption is sound at the bytecode level.
632+
Op::AddFloat => self.bin_float(&mut stack, i, |b, l, r| b.build_float_add(l, r, "fadd"))?,
633+
Op::SubFloat => self.bin_float(&mut stack, i, |b, l, r| b.build_float_sub(l, r, "fsub"))?,
634+
Op::MulFloat => self.bin_float(&mut stack, i, |b, l, r| b.build_float_mul(l, r, "fmul"))?,
615635
Op::Neg => {
616636
let v = pop(&mut stack, i, "Neg")?;
617637
let zero = i64_type.const_int(0, false);
@@ -767,6 +787,37 @@ impl<'ctx, 'a> FunctionLowerer<'ctx, 'a> {
767787
}
768788

769789
Op::Call(name, argc) => {
790+
// Path A.2 intrinsics: int↔float boundary.
791+
if name == "to_float" && *argc == 1 {
792+
let v = pop(&mut stack, i, "to_float arg")?;
793+
let f64_type = self.ctx.f64_type();
794+
let f = self
795+
.builder
796+
.build_signed_int_to_float(v, f64_type, "tof")
797+
.map_err(|e| format!("to_float sitofp at op{}: {}", i, e))?;
798+
let ri = self
799+
.builder
800+
.build_bit_cast(f, i64_type, "tof_i")
801+
.map_err(|e| format!("to_float bitcast at op{}: {}", i, e))?
802+
.into_int_value();
803+
stack.push(ri);
804+
continue;
805+
}
806+
if name == "to_int" && *argc == 1 {
807+
let v_i = pop(&mut stack, i, "to_int arg")?;
808+
let f64_type = self.ctx.f64_type();
809+
let v_f = self
810+
.builder
811+
.build_bit_cast(v_i, f64_type, "toi_f")
812+
.map_err(|e| format!("to_int bitcast at op{}: {}", i, e))?
813+
.into_float_value();
814+
let ri = self
815+
.builder
816+
.build_float_to_signed_int(v_f, i64_type, "toi")
817+
.map_err(|e| format!("to_int fptosi at op{}: {}", i, e))?;
818+
stack.push(ri);
819+
continue;
820+
}
770821
// Session B: only recursive self-calls. Cross-fn
771822
// calls (Session D) need a callable-resolution
772823
// strategy — currently routed through tree-walk's
@@ -873,6 +924,53 @@ impl<'ctx, 'a> FunctionLowerer<'ctx, 'a> {
873924
Ok(())
874925
}
875926

927+
/// Path A.2: float-arithmetic binop. The stack holds i64s; the
928+
/// operands are interpreted as f64 via bitcast. Result is bitcast
929+
/// back to i64 for storage. Caller is responsible for ensuring
930+
/// the operands actually contain float bit-patterns (the bytecode
931+
/// compiler enforces this via its typed AddFloat/SubFloat/MulFloat
932+
/// emission; the JIT just trusts the typed op).
933+
fn bin_float<F>(
934+
&self,
935+
stack: &mut Vec<inkwell::values::IntValue<'ctx>>,
936+
op_idx: usize,
937+
f: F,
938+
) -> Result<(), CodegenError>
939+
where
940+
F: FnOnce(
941+
&Builder<'ctx>,
942+
inkwell::values::FloatValue<'ctx>,
943+
inkwell::values::FloatValue<'ctx>,
944+
) -> Result<
945+
inkwell::values::FloatValue<'ctx>,
946+
inkwell::builder::BuilderError,
947+
>,
948+
{
949+
let f64_type = self.ctx.f64_type();
950+
let i64_type = self.ctx.i64_type();
951+
let rhs_i = pop(stack, op_idx, "fbin rhs")?;
952+
let lhs_i = pop(stack, op_idx, "fbin lhs")?;
953+
let rhs_f = self
954+
.builder
955+
.build_bit_cast(rhs_i, f64_type, "fbin_rf")
956+
.map_err(|e| format!("fbin rhs cast at op{}: {}", op_idx, e))?
957+
.into_float_value();
958+
let lhs_f = self
959+
.builder
960+
.build_bit_cast(lhs_i, f64_type, "fbin_lf")
961+
.map_err(|e| format!("fbin lhs cast at op{}: {}", op_idx, e))?
962+
.into_float_value();
963+
let r_f = f(&self.builder, lhs_f, rhs_f)
964+
.map_err(|e| format!("fbinop at op{}: {}", op_idx, e))?;
965+
let r_i = self
966+
.builder
967+
.build_bit_cast(r_f, i64_type, "fbin_ri")
968+
.map_err(|e| format!("fbin ret cast at op{}: {}", op_idx, e))?
969+
.into_int_value();
970+
stack.push(r_i);
971+
Ok(())
972+
}
973+
876974
fn cmp_op(
877975
&self,
878976
stack: &mut Vec<IntValue<'ctx>>,
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
//! Path A.2 — f64 support in scalar JIT lowerer.
2+
//!
3+
//! Floats are represented on the i64-shaped operand stack as bitcast
4+
//! IEEE-754 bit patterns. Float-typed ops (AddFloat / SubFloat /
5+
//! MulFloat) and the to_int / to_float intrinsics handle the bitcast
6+
//! at their boundary. The bytecode compiler emits the typed float ops
7+
//! when it has statically-typed-float operands; the JIT trusts the
8+
//! type discipline.
9+
//!
10+
//! Caller-facing fn signature stays scalar i64 in / i64 out. Float
11+
//! locals and intermediates are fine; the body must convert to int
12+
//! at the return boundary (or via `to_int`).
13+
14+
#![cfg(feature = "llvm-jit")]
15+
16+
use inkwell::context::Context;
17+
use omnimcode_codegen::JitContext;
18+
use omnimcode_core::parser::Parser;
19+
20+
fn jit(source: &str, fn_name: &str) -> (Context, omnimcode_codegen::JittedFn) {
21+
let mut parser = Parser::new(source);
22+
let statements = parser.parse().expect("parse");
23+
let module = omnimcode_core::compiler::compile_program(&statements).expect("compile");
24+
let ctx = Context::create();
25+
let jit = JitContext::new(&ctx).expect("jit");
26+
let jitted = jit.jit_module(&module).expect("jit_module");
27+
let f = *jitted.get(fn_name).expect("fn JIT'd");
28+
drop(jitted);
29+
drop(jit);
30+
(ctx, f)
31+
}
32+
33+
#[test]
34+
fn float_round_trip_to_int_and_back() {
35+
// to_int(to_float(x)) should round-trip an integer through the
36+
// float bit-pattern path.
37+
let source = r#"
38+
fn round_trip(x) {
39+
return to_int(to_float(x));
40+
}
41+
"#;
42+
// Need to keep the JitContext alive while calling — use a longer-
43+
// lived setup than `jit()` here since `jit` drops the JitContext
44+
// at fn end. Inline the equivalent here.
45+
let mut parser = Parser::new(source);
46+
let statements = parser.parse().expect("parse");
47+
let module = omnimcode_core::compiler::compile_program(&statements).expect("compile");
48+
let ctx = Context::create();
49+
let jit = JitContext::new(&ctx).expect("jit");
50+
let jitted = jit.jit_module(&module).expect("jit_module");
51+
let f = jitted.get("round_trip").expect("round_trip JIT'd");
52+
for x in &[0i64, 1, 42, -7, 1_000_000, -1_000_000] {
53+
assert_eq!(f.call(&[*x]).expect("call"), *x);
54+
}
55+
}
56+
57+
#[test]
58+
fn float_arithmetic_via_to_float() {
59+
// fn area(r) { return to_int(to_float(r) * to_float(r)); }
60+
// For r=10: r*r = 100.0 → to_int → 100
61+
let source = r#"
62+
fn area(r) {
63+
h rf = to_float(r);
64+
return to_int(rf * rf);
65+
}
66+
"#;
67+
let mut parser = Parser::new(source);
68+
let statements = parser.parse().expect("parse");
69+
let module = omnimcode_core::compiler::compile_program(&statements).expect("compile");
70+
let ctx = Context::create();
71+
let jit = JitContext::new(&ctx).expect("jit");
72+
let jitted = jit.jit_module(&module).expect("jit_module");
73+
let f = jitted.get("area").expect("area JIT'd");
74+
assert_eq!(f.call(&[10]).expect("call"), 100);
75+
assert_eq!(f.call(&[3]).expect("call"), 9);
76+
assert_eq!(f.call(&[0]).expect("call"), 0);
77+
assert_eq!(f.call(&[100]).expect("call"), 10_000);
78+
}
79+
80+
#[test]
81+
fn float_loop_accumulator() {
82+
// Float Add/Sub/Mul in a loop. Computes
83+
// sum_squares(n) = 1² + 2² + … + n² (in float space)
84+
// returned as int. Closed form: n(n+1)(2n+1)/6.
85+
//
86+
// Note: no Div in this test because the OMC compiler doesn't yet
87+
// emit a DivFloat op (plain Op::Div is always emitted, which the
88+
// JIT treats as signed integer division). Float division is on
89+
// the deferred list with array support and AVX-512 widening.
90+
let source = r#"
91+
fn sum_squares(n) {
92+
h sum = 0.0;
93+
h k = 1;
94+
while k <= n {
95+
h kf = to_float(k);
96+
sum = sum + kf * kf;
97+
k = k + 1;
98+
}
99+
return to_int(sum);
100+
}
101+
"#;
102+
let mut parser = Parser::new(source);
103+
let statements = parser.parse().expect("parse");
104+
let module = omnimcode_core::compiler::compile_program(&statements).expect("compile");
105+
let ctx = Context::create();
106+
let jit = JitContext::new(&ctx).expect("jit");
107+
let jitted = jit.jit_module(&module).expect("jit_module");
108+
let f = jitted.get("sum_squares").expect("sum_squares JIT'd");
109+
// 1² = 1
110+
assert_eq!(f.call(&[1]).expect("call"), 1);
111+
// 1² + 2² = 5
112+
assert_eq!(f.call(&[2]).expect("call"), 5);
113+
// 1² + 2² + 3² = 14
114+
assert_eq!(f.call(&[3]).expect("call"), 14);
115+
// 1² + … + 10² = 385
116+
assert_eq!(f.call(&[10]).expect("call"), 385);
117+
// 1² + … + 100² = 338350
118+
assert_eq!(f.call(&[100]).expect("call"), 338_350);
119+
}

0 commit comments

Comments
 (0)