From e100623997b28b1329e05582c1e5e389e9054303 Mon Sep 17 00:00:00 2001 From: Boris Batkin Date: Sun, 30 Aug 2026 12:44:37 -0700 Subject: [PATCH 1/9] scalar float Exp/Exp2/Log2/Pow call the CRT; UnitTest binds the CRT twins the benchmarks race The vecmath lane trick (extract, set_x, polynomial, extract) is 2-3x the CRT on the scalar path: the exp loop's 9.45 ns/iter against expf's 3.26 under the AOT unit's own flags on the 3990X, with the same shape on the M1. Winners by two-box probe: exp, exp2, pow (exact at tie speed) and log2 (v_log2_est_p5 was an estimate where the JIT is exact) go CRT; log, sin, cos, tan stay on the lane where it wins. benchmarks/core/math/scalar_crt.das races every pair through the UnitTest binds. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01J97ymQXmMxGTmHYUhLrgVd --- benchmarks/README.md | 1 + benchmarks/core/math/scalar_crt.das | 137 +++++++++++++++++++++++++ include/daScript/simulate/sim_policy.h | 8 +- modules/dasUnitTest/CMakeLists.txt | 1 + modules/dasUnitTest/module_unitTest.h | 1 + modules/dasUnitTest/test_crt_math.cpp | 17 +++ modules/dasUnitTest/test_handles.cpp | 1 + modules/dasUnitTest/unitTest.h | 12 ++- 8 files changed, 173 insertions(+), 5 deletions(-) create mode 100644 benchmarks/core/math/scalar_crt.das create mode 100644 modules/dasUnitTest/test_crt_math.cpp diff --git a/benchmarks/README.md b/benchmarks/README.md index 250fb177d0..670b7f0de6 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 rest of the lane-trick scalar family (log, log2, exp2, pow, sin, cos, tan) against the CRT twins `UnitTest` binds (`crt_logf`...); 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/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/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..7df6100ba2 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 twins of the scalar float math builtins whose interp/AOT arm runs the vecmath lane +// trick - the A/B lane for benchmarks/core/math; the JIT's intrinsics already call these +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); } From 640fd8fb0e3fdad09abce672ce50514c8aebf938 Mon Sep 17 00:00:00 2001 From: Boris Batkin Date: Sun, 30 Aug 2026 12:44:37 -0700 Subject: [PATCH 2/9] float to string through the Luau emitter over fmt's dragonbox - 2.4x on the hot path, byte-identical The presentation half of Luau's lnumprint.cpp (MIT, LUAU.LICENSE vendored and installed) drives the dragonbox digits fmt already instantiates; fmt's crossover and spelling are kept, so every finite float32 - all 2^32 of them, swept in tests-cpp - and sampled doubles print byte-for-byte what fmt::format_to("{}") produced. Both sinks swap: string(f) in runtime_string.cpp and "{f}" in string_writer.cpp; the {:.9} FIXEDFP path stays on fmt. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01J97ymQXmMxGTmHYUhLrgVd --- CMakeLists.txt | 6 +- ci/smoke_test_bundle.sh | 2 +- include/daScript/misc/float2string.h | 9 ++ src/misc/LUAU.LICENSE | 27 ++++++ src/misc/luau_float2string.cpp | 118 ++++++++++++++++++++++++ src/misc/string_writer.cpp | 13 ++- src/simulate/runtime_string.cpp | 15 ++-- tests-cpp/small/test_float2string.cpp | 124 ++++++++++++++++++++++++++ 8 files changed, 302 insertions(+), 12 deletions(-) create mode 100644 include/daScript/misc/float2string.h create mode 100644 src/misc/LUAU.LICENSE create mode 100644 src/misc/luau_float2string.cpp create mode 100644 tests-cpp/small/test_float2string.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 810fee7c68..042fad0d74 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,7 +1697,8 @@ 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/string_writer.h + include/daScript/misc/float2string.h +include/daScript/misc/string_writer.h include/daScript/misc/sysos.h include/daScript/misc/type_name.h include/daScript/misc/uric.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/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..a3391cbc52 --- /dev/null +++ b/include/daScript/misc/float2string.h @@ -0,0 +1,9 @@ +#pragma once + +namespace das { + // 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, and the buffer + // must have 48 bytes of headroom past buf - the emitter's fixed-length copies overshoot + DAS_API char * float2string ( char * buf, float value ); + DAS_API char * double2string ( char * buf, double value ); +} 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/luau_float2string.cpp b/src/misc/luau_float2string.cpp new file mode 100644 index 0000000000..258489b322 --- /dev/null +++ b/src/misc/luau_float2string.cpp @@ -0,0 +1,118 @@ +#include "daScript/misc/platform.h" + +#include "daScript/misc/float2string.h" + +#include "misc/include_fmt.h" + +#include +#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"; + + 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; + } + +// fixed-length memcpy/memset lower to plain SIMD+scalar writes; both buffers carry the +// headroom the header's contract demands, so the overshoot never leaves owned memory +#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 ) { + if ( std::signbit(x) ) { + *buf++ = '-'; + x = -x; + } + if ( !std::isfinite(x) ) { + memcpy(buf, std::isnan(x) ? "nan" : "inf", 3); + return buf + 3; + } + if ( x == TT(0) ) { + *buf++ = '0'; + return buf; + } + auto dec = fmt::detail::dragonbox::to_decimal(x); + char decbuf[48]; + char * decend = decbuf + 24; + 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..57c7ace983 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[64]; + return writeStr(buf, size_t(float2string(buf, v) - buf)); + } + StringWriter & StringWriter::operator << (double v) { + if ( fixed ) return format("{:.17}", v); + char buf[80]; + 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..fe73663e23 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 * das_lexical_cast_fp_f ( float x, Context * __context__, LineInfoArg * at ) { char buffer[128]; - auto result = fmt::format_to(buffer,FMT_STRING("{}"),x); + 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[128]; + 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/small/test_float2string.cpp b/tests-cpp/small/test_float2string.cpp new file mode 100644 index 0000000000..9219d1a665 --- /dev/null +++ b/tests-cpp/small/test_float2string.cpp @@ -0,0 +1,124 @@ +// The borrowed Luau emitter (src/misc/luau_float2string.cpp) must spell every float +// byte-for-byte as the fmt "{}" path it replaced - the corpus pins ~60 assertions and the +// msl/glsl goldens on that spelling. CI runs the sampled sweep; DAS_F2S_EXHAUSTIVE=1 runs +// every finite float32 (minutes, multithreaded) - the arc's local validation. +#include +#include "daScript/daScript.h" +#include "daScript/misc/float2string.h" +#include "misc/include_fmt.h" + +#include +#include +#include +#include +#include +#include + +using namespace das; + +static bool same_float ( uint32_t bits, std::string & bad ) { + float x; + memcpy(&x, &bits, sizeof(x)); + char ours[128]; + char * end = float2string(ours, x); + char theirs[128]; + auto tend = fmt::format_to(theirs, FMT_STRING("{}"), x); + if ( end - ours != tend - theirs || memcmp(ours, theirs, size_t(end - ours)) != 0 ) { + bad = "bits 0x" + std::to_string(bits) + ": ours '" + std::string(ours, end) + + "' fmt '" + std::string(theirs, tend) + "'"; + return false; + } + return true; +} + +static bool same_double ( uint64_t bits, std::string & bad ) { + double x; + memcpy(&x, &bits, sizeof(x)); + char ours[128]; + char * end = double2string(ours, x); + char theirs[128]; + auto tend = fmt::format_to(theirs, FMT_STRING("{}"), x); + if ( end - ours != tend - theirs || memcmp(ours, theirs, size_t(end - ours)) != 0 ) { + bad = "bits 0x" + std::to_string(bits) + ": ours '" + std::string(ours, end) + + "' fmt '" + std::string(theirs, tend) + "'"; + return false; + } + return true; +} + +TEST_CASE("float2string matches the fmt spelling it replaced") { + std::string bad; + // every special and boundary shape + const uint32_t fixtures[] = { + 0x00000000u, 0x80000000u, // +0 -0 + 0x7f800000u, 0xff800000u, // +inf -inf + 0x7fc00000u, 0xffc00000u, // nan -nan + 0x00000001u, 0x007fffffu, // subnormals + 0x00800000u, 0x7f7fffffu, // FLT_MIN FLT_MAX + 0x3f800000u, 0x3dcccccdu, 0x501502f9u, // 1, 0.1, 1e10 + }; + for ( auto bits : fixtures ) { + CHECK_MESSAGE(same_float(bits, bad), bad); + } + // every decade float reaches, in fixed steps through the mantissa + for ( uint32_t exp = 0; exp <= 0xff; ++exp ) { + for ( uint32_t m = 0; m < 0x800000u; m += 0x02467u ) { + uint32_t bits = (exp << 23) | m; + if ( !same_float(bits, bad) ) { FAIL(bad); return; } + bits |= 0x80000000u; + if ( !same_float(bits, bad) ) { FAIL(bad); return; } + } + } + // doubles: the same walk at double density + const uint64_t dfixtures[] = { + 0x0000000000000000ull, 0x8000000000000000ull, + 0x7ff0000000000000ull, 0xfff0000000000000ull, + 0x7ff8000000000000ull, 0xfff8000000000000ull, + 0x0000000000000001ull, 0x000fffffffffffffull, + 0x0010000000000000ull, 0x7fefffffffffffffull, + 0x3ff0000000000000ull, 0x3fb999999999999aull, 0x40c3880000000000ull, + }; + for ( auto bits : dfixtures ) { + CHECK_MESSAGE(same_double(bits, bad), bad); + } + uint64_t dbits = 1; + for ( int i = 0; i < 2000000; ++i ) { + dbits = dbits * 6364136223846793005ull + 1442695040888963407ull; + uint64_t b = dbits; + if ( (b & 0x7ff0000000000000ull) == 0x7ff0000000000000ull ) continue; + if ( !same_double(b, bad) ) { FAIL(bad); return; } + } + CHECK(true); +} + +TEST_CASE("float2string matches fmt on every finite float32" * doctest::skip(getenv("DAS_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 bads(workers); + std::vector pool; + for ( unsigned w = 0; w != workers; ++w ) { + pool.emplace_back([&, w]() { + std::string bad; + 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), bad) ) { + bads[w] = bad; + ok = false; + break; + } + } + } + }); + } + for ( auto & t : pool ) t.join(); + for ( auto & bad : bads ) { + CHECK_MESSAGE(bad.empty(), bad); + } + CHECK(ok.load()); +} From bcc8aff6cc05c7b4c4da6c8f88de37048eeef40f Mon Sep 17 00:00:00 2001 From: Boris Batkin Date: Sun, 30 Aug 2026 12:49:32 -0700 Subject: [PATCH 3/9] msl float literals: -nan is non-finite too fmt honours the sign bit and x86's default quiet NaN is negative, so "{0f/0f}" prints -nan; the guard compared against "nan" alone and emitted -nanf into MSL instead of raising the error. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01J97ymQXmMxGTmHYUhLrgVd --- modules/dasMetal/metal/msl_emit.das | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/dasMetal/metal/msl_emit.das b/modules/dasMetal/metal/msl_emit.das index a3727bb6cb..3a848ad4b0 100644 --- a/modules/dasMetal/metal/msl_emit.das +++ b/modules/dasMetal/metal/msl_emit.das @@ -1011,7 +1011,7 @@ def private msl_uint_literal(v : uint) : string { // 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 (s == "inf" || s == "-inf" || s == "nan" || s == "-nan") { err(ctx, at, "non-finite float literal has no MSL form") return "0.0{suffix}" } From 8dad723af499b870a4da48f32e85e510c1703dde Mon Sep 17 00:00:00 2001 From: Boris Batkin Date: Sun, 30 Aug 2026 13:52:28 -0700 Subject: [PATCH 4/9] benchmark ledger: the M1 AOT exp-loop residual is the loop shape, not the math Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01J97ymQXmMxGTmHYUhLrgVd --- plans/benchmark_followups.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/plans/benchmark_followups.md b/plans/benchmark_followups.md index 2adfa5a8c0..01c02f4482 100644 --- a/plans/benchmark_followups.md +++ b/plans/benchmark_followups.md @@ -56,3 +56,13 @@ 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. From f74a17a65d2e4aef3be43e6e585416cd493c37a4 Mon Sep 17 00:00:00 2001 From: Boris Batkin Date: Sun, 30 Aug 2026 14:05:22 -0700 Subject: [PATCH 5/9] comment harvest: the byte-identity and fixed-length-copy contracts land as rules, the bit patterns get names The test's narrowing ban lands in tests-cpp/small/REVIEW.md; the fixed-length-copy invariants in src/misc/REVIEW.md; the fixture bit patterns become named constants; the UnitTest twins keep a one-line contract doc; the header's buffer clause reads one way now. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01J97ymQXmMxGTmHYUhLrgVd --- benchmarks/README.md | 2 +- include/daScript/misc/float2string.h | 2 +- modules/dasUnitTest/unitTest.h | 4 +-- src/misc/REVIEW.md | 9 ++++++ src/misc/luau_float2string.cpp | 2 -- tests-cpp/small/REVIEW.md | 5 +++ tests-cpp/small/test_float2string.cpp | 46 +++++++++++++-------------- 7 files changed, 41 insertions(+), 29 deletions(-) diff --git a/benchmarks/README.md b/benchmarks/README.md index 670b7f0de6..1f8eb160ab 100644 --- a/benchmarks/README.md +++ b/benchmarks/README.md @@ -55,7 +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 rest of the lane-trick scalar family (log, log2, exp2, pow, sin, cos, tan) against the CRT twins `UnitTest` binds (`crt_logf`...); same shape as `exp.das` | +| `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/include/daScript/misc/float2string.h b/include/daScript/misc/float2string.h index a3391cbc52..986da1cfe3 100644 --- a/include/daScript/misc/float2string.h +++ b/include/daScript/misc/float2string.h @@ -3,7 +3,7 @@ namespace das { // 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, and the buffer - // must have 48 bytes of headroom past buf - the emitter's fixed-length copies overshoot + // must be at least 48 bytes - the emitter's fixed-length copies overshoot past the text DAS_API char * float2string ( char * buf, float value ); DAS_API char * double2string ( char * buf, double value ); } diff --git a/modules/dasUnitTest/unitTest.h b/modules/dasUnitTest/unitTest.h index 7df6100ba2..97b9ad8b2a 100644 --- a/modules/dasUnitTest/unitTest.h +++ b/modules/dasUnitTest/unitTest.h @@ -447,8 +447,8 @@ 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 ); -// CRT twins of the scalar float math builtins whose interp/AOT arm runs the vecmath lane -// trick - the A/B lane for benchmarks/core/math; the JIT's intrinsics already call these + +//! CRT reference twins the scalar math benchmarks in benchmarks/core/math A/B against the 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); } diff --git a/src/misc/REVIEW.md b/src/misc/REVIEW.md index 4325e48036..8ece698026 100644 --- a/src/misc/REVIEW.md +++ b/src/misc/REVIEW.md @@ -9,3 +9,12 @@ 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 the caller +buffer minimum stated in `include/daScript/misc/float2string.h`, in the same change.** Those +copies overshoot past the digits they write, and that stated minimum is what keeps the +overshoot inside the caller's buffer. diff --git a/src/misc/luau_float2string.cpp b/src/misc/luau_float2string.cpp index 258489b322..2746c45553 100644 --- a/src/misc/luau_float2string.cpp +++ b/src/misc/luau_float2string.cpp @@ -51,8 +51,6 @@ namespace das { return buf + 2; } -// fixed-length memcpy/memset lower to plain SIMD+scalar writes; both buffers carry the -// headroom the header's contract demands, so the overshoot never leaves owned memory #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) diff --git a/tests-cpp/small/REVIEW.md b/tests-cpp/small/REVIEW.md index c4c7567064..04f95939d3 100644 --- a/tests-cpp/small/REVIEW.md +++ b/tests-cpp/small/REVIEW.md @@ -7,3 +7,8 @@ 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 narrows what `test_float2string.cpp` compares - fewer bit patterns, a +tolerance in place of the byte compare, or a dropped `double2string` arm - is a defect.** +That test is what holds `float2string` and `double2string` to fmt's `"{}"` spelling, which +the msl/glsl goldens and the string corpus compare byte for byte. diff --git a/tests-cpp/small/test_float2string.cpp b/tests-cpp/small/test_float2string.cpp index 9219d1a665..dba8d0acf9 100644 --- a/tests-cpp/small/test_float2string.cpp +++ b/tests-cpp/small/test_float2string.cpp @@ -1,7 +1,4 @@ -// The borrowed Luau emitter (src/misc/luau_float2string.cpp) must spell every float -// byte-for-byte as the fmt "{}" path it replaced - the corpus pins ~60 assertions and the -// msl/glsl goldens on that spelling. CI runs the sampled sweep; DAS_F2S_EXHAUSTIVE=1 runs -// every finite float32 (minutes, multithreaded) - the arc's local validation. +// DAS_F2S_EXHAUSTIVE=1 runs the exhaustive case below: every finite float32, minutes, all cores. #include #include "daScript/daScript.h" #include "daScript/misc/float2string.h" @@ -16,6 +13,20 @@ using namespace das; +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 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 bool same_float ( uint32_t bits, std::string & bad ) { float x; memcpy(&x, &bits, sizeof(x)); @@ -48,19 +59,13 @@ static bool same_double ( uint64_t bits, std::string & bad ) { TEST_CASE("float2string matches the fmt spelling it replaced") { std::string bad; - // every special and boundary shape - const uint32_t fixtures[] = { - 0x00000000u, 0x80000000u, // +0 -0 - 0x7f800000u, 0xff800000u, // +inf -inf - 0x7fc00000u, 0xffc00000u, // nan -nan - 0x00000001u, 0x007fffffu, // subnormals - 0x00800000u, 0x7f7fffffu, // FLT_MIN FLT_MAX - 0x3f800000u, 0x3dcccccdu, 0x501502f9u, // 1, 0.1, 1e10 + const uint32_t specialAndBoundaryBits[] = { + kPosZero, kNegZero, kPosInf, kNegInf, kNan, kNegNan, + kMinSubnormal, kMaxSubnormal, kFltMin, kFltMax, kOne, kPointOne, kOneE10, }; - for ( auto bits : fixtures ) { + for ( auto bits : specialAndBoundaryBits ) { CHECK_MESSAGE(same_float(bits, bad), bad); } - // every decade float reaches, in fixed steps through the mantissa for ( uint32_t exp = 0; exp <= 0xff; ++exp ) { for ( uint32_t m = 0; m < 0x800000u; m += 0x02467u ) { uint32_t bits = (exp << 23) | m; @@ -69,16 +74,11 @@ TEST_CASE("float2string matches the fmt spelling it replaced") { if ( !same_float(bits, bad) ) { FAIL(bad); return; } } } - // doubles: the same walk at double density - const uint64_t dfixtures[] = { - 0x0000000000000000ull, 0x8000000000000000ull, - 0x7ff0000000000000ull, 0xfff0000000000000ull, - 0x7ff8000000000000ull, 0xfff8000000000000ull, - 0x0000000000000001ull, 0x000fffffffffffffull, - 0x0010000000000000ull, 0x7fefffffffffffffull, - 0x3ff0000000000000ull, 0x3fb999999999999aull, 0x40c3880000000000ull, + const uint64_t specialAndBoundaryDoubleBits[] = { + kDPosZero, kDNegZero, kDPosInf, kDNegInf, kDNan, kDNegNan, + kDMinSubnormal, kDMaxSubnormal, kDblMin, kDblMax, kDOne, kDPointOne, kDOneE4, }; - for ( auto bits : dfixtures ) { + for ( auto bits : specialAndBoundaryDoubleBits ) { CHECK_MESSAGE(same_double(bits, bad), bad); } uint64_t dbits = 1; From 21899df383be5083839a4e008b10d3e737495ea9 Mon Sep 17 00:00:00 2001 From: Boris Batkin Date: Sun, 30 Aug 2026 14:48:22 -0700 Subject: [PATCH 6/9] review round: IEEE-bit classification for fast-math builds, the buffer constant, the -nan fixture, the twins and wiring pins, six rule documents hardened The emitter classifies specials from the bits (a -ffinite-math-only build folds isfinite/isnan/signbit); DAS_F2S_BUFFER_SIZE sizes every caller buffer by compilation; the msl guard asks is_finite(v) and its rejection gets the _fc_nan_literal fixture and needle; tests/math/test_crt_twins.das pins the switched scalars to the CRT exactly and validates all eight twin binds; sprint-format pins the lexical-cast wiring; the byte-identity test splits by width, prints its bits in hex, names its strides, and gains the two-digit-scientific boundaries; ci, src/misc, tests-cpp, tests-cpp/small and dasMetal checklists re-worded under two dragon passes; the simulate ledger records the log2 trade. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01J97ymQXmMxGTmHYUhLrgVd --- CMakeLists.txt | 2 +- ci/REVIEW.md | 6 +- include/daScript/misc/float2string.h | 7 +- include/daScript/simulate/ARCHITECTURE.md | 10 ++ modules/dasMetal/REVIEW.md | 9 +- modules/dasMetal/metal/msl_emit.das | 4 +- modules/dasUnitTest/unitTest.h | 2 +- plans/benchmark_followups.md | 5 + src/misc/REVIEW.md | 11 +- src/misc/luau_float2string.cpp | 27 +++-- src/misc/string_writer.cpp | 4 +- src/simulate/runtime_string.cpp | 4 +- tests-cpp/REVIEW.md | 7 +- tests-cpp/small/REVIEW.md | 7 +- tests-cpp/small/test_float2string.cpp | 114 ++++++++++++--------- tests/debug/test_sprint_format.das | 12 +++ tests/math/test_crt_twins.das | 32 ++++++ tests/msl/_fail_closed/_fc_nan_literal.das | 18 ++++ tests/msl/test_msl_fail_closed.das | 1 + 19 files changed, 198 insertions(+), 84 deletions(-) create mode 100644 tests/math/test_crt_twins.das create mode 100644 tests/msl/_fail_closed/_fc_nan_literal.das diff --git a/CMakeLists.txt b/CMakeLists.txt index 042fad0d74..bd6d5b77f8 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1698,7 +1698,7 @@ SET(DAS_RELEASE_MISC_INCLUDE 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/string_writer.h include/daScript/misc/sysos.h include/daScript/misc/type_name.h include/daScript/misc/uric.h 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/include/daScript/misc/float2string.h b/include/daScript/misc/float2string.h index 986da1cfe3..5ff344903c 100644 --- a/include/daScript/misc/float2string.h +++ b/include/daScript/misc/float2string.h @@ -1,9 +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, and the buffer - // must be at least 48 bytes - the emitter's fixed-length copies overshoot past the text + // 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..762ebdaff1 100644 --- a/include/daScript/simulate/ARCHITECTURE.md +++ b/include/daScript/simulate/ARCHITECTURE.md @@ -16,6 +16,16 @@ 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 while the `vec4f` arms stay on the vecmath polynomials, + where four lanes pay for the setup. Two-box probe (MSVC/3990X SSE2, clang/M1): exp + 9.45 -> 3.26 ns/iter, exp2 4.50 -> 2.47, pow a tie on x64 and 1.6x cheaper on the M1; + log2 alone got ~0.4 ns dearer on x64 - accepted because `v_log2_est_p5` was an estimate + where the JIT's `@llvm.log2` is exact, so the swap buys correctness and tier parity. The + rejected alternative: keeping the lane trick, whose `v_set_x`/`v_extract_x` round-trip is + a partial-register dependency chain MSVC does not break. `log`, `sin`, `cos` and `tan` + stay on the lane, which wins on both boxes. + - **`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 diff --git a/modules/dasMetal/REVIEW.md b/modules/dasMetal/REVIEW.md index 1e16a0c516..e67ed5e48c 100644 --- a/modules/dasMetal/REVIEW.md +++ b/modules/dasMetal/REVIEW.md @@ -20,15 +20,18 @@ 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 3a848ad4b0..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" || 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/unitTest.h b/modules/dasUnitTest/unitTest.h index 97b9ad8b2a..071bed26ef 100644 --- a/modules/dasUnitTest/unitTest.h +++ b/modules/dasUnitTest/unitTest.h @@ -448,7 +448,7 @@ DAS_MOD_API void test_abi_lambda_and_function ( das::Lambda lambda, das::Func fn DAS_MOD_API bool testBindEnumFunction ( das::Context * context, das::LineInfoArg * at ); -//! CRT reference twins the scalar math benchmarks in benchmarks/core/math A/B against the builtins. +//! 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); } diff --git a/plans/benchmark_followups.md b/plans/benchmark_followups.md index 01c02f4482..8da3ea8074 100644 --- a/plans/benchmark_followups.md +++ b/plans/benchmark_followups.md @@ -66,3 +66,8 @@ shape itself: the `das_iterator` machinery and the `rcp_est` lane round-t 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/REVIEW.md b/src/misc/REVIEW.md index 8ece698026..d04a19faf4 100644 --- a/src/misc/REVIEW.md +++ b/src/misc/REVIEW.md @@ -14,7 +14,10 @@ returns 0 when the platform reports no tier structure; a diff that makes it gues `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 the caller -buffer minimum stated in `include/daScript/misc/float2string.h`, in the same change.** Those -copies overshoot past the digits they write, and that stated minimum is what keeps the -overshoot inside the caller's buffer. +**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 index 2746c45553..7ee39b9348 100644 --- a/src/misc/luau_float2string.cpp +++ b/src/misc/luau_float2string.cpp @@ -4,7 +4,6 @@ #include "misc/include_fmt.h" -#include #include // the digit and layout emitter below is borrowed from the Luau programming language @@ -18,6 +17,12 @@ 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); @@ -56,21 +61,27 @@ namespace das { template static __forceinline char * print_shortest ( char * buf, TT x ) { - if ( std::signbit(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++ = '-'; - x = -x; + bits &= ~signMask; + memcpy(&x, &bits, sizeof(x)); } - if ( !std::isfinite(x) ) { - memcpy(buf, std::isnan(x) ? "nan" : "inf", 3); + if ( (bits & expMask) == expMask ) { + memcpy(buf, bits != expMask ? "nan" : "inf", 3); return buf + 3; } - if ( x == TT(0) ) { + if ( bits == 0 ) { *buf++ = '0'; return buf; } auto dec = fmt::detail::dragonbox::to_decimal(x); - char decbuf[48]; - char * decend = decbuf + 24; + 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; diff --git a/src/misc/string_writer.cpp b/src/misc/string_writer.cpp index 57c7ace983..a84a5192b6 100644 --- a/src/misc/string_writer.cpp +++ b/src/misc/string_writer.cpp @@ -67,12 +67,12 @@ namespace das { StringWriter & StringWriter::operator << (const string & v) { return v.length() ? writeStr(v.c_str(), v.length()) : *this; } StringWriter & StringWriter::operator << (float v) { if ( fixed ) return format("{:.9}", v); - char buf[64]; + 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[80]; + char buf[DAS_F2S_BUFFER_SIZE]; return writeStr(buf, size_t(double2string(buf, v) - buf)); } diff --git a/src/simulate/runtime_string.cpp b/src/simulate/runtime_string.cpp index fe73663e23..7924f9def6 100644 --- a/src/simulate/runtime_string.cpp +++ b/src/simulate/runtime_string.cpp @@ -254,13 +254,13 @@ namespace das } char * das_lexical_cast_fp_f ( float x, Context * __context__, LineInfoArg * at ) { - char buffer[128]; + 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_d ( double x, Context * __context__, LineInfoArg * at ) { - char buffer[128]; + char buffer[DAS_F2S_BUFFER_SIZE]; auto result = double2string(buffer, x); *result = 0; return __context__->allocateString(buffer,uint32_t(result-buffer),at); 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 04f95939d3..b14db073bd 100644 --- a/tests-cpp/small/REVIEW.md +++ b/tests-cpp/small/REVIEW.md @@ -8,7 +8,6 @@ assertion watches - the struct, offset, or file named in the pin's own assertion pin test (`*_pin.cpp`) asserts that a compiled-in layout, offset, or watched file set stays put. -**A diff that narrows what `test_float2string.cpp` compares - fewer bit patterns, a -tolerance in place of the byte compare, or a dropped `double2string` arm - is a defect.** -That test is what holds `float2string` and `double2string` to fmt's `"{}"` spelling, which -the msl/glsl goldens and the string corpus compare byte for byte. +**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 index dba8d0acf9..e63c4488ec 100644 --- a/tests-cpp/small/test_float2string.cpp +++ b/tests-cpp/small/test_float2string.cpp @@ -1,4 +1,3 @@ -// DAS_F2S_EXHAUSTIVE=1 runs the exhaustive case below: every finite float32, minutes, all cores. #include #include "daScript/daScript.h" #include "daScript/misc/float2string.h" @@ -6,6 +5,7 @@ #include #include +#include #include #include #include @@ -13,93 +13,112 @@ using namespace das; -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 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 bool same_float ( uint32_t bits, std::string & bad ) { +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 theirs[128]; - auto tend = fmt::format_to(theirs, FMT_STRING("{}"), x); - if ( end - ours != tend - theirs || memcmp(ours, theirs, size_t(end - ours)) != 0 ) { - bad = "bits 0x" + std::to_string(bits) + ": ours '" + std::string(ours, end) - + "' fmt '" + std::string(theirs, tend) + "'"; + 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 & bad ) { +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 theirs[128]; - auto tend = fmt::format_to(theirs, FMT_STRING("{}"), x); - if ( end - ours != tend - theirs || memcmp(ours, theirs, size_t(end - ours)) != 0 ) { - bad = "bits 0x" + std::to_string(bits) + ": ours '" + std::string(ours, end) - + "' fmt '" + std::string(theirs, tend) + "'"; + 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 bad; + 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, bad), bad); + 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 <= 0xff; ++exp ) { - for ( uint32_t m = 0; m < 0x800000u; m += 0x02467u ) { + 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, bad) ) { FAIL(bad); return; } + if ( !same_float(bits, mismatch) ) { FAIL(mismatch); return; } bits |= 0x80000000u; - if ( !same_float(bits, bad) ) { FAIL(bad); return; } + 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, bad), bad); + 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 < 2000000; ++i ) { - dbits = dbits * 6364136223846793005ull + 1442695040888963407ull; - uint64_t b = dbits; - if ( (b & 0x7ff0000000000000ull) == 0x7ff0000000000000ull ) continue; - if ( !same_double(b, bad) ) { FAIL(bad); return; } + for ( int i = 0; i < kDoubleSamples; ++i ) { + dbits = dbits * kLcgMul + kLcgAdd; + if ( (dbits & kDExpMask) == kDExpMask ) continue; + if ( !same_double(dbits, mismatch) ) { FAIL(mismatch); return; } } - CHECK(true); } -TEST_CASE("float2string matches fmt on every finite float32" * doctest::skip(getenv("DAS_F2S_EXHAUSTIVE") == nullptr)) { +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 bads(workers); + std::vector mismatches(workers); std::vector pool; for ( unsigned w = 0; w != workers; ++w ) { pool.emplace_back([&, w]() { - std::string bad; + std::string mismatch; const uint64_t chunk = 1u << 20; for ( ;; ) { uint64_t base = next.fetch_add(chunk); @@ -107,8 +126,8 @@ TEST_CASE("float2string matches fmt on every finite float32" * doctest::skip(get uint64_t top = base + chunk; if ( top > 0x100000000ull ) top = 0x100000000ull; for ( uint64_t b = base; b != top; ++b ) { - if ( !same_float(uint32_t(b), bad) ) { - bads[w] = bad; + if ( !same_float(uint32_t(b), mismatch) ) { + mismatches[w] = mismatch; ok = false; break; } @@ -117,8 +136,7 @@ TEST_CASE("float2string matches fmt on every finite float32" * doctest::skip(get }); } for ( auto & t : pool ) t.join(); - for ( auto & bad : bads ) { - CHECK_MESSAGE(bad.empty(), bad); + for ( auto & m : mismatches ) { + CHECK_MESSAGE(m.empty(), m); } - CHECK(ok.load()); } 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") From 7515af2bfcfe9c60c06161d263a5e3b10710f37d Mon Sep 17 00:00:00 2001 From: Boris Batkin Date: Sun, 30 Aug 2026 14:48:50 -0700 Subject: [PATCH 7/9] dasMetal checklist: re-wrap the pasted rule head Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01J97ymQXmMxGTmHYUhLrgVd --- modules/dasMetal/REVIEW.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/modules/dasMetal/REVIEW.md b/modules/dasMetal/REVIEW.md index e67ed5e48c..d100589112 100644 --- a/modules/dasMetal/REVIEW.md +++ b/modules/dasMetal/REVIEW.md @@ -22,7 +22,8 @@ the diff puts it.** An emitted-text fixture answers to `tests/msl/REVIEW.md` (re - **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 + `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 From 8e7c69414dd1748d6e1cee2a1ff52dfedf8ac538 Mon Sep 17 00:00:00 2001 From: Boris Batkin Date: Sun, 30 Aug 2026 14:54:04 -0700 Subject: [PATCH 8/9] the sanctioned-additions entry states the why, not the probe - numbers live in the ledger Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01J97ymQXmMxGTmHYUhLrgVd --- include/daScript/simulate/ARCHITECTURE.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/include/daScript/simulate/ARCHITECTURE.md b/include/daScript/simulate/ARCHITECTURE.md index 762ebdaff1..2e5103e410 100644 --- a/include/daScript/simulate/ARCHITECTURE.md +++ b/include/daScript/simulate/ARCHITECTURE.md @@ -17,14 +17,14 @@ The ledger the checklist's hot-path rule routes to. Each entry: what was added, 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 while the `vec4f` arms stay on the vecmath polynomials, - where four lanes pay for the setup. Two-box probe (MSVC/3990X SSE2, clang/M1): exp - 9.45 -> 3.26 ns/iter, exp2 4.50 -> 2.47, pow a tie on x64 and 1.6x cheaper on the M1; - log2 alone got ~0.4 ns dearer on x64 - accepted because `v_log2_est_p5` was an estimate - where the JIT's `@llvm.log2` is exact, so the swap buys correctness and tier parity. The - rejected alternative: keeping the lane trick, whose `v_set_x`/`v_extract_x` round-trip is - a partial-register dependency chain MSVC does not break. `log`, `sin`, `cos` and `tan` - stay on the lane, which wins on both boxes. + `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 From 11dec3df44f6fb1949cce0fa073e21853bd89944 Mon Sep 17 00:00:00 2001 From: Boris Batkin Date: Sun, 30 Aug 2026 14:54:45 -0700 Subject: [PATCH 9/9] das_ordered2 entry loses its provenance aside - the A/B lives in git Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01J97ymQXmMxGTmHYUhLrgVd --- include/daScript/simulate/ARCHITECTURE.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/include/daScript/simulate/ARCHITECTURE.md b/include/daScript/simulate/ARCHITECTURE.md index 2e5103e410..6b6932976e 100644 --- a/include/daScript/simulate/ARCHITECTURE.md +++ b/include/daScript/simulate/ARCHITECTURE.md @@ -30,8 +30,8 @@ correctness required it, and the alternative that was rejected. 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