diff --git a/src/astgen.cpp b/src/astgen.cpp index 042275f..450dd0c 100644 --- a/src/astgen.cpp +++ b/src/astgen.cpp @@ -415,13 +415,34 @@ static JirRef astgenNumberLit(AstGenCtx &gctx, const AstNode &n, gctx.jfn.insts.empty() ? 0 : static_cast(0); // patched below if (isFloat) { + // This is the coercion point (we know `expected`), so produce the + // value rounded ONCE to the target f32/f64, avoiding the + // decimal→f64→f32 double-round. Codegen is unchanged: it emits the + // already-correctly-rounded value via ConstReal. + bool toF32 = (expected != kNoType && expected == BuiltinType::F32); + double d; + if ((n.flags & 4) != 0) { + // Full f128 in the extra pool — round it to the target here. + const NodeStore &ns = gctx.ctx.getNodeStore(); + ExtraIdx ei = static_cast(n.lhs); + uint32_t quad[4] = {ns.getExtra(ei), ns.getExtra(ei + 1), + ns.getExtra(ei + 2), ns.getExtra(ei + 3)}; + d = JamLLVMQuadToTargetAsDouble(quad, toF32); + } else { + // f64 stored inline, and it is the exact f128 value (lossless), so + // codegen's ConstReal(target, d) rounds it once to the target with + // no double-rounding (f64→f32 here == f128→f32). + uint64_t bits = static_cast(n.lhs) | + (static_cast(n.rhs) << 32); + __builtin_memcpy(&d, &bits, sizeof(d)); + } + uint64_t bits; + __builtin_memcpy(&bits, &d, sizeof(bits)); inst.tag = JirTag::Float; - inst.a = static_cast(val & 0xFFFFFFFFu); - inst.b = static_cast(val >> 32); + inst.a = static_cast(bits & 0xFFFFFFFFu); + inst.b = static_cast(bits >> 32); if (isNeg) inst.flags |= 1; // sign bit applied at codegen - inst.ty = (expected != kNoType && expected == BuiltinType::F32) - ? BuiltinType::F32 - : BuiltinType::F64; + inst.ty = toF32 ? BuiltinType::F32 : BuiltinType::F64; return emit(gctx, inst); } @@ -2060,7 +2081,13 @@ static JirRef astgenAsCast(AstGenCtx &gctx, const AstNode &n) { } } const TypeKey &dst = gctx.ctx.getTypePool().get(dstTy); - TypeIdx hint = (dst.kind == TypeKind::Int) ? dstTy : kNoType; + // Pass the destination as a peer-type hint for Int *and* Float so a + // numeric literal operand settles directly at the target width. For + // floats this is essential: ` as f32` must round the literal to f32 + // in one step, not lower it at f64 and then FPTrunc (which double-rounds). + TypeIdx hint = + (dst.kind == TypeKind::Int || dst.kind == TypeKind::Float) ? dstTy + : kNoType; JirRef val = astgenExpr(gctx, operandIdx, hint); TypeIdx srcTy = gctx.jfn.getInst(val).ty; if (srcTy == dstTy) return val; diff --git a/src/comptime.cpp b/src/comptime.cpp index af056f7..76036cb 100644 --- a/src/comptime.cpp +++ b/src/comptime.cpp @@ -7,6 +7,8 @@ #include "comptime.h" +#include "jam_llvm.h" + namespace jam { // ─── ComptimeValue constructors ────────────────────────────────── @@ -212,16 +214,29 @@ ComptimeValue ComptimeEvaluator::evalRequired(NodeIdx expr, } ComptimeValue ComptimeEvaluator::evalNumberLit(const AstNode &n) const { - uint64_t bits = - static_cast(n.lhs) | (static_cast(n.rhs) << 32); bool isNeg = (n.flags & 1) != 0; bool isFloat = (n.flags & 2) != 0; if (isFloat) { + // The comptime float value is f64. Either the literal is stored inline + // as f64 bits (lossless), or as full f128 in the extra pool (flag bit 2) + // which we round once to f64 (f128→f64 equals decimal→f64; no double- + // rounding). See the parser / astgenNumberLit. double v; - __builtin_memcpy(&v, &bits, sizeof(v)); + if ((n.flags & 4) != 0) { + ExtraIdx ei = static_cast(n.lhs); + uint32_t quad[4] = {nodes_.getExtra(ei), nodes_.getExtra(ei + 1), + nodes_.getExtra(ei + 2), nodes_.getExtra(ei + 3)}; + v = JamLLVMQuadToTargetAsDouble(quad, /*toF32=*/false); + } else { + uint64_t bits = static_cast(n.lhs) | + (static_cast(n.rhs) << 32); + __builtin_memcpy(&v, &bits, sizeof(v)); + } if (isNeg) v = -v; return ComptimeValue::makeFloat(v, 64); } + uint64_t bits = + static_cast(n.lhs) | (static_cast(n.rhs) << 32); // Default integer width: u64 (or i64 if negative). Callers can // narrow via the surrounding type context, but at this evaluator // layer we keep the literal at full width to preserve precision diff --git a/src/jam_llvm.cpp b/src/jam_llvm.cpp index 7f58400..5de2fcf 100644 --- a/src/jam_llvm.cpp +++ b/src/jam_llvm.cpp @@ -11,6 +11,9 @@ #include "llvm/Analysis/CGSCCPassManager.h" #include "llvm/Analysis/LoopAnalysisManager.h" #include "llvm/Analysis/TargetLibraryInfo.h" +#include "llvm/ADT/APFloat.h" +#include "llvm/ADT/APInt.h" +#include "llvm/ADT/ArrayRef.h" #include "llvm/Bitcode/BitcodeWriter.h" #include "llvm/IR/Constants.h" #include "llvm/IR/DerivedTypes.h" @@ -26,6 +29,7 @@ #include "llvm/Passes/OptimizationLevel.h" #include "llvm/Passes/PassBuilder.h" #include "llvm/Passes/StandardInstrumentations.h" +#include "llvm/Support/Error.h" #include "llvm/Support/FileSystem.h" #include "llvm/Support/TargetSelect.h" #include "llvm/Support/raw_ostream.h" @@ -285,6 +289,58 @@ JamValueRef JamLLVMConstReal(JamTypeRef type, double val) { return WRAP_VALUE(llvm::ConstantFP::get(UNWRAP_TYPE(type), val)); } +bool JamLLVMParseDecimalFloat(const char *str, unsigned len, uint64_t *outF64, + uint32_t *outQuad) { + // Parse the decimal/hex float text into IEEE binary128 with one correctly + // rounded step (APFloat is arbitrary-precision internally — this is the + // path clang uses for float literals). f128 is wide enough that a later + // round to f32/f64 matches rounding the original decimal directly. + llvm::APFloat q(llvm::APFloat::IEEEquad()); + auto parsed = q.convertFromString(llvm::StringRef(str, len), + llvm::APFloat::rmNearestTiesToEven); + if (!parsed) { + llvm::consumeError(parsed.takeError()); + *outF64 = 0; // 0.0 — shouldn't happen (tokenizer already validated) + return false; + } + // If the f128 round-trips through f64 with no loss of precision, store the + // compact f64 form instead of the full f128. + llvm::APFloat asF64 = q; + bool lostInfo = false; + asF64.convert(llvm::APFloat::IEEEdouble(), llvm::APFloat::rmNearestTiesToEven, + &lostInfo); + if (!lostInfo) { + *outF64 = asF64.bitcastToAPInt().getZExtValue(); + return false; // fits f64 losslessly — caller stores f64 inline + } + llvm::APInt bits = q.bitcastToAPInt(); // 128-bit pattern + const uint64_t *raw = bits.getRawData(); // raw[0] = low 64, raw[1] = high 64 + outQuad[0] = static_cast(raw[0] & 0xFFFFFFFFu); + outQuad[1] = static_cast(raw[0] >> 32); + outQuad[2] = static_cast(raw[1] & 0xFFFFFFFFu); + outQuad[3] = static_cast(raw[1] >> 32); + return true; // needs the full f128 +} + +double JamLLVMQuadToTargetAsDouble(const uint32_t *quad, bool toF32) { + uint64_t words[2] = { + static_cast(quad[0]) | (static_cast(quad[1]) << 32), + static_cast(quad[2]) | (static_cast(quad[3]) << 32), + }; + llvm::APInt bits(128, llvm::ArrayRef(words, 2)); + llvm::APFloat q(llvm::APFloat::IEEEquad(), bits); + bool lostInfo = false; + // The single, final rounding: f128 → target semantics. + q.convert(toF32 ? llvm::APFloat::IEEEsingle() : llvm::APFloat::IEEEdouble(), + llvm::APFloat::rmNearestTiesToEven, &lostInfo); + // Widen an f32 result to f64 for the C-ABI return — exact, no rounding. + if (toF32) { + q.convert(llvm::APFloat::IEEEdouble(), + llvm::APFloat::rmNearestTiesToEven, &lostInfo); + } + return q.convertToDouble(); +} + JamValueRef JamLLVMConstNull(JamTypeRef type) { return WRAP_VALUE(llvm::Constant::getNullValue(UNWRAP_TYPE(type))); } diff --git a/src/jam_llvm.h b/src/jam_llvm.h index fd629dd..cfae12d 100644 --- a/src/jam_llvm.h +++ b/src/jam_llvm.h @@ -137,6 +137,21 @@ JAM_EXTERN_C unsigned JamLLVMGetIntTypeWidth(JamTypeRef type); JAM_EXTERN_C JamValueRef JamLLVMConstInt(JamTypeRef type, uint64_t val, bool signExtend); JAM_EXTERN_C JamValueRef JamLLVMConstReal(JamTypeRef type, double val); +// Parse a decimal/hex float lexeme (sign-stripped, underscores removed) into an +// IEEE-754 binary128 (f128) value — the widest float, used as the precision- +// preserving intermediate for a literal. Then pick the smallest lossless +// storage: if the f128 round-trips through f64 with no loss, write the f64 bit +// pattern to *outF64 and return false; otherwise write the 128-bit pattern to +// outQuad[4] (little-endian word order) and return true. Rounding to the final +// f32/f64 type happens later, at the coercion point — so a literal is rounded +// to its target exactly once, never decimal→f64→f32. +JAM_EXTERN_C bool JamLLVMParseDecimalFloat(const char *str, unsigned len, + uint64_t *outF64, uint32_t *outQuad); +// Round an f128 value (the 4×u32 pattern above) once to f32 (toF32=true) or f64, +// returned widened to a C double (an f32 result is exact in f64). This is the +// single, final rounding — no double-rounding through f64. +JAM_EXTERN_C double JamLLVMQuadToTargetAsDouble(const uint32_t *quad, + bool toF32); JAM_EXTERN_C JamValueRef JamLLVMConstNull(JamTypeRef type); JAM_EXTERN_C JamValueRef JamLLVMConstString(JamContextRef ctx, const char *str, unsigned length, diff --git a/src/parser.cpp b/src/parser.cpp index a45c786..cec7da3 100644 --- a/src/parser.cpp +++ b/src/parser.cpp @@ -6,8 +6,10 @@ */ #include "parser.h" +#include "jam_llvm.h" #include "number_literal.h" #include +#include // Validate a number lexeme via the dedicated validator // (number_literal.cpp). Strips a leading `-` sign before delegating — @@ -225,22 +227,51 @@ NodeIdx Parser::parsePrimary() { } if (match(TOK_NUMBER)) { - // `parseNumLexeme` returns the magnitude; the sign is recorded in - // the node's flags bit 0 (negative) and bit 1 (isFloat: when - // set the magnitude is the bit pattern of a `double`). + // `parseNumLexeme` validates the lexeme and classifies it; the sign + // is recorded in the node's flags bit 0 (negative) and bit 1 + // (isFloat). For integers `lhs`/`rhs` hold the 64-bit magnitude. bool isNegative = false; bool isFloat = false; uint64_t mag = parseNumLexeme(previous().text(source_), isNegative, isFloat); uint16_t flags = 0; if (isNegative) flags |= 1; - if (isFloat) flags |= 2; - AstNode n{AstTag::NumberLit, - 0, - flags, - 0, - static_cast(mag & 0xFFFFFFFFu), - static_cast(mag >> 32)}; + uint32_t lhsSlot = 0; + uint32_t rhsSlot = 0; + if (isFloat) { + flags |= 2; + // Parse the decimal to f128 ONCE here (the widest float), then pick + // the smallest lossless storage: an f128 that round-trips through + // f64 is stored inline as f64 bits (lhs/rhs); otherwise the full + // f128 goes in the extra pool (lhs = ExtraIdx) and flag bit 2 is + // set. Rounding to the target f32/f64 is deferred to coercion + // (astgen), so we never double-round through f64. + std::string_view raw = previous().text(source_); + if (isNegative && !raw.empty() && raw.front() == '-') { + raw.remove_prefix(1); + } + std::string clean; + clean.reserve(raw.size()); + for (char c : raw) { + if (c != '_') clean.push_back(c); + } + uint64_t f64bits = 0; + uint32_t quad[4] = {0, 0, 0, 0}; + if (JamLLVMParseDecimalFloat(clean.data(), + static_cast(clean.size()), + &f64bits, quad)) { + flags |= 4; // value needs full f128, held in the extra pool + lhsSlot = static_cast(nodes->pushExtraSpan(quad, 4)); + } else { + // Fits f64 losslessly — store the f64 bit pattern inline. + lhsSlot = static_cast(f64bits & 0xFFFFFFFFu); + rhsSlot = static_cast(f64bits >> 32); + } + } else { + lhsSlot = static_cast(mag & 0xFFFFFFFFu); + rhsSlot = static_cast(mag >> 32); + } + AstNode n{AstTag::NumberLit, 0, flags, 0, lhsSlot, rhsSlot}; return emit(n); } if (match(TOK_TRUE)) { diff --git a/tests/unit/test_float_rounding.jam b/tests/unit/test_float_rounding.jam new file mode 100644 index 0000000..8d15f3b --- /dev/null +++ b/tests/unit/test_float_rounding.jam @@ -0,0 +1,55 @@ +// Float-literal rounding parity. +// +// A decimal float must be rounded to its target f32/f64 type in ONE step +// (as a C `…f` literal is), not via the intermediate `decimal -> f64 -> f32` +// chain, which double-rounds. +// +// The probe value 1.0000000596046447753906250000001 sits just above the f32 +// midpoint between 1.0 (0x3f800000) and 1.0+2^-23 (0x3f800001), but within +// half an f64 ULP of that midpoint. So: +// - correct single rounding decimal -> f32 = 0x3f800001 +// - the old double rounding decimal -> f64 -> f32 = 0x3f800000 +// (verified against clang strtof / LLVM APFloat). + +const { assert } = import("test"); + +const FloatBits = union { i: u32, f: f32 }; +const DoubleBits = union { i: u64, f: f64 }; + +// Explicit `as f32` cast — the form the jamstation GPU-timing constants use. +tfn f32CastRoundsOnce() { + const x: f32 = 1.0000000596046447753906250000001 as f32; + var b: FloatBits = FloatBits { f: x }; + assert(b.i, 0x3f800001); +} + +// Bare f32 type annotation — exercises peer-type propagation into the literal. +tfn f32AnnotationRoundsOnce() { + const x: f32 = 1.0000000596046447753906250000001; + var b: FloatBits = FloatBits { f: x }; + assert(b.i, 0x3f800001); +} + +// Sign is applied after the (single) round of the magnitude. +tfn f32NegativeRoundsOnce() { + const x: f32 = -1.0000000596046447753906250000001 as f32; + var b: FloatBits = FloatBits { f: x }; + assert(b.i, 0xbf800001); +} + +// f64 literals are unaffected: decimal -> f128 -> f64 equals decimal -> f64. +tfn f64LiteralUnchanged() { + const x: f64 = 0.1; + var b: DoubleBits = DoubleBits { f: x }; + assert(b.i, 0x3fb999999999999a); +} + +// f64-inline storage path: exactly 1 + 2^-24 (the f32 midpoint) is f64-exact, +// so it's stored inline rather than as f128. Rounding f64->f32 is round-to- +// nearest-EVEN, so the midpoint goes to 1.0 (0x3f800000), not 0x3f800001. +// Complements f32CastRoundsOnce (just *above* the midpoint -> f128 path -> up). +tfn f32MidpointRoundsToEven() { + const x: f32 = 1.000000059604644775390625 as f32; + var b: FloatBits = FloatBits { f: x }; + assert(b.i, 0x3f800000); +}