diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3a1c7b75..0ba07b59 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -85,6 +85,13 @@ jobs: ./slua tests/conformance/assert.luau ./slua-analyze tests/conformance/assert.luau ./slua-compile tests/conformance/assert.luau + # ServerLua: scoped to SLExecutor since TSan is slow and the threading lives there + - name: run SLExecutor tests under TSan + if: matrix.os.name == 'ubuntu' + run: | + make -j4 config=tsan slua-tests + ./build/tsan/slua-tests -ts=SLExecutor + ./build/tsan/slua-tests -ts=SLExecutor --fflags=true windows: runs-on: windows-2022 diff --git a/.github/workflows/build_release.yml b/.github/workflows/build_release.yml index a1325101..ee935917 100644 --- a/.github/workflows/build_release.yml +++ b/.github/workflows/build_release.yml @@ -19,7 +19,7 @@ jobs: - name: configure run: cmake . -DCMAKE_BUILD_TYPE=Release - name: build - run: cmake --build . --target Luau.Repl.CLI Luau.Analyze.CLI Luau.Compile.CLI Luau.Ast.CLI --config Release -j 2 + run: cmake --build . --target Luau.Repl.CLI Luau.Analyze.CLI Luau.Compile.CLI Luau.Ast.CLI Luau.Harness.CLI --config Release -j 2 - name: pack if: matrix.os.name != 'windows' run: zip slua-${{github.event.release.tag_name}}-${{matrix.os.name}}.zip slua* diff --git a/CLI/src/Harness.cpp b/CLI/src/Harness.cpp index 0f085e89..b8da56b0 100644 --- a/CLI/src/Harness.cpp +++ b/CLI/src/Harness.cpp @@ -91,6 +91,194 @@ static void log_to_stderr(LogLevel level, const char* source, const char* messag fprintf(stderr, "[%s] %s: %s\n", level_names[(int)level], source, message); } +// Drives the script through run windows until it completes, faults, or +// refuses. `lsl_state` is left at whatever state it ended in so a later +// dispatch lands in the right handler table. +static RunResult run_to_completion(Script& script, double quanta, bool is_lsl, double& accum_sleep, size_t& slices, int& lsl_state) +{ + // state_entry is implicit in Lua, but LSL needs it specifically dispatched. + bool dispatch_state_entry = is_lsl; + RunResult result; + for (;;) + { + // Fake the sleep, just collect it so we know the accumulated + // sleep across the entire script run. + if (script.getSleep() > 0.0f) + { + accum_sleep += script.getSleep(); + script.setSleep(0.0f); + } + + { + RunWindow window(script, quanta); + if (dispatch_state_entry) + { + result = script.callEventHandler(lsl_state, "state_entry", nullptr); + dispatch_state_entry = false; + } + else + { + result = script.resumeEventHandler(); + } + } + ++slices; + + if (result.status == HandlerRunStatus::Preempted) + continue; + if (result.status == HandlerRunStatus::StateChange) + { + // TODO: Do state_exit too... meh. + lsl_state = result.newState; + dispatch_state_entry = true; + continue; + } + + // Anything else means we're done. + break; + } + + // Bank sleep the final slice left behind + if (script.getSleep() > 0.0f) + accum_sleep += script.getSleep(); + + return result; +} + +// Prints why a run stopped early, for the statuses that mean the script is +// done for good. Returns the process exit code. +static int report_failure(Script& script, const RunResult& result) +{ + if (result.status == HandlerRunStatus::Fault) + { + auto& fault_str = script.getExtendedFaultString().empty() ? script.getFaultString() : script.getExtendedFaultString(); + fprintf(stderr, "Fault: %s\n", fault_str.c_str()); + return 1; + } + if (result.status == HandlerRunStatus::Refused) + { + fprintf(stderr, "Error: engine refused to run the handler\n"); + return 1; + } + return 0; +} + +static void print_watchdog_stats(const IProvisioner& provisioner) +{ + // Resident never fires or wakes, so this only shows for the deadline installers + WatchdogStats wd_stats = provisioner.getWatchdogStats(); + if (wd_stats.fires == 0 && wd_stats.wakes == 0) + return; + + double avg = wd_stats.fires > 0 ? wd_stats.latenessSum / (double)wd_stats.fires : 0.0; + fprintf( + stderr, + "Watchdog lead: %.1f usecs, wakes: %llu, fires: %llu, late fires: %llu, lateness usecs min/avg/max: %.1f/%.1f/%.1f\n", + wd_stats.fireLead * 1e6, + (unsigned long long)wd_stats.wakes, + (unsigned long long)wd_stats.fires, + (unsigned long long)wd_stats.lateFires, + wd_stats.latenessMin * 1e6, + avg * 1e6, + wd_stats.latenessMax * 1e6 + ); +} + +struct WindowTiming +{ + // Windows actually run, short of the request only when the body bailed + size_t completed = 0; + double total = 0.0; + double min = 0.0; + double max = 0.0; +}; + +// Runs `body` `count` times, timing each. The body returns false to stop. +template +static WindowTiming time_windows(size_t count, Body&& body) +{ + WindowTiming timing; + double phase_start = lua_clock(); + for (size_t i = 0; i < count; ++i) + { + double window_start = lua_clock(); + bool keep_going = body(); + double window_time = lua_clock() - window_start; + + ++timing.completed; + if (i == 0 || window_time < timing.min) + timing.min = window_time; + if (window_time > timing.max) + timing.max = window_time; + if (!keep_going) + break; + } + timing.total = lua_clock() - phase_start; + return timing; +} + +static void print_window_timing(const char* label, const WindowTiming& timing) +{ + double avg = timing.completed > 0 ? timing.total / (double)timing.completed : 0.0; + fprintf( + stderr, + "%s windows: %zu, total %.3fs, per window usecs avg/min/max: %.3f/%.1f/%.1f\n", + label, + timing.completed, + timing.total, + avg * 1e6, + timing.min * 1e6, + timing.max * 1e6 + ); +} + +// Opens and closes `count` windows twice over: once empty, so the installer +// and GC bookkeeping are all that's measured, then once dispatching a +// handler each time. The difference is the Lua dispatch cost. +static int run_window_bench(Script& script, double quanta, size_t count, int lsl_state) +{ + WindowTiming empty = time_windows(count, [&]() { + RunWindow window(script, quanta); + return true; + }); + print_window_timing("Empty", empty); + + // A deadline install can land at the first safepoint when the fire lead + // exceeds the quanta, so a preempted handler is resumed in the next window + // rather than treated as a failure. + bool resuming = false; + RunResult result; + WindowTiming handler = time_windows(count, [&]() { + RunWindow window(script, quanta); + if (resuming) + result = script.resumeEventHandler(); + else + script.callEventHandler(lsl_state, "touch_start", [](lua_State* L, void *ctx) + { + lua_pushnumber(L, 0); + }); + resuming = result.status == HandlerRunStatus::Preempted; + return resuming || result.status == HandlerRunStatus::Ok; + }); + print_window_timing("Handler", handler); + + // The script can't be torn down with a handler still staged + while (resuming) + { + RunWindow window(script, quanta); + result = script.resumeEventHandler(); + resuming = result.status == HandlerRunStatus::Preempted; + } + + // A missing state_entry is fine for a normal run, but here it means the + // user's script has nothing to dispatch. + if (result.status == HandlerRunStatus::NotRun) + { + fprintf(stderr, "Error: script has no touch_start handler to benchmark\n"); + return 1; + } + return report_failure(script, result); +} + static void displayHelp(const char* argv0) { printf("Usage: %s [options] script\n", argv0); @@ -100,15 +288,28 @@ static void displayHelp(const char* argv0) printf("\n"); printf("Options:\n"); printf(" --quanta=: time slice per run window (default 200)\n"); + printf(" --window-bench=: after the script completes, open and close n run\n" + " windows, empty and then dispatching its touch_start handler,\n" + " to measure the per-window overhead\n"); + printf(" --fire-lead=: how early the threaded or signal installer puts the\n" + " interrupt handler in ahead of the deadline (default: per policy)\n"); printf(" -O: compile with optimization level n (default 1)\n"); printf(" --fflags=: comma-separated fast flag settings (name=true/false),\n"); + printf(" --use-lua-clock: Use lua_clock() instead of a specialized quanta timer\n"); + printf(" --interrupt=: keep the interrupt handler resident,\n" + " have the watchdog thread install it at the deadline, or have a\n" + " POSIX timer signal install it (Linux only)\n"); } int main(int argc, char** argv) { const char* script_path = nullptr; double quanta_usec = 200.0; + double fire_lead_usec = 0.0; + size_t window_bench = 0; + bool use_lua_clock = false; int optimization_level = 1; + InterruptInstallPolicy interrupt_policy = InterruptInstallPolicy::Default; for (int i = 1; i < argc; ++i) { @@ -126,10 +327,54 @@ int main(int argc, char** argv) return 1; } } + else if (strncmp(argv[i], "--fire-lead=", 12) == 0) + { + fire_lead_usec = atof(argv[i] + 12); + if (fire_lead_usec <= 0.0) + { + fprintf(stderr, "Error: --fire-lead must be a positive number of usecs.\n"); + return 1; + } + } + else if (strncmp(argv[i], "--window-bench=", 15) == 0) + { + long long count = atoll(argv[i] + 15); + if (count <= 0) + { + fprintf(stderr, "Error: --window-bench must be a positive number of windows.\n"); + return 1; + } + window_bench = (size_t)count; + } else if (strncmp(argv[i], "--fflags=", 9) == 0) { setLuauFlags(argv[i] + 9); } + else if (strncmp(argv[i], "--interrupt=", 12) == 0) + { + const char* value = argv[i] + 12; + if (strcmp(value, "resident") == 0) + { + interrupt_policy = InterruptInstallPolicy::Resident; + } + else if (strcmp(value, "threaded") == 0) + { + interrupt_policy = InterruptInstallPolicy::Threaded; + } + else if (strcmp(value, "signal") == 0) + { + interrupt_policy = InterruptInstallPolicy::Signal; + } + else + { + fprintf(stderr, "Error: invalid --interrupt value.\n"); + return 1; + } + } + else if (strcmp(argv[i], "--use-lua-clock") == 0) + { + use_lua_clock = true; + } else if (strncmp(argv[i], "-O", 2) == 0) { int level = atoi(argv[i] + 2); @@ -199,7 +444,13 @@ int main(int argc, char** argv) HostCallbacks callbacks; callbacks.clockProvider = script_clock; callbacks.populateEnvironment = populate_environment; - // quantaClockProvider stays null so we exercise the engine's default + callbacks.interruptInstallPolicy = interrupt_policy; + callbacks.interruptFireLead = fire_lead_usec * 1e-6; + if (use_lua_clock) + { + // Leaving this null uses a platform-optimized quanta clock provider + callbacks.quantaClockProvider = lua_clock; + } Provisioner<> provisioner(callbacks); @@ -239,67 +490,16 @@ int main(int argc, char** argv) double quanta = quanta_usec * 1e-6; double accum_sleep = 0.0; size_t slices = 0; - double start = lua_clock(); - - // state_entry is implicit in Lua, but LSL needs it specifically dispatched. - bool dispatch_state_entry = is_lsl; int lsl_state = 0; - RunResult result; - for (;;) - { - // Fake the sleep, just collect it so we know the accumulated - // sleep across the entire script run. - if (script->getSleep() > 0.0f) - { - accum_sleep += script->getSleep(); - script->setSleep(0.0f); - } - - script->beginRunWindow(quanta); - if (dispatch_state_entry) - { - result = script->callEventHandler(lsl_state, "state_entry", nullptr); - dispatch_state_entry = false; - } - else - { - result = script->resumeEventHandler(); - } - script->endRunWindow(); - ++slices; - - if (result.status == HandlerRunStatus::Preempted) - continue; - if (result.status == HandlerRunStatus::StateChange) - { - // TODO: Do state_exit too... meh. - lsl_state = result.newState; - dispatch_state_entry = true; - continue; - } - - // Anything else means we're done. - break; - } - - // Bank sleep the final slice left behind - if (script->getSleep() > 0.0f) - accum_sleep += script->getSleep(); - + double start = lua_clock(); + RunResult result = run_to_completion(*script, quanta, is_lsl, accum_sleep, slices, lsl_state); double runtime = lua_clock() - start; fprintf(stderr, "Runtime: %f, Accum. Sleep: %f, Time Slices: %zu\n", runtime, accum_sleep, slices); - if (result.status == HandlerRunStatus::Fault) - { - auto &fault_str = script->getExtendedFaultString().empty() ? script->getFaultString() : script->getExtendedFaultString(); - fprintf(stderr, "Fault: %s\n", fault_str.c_str()); - return 1; - } - if (result.status == HandlerRunStatus::Refused) - { - fprintf(stderr, "Error: engine refused to run the handler\n"); - return 1; - } + int exit_code = report_failure(*script, result); + if (exit_code == 0 && window_bench > 0) + exit_code = run_window_bench(*script, quanta, window_bench, lsl_state); - return 0; + print_watchdog_stats(provisioner); + return exit_code; } diff --git a/CMakeLists.txt b/CMakeLists.txt index ce344864..fc5e21a8 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -14,6 +14,7 @@ option(LUAU_STATIC_CRT "Link with the static CRT (/MT)" OFF) option(LUAU_EXTERN_C "Use extern C for all APIs" OFF) option(LUAU_USE_TAILSLIDE "Link against tailslide for LSL support" OFF) option(LUAU_BUILD_SHARED "Build as a shared library" OFF) +option(LUAU_TSAN "Build with ThreadSanitizer" OFF) if(LUAU_BUILD_SHARED AND NOT LUAU_EXTERN_C) message(FATAL_ERROR "LUAU_BUILD_SHARED requires LUAU_EXTERN_C to be ON") @@ -40,6 +41,15 @@ endif() project(Luau LANGUAGES CXX C) +if(LUAU_TSAN) + if(MSVC) + message(FATAL_ERROR "LUAU_TSAN requires a Clang or GCC toolchain") + endif() + add_compile_options(-fsanitize=thread) + set(CMAKE_EXE_LINKER_FLAGS "${CMAKE_EXE_LINKER_FLAGS} -fsanitize=thread") + set(CMAKE_SHARED_LINKER_FLAGS "${CMAKE_SHARED_LINKER_FLAGS} -fsanitize=thread") +endif() + # Embed builtins.txt into a header file at configure time file(READ "${CMAKE_SOURCE_DIR}/builtins.txt" BUILTINS_HEX HEX) string(REGEX REPLACE "([0-9a-f][0-9a-f])" "0x\\1, " BUILTINS_HEX_ARRAY "${BUILTINS_HEX}") @@ -311,6 +321,13 @@ if(CMAKE_SYSTEM_NAME MATCHES "Linux|Darwin|iOS") target_link_libraries(osthreads INTERFACE "-lpthread") endif () +# ServerLua: We use threads internally, library consumers need pthreads if applciable. +target_link_libraries(Luau.Executor PUBLIC osthreads) +# ServerLua: POSIX timers live in librt on glibc < 2.34, a stub elsewhere +if(CMAKE_SYSTEM_NAME MATCHES "Linux") + target_link_libraries(Luau.Executor PUBLIC "-lrt") +endif () + if(LUAU_BUILD_CLI) target_compile_options(Luau.Repl.CLI PRIVATE ${LUAU_OPTIONS}) target_compile_options(Luau.Reduce.CLI PRIVATE ${LUAU_OPTIONS}) diff --git a/Common/include/Luau/Common.h b/Common/include/Luau/Common.h index 4f086619..3f9082b5 100644 --- a/Common/include/Luau/Common.h +++ b/Common/include/Luau/Common.h @@ -3,6 +3,11 @@ #include +// ServerLua: for the std::atomic_thread_fence fallback below +#if defined(_MSC_VER) && !defined(__clang__) +#include +#endif + // Compiler codegen control macros #ifdef _MSC_VER #define LUAU_NORETURN __declspec(noreturn) @@ -56,6 +61,31 @@ namespace Luau { +// ServerLua: TSan-visible accessors for fields the VM reads unsynchronized by +// contract, like lua_Callbacks::interrupt. Same codegen as a plain access on +// x86/ARM64 (the release store is stlr on ARM64). +// clang-cl defines both _MSC_VER and __clang__, so guard on __clang__ too. +template +inline T opaque_load(T* p) +{ +#if defined(_MSC_VER) && !defined(__clang__) + return *static_cast(p); +#else + return __atomic_load_n(p, __ATOMIC_RELAXED); +#endif +} + +template +inline void release_store(T* p, T v) +{ +#if defined(_MSC_VER) && !defined(__clang__) + std::atomic_thread_fence(std::memory_order_release); + *static_cast(p) = v; +#else + __atomic_store_n(p, v, __ATOMIC_RELEASE); +#endif +} + using AssertHandler = int (*)(const char* expression, const char* file, int line, const char* function); inline AssertHandler& assertHandler() @@ -88,6 +118,10 @@ LUAU_NOINLINE inline int assertCallHandler(const char* expression, const char* f #define LUAU_ASSERT(expr) (void)sizeof(!!(expr)) #endif +// ServerLua: for invariants about the host process rather than our own code, +// which only release builds will ever get to see +#define LUAU_ASSERT_ALWAYS(expr) ((void)(!!(expr) || (Luau::assertCallHandler(#expr, __FILE__, __LINE__, __FUNCTION__) && (LUAU_DEBUGBREAK(), 0)))) + namespace Luau { diff --git a/Executor/include/Luau/Executor.h b/Executor/include/Luau/Executor.h index a4ecea6d..243fd6cd 100644 --- a/Executor/include/Luau/Executor.h +++ b/Executor/include/Luau/Executor.h @@ -1,6 +1,7 @@ // ServerLua: per-script execution engine shared with the script host. #pragma once +#include #include #include @@ -32,6 +33,7 @@ enum class LogLevel : uint8_t Debug = 0, Info, Warn, + Error, }; // Parameters that define the sealed image consumed by buildImage() @@ -87,8 +89,95 @@ void logDebug(const char* source, const char* fmt, ...) LUA_PRINTF_ATTR(2, 3); void logInfo(const char* source, const char* fmt, ...) LUA_PRINTF_ATTR(2, 3); void logWarn(const char* source, const char* fmt, ...) LUA_PRINTF_ATTR(2, 3); -// Figure out which clock source to use -lua_clockProvider resolveDefaultQuantaClock(); +// Monotonic seconds for the per-safepoint elapsed check, which is the one +// place clock cost shows up in throughput. Nothing else reads it. +using QuantaClock = double (*)(); + +QuantaClock resolveDefaultQuantaClock(); + +using InterruptCallback = void (*)(lua_State* L, int gc); + +// Lateness is how far past the intended fire instant the handler actually got +// installed. +struct WatchdogStats +{ + uint64_t fires = 0; + // Fires that landed after the deadline itself, not just after the fire lead + uint64_t lateFires = 0; + // Watchdog thread wakeups, whether or not they led to a fire. Always zero + // for installers without a thread. + uint64_t wakes = 0; + double latenessSum = 0.0; + double latenessMin = 0.0; + double latenessMax = 0.0; + // The lead in effect, so a stats dump is self-describing + double fireLead = 0.0; +}; + +// Owns `cb.interrupt` for a run window and decides when it's installed. +// The handler checks the clock itself, so the contract is a latest install +// time, not an exact one. One per provisioner, one open window at a time. +class InterruptInstaller +{ +public: + explicit InterruptInstaller(InterruptCallback handler) + : mHandler(handler) + { + } + + virtual ~InterruptInstaller(); + + // Install `cb.interrupt` no later than `seconds` from now. Installing it + // earlier is allowed. + virtual void installWithin(lua_Callbacks* target, double seconds) = 0; + + // For things that need the script to yield immediately (sleep, force-yield). + // Harmless if it's already installed. + virtual void installNow() = 0; + + // Forget the deadline. Nothing touches `target` after this returns. + virtual void cancel() = 0; + + // From the handler when it had nothing to do: uninstall it if a deadline + // install is still pending to bring it back, otherwise leave it resident. + // Called at every safepoint while the handler is in early, so the common + // "nothing pending" answer stays inline. + void uninstallIfPending() + { + if (mPending.load(std::memory_order_relaxed)) + uninstallPending(); + } + + // How far past the deadline the current window's handler went in, in + // seconds. Zero until it does, and zero again after the next installWithin(). + virtual double getInstallOverrun() { return 0.0; } + + virtual WatchdogStats getStats() { return {}; } + +protected: + virtual void uninstallPending() = 0; + + InterruptCallback mHandler = nullptr; + // An installWithin() whose install hasn't landed yet + std::atomic mPending{false}; +}; + +enum class InterruptInstallPolicy +{ + // Resident, or Threaded when the SLuaThreadedQuantaWatchdog fflag is on + Default, + // Handler stays resident and checks the clock at every safepoint + Resident, + // A watchdog thread installs the handler just ahead of the deadline + Threaded, + // A POSIX timer signals the script thread, which installs the handler in + // the signal handler. No second thread and no scheduling priority needed. + // Linux only, falls back to Threaded elsewhere. + Signal, +}; + +// Throws std::system_error if the threaded policy can't create its thread. +std::unique_ptr createInterruptInstaller(InterruptInstallPolicy policy, InterruptCallback handler, double fireLead); // Give the embedder a chance to plop their own things into the environment before it's // fully set up. This is called before GC fixing / ares perms registration. @@ -102,13 +191,24 @@ struct HostCallbacks lua_randomProvider randomProvider = nullptr; lua_setTimerEventCallback setTimerEventCb = nullptr; lua_eventHandlerRegistrationCallback eventHandlerRegistrationCb = nullptr; - lua_clockProvider quantaClockProvider = nullptr; + QuantaClock quantaClockProvider = nullptr; + // Resident is required when quantaClockProvider doesn't track real time + // (test fake clocks), since the deadline installers schedule on lua_clock() + InterruptInstallPolicy interruptInstallPolicy = InterruptInstallPolicy::Default; + // How early, in seconds, the Threaded and Signal policies put the handler in + // ahead of the deadline to cover delivery latency. Zero takes the policy's + // default. Size it from the harness's lateness stats on the target hardware. + double interruptFireLead = 0.0; PopulateEnvironmentCallback populateEnvironment = nullptr; }; // An environment is... basically just a Lua VM with some particular settings. It's // intended for multiple of these to be able to be living at any given moment, one // for LSL, one for a particular version of the Lua API, etc. +// +// It is expected that any use of a given environment is _exclusive_ to a particular +// thread at any given time, with no interleaved access. In almost all cases, it is +// in fact exclusive to a particular thread for its lifetime. class IEnvironment { public: @@ -340,6 +440,10 @@ class IProvisioner virtual const HostCallbacks& getCallbacks() const = 0; + // Engine-internal, for Script. Null until the first environment exists. + virtual InterruptInstaller* getInterruptInstaller() const = 0; + virtual WatchdogStats getWatchdogStats() const = 0; + virtual std::shared_ptr createEnvironment(bool is_lsl, uint32_t api_version) = 0; virtual std::shared_ptr buildImage(std::shared_ptr environment, const ImageConfig& config) = 0; virtual std::shared_ptr