diff --git a/CMakeLists.txt b/CMakeLists.txt index 810fee7c68..bd6d5b77f8 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1018,6 +1018,7 @@ include/daScript/misc/platform.h include/daScript/misc/vectypes.h include/daScript/misc/arraytype.h include/daScript/misc/rangetype.h +include/daScript/misc/float2string.h include/daScript/misc/string_writer.h include/daScript/misc/type_name.h include/daScript/misc/memory_model.h @@ -1043,6 +1044,7 @@ src/misc/globals.cpp src/misc/hal.cpp src/misc/sysos.cpp src/misc/string_writer.cpp +src/misc/luau_float2string.cpp src/misc/memory_model.cpp src/misc/job_que.cpp src/misc/handle_registry.cpp @@ -1695,6 +1697,7 @@ SET(DAS_RELEASE_MISC_INCLUDE include/daScript/misc/runtime_table_utils.h include/daScript/misc/safebox.h include/daScript/misc/smart_ptr.h + include/daScript/misc/float2string.h include/daScript/misc/string_writer.h include/daScript/misc/sysos.h include/daScript/misc/type_name.h @@ -1933,6 +1936,7 @@ install(FILES ${PROJECT_SOURCE_DIR}/include/dag_noise/LICENSE DESTINATION ${DAS_ install(FILES ${PROJECT_SOURCE_DIR}/include/vecmath/LICENSE DESTINATION ${DAS_INSTALL_DOCDIR} RENAME VEC_MATH.LICENSE) install(FILES ${PROJECT_SOURCE_DIR}/3rdparty/fmt/LICENSE DESTINATION ${DAS_INSTALL_DOCDIR} RENAME FMT.LICENSE) install(FILES ${PROJECT_SOURCE_DIR}/include/fast_float/LICENSE DESTINATION ${DAS_INSTALL_DOCDIR} RENAME FAST_FLOAT.LICENSE) +install(FILES ${PROJECT_SOURCE_DIR}/src/misc/LUAU.LICENSE DESTINATION ${DAS_INSTALL_DOCDIR} RENAME LUAU.LICENSE) # utils/internal/ never installs — the folder is the audience decision # (machine-checked by utils/REVIEW.das). diff --git a/benchmarks/README.md b/benchmarks/README.md index 250fb177d0..1f8eb160ab 100644 --- a/benchmarks/README.md +++ b/benchmarks/README.md @@ -55,6 +55,7 @@ Every `.das` benchmark file in this directory tree is listed below, grouped by s |---|---| | `horizontal_reduce.das` | `hmin`/`hmax`/`hadd` over a hot float4 array - measures the JIT `llvm.vector.reduce.*` lowering vs the extern-call fallback (and interp); non-constant inputs + a global sink defeat const-fold / DCE | | `exp.das` | scalar `exp` (vecmath polynomial in interp/AOT, `@llvm.exp` in JIT) against `exp_std` (the C library `expf`) and `exp_est` (the 4th-order vecmath estimate) over a hot float array; same DCE defeats | +| `scalar_crt.das` | the scalar float family (log, log2, exp2, pow, sin, cos, tan) against the CRT twins `UnitTest` binds (`crt_logf`...); log/sin/cos/tan still race the vecmath lane, while the exp2/log2/pow rows read as builtin-node-vs-extern overhead over the same CRT since the sim_policy switch; same shape as `exp.das` | ## audio/ diff --git a/benchmarks/core/math/scalar_crt.das b/benchmarks/core/math/scalar_crt.das new file mode 100644 index 0000000000..d0ad4e76ef --- /dev/null +++ b/benchmarks/core/math/scalar_crt.das @@ -0,0 +1,137 @@ +options gen2 +options persistent_heap + +require dastest/testing_boost +require math +require UnitTest + +let N = 65536 + +var g_sink = 0. + +def fill(var xs : array; lo : float; hi : float) { + xs |> resize(N) + var seed = 12345u + for (i in range(N)) { + seed = seed * 1664525u + 1013904223u + xs[i] = lo + float(seed >> 8u) / float(1 << 24) * (hi - lo) + } +} + +[benchmark] +def log_scalar(b : B?) { + var xs : array + fill(xs, 0.001, 100.) + b |> run("log/{N}", N) { + var acc = 0. + for (x in xs) { + acc += log(x) + } + g_sink += acc + } + b |> run("crt_logf/{N}", N) { + var acc = 0. + for (x in xs) { + acc += crt_logf(x) + } + g_sink += acc + } + b |> run("log2/{N}", N) { + var acc = 0. + for (x in xs) { + acc += log2(x) + } + g_sink += acc + } + b |> run("crt_log2f/{N}", N) { + var acc = 0. + for (x in xs) { + acc += crt_log2f(x) + } + g_sink += acc + } + b |> run("exp2/{N}", N) { + var acc = 0. + for (x in xs) { + acc += exp2(x * 0.05 - 3.) + } + g_sink += acc + } + b |> run("crt_exp2f/{N}", N) { + var acc = 0. + for (x in xs) { + acc += crt_exp2f(x * 0.05 - 3.) + } + g_sink += acc + } +} + +[benchmark] +def pow_scalar(b : B?) { + var xs : array + var ys : array + fill(xs, 0.1, 10.) + fill(ys, -2., 2.) + b |> run("pow/{N}", N) { + var acc = 0. + for (x, y in xs, ys) { + acc += pow(x, y) + } + g_sink += acc + } + b |> run("crt_powf/{N}", N) { + var acc = 0. + for (x, y in xs, ys) { + acc += crt_powf(x, y) + } + g_sink += acc + } +} + +[benchmark] +def trig_scalar(b : B?) { + var xs : array + fill(xs, -1.4, 1.4) + b |> run("sin/{N}", N) { + var acc = 0. + for (x in xs) { + acc += sin(x) + } + g_sink += acc + } + b |> run("crt_sinf/{N}", N) { + var acc = 0. + for (x in xs) { + acc += crt_sinf(x) + } + g_sink += acc + } + b |> run("cos/{N}", N) { + var acc = 0. + for (x in xs) { + acc += cos(x) + } + g_sink += acc + } + b |> run("crt_cosf/{N}", N) { + var acc = 0. + for (x in xs) { + acc += crt_cosf(x) + } + g_sink += acc + } + b |> run("tan/{N}", N) { + var acc = 0. + for (x in xs) { + acc += tan(x) + } + g_sink += acc + } + b |> run("crt_tanf/{N}", N) { + var acc = 0. + for (x in xs) { + acc += crt_tanf(x) + } + g_sink += acc + } +} diff --git a/ci/REVIEW.md b/ci/REVIEW.md index 3bc73d5c3a..964392c68c 100644 --- a/ci/REVIEW.md +++ b/ci/REVIEW.md @@ -3,6 +3,6 @@ **Read `REVIEW_COMMON.md` (repo root) first - its contract binds this checklist.** Architecture doc: `CLAUDE.md` (repo root). -**A diff that shrinks what `smoke_test_bundle.sh` - the script that checks a built release -bundle - rejects is a defect**: every bundle it failed before the diff still fails. A new -exemption may name only a file no existing check matched. +**A diff that shrinks what the bundle gate rejects - `smoke_test_bundle.sh` and the checkers +it runs (`check_shipped_skills.py`) - is a defect**: every bundle it failed before the diff +still fails. A new `--exclude` or skip may name only a file no check flagged before the diff. diff --git a/ci/smoke_test_bundle.sh b/ci/smoke_test_bundle.sh index 931b5939ed..20803a1480 100644 --- a/ci/smoke_test_bundle.sh +++ b/ci/smoke_test_bundle.sh @@ -281,7 +281,7 @@ fi # mode that left shipped binaries without their notices. printf ' %-30s ' "third-party licenses present" MISSING_LICENSES="" -for lic in URIPARSER DAG_NOISE VEC_MATH FMT FAST_FLOAT GLTF_SAMPLE_ASSETS \ +for lic in URIPARSER DAG_NOISE VEC_MATH FMT FAST_FLOAT LUAU GLTF_SAMPLE_ASSETS \ HV OPENSSL LLVM Z3 MINIAUDIO OPENMPT CIPIC PUGIXML GLFW \ IMGUI FREETYPE MD4C JETBRAINS_MONO KHRONOS_GL \ VULKAN_HEADERS VOLK TREE_SITTER TREE_SITTER_ICU TREE_SITTER_C \ diff --git a/include/daScript/misc/float2string.h b/include/daScript/misc/float2string.h new file mode 100644 index 0000000000..5ff344903c --- /dev/null +++ b/include/daScript/misc/float2string.h @@ -0,0 +1,12 @@ +#pragma once + +#include "daScript/misc/platform.h" + +namespace das { + enum { DAS_F2S_BUFFER_SIZE = 48 }; + // shortest round-trip float printing, spelled byte-for-byte as fmt's default "{}"; + // returns the end of the written text, which is NOT null-terminated; the buffer is at + // least DAS_F2S_BUFFER_SIZE bytes - the emitter's fixed-length copies overshoot the text + DAS_API char * float2string ( char * buf, float value ); + DAS_API char * double2string ( char * buf, double value ); +} diff --git a/include/daScript/simulate/ARCHITECTURE.md b/include/daScript/simulate/ARCHITECTURE.md index a829213e48..6b6932976e 100644 --- a/include/daScript/simulate/ARCHITECTURE.md +++ b/include/daScript/simulate/ARCHITECTURE.md @@ -16,12 +16,22 @@ the hot set; its cost is judged against the allocate/copy/rehash it rides. The ledger the checklist's hot-path rule routes to. Each entry: what was added, where, why correctness required it, and the alternative that was rejected. +- **CRT scalar transcendentals** (`sim_policy.h`) - the scalar float arms of `Exp`, `Exp2`, + `Log2` and `Pow` call the CRT; the `vec4f` arms stay on the vecmath polynomials, where + four lanes amortize the setup. The lane trick's `v_set_x`/`v_extract_x` round-trip is a + partial-register dependency chain scalar codegen does not break, so for these arms the + CRT call is cheaper than the inlined polynomial - that is the rejected alternative. `Log2` + is the one arm the swap does not speed up: it trades the `v_log2_est_p5` estimate for the + exact answer the JIT already computes, so interp, AOT and JIT agree. `log`, `sin`, `cos` + and `tan` stay on the lane, which is cheaper for them. The measurements behind the split: + `plans/benchmark_followups.md` (repo root), the scalar-exp section. + - **`das_ordered2`** (`aot.h`) - a two-member aggregate the AOT emitter wraps around any binary op whose operands are not both side-effect-free, because braced aggregate init is the C++ construct that guarantees left-to-right evaluation; a plain call argument list or binary operator is unsequenced, and the interpreter and JIT both evaluate left-then-right. - Optimized builds flatten the wrapper to nothing (full-corpus A/B: regen + compile of all - AOT TUs is timing-neutral); an unoptimized AOT build pays a copy of both operand values + Optimized builds flatten the wrapper to nothing; an unoptimized AOT build pays a copy of + both operand values plus an immediately-invoked lambda frame per wrapped op. Ops whose policy operands need a ref cast decline the wrapper and keep the plain unordered emission. Rejected alternative: hoisting operands to named temporaries in the emitter, which needs statement-position diff --git a/include/daScript/simulate/sim_policy.h b/include/daScript/simulate/sim_policy.h index 147f3af7f2..e5de27807a 100644 --- a/include/daScript/simulate/sim_policy.h +++ b/include/daScript/simulate/sim_policy.h @@ -253,11 +253,11 @@ namespace das { static __forceinline int Floori ( float a, Context &, LineInfo * ) { return v_extract_xi(v_cvt_floori(v_set_x(a))); } static __forceinline int Ceili ( float a, Context &, LineInfo * ) { return v_extract_xi(v_cvt_ceili(v_set_x(a))); } - static __forceinline float Exp ( float a, Context &, LineInfo * ) { return v_extract_x(v_exp(v_set_x(a))); } + static __forceinline float Exp ( float a, Context &, LineInfo * ) { return expf(a); } static __forceinline float Log ( float a, Context &, LineInfo * ) { return v_extract_x(v_log(v_set_x(a))); } - static __forceinline float Exp2 ( float a, Context &, LineInfo * ) { return v_extract_x(v_exp2(v_set_x(a))); } - static __forceinline float Log2 ( float a, Context &, LineInfo * ) { return v_extract_x(v_log2_est_p5(v_set_x(a))); } - static __forceinline float Pow ( float a, float b, Context &, LineInfo * ) { return v_extract_x(v_pow(v_set_x(a), v_set_x(b))); } + static __forceinline float Exp2 ( float a, Context &, LineInfo * ) { return exp2f(a); } + static __forceinline float Log2 ( float a, Context &, LineInfo * ) { return log2f(a); } + static __forceinline float Pow ( float a, float b, Context &, LineInfo * ) { return powf(a, b); } static __forceinline float Rcp ( float a, Context &, LineInfo * ) { return v_extract_x(v_rcp_x(v_set_x(a))); } static __forceinline float RcpEst( float a, Context &, LineInfo * ) { return v_extract_x(v_rcp_est_x(v_set_x(a))); } diff --git a/modules/dasMetal/REVIEW.md b/modules/dasMetal/REVIEW.md index 1e16a0c516..d100589112 100644 --- a/modules/dasMetal/REVIEW.md +++ b/modules/dasMetal/REVIEW.md @@ -20,15 +20,19 @@ the diff puts it.** An emitted-text fixture answers to `tests/msl/REVIEW.md` (re macro declares for it - a module-level global holding the kernel's MSL text or a compile option - or the difference it makes to the emitted text. -- **A new construct the MSL emitter rejects at compile time ships a `tests/msl/_fail_closed/` - (repo root) fixture in the same change.** The same change asserts that construct's error +- **A diff that makes the MSL emitter reject at compile time a construct it accepted before - + a new rejection, or a widened condition on an existing one - ships a + `tests/msl/_fail_closed/` (repo root) fixture for that construct in the same change.** The + same change asserts that construct's error needle in `tests/msl/test_msl_fail_closed.das`. An error needle is the substring of the compile error that names the rejected construct. A diff that stops rejecting a construct deletes that construct's `_fail_closed/` fixture and its needle assertion in `test_msl_fail_closed.das`, in the same change. - **A kernel behavioral change ships a CPU-oracle test under `tests/metal/` (repo root).** A - CPU-oracle test compares the GPU result against a CPU-computed expectation. + kernel behavioral change is a change to what a kernel computes - its emitted arithmetic, + indexing, or synchronization; a CPU-oracle test compares the GPU result against a + CPU-computed expectation. - **A change visible only in the emitted text ships a `tests/msl/` (repo root) fixture.** The fixture asserts the emitted text that the change alters. diff --git a/modules/dasMetal/metal/msl_emit.das b/modules/dasMetal/metal/msl_emit.das index a3727bb6cb..66315f1d3b 100644 --- a/modules/dasMetal/metal/msl_emit.das +++ b/modules/dasMetal/metal/msl_emit.das @@ -1010,11 +1010,11 @@ def private msl_uint_literal(v : uint) : string { // das prints integral floats bare ("{2f}" is "2"), and bare-int + suffix is not a valid MSL // literal; `suffix` is "f" for float and "h" for half (the das float16 literal's MSL spelling) def private msl_float_literal(var ctx : MslCtx; at : LineInfo; v : float; suffix : string) : string { - let s = "{v}" - if (s == "inf" || s == "-inf" || s == "nan") { + if (!is_finite(v)) { err(ctx, at, "non-finite float literal has no MSL form") return "0.0{suffix}" } + let s = "{v}" if ((s |> find(".")) < 0 && (s |> find("e")) < 0) { return "{s}.0{suffix}" } diff --git a/modules/dasUnitTest/CMakeLists.txt b/modules/dasUnitTest/CMakeLists.txt index db7464de01..5254b86730 100644 --- a/modules/dasUnitTest/CMakeLists.txt +++ b/modules/dasUnitTest/CMakeLists.txt @@ -12,6 +12,7 @@ IF ((NOT DAS_UNIT_TEST_INCLUDED) AND ((NOT ${DAS_UNIT_TEST_DISABLED}) OR (NOT DE ${DAS_UNIT_TEST_DIR}/bytecode.h ${DAS_UNIT_TEST_DIR}/test_handles.cpp ${DAS_UNIT_TEST_DIR}/test_enum.cpp + ${DAS_UNIT_TEST_DIR}/test_crt_math.cpp ${DAS_UNIT_TEST_DIR}/module_unitTest.h ${DAS_UNIT_TEST_DIR}/unitTest.h ${DAS_UNIT_TEST_DIR}/unit_test.das.inc diff --git a/modules/dasUnitTest/module_unitTest.h b/modules/dasUnitTest/module_unitTest.h index b2408145a0..effb62ee33 100644 --- a/modules/dasUnitTest/module_unitTest.h +++ b/modules/dasUnitTest/module_unitTest.h @@ -6,6 +6,7 @@ class Module_UnitTest : public Module { public: Module_UnitTest(); void addEnumTest(ModuleLibrary &); + void addCrtMath(ModuleLibrary &); virtual ModuleAotType aotRequire ( TextWriter & tw ) const override; bool appendCompiledFunctions(); }; diff --git a/modules/dasUnitTest/test_crt_math.cpp b/modules/dasUnitTest/test_crt_math.cpp new file mode 100644 index 0000000000..12ad13231f --- /dev/null +++ b/modules/dasUnitTest/test_crt_math.cpp @@ -0,0 +1,17 @@ +#include "daScript/misc/platform.h" + +#include "daScript/ast/ast_interop.h" + +#include "module_unitTest.h" +#include "unitTest.h" + +void Module_UnitTest::addCrtMath(ModuleLibrary & lib) { + addExternInline(*this, lib, "crt_expf", SideEffects::none, "crt_expf")->arg("x"); + addExternInline(*this, lib, "crt_exp2f", SideEffects::none, "crt_exp2f")->arg("x"); + addExternInline(*this, lib, "crt_logf", SideEffects::none, "crt_logf")->arg("x"); + addExternInline(*this, lib, "crt_log2f", SideEffects::none, "crt_log2f")->arg("x"); + addExternInline(*this, lib, "crt_powf", SideEffects::none, "crt_powf")->args({"x","y"}); + addExternInline(*this, lib, "crt_sinf", SideEffects::none, "crt_sinf")->arg("x"); + addExternInline(*this, lib, "crt_cosf", SideEffects::none, "crt_cosf")->arg("x"); + addExternInline(*this, lib, "crt_tanf", SideEffects::none, "crt_tanf")->arg("x"); +} diff --git a/modules/dasUnitTest/test_handles.cpp b/modules/dasUnitTest/test_handles.cpp index d7a76aeb55..2351a980f6 100644 --- a/modules/dasUnitTest/test_handles.cpp +++ b/modules/dasUnitTest/test_handles.cpp @@ -574,6 +574,7 @@ Module_UnitTest::Module_UnitTest() : Module("UnitTest") { lib.addBuiltInModule(); addBuiltinDependency(lib, Module::require("math")); addEnumTest(lib); + addCrtMath(lib); // options options["unit_test"] = Type::tFloat; // constant diff --git a/modules/dasUnitTest/unitTest.h b/modules/dasUnitTest/unitTest.h index 41e6cfcf30..071bed26ef 100644 --- a/modules/dasUnitTest/unitTest.h +++ b/modules/dasUnitTest/unitTest.h @@ -446,4 +446,14 @@ DAS_MOD_API inline void deleteFancyClassDummy(FancyClass& ) { } // this one in DAS_MOD_API void test_abi_lambda_and_function ( das::Lambda lambda, das::Func fn, int32_t lambdaSize, das::Context * context, das::LineInfoArg * lineinfo ); -DAS_MOD_API bool testBindEnumFunction ( das::Context * context, das::LineInfoArg * at ); \ No newline at end of file +DAS_MOD_API bool testBindEnumFunction ( das::Context * context, das::LineInfoArg * at ); + +//! CRT reference twins - benchmarks/core/math A/Bs these against the math builtins. +inline float crt_expf ( float a ) { return expf(a); } +inline float crt_exp2f ( float a ) { return exp2f(a); } +inline float crt_logf ( float a ) { return logf(a); } +inline float crt_log2f ( float a ) { return log2f(a); } +inline float crt_powf ( float a, float b ) { return powf(a, b); } +inline float crt_sinf ( float a ) { return sinf(a); } +inline float crt_cosf ( float a ) { return cosf(a); } +inline float crt_tanf ( float a ) { return tanf(a); } diff --git a/plans/benchmark_followups.md b/plans/benchmark_followups.md index 2adfa5a8c0..8da3ea8074 100644 --- a/plans/benchmark_followups.md +++ b/plans/benchmark_followups.md @@ -56,3 +56,18 @@ proper benchmarks - bind the CRT twins of every scalar math builtin in the dasTe the test cycle is short (no core rebuild), compare side by side in all three tiers (AOT, interpreter, JIT), and keep the winners; `benchmarks/core/math/exp.das` and the two bound functions stay. + +## the M1 AOT exp-loop residual after the CRT switch + +The 2026-08-30 re-profile: zen2 AOT exp loop 9215 -> 3412 us (level with JIT 3383 and C++ 3240), +but the M1's went only 3592 -> 3414 while its JIT and C++ sit at ~1650. The AOT TU verifiably +recompiled against the switched header, so exp is the CRT there - the residual is the AOT loop +shape itself: the `das_iterator` machinery and the `rcp_est` lane round-trip are the +suspects, and neither shows on zen2 because MSVC's codegen was the bottleneck there. Wants its +own probe on the M1 (the family-probe pattern, loop shapes instead of math). The zen2 dictionary +JIT row swung +43% in the same capture - the known bimodal per-process lane, not a change. + +The switch leaves a scalar/vector split: scalar `log2` and `pow` are exact CRT while the +`float4` arms keep the vecmath estimates (`v_log2_est_p5`, `v_pow`), so `float` and `float4` +results diverge for those two. The JIT was already exact for the scalars; the vec4f arms are +the follow-up if the divergence ever bites. diff --git a/src/misc/LUAU.LICENSE b/src/misc/LUAU.LICENSE new file mode 100644 index 0000000000..f033ccad50 --- /dev/null +++ b/src/misc/LUAU.LICENSE @@ -0,0 +1,27 @@ +The shortest-float presentation emitter in src/misc/luau_float2string.cpp is borrowed +from the Luau programming language (https://github.com/luau-lang/luau, VM/src/lnumprint.cpp), +licensed under the MIT License. The Luau license carries the Lua.org copyright for the +Lua 5.x code it derives from: + +MIT License + +Copyright (c) 2019-2025 Roblox Corporation +Copyright (c) 1994-2019 Lua.org, PUC-Rio. + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/src/misc/REVIEW.md b/src/misc/REVIEW.md index 4325e48036..d04a19faf4 100644 --- a/src/misc/REVIEW.md +++ b/src/misc/REVIEW.md @@ -9,3 +9,15 @@ that treats an unmatched name as compute is a defect. **A core-count query on a topology it cannot read answers 0.** `JobQue::get_num_perf_cores` returns 0 when the platform reports no tier structure; a diff that makes it guess is a defect. + +**A fixed-length copy in `luau_float2string.cpp` stays fixed-length.** `DAS_F2S_MEMCPY` and +`DAS_F2S_MEMSET` write the constant `sizefast` bytes, not the requested `size`; a diff that +switches either to `size` is a defect - the constant length is what the compiler inlines. + +**A diff that raises a `sizefast` constant in `luau_float2string.cpp` also raises +`DAS_F2S_BUFFER_SIZE` (`include/daScript/misc/float2string.h`), which sizes every caller's +buffer.** + +**Never call `isfinite`, `isnan`, or `signbit` in `luau_float2string.cpp` - classify special +values from the IEEE bits instead.** A build with `-ffinite-math-only` folds those calls to +constants. diff --git a/src/misc/luau_float2string.cpp b/src/misc/luau_float2string.cpp new file mode 100644 index 0000000000..7ee39b9348 --- /dev/null +++ b/src/misc/luau_float2string.cpp @@ -0,0 +1,127 @@ +#include "daScript/misc/platform.h" + +#include "daScript/misc/float2string.h" + +#include "misc/include_fmt.h" + +#include + +// the digit and layout emitter below is borrowed from the Luau programming language +// (VM/src/lnumprint.cpp), MIT License - see LUAU.LICENSE beside this file; that code is +// based on the Lua 5.x implementation, whose notice LUAU.LICENSE carries too. The digits +// come from fmt's dragonbox and the fixed/scientific crossover is fmt's, so the spelling +// is byte-identical to the fmt::format_to("{}", x) it replaces. + +namespace das { + + static const char kDigitTable[] = "0001020304050607080910111213141516171819202122232425262728293031323334353637383940414243444546474849" + "5051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899"; + + enum { kDigitRoom = 24, kOverreadSlack = 24 }; + + template struct F2SBits; + template <> struct F2SBits { typedef uint32_t U; static const uint32_t expMask = 0x7f800000u; }; + template <> struct F2SBits { typedef uint64_t U; static const uint64_t expMask = 0x7ff0000000000000ull; }; + + static char * printunsignedrev ( char * end, uint64_t num ) { + while ( num >= 10000 ) { + unsigned int tail = unsigned(num % 10000); + memcpy(end - 4, &kDigitTable[int(tail / 100) * 2], 2); + memcpy(end - 2, &kDigitTable[int(tail % 100) * 2], 2); + num /= 10000; + end -= 4; + } + unsigned int rest = unsigned(num); + while ( rest >= 10 ) { + memcpy(end - 2, &kDigitTable[int(rest % 100) * 2], 2); + rest /= 100; + end -= 2; + } + if ( rest ) { + end[-1] = char('0' + int(rest)); + end -= 1; + } + return end; + } + + static char * printexp ( char * buf, int num ) { + *buf++ = 'e'; + *buf++ = num < 0 ? '-' : '+'; + int v = num < 0 ? -num : num; + if ( v >= 100 ) { + *buf++ = char('0' + (v / 100)); + v %= 100; + } + memcpy(buf, &kDigitTable[v * 2], 2); + return buf + 2; + } + +#define DAS_F2S_MEMCPY(dst, src, size, sizefast) do { DAS_ASSERT((size) <= sizefast); memcpy(dst, src, sizefast); } while (0) +#define DAS_F2S_MEMSET(dst, ch, size, sizefast) do { DAS_ASSERT((size) <= sizefast); memset(dst, ch, sizefast); } while (0) + + template + static __forceinline char * print_shortest ( char * buf, TT x ) { + typedef typename F2SBits::U U; + const U expMask = F2SBits::expMask; + U bits; + memcpy(&bits, &x, sizeof(x)); + const U signMask = U(U(1) << (sizeof(U) * 8 - 1)); + if ( bits & signMask ) { + *buf++ = '-'; + bits &= ~signMask; + memcpy(&x, &bits, sizeof(x)); + } + if ( (bits & expMask) == expMask ) { + memcpy(buf, bits != expMask ? "nan" : "inf", 3); + return buf + 3; + } + if ( bits == 0 ) { + *buf++ = '0'; + return buf; + } + auto dec = fmt::detail::dragonbox::to_decimal(x); + char decbuf[kDigitRoom + kOverreadSlack]; + char * decend = decbuf + kDigitRoom; + char * digits = printunsignedrev(decend, uint64_t(dec.significand)); + int declen = int(decend - digits); + int output_exp = dec.exponent + declen - 1; + if ( output_exp < -4 || output_exp >= 16 ) { + *buf++ = digits[0]; + if ( declen > 1 ) { + *buf++ = '.'; + DAS_F2S_MEMCPY(buf, digits + 1, declen - 1, 16); + buf += declen - 1; + } + return printexp(buf, output_exp); + } + int dot = declen + dec.exponent; + if ( dec.exponent >= 0 ) { + DAS_F2S_MEMCPY(buf, digits, declen, 17); + DAS_F2S_MEMSET(buf + declen, '0', dec.exponent, 16); + return buf + declen + dec.exponent; + } else if ( dot > 0 ) { + DAS_F2S_MEMCPY(buf, digits, dot, 16); + buf[dot] = '.'; + DAS_F2S_MEMCPY(buf + dot + 1, digits + dot, declen - dot, 16); + return buf + declen + 1; + } else { + buf[0] = '0'; + buf[1] = '.'; + DAS_F2S_MEMSET(buf + 2, '0', -dot, 4); + DAS_F2S_MEMCPY(buf + 2 - dot, digits, declen, 17); + return buf + 2 - dot + declen; + } + } + +#undef DAS_F2S_MEMCPY +#undef DAS_F2S_MEMSET + + char * float2string ( char * buf, float value ) { + return print_shortest(buf, value); + } + + char * double2string ( char * buf, double value ) { + return print_shortest(buf, value); + } + +} diff --git a/src/misc/string_writer.cpp b/src/misc/string_writer.cpp index 53c9c06d03..a84a5192b6 100644 --- a/src/misc/string_writer.cpp +++ b/src/misc/string_writer.cpp @@ -1,6 +1,7 @@ #include "daScript/misc/platform.h" #include "daScript/misc/string_writer.h" #include "misc/include_fmt.h" +#include "daScript/misc/float2string.h" #include @@ -64,8 +65,16 @@ namespace das { StringWriter & StringWriter::operator << (char * v) { return write(v ? (const char*)v : ""); } StringWriter & StringWriter::operator << (const char * v) { return write(v ? v : ""); } StringWriter & StringWriter::operator << (const string & v) { return v.length() ? writeStr(v.c_str(), v.length()) : *this; } - StringWriter & StringWriter::operator << (float v) { return fixed ? format("{:.9}", v) : format("{}", v); } - StringWriter & StringWriter::operator << (double v) { return fixed ? format("{:.17}", v) : format("{}", v); } + StringWriter & StringWriter::operator << (float v) { + if ( fixed ) return format("{:.9}", v); + char buf[DAS_F2S_BUFFER_SIZE]; + return writeStr(buf, size_t(float2string(buf, v) - buf)); + } + StringWriter & StringWriter::operator << (double v) { + if ( fixed ) return format("{:.17}", v); + char buf[DAS_F2S_BUFFER_SIZE]; + return writeStr(buf, size_t(double2string(buf, v) - buf)); + } // fixed buffer string writer diff --git a/src/simulate/runtime_string.cpp b/src/simulate/runtime_string.cpp index 4c804461ed..7924f9def6 100644 --- a/src/simulate/runtime_string.cpp +++ b/src/simulate/runtime_string.cpp @@ -9,6 +9,7 @@ #include "daScript/simulate/simulate_nodes.h" #include "daScript/simulate/sim_policy.h" #include "misc/include_fmt.h" +#include "daScript/misc/float2string.h" namespace das { @@ -252,19 +253,17 @@ namespace das return das_lexical_cast_int_T(x, hex, __context__, at); } - template - __forceinline char * das_lexical_cast_fp_T ( TT x, Context * __context__, LineInfoArg * at ) { - char buffer[128]; - auto result = fmt::format_to(buffer,FMT_STRING("{}"),x); + char * das_lexical_cast_fp_f ( float x, Context * __context__, LineInfoArg * at ) { + char buffer[DAS_F2S_BUFFER_SIZE]; + auto result = float2string(buffer, x); *result = 0; return __context__->allocateString(buffer,uint32_t(result-buffer),at); } - - char * das_lexical_cast_fp_f ( float x, Context * __context__, LineInfoArg * at ) { - return das_lexical_cast_fp_T(x, __context__, at); - } char * das_lexical_cast_fp_d ( double x, Context * __context__, LineInfoArg * at ) { - return das_lexical_cast_fp_T(x, __context__, at); + char buffer[DAS_F2S_BUFFER_SIZE]; + auto result = double2string(buffer, x); + *result = 0; + return __context__->allocateString(buffer,uint32_t(result-buffer),at); } // temp-string reclaim wrapper: the compiler inserts this around a [temp_string_result] call diff --git a/tests-cpp/REVIEW.md b/tests-cpp/REVIEW.md index e857370692..af4b8d8543 100644 --- a/tests-cpp/REVIEW.md +++ b/tests-cpp/REVIEW.md @@ -9,7 +9,6 @@ checklist as well as this one.** **A test that owns its own `CMakeLists.txt`, wherever the diff puts it, answers to the `big/` subfolder's checklist as well as this one.** -**A diff adding or changing a test that returns early on a missing artifact - a file or -binary the test needs that not every lane builds - says in the PR which lane runs it with -that artifact present, naming the command.** A self-skipping test reports pass wherever the -artifact is absent. +**A diff adding or changing a test that skips or reports pass without running, under any +condition not every lane meets - a missing file or binary, an unset environment variable - +says in the PR which lane runs it for real, naming the command.** diff --git a/tests-cpp/small/REVIEW.md b/tests-cpp/small/REVIEW.md index c4c7567064..b14db073bd 100644 --- a/tests-cpp/small/REVIEW.md +++ b/tests-cpp/small/REVIEW.md @@ -7,3 +7,7 @@ doc: `skills/internal/writing_cpp_tests.md` (repo root). assertion watches - the struct, offset, or file named in the pin's own assertion text.** A pin test (`*_pin.cpp`) asserts that a compiled-in layout, offset, or watched file set stays put. + +**A diff that weakens `test_float2string.cpp`'s byte-for-byte compare against fmt's `"{}"` - +fewer patterns, looser comparison, or a dropped arm - is a defect.** That test holds +`float2string`/`double2string` to the spelling the `tests/msl/` and `tests/glsl/` goldens pin. diff --git a/tests-cpp/small/test_float2string.cpp b/tests-cpp/small/test_float2string.cpp new file mode 100644 index 0000000000..e63c4488ec --- /dev/null +++ b/tests-cpp/small/test_float2string.cpp @@ -0,0 +1,142 @@ +#include +#include "daScript/daScript.h" +#include "daScript/misc/float2string.h" +#include "misc/include_fmt.h" + +#include +#include +#include +#include +#include +#include +#include + +using namespace das; + +static bool same_float ( uint32_t bits, std::string & mismatch ) { + float x; + memcpy(&x, &bits, sizeof(x)); + char ours[128]; + char * end = float2string(ours, x); + char fmtText[128]; + auto fmtEnd = fmt::format_to(fmtText, FMT_STRING("{}"), x); + if ( end - ours != fmtEnd - fmtText || memcmp(ours, fmtText, size_t(end - ours)) != 0 ) { + mismatch = fmt::format("bits 0x{:08x}: ours '{}' fmt '{}'", bits, + std::string(ours, end), std::string(fmtText, fmtEnd)); + return false; + } + return true; +} + +static bool same_double ( uint64_t bits, std::string & mismatch ) { + double x; + memcpy(&x, &bits, sizeof(x)); + char ours[128]; + char * end = double2string(ours, x); + char fmtText[128]; + auto fmtEnd = fmt::format_to(fmtText, FMT_STRING("{}"), x); + if ( end - ours != fmtEnd - fmtText || memcmp(ours, fmtText, size_t(end - ours)) != 0 ) { + mismatch = fmt::format("bits 0x{:016x}: ours '{}' fmt '{}'", bits, + std::string(ours, end), std::string(fmtText, fmtEnd)); + return false; + } + return true; +} + +static const uint32_t kPosZero = 0x00000000u, kNegZero = 0x80000000u; +static const uint32_t kPosInf = 0x7f800000u, kNegInf = 0xff800000u; +static const uint32_t kNan = 0x7fc00000u, kNegNan = 0xffc00000u; +static const uint32_t kMinSubnormal = 0x00000001u, kMaxSubnormal = 0x007fffffu; +static const uint32_t kFltMin = 0x00800000u, kFltMax = 0x7f7fffffu; +static const uint32_t kOne = 0x3f800000u, kPointOne = 0x3dcccccdu, kOneE10 = 0x501502f9u; +static const uint32_t kMantissaStride = 0x02467u; + +TEST_CASE("float2string matches the fmt spelling it replaced") { + std::string mismatch; + const uint32_t specialAndBoundaryBits[] = { + kPosZero, kNegZero, kPosInf, kNegInf, kNan, kNegNan, + kMinSubnormal, kMaxSubnormal, kFltMin, kFltMax, kOne, kPointOne, kOneE10, + }; + for ( auto bits : specialAndBoundaryBits ) { + CHECK_MESSAGE(same_float(bits, mismatch), mismatch); + } + const float kTwoDigitScientific[] = { 1.5e20f, 2.5e-9f, -7.5e18f }; + for ( float v : kTwoDigitScientific ) { + uint32_t b; + memcpy(&b, &v, sizeof(b)); + CHECK_MESSAGE(same_float(b, mismatch), mismatch); + } + for ( uint32_t exp = 0; exp <= 0xffu; ++exp ) { + for ( uint32_t m = 0; m < 0x800000u; m += kMantissaStride ) { + uint32_t bits = (exp << 23) | m; + if ( !same_float(bits, mismatch) ) { FAIL(mismatch); return; } + bits |= 0x80000000u; + if ( !same_float(bits, mismatch) ) { FAIL(mismatch); return; } + } + } +} + +static const uint64_t kDPosZero = 0x0000000000000000ull, kDNegZero = 0x8000000000000000ull; +static const uint64_t kDPosInf = 0x7ff0000000000000ull, kDNegInf = 0xfff0000000000000ull; +static const uint64_t kDNan = 0x7ff8000000000000ull, kDNegNan = 0xfff8000000000000ull; +static const uint64_t kDMinSubnormal = 0x0000000000000001ull, kDMaxSubnormal = 0x000fffffffffffffull; +static const uint64_t kDblMin = 0x0010000000000000ull, kDblMax = 0x7fefffffffffffffull; +static const uint64_t kDOne = 0x3ff0000000000000ull, kDPointOne = 0x3fb999999999999aull, kDOneE4 = 0x40c3880000000000ull; +static const uint64_t kDExpMask = 0x7ff0000000000000ull; +static const uint64_t kLcgMul = 6364136223846793005ull, kLcgAdd = 1442695040888963407ull; +static const int kDoubleSamples = 2000000; + +TEST_CASE("double2string matches the fmt spelling it replaced") { + std::string mismatch; + const uint64_t specialAndBoundaryDoubleBits[] = { + kDPosZero, kDNegZero, kDPosInf, kDNegInf, kDNan, kDNegNan, + kDMinSubnormal, kDMaxSubnormal, kDblMin, kDblMax, kDOne, kDPointOne, kDOneE4, + }; + for ( auto bits : specialAndBoundaryDoubleBits ) { + CHECK_MESSAGE(same_double(bits, mismatch), mismatch); + } + const double kTwoDigitScientificD[] = { 1.5e17, 2.5e-7, -7.5e300 }; + for ( double v : kTwoDigitScientificD ) { + uint64_t b; + memcpy(&b, &v, sizeof(b)); + CHECK_MESSAGE(same_double(b, mismatch), mismatch); + } + uint64_t dbits = 1; + for ( int i = 0; i < kDoubleSamples; ++i ) { + dbits = dbits * kLcgMul + kLcgAdd; + if ( (dbits & kDExpMask) == kDExpMask ) continue; + if ( !same_double(dbits, mismatch) ) { FAIL(mismatch); return; } + } +} + +TEST_CASE("float2string matches fmt on every finite float32 - set DASLANG_F2S_EXHAUSTIVE=1, minutes, all cores" + * doctest::skip(getenv("DASLANG_F2S_EXHAUSTIVE") == nullptr)) { + const unsigned workers = std::thread::hardware_concurrency() ? std::thread::hardware_concurrency() : 8; + std::atomic next(0); + std::atomic ok(true); + std::vector mismatches(workers); + std::vector pool; + for ( unsigned w = 0; w != workers; ++w ) { + pool.emplace_back([&, w]() { + std::string mismatch; + const uint64_t chunk = 1u << 20; + for ( ;; ) { + uint64_t base = next.fetch_add(chunk); + if ( base > 0xffffffffull || !ok.load(std::memory_order_relaxed) ) break; + uint64_t top = base + chunk; + if ( top > 0x100000000ull ) top = 0x100000000ull; + for ( uint64_t b = base; b != top; ++b ) { + if ( !same_float(uint32_t(b), mismatch) ) { + mismatches[w] = mismatch; + ok = false; + break; + } + } + } + }); + } + for ( auto & t : pool ) t.join(); + for ( auto & m : mismatches ) { + CHECK_MESSAGE(m.empty(), m); + } +} diff --git a/tests/debug/test_sprint_format.das b/tests/debug/test_sprint_format.das index 18ff754b6f..d1909117c9 100644 --- a/tests/debug/test_sprint_format.das +++ b/tests/debug/test_sprint_format.das @@ -177,3 +177,15 @@ def test_variant_format(t : T?) { t |> success(s |> find("variant(") != -1, "should use variant() syntax") } } + +[test] +def test_float_default_spelling(t : T?) { + t |> run("float and double default spelling") @@(t : T?) { + let f = 3.14f + let d = 2.5e-7lf + t |> equal("{f}", "3.14") + t |> equal("{d}", "2.5e-07") + t |> equal(string(f), "3.14") + t |> equal(string(d), "2.5e-07") + } +} diff --git a/tests/math/test_crt_twins.das b/tests/math/test_crt_twins.das new file mode 100644 index 0000000000..12342fdcfc --- /dev/null +++ b/tests/math/test_crt_twins.das @@ -0,0 +1,32 @@ +options gen2 +require dastest/testing_boost +require math +require UnitTest + +[test] +def test_scalar_crt_twins(t : T?) { + t |> run("switched scalar builtins are the CRT exactly") @@(t : T?) { + var y = 0.05f + while (y < 20.) { + t |> equal(exp(y), crt_expf(y), "exp({y})") + t |> equal(exp2(y), crt_exp2f(y), "exp2({y})") + t |> equal(log2(y), crt_log2f(y), "log2({y})") + t |> equal(pow(y, 1.5f), crt_powf(y, 1.5f), "pow({y}, 1.5)") + y += 0.37f + } + } + t |> run("the measuring twins agree with the double CRT") @@(t : T?) { + var y = 0.05f + while (y < 20.) { + t |> success(abs(crt_logf(y) - float(log(double(y)))) <= 4e-7f * max(1.0f, abs(crt_logf(y))), "crt_logf({y}) = {crt_logf(y)}") + y += 0.37f + } + var z = -1.4f + while (z < 1.4) { + t |> success(abs(crt_sinf(z) - float(sin(double(z)))) <= 4e-7f, "crt_sinf({z}) = {crt_sinf(z)}") + t |> success(abs(crt_cosf(z) - float(cos(double(z)))) <= 4e-7f, "crt_cosf({z}) = {crt_cosf(z)}") + t |> success(abs(crt_tanf(z) - float(tan(double(z)))) <= 2e-6f * max(1.0f, abs(crt_tanf(z))), "crt_tanf({z}) = {crt_tanf(z)}") + z += 0.11f + } + } +} diff --git a/tests/msl/_fail_closed/_fc_nan_literal.das b/tests/msl/_fail_closed/_fc_nan_literal.das new file mode 100644 index 0000000000..80710e14e7 --- /dev/null +++ b/tests/msl/_fail_closed/_fc_nan_literal.das @@ -0,0 +1,18 @@ +// Fail-closed fixture: a non-finite float literal (constant folding mints -nan here) +// must be rejected - MSL has no spelling for it. + +expect 50501 + +options gen2 + +require metal/msl_shader + +class FcNanLiteral { + @ssbo @binding = 0 dst : array + + [metal_kernel(name="fc_nan_literal_msl")] + def kern { + let i = gl_GlobalInvocationID.x + dst[i] = 0.0f / 0.0f + } +} diff --git a/tests/msl/test_msl_fail_closed.das b/tests/msl/test_msl_fail_closed.das index b40dceecef..b3c47f70ff 100644 --- a/tests/msl/test_msl_fail_closed.das +++ b/tests/msl/test_msl_fail_closed.das @@ -57,6 +57,7 @@ def private check_rejects(t : T?; fixture, needle : string) { def test_fail_closed_rejections(t : T?) { t |> run("emitter rejects kernel-illegal constructs cleanly") <| @(t : T?) { check_rejects(t, "_fc_uniform_written", "@uniform member `n` is written") + check_rejects(t, "_fc_nan_literal", "non-finite float literal has no MSL form") check_rejects(t, "_fc_uniform_array", "array members are @ssbo buffers") check_rejects(t, "_fc_return_value", "kernels return void") check_rejects(t, "_fc_recursion", "is recursive - MSL forbids recursion")