/** * raytrace_threads_test.cpp * * Minimal, fast-building proof that the KiCad WASM raytracer's threading * mechanisms (kicad/3d-viewer/3d_rendering/raytracing/render_3d_raytrace_base.cpp, * the post-process passes) genuinely run MULTI-CORE in the browser — several * candidate mechanisms in one binary, switchable by URL query param so we build * ONCE and test every variant. * * Each variant must demonstrate real parallelism (workersRan > 1). The suite is * green iff multithreading actually happens; a variant that degrades to a single * thread fails. Variant A (m=0) is the NAIVE pattern that deadlocks the browser tab * (see WHY below): it is kept ONLY as a negative control. It is held to the same bar * (workersRan > 1), which it cannot meet — the spec marks its test `test.fail()` so it * is reported as an EXPECTED failure, never a green pass. A frozen tab is never a * passing state. (Variant A self-recovers after a 12s cap so it reports workersRan=0 * instead of hanging the tab forever.) * * ------------------------------------------------------------------------------ * WHY A MAIN-THREAD BUSY-WAIT DEADLOCKS (the trap these mechanisms avoid): * - std::thread == a Web Worker. Creating one needs a message processed by the * browser MAIN-THREAD event loop. * - KiCad already pre-spawned hardware_concurrency() thread-pool workers at * startup, so the pre-warmed worker slots * (-sPTHREAD_POOL_SIZE='navigator.hardwareConcurrency') are already full. * The raytracer's new threads must therefore be spawned ON DEMAND, which * again needs the event loop. * - A busy-wait `sleep_for` NEVER returns to the event loop, so those new * workers are never created -> threadsFinished never advances -> frozen tab. * * THE MECHANISMS THAT WORK: either YIELD — emscripten_sleep() (Asyncify) hands * control back to the event loop each tick so pending worker spawns complete * (B1/B2/m4) — or keep a PERSISTENT pre-alive pool whose workers already exist, so * a main-thread busy-wait still completes because they run on their own cores with * no event-loop dependency (B3/m5). * * TODO(asyncify-nesting) — IMPORTANT CAVEAT this harness does NOT capture: the * emscripten_sleep variants (B1/B2/m4) pass here but ABORT the real KiCad 3D viewer * with `Aborted(invalid state: 1)`. The viewer renders inside the wx modal/event-pump, * which is already mid-Asyncify-unwind, and emscripten_sleep can't nest on that context. * This program runs from a clean OnInit, so it never nests. The variant actually ported * to KiCad is B3 (m=5): a persistent pool + sleep_for busy-wait — NO emscripten_sleep, so * no nesting. That multi-core port is currently PARKED (git -C kicad stash) pending * research into whether a nestable yield (fibers / emscripten_fiber_swap / JSPI) works. * So this suite validates the threading MECHANISM in isolation, not the shipped viewer * (which still ships serial). See docs/features/async/11-asyncify-nesting-raytracer.md. * * To reproduce KiCad faithfully we first pre-spawn `park` "parked" workers that * block on a condition variable (zero CPU) to occupy the pool slots — exactly * what KiCad's singleton thread pool does, forcing the on-demand spawns the * yield-based variants must service. * * ------------------------------------------------------------------------------ * URL params (all optional): * ?m=0 variant A : detached threads + std::this_thread::sleep_for (NEGATIVE CONTROL: deadlocks) * ?m=1 variant B1 : detached threads + emscripten_sleep yield (fresh threads) * ?m=2 variant B2 : persistent pre-warmed pool + emscripten_sleep (reused threads) * ?m=3 variant C : serial on the calling thread (baseline for the speedup comparison) * ?m=4 variant B1 with STACK-LOCAL atomics (mirrors the raytracer exactly) * ?m=5 variant B3 : persistent pool + sleep_for busy-wait (the real ported mechanism; default) * ?park=K parked workers pre-spawned to exhaust the pool (default = hardware_concurrency) * ?work=K worker threads the pass uses (default = hardware_concurrency) * ?blocks=K number of work blocks (default 64) * ?iters=K compute iterations per block (default 4000000; tune so serial ~2-3s) * ?passes=K how many times to run the pass (default 1; use >1 to show B2/B3 reuse) * * Console contract (the Playwright spec asserts on these): * [RTPOOL] START m=.. park=.. work=.. blocks=.. iters=.. passes=.. hwc=.. * [RTPOOL] PASS done pass=.. workersRan=.. passMs=.. * [RTPOOL] SUCCESS mode=.. workersRan=.. totalMs=.. (workersRan=0 for m=0, the deadlock) * [RTPOOL] DEADLOCK mode=0: ... (informational; m=0 negative control only) */ #include "wx/wx.h" #include #include #include #include #include #include #include #include #include #include #include #ifdef __EMSCRIPTEN__ #include #endif using clock_t_ = std::chrono::steady_clock; // ---------------------------------------------------------------------------- // logging: print to stdout AND the browser console (Playwright captures console) // ---------------------------------------------------------------------------- static void rtlog( const char* fmt, ... ) { char buf[512]; va_list ap; va_start( ap, fmt ); vsnprintf( buf, sizeof( buf ), fmt, ap ); va_end( ap ); #ifdef __EMSCRIPTEN__ // NOTE: only console.log here. Emscripten already routes printf/stdout to // console.log, so doing both would duplicate every line. EM_ASM( { console.log( UTF8ToString( $0 ) ); }, buf ); #else printf( "%s\n", buf ); #endif } // ---------------------------------------------------------------------------- // read the URL query params in one shot into out[6] = {m,park,work,blocks,iters,passes}. // INT_MIN means "param absent -> use the C++ default". We write through HEAP32 (an // exported runtime method) instead of UTF8ToString/setValue, which are NOT guaranteed // to be present in the EM_ASM scope under this build's EXPORTED_RUNTIME_METHODS. // ---------------------------------------------------------------------------- static void readUrlParams( int* out /* [6] */ ) { for( int i = 0; i < 6; ++i ) out[i] = INT_MIN; #ifdef __EMSCRIPTEN__ EM_ASM( { // serve-handler defaults cleanUrls:true, which 301-redirects *.html and // DROPS the query string -> read the fragment (#...) too, which survives. var raw = location.search ? location.search.slice( 1 ) : location.hash.slice( 1 ); var p = new URLSearchParams( raw ); function gi( name ) { var v = p.get( name ); if( !v ) return -2147483648; // INT_MIN sentinel var x = parseInt( v, 10 ); return isNaN( x ) ? -2147483648 : x; } var b = $0 >> 2; HEAP32[b + 0] = gi( 'm' ); HEAP32[b + 1] = gi( 'park' ); HEAP32[b + 2] = gi( 'work' ); HEAP32[b + 3] = gi( 'blocks' ); HEAP32[b + 4] = gi( 'iters' ); HEAP32[b + 5] = gi( 'passes' ); console.log( '[RTPOOL] location.search=' + location.search ); }, out ); #endif } static long elapsedMs( clock_t_::time_point t0 ) { return (long) std::chrono::duration_cast( clock_t_::now() - t0 ).count(); } // ---------------------------------------------------------------------------- // the shared "work" — mirrors the raytracer: workers cooperatively drain a // global atomic block counter; each block is pure CPU math (no JS/DOM/wx — the // reason the real raytracer workers are safe to run off the main thread). // ---------------------------------------------------------------------------- static std::atomic g_nextBlock{ 0 }; static std::atomic g_threadsFinished{ 0 }; static std::atomic g_workersRan{ 0 }; // distinct workers that ran >=1 block static std::atomic g_sink{ 0.0 }; // keep the optimizer honest static int g_numBlocks = 64; static long g_iters = 4000000; static double computeBlock( int b, long iters ) { double acc = 0.0; for( long i = 0; i < iters; ++i ) acc += std::sin( (double) ( b * 131 + i ) * 1e-6 ) * std::cos( (double) i * 1e-7 ); return acc; } static void workerBody() { bool counted = false; for( ;; ) { size_t b = g_nextBlock.fetch_add( 1 ); if( b >= (size_t) g_numBlocks ) break; if( !counted ) { g_workersRan.fetch_add( 1 ); counted = true; } double r = computeBlock( (int) b, g_iters ); g_sink.store( g_sink.load( std::memory_order_relaxed ) + r, std::memory_order_relaxed ); } g_threadsFinished.fetch_add( 1 ); } static void resetPass() { g_nextBlock.store( 0 ); g_threadsFinished.store( 0 ); g_workersRan.store( 0 ); } // ---------------------------------------------------------------------------- // "parked" pool — block N workers on a condition variable (zero CPU) to occupy // the pre-warmed pool slots, exactly like KiCad's singleton thread pool. // ---------------------------------------------------------------------------- static std::mutex g_parkMtx; static std::condition_variable g_parkCv; static bool g_parkRelease = false; static void spawnParkedPool( int p ) { for( int i = 0; i < p; ++i ) { std::thread( [] { std::unique_lock lk( g_parkMtx ); g_parkCv.wait( lk, [] { return g_parkRelease; } ); } ) .detach(); } } static void releaseParkedPool() { { std::lock_guard lk( g_parkMtx ); g_parkRelease = true; } g_parkCv.notify_all(); } // ---------------------------------------------------------------------------- // variant B1: detached threads + emscripten_sleep yield-poll. // ---------------------------------------------------------------------------- static void waitYield( int n ) { while( g_threadsFinished.load() < (size_t) n ) { #ifdef __EMSCRIPTEN__ emscripten_sleep( 10 ); // Asyncify: hand control back to the event loop #else std::this_thread::sleep_for( std::chrono::milliseconds( 10 ) ); #endif } } // ---------------------------------------------------------------------------- // variant B2: a persistent pre-warmed worker pool. Workers are created once and // block on a condition variable between passes (blocking is fine on workers — // only the MAIN thread can't block). Each pass: main bumps the generation, // notifies, then emscripten_sleep-polls the done counter. // ---------------------------------------------------------------------------- struct PersistentPool { int n = 0; std::vector ts; std::mutex mtx; std::condition_variable cv; unsigned long generation = 0; bool stop = false; std::atomic finished{ 0 }; std::atomic started{ 0 }; // workers that have come alive void start( int n_ ) { n = n_; for( int i = 0; i < n; ++i ) ts.emplace_back( [this] { loop(); } ); } bool ready() const { return started.load() == (size_t) n; } void loop() { started.fetch_add( 1 ); unsigned long seen = 0; for( ;; ) { std::unique_lock lk( mtx ); cv.wait( lk, [&] { return stop || generation != seen; } ); if( stop ) return; seen = generation; lk.unlock(); workerBody(); // drains g_nextBlock cooperatively with the other workers finished.fetch_add( 1 ); } } void runPass() // called on the MAIN thread { finished.store( 0 ); resetPass(); { std::lock_guard lk( mtx ); ++generation; } cv.notify_all(); while( finished.load() < (size_t) n ) { #ifdef __EMSCRIPTEN__ emscripten_sleep( 5 ); #else std::this_thread::sleep_for( std::chrono::milliseconds( 5 ) ); #endif } } // Like runPass(), but waits with a BUSY sleep_for (NOT emscripten_sleep). This is // the mechanism the real raytracer uses: the workers are already alive, so a // main-thread busy-wait still completes (they run on their own cores), and there // is NO emscripten_sleep — so it can't hit the Asyncify-nesting abort that the // real 3D viewer triggers under its modal/event-pump. REQUIRES ready()==true. void runPassBusyWait() // called on the MAIN thread { finished.store( 0 ); resetPass(); { std::lock_guard lk( mtx ); ++generation; } cv.notify_all(); while( finished.load() < (size_t) n ) std::this_thread::sleep_for( std::chrono::microseconds( 200 ) ); } }; static PersistentPool g_pool; // ---------------------------------------------------------------------------- // app // ---------------------------------------------------------------------------- class RtFrame : public wxFrame { public: RtFrame() : wxFrame( nullptr, wxID_ANY, "Raytrace Threads Repro", wxDefaultPosition, wxSize( 420, 160 ) ) { wxPanel* panel = new wxPanel( this ); wxBoxSizer* sizer = new wxBoxSizer( wxVERTICAL ); sizer->Add( new wxStaticText( panel, wxID_ANY, "Raytrace threading repro — see the console." ), 0, wxALL, 16 ); panel->SetSizer( sizer ); } }; class RtApp : public wxApp { public: bool OnInit() override { const int hwc = std::max( 1u, std::thread::hardware_concurrency() ); int raw[6]; readUrlParams( raw ); auto pick = [&]( int i, int dflt ) { return raw[i] == INT_MIN ? dflt : raw[i]; }; const int mode = pick( 0, 5 ); // default = B3, the real ported mechanism const int park = pick( 1, hwc ); const int work = pick( 2, hwc ); g_numBlocks = pick( 3, 64 ); g_iters = (long) pick( 4, 4000000 ); const int passes = pick( 5, 1 ); rtlog( "[RTPOOL] START m=%d park=%d work=%d blocks=%d iters=%ld passes=%d hwc=%d", mode, park, work, g_numBlocks, g_iters, passes, hwc ); // exhaust the pre-warmed pool (skip for the pure-serial baseline) if( mode != 3 && park > 0 ) { spawnParkedPool( park ); rtlog( "[RTPOOL] parked %d workers (pool slots now exhausted)", park ); } auto t0 = clock_t_::now(); for( int pass = 0; pass < passes; ++pass ) { auto p0 = clock_t_::now(); switch( mode ) { case 3: // C — serial on the calling (main) thread resetPass(); workerBody(); break; case 0: // A (NEGATIVE CONTROL) — detached threads + main-thread busy-wait. // CANNOT multi-core in the browser: the busy-wait starves the event // loop, so the on-demand worker spawns never run and no worker ever // executes -> workersRan stays 0. Self-recovers after a 12s cap so the // harness reports (workersRan=0) instead of hanging the tab forever. { resetPass(); for( int i = 0; i < work; ++i ) std::thread( workerBody ).detach(); auto dlStart = clock_t_::now(); while( g_threadsFinished.load() < (size_t) work ) { std::this_thread::sleep_for( std::chrono::milliseconds( 10 ) ); if( std::chrono::duration_cast( clock_t_::now() - dlStart ).count() >= 12 ) { rtlog( "[RTPOOL] DEADLOCK mode=0: workers never spawned within 12s " "(main-thread busy-wait starved the event loop)" ); break; } } break; } case 1: // B1 — detached threads + emscripten_sleep yield resetPass(); for( int i = 0; i < work; ++i ) std::thread( workerBody ).detach(); waitYield( work ); break; case 2: // B2 — persistent pool + emscripten_sleep yield if( pass == 0 ) g_pool.start( work ); g_pool.runPass(); break; case 4: // B1 but with STACK-LOCAL atomics — mirrors the real raytracer // EXACTLY (threadsFinished/nextBlock are locals shared by ref with // the workers). Proves Asyncify's emscripten_sleep unwind/rewind // doesn't clobber concurrent worker writes to C-stack locals. { std::atomic lNext( 0 ); std::atomic lFinished( 0 ); std::atomic lRan( 0 ); auto localWorker = [&]() { bool counted = false; for( ;; ) { size_t b = lNext.fetch_add( 1 ); if( b >= (size_t) g_numBlocks ) break; if( !counted ) { lRan.fetch_add( 1 ); counted = true; } g_sink.store( g_sink.load( std::memory_order_relaxed ) + computeBlock( (int) b, g_iters ), std::memory_order_relaxed ); } lFinished.fetch_add( 1 ); }; for( int i = 0; i < work; ++i ) std::thread( localWorker ).detach(); while( lFinished.load() < (size_t) work ) #ifdef __EMSCRIPTEN__ emscripten_sleep( 2 ); #else std::this_thread::sleep_for( std::chrono::milliseconds( 10 ) ); #endif g_workersRan.store( lRan.load() ); break; } case 5: // B3 — persistent pool + sleep_for BUSY-WAIT. This is the REAL // raytracer's mechanism: workers are pre-alive, so a main-thread // busy-wait still completes (no event-loop dependency), and there is // NO emscripten_sleep, so it can't hit the Asyncify-nesting abort the // 3D viewer triggers under its modal/event-pump. if( pass == 0 ) { g_pool.start( work ); // Warm up: the workers must actually be created (needs the event loop). // In the real app this happens across paint frames; here we yield once. while( !g_pool.ready() ) #ifdef __EMSCRIPTEN__ emscripten_sleep( 5 ); #else std::this_thread::sleep_for( std::chrono::milliseconds( 5 ) ); #endif } g_pool.runPassBusyWait(); break; } long passMs = elapsedMs( p0 ); rtlog( "[RTPOOL] PASS done pass=%d workersRan=%d passMs=%ld", pass, g_workersRan.load(), passMs ); } long totalMs = elapsedMs( t0 ); rtlog( "[RTPOOL] SUCCESS mode=%d workersRan=%d totalMs=%ld sink=%.3f", mode, g_workersRan.load(), totalMs, g_sink.load() ); releaseParkedPool(); ( new RtFrame() )->Show(); return true; } }; wxIMPLEMENT_APP( RtApp );