From 17167fe21835996212dfcb8a2814435a24570a46 Mon Sep 17 00:00:00 2001 From: DiscoMan <143973063+DiscoManOfficial@users.noreply.github.com> Date: Tue, 20 Oct 2026 16:56:13 -0300 Subject: [PATCH 1/2] feat(gsu): optimize checked code generation and add gem-chess scene --- CMakeLists.txt | 17 + README.MD | 7 + build.ps1 | 2 +- build.sh | 2 +- cmake/build-sources.txt | 4 +- docs/optimization.md | 299 ++++++++- docs/testing.md | 103 ++- examples/snes/gem_chess/README.md | 263 ++++++++ examples/snes/gem_chess/build-snes.cmake | 64 ++ examples/snes/gem_chess/discoc.toml | 22 + examples/snes/gem_chess/display.inc | 137 ++++ examples/snes/gem_chess/geometry-checks.lua | 90 +++ examples/snes/gem_chess/main.dc | 700 ++++++++++++++++++++ examples/snes/gem_chess/preview.png | Bin 0 -> 4467 bytes examples/snes/gem_chess/snes_host.asm | 453 +++++++++++++ examples/snes/gem_chess/verify-negative.lua | 15 + examples/snes/gem_chess/verify.lua | 181 +++++ src/ABI.hpp | 3 + src/AssemblyGenerator.cpp | 10 +- src/CompilerDriver.cpp | 13 + src/GSUAddressProof.cpp | 25 + src/GSUAddressProof.hpp | 7 + src/GSUCostModel.hpp | 20 +- src/GSUMachineScheduler.cpp | 328 ++++++++- src/GSUMachineScheduler.hpp | 5 + src/IR.cpp | 231 ++++++- src/IR.hpp | 13 + src/IRBackendOptimization.cpp | 528 +++++++++++++++ src/IRCodeGenerator.cpp | 657 +++++++++++++++--- src/IRCodeGenerator.hpp | 125 +++- src/IRGlobalOptimizer.cpp | 60 +- src/IRGlobalOptimizer.hpp | 4 +- src/IRIntegerCodeGenerator.cpp | 64 +- src/IRPointerCodeGenerator.cpp | 116 +++- src/IRStorageOptimizer.cpp | 320 +++++++++ src/IRStorageOptimizer.hpp | 23 + src/LinearScanAllocator.cpp | 73 +- src/LinearScanAllocator.hpp | 11 + src/LinkerDriver.cpp | 13 +- src/ProjectBuild.cpp | 10 +- src/TargetConfig.hpp | 2 + tests/GSUCheckedProofTests.cpp | 368 +++++++++- tests/GSUCompactionTests.cpp | 506 +++++++++++++- tests/GSUExecutionTests.cpp | 96 +++ tests/GSUInstructionModel.hpp | 6 +- tests/GSUMachineSchedulerTests.cpp | 250 ++++++- tests/GSUSevenBackendTests.cpp | 484 ++++++++++++++ tests/GSUSizeOptimizationTests.cpp | 318 ++++++++- tests/GemChessRasterOracle.hpp | 205 ++++++ tests/IRConditionalOptimizerTests.cpp | 163 +++++ tests/IRStorageOptimizerTests.cpp | 359 ++++++++++ tests/LinkerHardeningTests.cpp | 22 +- tests/run_gem_chess_regression.cmake | 108 +++ tests/run_graphics_regression.cmake | 14 +- tests/run_no_spill_regression.cmake | 210 ++++++ tests/run_prebuilt_tests.cmake | 4 +- tests/run_toolchain_regression.cmake | 87 ++- tests/storage/raw_numeric_alias_read.dc | 7 + tests/storage/raw_numeric_alias_write.dc | 8 + tests/toolchain-cases.cmake | 6 + 60 files changed, 8020 insertions(+), 221 deletions(-) create mode 100644 examples/snes/gem_chess/README.md create mode 100644 examples/snes/gem_chess/build-snes.cmake create mode 100644 examples/snes/gem_chess/discoc.toml create mode 100644 examples/snes/gem_chess/display.inc create mode 100644 examples/snes/gem_chess/geometry-checks.lua create mode 100644 examples/snes/gem_chess/main.dc create mode 100644 examples/snes/gem_chess/preview.png create mode 100644 examples/snes/gem_chess/snes_host.asm create mode 100644 examples/snes/gem_chess/verify-negative.lua create mode 100644 examples/snes/gem_chess/verify.lua create mode 100644 src/IRBackendOptimization.cpp create mode 100644 src/IRStorageOptimizer.cpp create mode 100644 src/IRStorageOptimizer.hpp create mode 100644 tests/GSUSevenBackendTests.cpp create mode 100644 tests/GemChessRasterOracle.hpp create mode 100644 tests/IRStorageOptimizerTests.cpp create mode 100644 tests/run_gem_chess_regression.cmake create mode 100644 tests/run_no_spill_regression.cmake create mode 100644 tests/storage/raw_numeric_alias_read.dc create mode 100644 tests/storage/raw_numeric_alias_write.dc diff --git a/CMakeLists.txt b/CMakeLists.txt index 685fd9a..eb9c18c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -193,6 +193,10 @@ if(BUILD_TESTING AND NOT DISCO_DOS_BUILD) target_link_libraries(disco_gsu_checked_proof_tests PRIVATE disco_compiler_core) add_executable(disco_gsu_size_optimization_tests tests/GSUSizeOptimizationTests.cpp) target_link_libraries(disco_gsu_size_optimization_tests PRIVATE disco_compiler_core) + add_executable(disco_ir_storage_optimizer_tests tests/IRStorageOptimizerTests.cpp) + target_link_libraries(disco_ir_storage_optimizer_tests PRIVATE disco_compiler_core) + add_executable(disco_gsu_seven_backend_tests tests/GSUSevenBackendTests.cpp) + target_link_libraries(disco_gsu_seven_backend_tests PRIVATE disco_compiler_core disco_assembler_core disco_linker_core) add_executable(disco_gsu_compaction_tests tests/GSUCompactionTests.cpp) target_link_libraries(disco_gsu_compaction_tests PRIVATE disco_compiler_core disco_assembler_core) @@ -234,6 +238,11 @@ if(BUILD_TESTING AND NOT DISCO_DOS_BUILD) set_tests_properties(${name} PROPERTIES LABELS "language;conformance") elseif(case_name MATCHES "^graphics_") set_tests_properties(${name} PROPERTIES LABELS "backend;gsu;graphics") + elseif(case_name MATCHES "^gem_chess_(raster|raster_private|paint)$") + # Each mode keeps its complete deterministic corpus. Allow Debug + # and sanitizer models to finish without sharing one suite timeout. + set_tests_properties(${name} PROPERTIES + LABELS "backend;gsu;graphics;gem-chess;optimization;correctness" TIMEOUT 1800) endif() if(case_name MATCHES "^gsu_(contributor_correctness|optimization|local_optimization|global_optimization|value_optimization|sccp|checked_proofs|size_optimization|selection_optimization|sbk)$" OR ARGV2 MATCHES "^[12]$") set_property(TEST ${name} APPEND PROPERTY LABELS "gsu;optimization;correctness") @@ -248,6 +257,11 @@ if(BUILD_TESTING AND NOT DISCO_DOS_BUILD) -DTEST_DIR=${CMAKE_CURRENT_BINARY_DIR}/test-output/build_target_parity -P ${CMAKE_CURRENT_SOURCE_DIR}/tests/BuildTargetParityTests.cmake) set_tests_properties(build_target_parity_regressions PROPERTIES LABELS "build;unit") + add_test(NAME require_no_spills_cli COMMAND ${CMAKE_COMMAND} + -DDISCC=$ + -DTEST_DIR=${CMAKE_CURRENT_BINARY_DIR}/test-output/require-no-spills + -P ${CMAKE_CURRENT_SOURCE_DIR}/tests/run_no_spill_regression.cmake) + set_tests_properties(require_no_spills_cli PROPERTIES LABELS "gsu;optimization;cli;project" TIMEOUT 300) add_test(NAME object_file_hardening COMMAND disco_object_tests) add_test(NAME linker_hardening COMMAND disco_linker_hardening_tests) add_test(NAME ir_verifier COMMAND disco_ir_verifier_tests) @@ -264,6 +278,9 @@ if(BUILD_TESTING AND NOT DISCO_DOS_BUILD) add_test(NAME gsu_checked_proofs COMMAND disco_gsu_checked_proof_tests) set_tests_properties(gsu_checked_proofs PROPERTIES LABELS "gsu;optimization;unit") add_test(NAME gsu_size_optimization_unit COMMAND disco_gsu_size_optimization_tests) + add_test(NAME ir_storage_optimizer_unit COMMAND disco_ir_storage_optimizer_tests) + add_test(NAME gsu_seven_backend_unit COMMAND disco_gsu_seven_backend_tests) + set_tests_properties(ir_storage_optimizer_unit gsu_seven_backend_unit PROPERTIES LABELS "gsu;ir;optimization;unit") add_test(NAME gsu_compaction_unit COMMAND disco_gsu_compaction_tests) set_tests_properties(gsu_compaction_unit PROPERTIES LABELS "gsu;ir;optimization;unit") set_tests_properties(gsu_size_optimization_unit PROPERTIES LABELS "gsu;ir;optimization;unit") diff --git a/README.MD b/README.MD index 91696b5..d48fd48 100644 --- a/README.MD +++ b/README.MD @@ -121,6 +121,13 @@ The [interactive OBJ demo](tests/graphics/interactive_shapes/README.md) adds a cube and an icosahedral sphere: D-Pad rotation, A to toggle wireframe/filled faces, and Select + Up/Down to resize, using OBJ/4bpp at approximately 21 MHz. +### Gem Chess Scene + +The [gem-chess demo](examples/snes/gem_chess/README.md) renders an 8×8 board and +32 faceted pieces with an orbiting camera in a BG1 bitmap. See its +[Mesen preview](examples/snes/gem_chess/preview.png), build instructions and +measurement limits. It is a scene viewer, not a chess rules engine or a real-time game. + **The example SNES ROMs contain no Nintendo proprietary code, commercial game assets, or Nintendo SDK components.** These are emulator demonstrations, not physical-hardware validation. diff --git a/build.ps1 b/build.ps1 index cd0f129..9b2a76e 100644 --- a/build.ps1 +++ b/build.ps1 @@ -128,7 +128,7 @@ No tools are installed automatically and no build directories are deleted. 'disco_target_foundation_tests', 'disco_gsu_execution_tests', 'disco_gsu_differential_tests', 'disco_gsu_mapping_tests', 'disco_assembly_export_tests', 'disco_language_contract_tests', 'disco_project_manifest_tests', 'disco_module_loader_tests', 'disco_linker_hardening_tests', 'disco_gsu_benchmarks', - 'disco_ir_local_optimizer_tests', 'disco_ir_global_optimizer_tests', 'disco_ir_value_optimizer_tests', 'disco_gsu_cost_tests', 'disco_ir_conditional_optimizer_tests', 'disco_gsu_scheduler_tests', 'disco_gsu_checked_proof_tests', 'disco_gsu_size_optimization_tests', 'disco_gsu_compaction_tests') + 'disco_ir_local_optimizer_tests', 'disco_ir_global_optimizer_tests', 'disco_ir_value_optimizer_tests', 'disco_gsu_cost_tests', 'disco_ir_conditional_optimizer_tests', 'disco_gsu_scheduler_tests', 'disco_gsu_checked_proof_tests', 'disco_gsu_size_optimization_tests', 'disco_gsu_compaction_tests', 'disco_ir_storage_optimizer_tests', 'disco_gsu_seven_backend_tests') } $flags = @('-std=c++23', '-Wall', '-Wextra', '-Wpedantic', '-I', "$PSScriptRoot/src") switch ($Configuration) { diff --git a/build.sh b/build.sh index 3e93099..44b9d58 100644 --- a/build.sh +++ b/build.sh @@ -110,7 +110,7 @@ else disco_target_foundation_tests disco_gsu_execution_tests disco_gsu_differential_tests disco_gsu_mapping_tests disco_assembly_export_tests disco_language_contract_tests disco_project_manifest_tests disco_module_loader_tests disco_linker_hardening_tests disco_gsu_benchmarks - disco_ir_local_optimizer_tests disco_ir_global_optimizer_tests disco_ir_value_optimizer_tests disco_gsu_cost_tests disco_ir_conditional_optimizer_tests disco_gsu_scheduler_tests disco_gsu_checked_proof_tests disco_gsu_size_optimization_tests disco_gsu_compaction_tests) + disco_ir_local_optimizer_tests disco_ir_global_optimizer_tests disco_ir_value_optimizer_tests disco_gsu_cost_tests disco_ir_conditional_optimizer_tests disco_gsu_scheduler_tests disco_gsu_checked_proof_tests disco_gsu_size_optimization_tests disco_gsu_compaction_tests disco_ir_storage_optimizer_tests disco_gsu_seven_backend_tests) fi # Recursive lookup uses only Bash arrays, not Bash 4 associative arrays or eval. resolve_sources() { diff --git a/cmake/build-sources.txt b/cmake/build-sources.txt index 19a274e..cec221a 100644 --- a/cmake/build-sources.txt +++ b/cmake/build-sources.txt @@ -3,7 +3,7 @@ object src/ObjectFile.cpp src/AssemblyGenerator.cpp project src/ManifestParser.cpp src/ProjectManifest.cpp src/ProjectPaths.cpp linker src/LinkerDriver.cpp frontend src/ModuleLoader.cpp src/ModuleInterface.cpp src/Lexer.cpp src/Token.cpp src/Parser.cpp -compiler src/LanguageWarnings.cpp src/ConstantEvaluator.cpp src/Analyzer.cpp src/Optimizer.cpp src/IR.cpp src/IRControlFlow.cpp src/IRGlobalOptimizer.cpp src/IRValueOptimizer.cpp src/IRSizeOptimizer.cpp src/IRCompactOptimizer.cpp src/IRConditionalOptimizer.cpp src/IRLocalOptimizer.cpp src/IRDivModFusion.cpp src/GSUMachineScheduler.cpp src/GSUAddressProof.cpp src/IRCodeGenerator.cpp src/IRPointerCodeGenerator.cpp src/IRIntegerCodeGenerator.cpp src/LinearScanAllocator.cpp src/DataSegment.cpp src/ASTPrinter.cpp +compiler src/LanguageWarnings.cpp src/ConstantEvaluator.cpp src/Analyzer.cpp src/Optimizer.cpp src/IR.cpp src/IRControlFlow.cpp src/IRGlobalOptimizer.cpp src/IRValueOptimizer.cpp src/IRSizeOptimizer.cpp src/IRCompactOptimizer.cpp src/IRConditionalOptimizer.cpp src/IRLocalOptimizer.cpp src/IRDivModFusion.cpp src/IRStorageOptimizer.cpp src/GSUMachineScheduler.cpp src/GSUAddressProof.cpp src/IRCodeGenerator.cpp src/IRBackendOptimization.cpp src/IRPointerCodeGenerator.cpp src/IRIntegerCodeGenerator.cpp src/LinearScanAllocator.cpp src/DataSegment.cpp src/ASTPrinter.cpp assembler src/Assembler.cpp discc src/discc.cpp src/CompilerDriver.cpp src/ProjectBuild.cpp @compiler @frontend @object @project @linker discld src/discld.cpp @object @project @linker @@ -22,6 +22,8 @@ disco_ir_conditional_optimizer_tests tests/IRConditionalOptimizerTests.cpp @comp disco_gsu_scheduler_tests tests/GSUMachineSchedulerTests.cpp @compiler @frontend @assembler @object @project disco_gsu_checked_proof_tests tests/GSUCheckedProofTests.cpp @compiler @frontend @object @project disco_gsu_size_optimization_tests tests/GSUSizeOptimizationTests.cpp @compiler @frontend @object @project +disco_ir_storage_optimizer_tests tests/IRStorageOptimizerTests.cpp @compiler @frontend @object @project +disco_gsu_seven_backend_tests tests/GSUSevenBackendTests.cpp @compiler @frontend @assembler @object @project @linker disco_gsu_compaction_tests tests/GSUCompactionTests.cpp @compiler @frontend @assembler @object @project disco_project_manifest_tests tests/ProjectManifestTests.cpp @project disco_module_loader_tests tests/ModuleLoaderTests.cpp @compiler @frontend @object @project diff --git a/docs/optimization.md b/docs/optimization.md index 1754abc..3482b5b 100644 --- a/docs/optimization.md +++ b/docs/optimization.md @@ -22,6 +22,14 @@ selection and pipeline-aware machine scheduling. Os uses an explicit byte-size policy with bounded emitted-code candidate selection. O3/Ofast are unsupported and diagnosed. +## Condition lowering + +At every level, `if`, `while` and conditional `for` tests lower `&&`, `||` +and `!` directly to short-circuit control flow rather than temporary bool slots. +Integer-to-bool casts preserve zero/nonzero tests; narrowing casts still execute. +Expressions used as values still produce bool values. This changes no language +semantics: operand evaluation, calls, volatile reads and faults retain their order. + ## Selecting the mode ```sh @@ -71,6 +79,49 @@ A CMake `Release` build optimizes the native C++ toolchain itself; it does not implicitly select O1 for DiscoC sources. Select the generated-code policy explicitly through the compiler flag or manifest. +## Requiring spill-free allocation + +`--require-no-spills` makes compiler-generated spill storage an error for GSU +source compiled in that invocation. It is off by default and has no TOML key, +object metadata, object-format change or ABI change: + +```sh +discc -O2 --require-no-spills main.dc -o main.o +discc build -Os --require-no-spills +discc -O2 --require-no-spills --check main.dc +``` + +The constraint checks the actual selected backend candidate's final IR-value +spill homes and auxiliary DivMod result homes. Explicit local objects, ABI +arguments and register saves are excluded: this is **not a stackless ABI** or +a guarantee of no stack accesses. An address calculation for a local array can +itself spill, even though the array's storage is excluded; for example, an O0 +`local[0] = 42` can still fail the constraint. It does not certify prebuilt +objects subsequently linked with the result. + +Ordinary compilation, `--check` and `--emit-ir` run the selected code-generation +allocation validation. The inspection modes do not write backend output files. +`--emit-ast` and SPC700 compilation reject the option as unsupported rather than +silently skipping validation. + +At Os, spill-free eligibility precedes byte comparison within the bounded +candidate search, including per-function policy mixing. The final emitted +winner owns its spill evidence; evidence from another alternative is not reused. +Equal-byte ties use the deterministic speed proxy described below, then stable order. +If no eligible candidate exists, compilation fails. The constraint can therefore +select larger code: a tested two-word, 32-trip wrapping recurrence selects +147 bytes with LOOP instead of the ordinary 121-byte spilling candidate. +This is a constraint tradeoff, not a free size or speed optimization. With the +flag omitted, candidate selection remains unchanged. + +In the previously recorded 36-case benchmark matrix, 19 strict cases have eligible +candidates and 17 reject genuine spills. Eighteen eligible ordinary winners +remain byte- and timing-identical; Os `memcpy` instead grows from 303 to 345 +bytes while measured Mesen GSU cycles fall from 65,605 to 54,955. Size, +spill-freedom and execution cost are distinct objectives, not interchangeable +metrics. The historical gem-chess snapshot failed the strict constraint; see +its [measured tradeoffs](../examples/snes/gem_chess/README.md#historical-optimization-snapshot-2026-10-18). + ## Size policy (-Os) Os minimizes emitted CODE plus DATA and necessary DATA alignment, not executed @@ -93,16 +144,48 @@ graphics effects. Smaller code may execute more instructions or cycles. verified lowering, bounded branch relaxation and machine emission. When an automatic CACHE probe is present, also emit its no-auto-CACHE alternative: scheduling a useful delay-slot instruction can beat a CACHE occupying that - byte. There are at most sixteen emitted alternatives. Keep the - smallest actual CODE/DATA result; ties retain the earlier candidate. An O2 - transformation is therefore still allowed when its complete candidate wins - on bytes. This is a bounded search, not a claim that every individual - transformation is byte-optimal. Larger units retain the compact O2-policy - fallback without candidate expansion. + byte. RAM-payload units also try at most two primary no-array-pool policies + (O2 and size, compacted, without shared division), not another Cartesian + search. Thus there are eight base IR policies, at most ten with these additions. + Retain an owned best function from the emitted alternatives, with its IR, + allocation, division and array-pool policies, then re-emit one mixed module. + Preserve this complete legacy whole-object winner before extending the search. + The working-tree extension anchors candidate-owned scheduled total/per-function + metrics to a fixed reference of that winner, not the greedy function map. + Reject worsening structural loop-weighted proxies before replacing function + snapshots and again before whole-object byte preference. Missing or invalid + function names/bounds/metrics reject the optional extension, not the legacy + winner. With `--require-no-spills`, complete spill-free eligibility takes + precedence over the proxy after metric validation; ordinary compilation does + not collect spill diagnostics or certify spill-freedom. The reference is + borrowed only synchronously; scoped cleanup releases phase/override/IR observers + on return and exceptions. + Two additional primaries use O2 or size allocation with private division and + near-pointer promotion only: checked pointer SSA remains available without + counted-loop conversion or initializer batching. The existing enabled/disabled + compaction policies retain their behavior. Re-emit an enriched mixed module + from the candidate-owned snapshots as well. Each policy and mixed module can + have a no-auto-CACHE alternative: at most 28 emitted alternatives, excluding + the unchanged bounded branch-relaxation retries. Strict spill-free eligibility + still precedes byte comparison, with diagnostics owned by the selected candidate. + The final whole-object comparison includes CODE, all DATA, deduplication and + required padding; per-function estimates cannot override it. Equal-byte + candidates prefer fewer structurally loop-weighted scheduled bytes, then + stable order. This speed proxy is not measured cycles. An O2 transformation + remains allowed when its complete candidate wins. This is bounded search, + not byte-optimality. Larger units retain the compact O2-policy fallback + without candidate expansion or immutable-array pools. - **Use static allocation costs.** The conservative size allocator counts static uses, transfers, PHI copies and caller preservation instead of weighting loop frequency. Its alternative must strictly reduce estimated fetch bytes; the final machine comparison includes real spills and copies. +- **Avoid proven redundant transfers.** Working-tree Os inline signed/unsigned + division outside plot copies the divisor directly into R3 instead of staging + it in R4; plot, shared-kernel and O0/O1/O2 paths are unchanged. An already-proved + near-RAM offset can copy an authoritative allocated scalar WORD index directly + into R6, without R0. Spilled, boundary, regional, ephemeral, rematerialized, + byte, far and unproved cases retain their original path. Base materialization, + scaling, checks and fault order remain unchanged; CACHE ownership is unaffected. - **Pool repeated software division.** Eligible signed and unsigned word division/remainder sites can call separate private, same-unit divmod kernels. A single generic division keeps the inline sequence. A pool is selected only @@ -128,7 +211,7 @@ graphics effects. Smaller code may execute more instructions or cycles. linker placement checks remain intact. `--emit-ir -Os` and `--check -Os` inspect/verify the conservative size IR. -Machine emission may choose the smaller alternate O2-policy candidate; +Machine emission may choose alternate O2-policy or mixed-function candidates; compiler assembly and final linked assembly still describe their actual selected bytes. Candidate search, suffix matching and branch widening are bounded and deterministic. No profile input, unchecked-memory mode, arbitrary @@ -413,7 +496,9 @@ final committed revision before treating them as release-candidate evidence. - Keep short CFG/internal branches individually; widen only out-of-range branches and invert simple conditions when the false edge is fallthrough. Widening is monotonic, capped at eight emission attempts with a safe long-form - fallback. All branches retain their pipeline delay slots. + fallback. Each attempt collects widening requests across the whole module; + exhausted retries force long forms only in functions still requiring them. + All branches retain their pipeline delay slots. - Emit nontrivial SSA definitions once, in IR order. Allocate single-use and reused scalar results to R5/R7/R8, including checked-pointer/plot functions. Retain real aligned frame spills under register pressure. @@ -437,8 +522,11 @@ final committed revision before treating them as release-candidate evidence. signed/unsigned branches. Comparisons used as values still produce bool. - Scale near-pointer offsets with checked shifts/add/sub for power-of-two strides instead of walking elements. Retain carry/borrow, alignment, null, - bank-window and fault checks. Far transitions and other strides keep the - explicit baseline algorithm. + bank-window and fault checks. Constant near offsets use one scaled ADD/SUB, + including non-power-of-two strides. Base representation checks precede + displacement overflow, followed by carry/borrow and complete result-span + checks unless proved redundant. Far transitions retain the explicit baseline + algorithm; other nonconstant strides keep their existing stepping path. - Elide repeated identical literal COLOR writes only within a basic block with unchanged POR. Still evaluate expressions; calls, emitted CMODE, GETC, block boundaries and implicit hardware-loop boundaries invalidate the @@ -584,10 +672,19 @@ bounded machine scheduling. It has no LLVM dependency. scaled magnitude must fit a word. Numeric wrapping never becomes a guessed small interval. Unknown loads/calls, recursive pointer PHIs, over-aligned locals, far/ROM addresses and scaled-index recurrences retain their checks. +- **Bounded multiply selection.** O2/Os use MULT only when both operands are + proved within -128..127, or UMULT when both are within 0..255. A small product + alone is insufficient because hardware truncates both inputs. Eligible + results go directly to allocated R5/R7/R8, using an immediate operand when + available. Full-word products retain LMULT; loading an allocated operand + directly into R6 avoids an unnecessary accumulator copy. No special-register + allocation, interprocedural range assumption, ABI or object-version change + is introduced. - **Local successful-check credit.** Within one emitted block, a successful access check of an immutable SSA near-RAM address can justify an equal or narrower access to that same value. Pointer-offset arithmetic must first pass its original carry/borrow, representation and result-span checks. + A checked near-RAM offset result supplies the same bounded-width credit. CFG block entries, PHI copies and calls discard the credit; a reloaded pointer or a cast has its own SSA identity. A byte check does not justify a word access. - **Adjacent transient values.** A single-use, nonallocated scalar or proved @@ -605,10 +702,15 @@ bounded machine scheduling. It has no LLVM dependency. its physical R1 increment; no extra increment is introduced for a pixel. - **Stack-check reuse.** Track a lower-bound credit relative to the linker's common stack floor. The existing frame guard covers the two saved-register - pushes; later paired pushes/pops can reuse an established bound. Allocation - and pushes consume credit; cleanup restores it. Calls, SP replacement, - blocks, helper joins and hardware backedges fence it. No larger requirement + pushes; later paired pushes/pops can reuse a dominating bound only within + straight-line emission. Allocation and pushes consume credit; cleanup restores + it. Every CFG block entry and local-label binding must reset the credit, + including call-free loop bodies; calls and SP replacement also fence it. + Credit must not cross alternative control-flow arms. No larger requirement is hoisted before an observable effect or onto an untaken path. + The conservative reset repair is covered by the current + [local validation](testing.md#gem-chess-local-validation); older timing + snapshots below do not validate later control-flow changes. - **Divmod fusion.** A dominating word division/remainder can own both results when a later opposite operation uses the same resolved SSA operands and type. `divmod /` (or `%`) keeps the first operation's result; `divmod.result` selects @@ -662,6 +764,134 @@ unproved accesses checked; it does not weaken safety or invent first-trip PHI facts. O2 can increase code size or lose cycles on a particular workload; benchmark both levels rather than assume a universal improvement. +## Final storage and loop-state planning + +O2/Os plan storage on each owned candidate after SSA and CFG transformations, +without changing analyzer symbol tables or parameter ABI offsets. Dead local +homes disappear; whole-object CFG liveness permits noninterfering objects to +share aligned homes. Partial stores do not kill a whole object's lifetime. +Unknown flat RAM aliases, numeric/incoming pointers, escapes, calls, volatile +accesses and unproved access bounds conservatively retain the original layout: +absence of `&local` is not a no-alias contract. Planning is bounded to 256 local +symbols, 256 blocks, 8,192 values, 16,000 instructions and two million liveness +work units. A changed IR must be replanned from the original unplanned input. + +For copied RAM execution, eligible immutable local integer arrays can instead +use private near-RAM DATA pools. They require one complete constant initializer +dominating every read, no mutation/escape/volatile access, and proven complete +in-object access widths. Eligible arrays have 4–256 byte/word/bool elements; +eligibility alone is not profitability. Bounded O2/Os searches also compare +unpooled emission. Reads remain ordinary RAM loads, never ROM-buffer GETB reads. +The host must copy and preserve the entire linked CODE/padding/DATA payload, +including pools, across calls and runs; it must not reuse it as working RAM. +See the [loading contract](getting-started.md#load-and-execute). +Planning is intrafunctional: it does not prove external writers absent. +Raw/numeric pointers have no dynamic guard protecting pool DATA; overwriting +a pool corrupts the reserved payload. Private symbol names are not physical +memory protection. The selected near RAM bank must match the RAM program bank; +relinking a pooled object into ROM or the wrong bank is diagnosed. Pool bounds +are not frame bounds. Object format, calling ABI and ordinary DATA relocation +rules remain unchanged. + +Tied two-address affinities prefer a dying input's register for its result +without removing interference edges. PHI constants can materialize directly +into their destination. Bounded closed hardware-loop regions may borrow R4/R6 +for invariant spilled scalar snapshots only when all selected operations, +entry/PHI copies and slot writes prove the registers and homes safe. These are +regional copies, not a new general allocation pool; unsupported regions retain +frame loads. Internal call-clobber summaries propagate transitively through +recursive call graphs. Unknown/external calls and exhausted analysis budgets +retain the conservative clobber envelope, including hardware-loop state. + +Hardware LOOP targets can thread branch-only edges only when PHI copies and +allocation-boundary stores/reloads are empty; required copies remain on-path. +Comparisons against a scalar literal used only by two conditional branches may +be recreated at the consumers from the same SSA snapshot. Producer evaluation, +including a volatile load, is not duplicated; liveness is reconstructed before +allocation. Automatic division CACHE remains an alternative, not a guarantee +that caller cache state survives calls. See [regression coverage](testing.md#gsu-storage-and-candidate-planning-checks). + +### Historical storage/planning measurements (2026-10-20) + +These compare a frozen repair-only baseline with a later storage/planning +snapshot, not a Git release or validation of the current source. Compiler +SHA-256 identities are baseline +`22323ADCD98AA247BA7D434430855D0B78137529B69486ED9862FD73A54394DD` +and storage/planning snapshot +`22C91A501E2EC91A210358B7EED608CF9F33A09BDAC820A2989A094A42CFCCB5`. +Workloads, hosts, resource caps and independent analytic oracles are unchanged. +Payload includes CODE, padding and DATA; cycle columns measure GSU entry to STOP. + +| Workload / policy | Payload bytes baseline → snapshot | GSU cycles baseline → snapshot | +| --- | ---: | ---: | +| Rotation O2, mean first 64 poses | 2,583 → 2,443 | 873,754.265625 → 838,703.75 | +| Rotation Os, mean first 64 poses | 2,583 → 2,430 | 872,652.390625 → 1,012,974.25 | +| Scaling O2, mean first 64 poses | 654 → 608 | 33,393.75 → 33,322.46875 | +| Scaling Os, mean first 64 poses | 542 → 507 | 40,893.625 → 41,931.34375 | +| Shapes O2, wire / filled means | 10,874 → 10,383 | 325,892 → 313,943 / 1,182,443 → 1,147,858 | +| Shapes Os, wire / filled means | 10,807 → 10,035 | 331,676 → 317,821 / 1,214,695 → 1,174,261 | +| Official arithmetic Os | 254 → 250 | 26,713 → 26,755 | + +Os rotation regresses 16.08% in cycles for 5.92% fewer payload bytes; scaling +regresses 2.54%, and arithmetic 0.157%. These accepted size-first tradeoffs are +not universal speed gains or proof of byte optimality. Rotation's conservative +Os candidate loses two hardware LOOPs and repeats frame-pointer loads/checks; +its pixel-region savings outweigh a larger clear region. No equally small, +faster complete candidate was demonstrated. O0/O1 outputs remain unchanged; +shapes O0/O1 retain their original bank/stack non-fits and are unmeasured. + +Mesen 2.2.1 (`20ba206cef5ba207c21203176d02cb9f43dda9fb`) used NTSC +21,477,270 Hz, CLSR=1, normal multiply and cold instruction CACHE. Official +benchmarks execute in ROM bank 00 with near RAM 70; demos execute in RAM 70. +Do not aggregate unlike profiles or interpret these cycles as CPU/DMA time, +display FPS or physical-hardware timing. + +Independent acceptance passed 381 Release and 381 Debug tests, 36 official +native comparisons, placement/focused checks and all original Mesen functional +and negative gates. The 117 serial emulator processes yielded 865 unique timing +samples, 572 aligned differences and 404 exact stable-image comparisons. +Image collection compares rotation/scaling by fixed phase and shapes by required +named captures, not dynamic frame ordinals; the original analytic oracles remain +unchanged. +Local ignored evidence is retained in `build/7rt/` (`runtime-handoff.json`, +`report.json`, `comparison.csv` and `per-sample-diff.csv`), not as a new release +benchmark report. + +### Historical Os recovery measurements (2026-10-20) + +The later frozen recovery snapshot improves the storage/planning snapshot above; +it predates the subsequent stack-credit control-flow repair and does not +validate that repair or the current source. It is not a validated commit or +release. Its compiler SHA-256 is +`50EC301333B060512E4C0690B412722AD16502F846713331DC90F7B7FD02F5B6`. +The same workloads, hosts, limits and independent oracles are retained. + +| Os workload | Payload bytes storage/planning → recovery | GSU cycles storage/planning → recovery | +| --- | ---: | ---: | +| Rotation, mean first 64 poses | 2,430 → 2,299 | 1,012,974.25 → 883,865.53125 | +| Scaling, mean first 64 poses | 507 → 507 | 41,931.34375 → 41,931.34375 | +| Shapes, wire / filled means | 10,035 → 10,015 | 317,821 → 316,981 / 1,174,261 → 1,171,741 | +| Official arithmetic | 250 → 248 | 26,755 → 26,435 | +| Official dense switch | 331 → 327 | 7,140 → 6,960 | +| Official sparse switch | 334 → 330 | 7,090 → 6,910 | + +Rotation uses 12.745508% fewer cycles than the storage/planning snapshot, with +no measured regression against it, including individual paired samples. This +does not fully recover the repair-only baseline: rotation remains 1.284949% +slower than its 872,652.390625 cycles, and scaling remains 2.537605% slower than +its 40,893.625. Arithmetic is 278 cycles faster than that baseline. A rejected +pointer-only experiment is not the baseline; recovery shapes is 91 bytes larger +than that unaccepted 9,924-byte experiment. + +Measurements retain Mesen 2.2.1 and the NTSC/CLSR=1/cold-initial-CACHE profiles +above: official ROM 00/near RAM 70 and demo RAM 70. GSU cycles are not CPU/DMA +time or FPS. Shapes O0/O1 remain unexecuted original placement non-fits; no +physical-hardware or new sanitizer result is implied. See the +[recovery validation accounting](testing.md#os-recovery-validation-snapshot). +Local evidence is `build/ofr/runtime-handoff.json`, `report.json`, +`comparison.csv` and the separate `S2-comparison.csv`; historical reports above +remain unchanged. + ## Hardware constraints Selection was checked against the @@ -683,9 +913,9 @@ Registers are not an interchangeable ARM-like pool: | R0 | Expression/result and selected RAM address | | R1 / R2 | Plot X / Y; otherwise arithmetic scratch; division preserves them inside plot | | R3 | Checks, addresses, scalar scratch and frame adjustments | -| R4 | Far-pointer bank, multiply low result, division/check scratch | +| R4 | Far-pointer bank, multiply low result, division/check scratch; proven regional loop copies only | | R5 / R7 / R8 | O1/O2 scalar allocation pool; caller-live values are saved | -| R6 | Multiply operand, scratch and fail-stop category; not allocated | +| R6 | Multiply operand, scratch and fail-stop category; proven regional loop copies only | | R7 / R8 hardware role | MERGE inputs; current operations do not emit MERGE, and future selection must reserve/model them | | R9 / R10 / R11 | Frame pointer / stack pointer / LINK return address | | R12 / R13 | Hardware LOOP counter / loop address; not allocated | @@ -709,10 +939,12 @@ address used by LOAD/STORE, not an address operand or an address register. It writes the source low byte at that offset and the high byte at offset XOR 1 using the current RAMBR. ALT1 does not make it a byte store. The backend therefore never selects it for byte, far-pointer or aggregate stores. -It tracks the address by SSA value, also recognizing fresh plain Address -nodes for the same declaration's stable SymbolId. Distinct pointer loads -are not assumed equivalent. Value materialization happens before checking -the latch: any resulting spill/reload invalidates the proof. Successful prior +It tracks pointer SSA identities and fresh plain Address nodes for the same +declaration's stable SymbolId, plus physical frame offsets for spill traffic. +Distinct pointer loads are not assumed equivalent. Value materialization +happens before checking the latch: intervening memory traffic updates or +invalidates the physical-address proof. A spill word can reuse SBK only when +the same frame offset is still latched in the same RAM bank. Successful prior word accesses justify reuse of their address checks; no check is removed for an unproven address. Volatile word accesses can use SBK without eliminating or reordering the read/write. Calls and CFG/implicit hardware-loop boundaries @@ -943,6 +1175,12 @@ not operand bytes mistaken for opcodes. It remaps CODE symbols, relocation patches and relative branch destinations together, after branch relaxation. Its optional per-byte analysis is limited to one 64 KiB program bank. +After slot/layout compaction, bounded relaxation can shorten a symbol-resolved +non-call `IWT R15` to BRA when its signed-byte displacement fits. It preserves +the existing delay slot and requires compatible plain selector state. Numeric +unrelocated destinations, LINK calls, protected interior entries and unsafe +relocations are excluded; symbols and remaining relocations are remapped together. + - Fill eligible NOP slots with a one-byte INC/DEC, keeping branch register and flag dependencies intact. N/Z-dependent conditions cannot move INC/DEC. `LINK #4` retains its return address when the final stack adjustment moves @@ -1408,6 +1646,29 @@ there is no measured FPS gain. The [rotation summary](../benchmarks/results/rotation-compaction-summary.json) retains fingerprints and both independent timing/presentation windows. +## Bounded stack-traffic and CACHE refinements + +The following guarded refinements reduce backend overhead without changing +the ABI or removing required failure paths: + +- Eligible pixel-only hardware loops can back up loop state in R4/R6 instead + of pushing it. The bounded eligibility proof excludes conflicting effects; + low-stack fallback preserves the original first/second-save failure behavior. +- Near-pointer checks can reuse proven immutable pointer-component facts. + Mutation, unsupported provenance and failed proofs retain checks; this is + not blanket pointer-check removal. +- Direct CMP and cursor selection avoid redundant transfers only where the + selected registers and flags permit it. Private LOOP/PLOT delay-slot + scheduling retains the final and taken paths. +- Word add/subtract can use a register left operand and frame-resident right + operand directly, preserving operand order and arithmetic semantics. + +The gem-chess source refinement also packs a 487-byte collector hot body and +its frequent left-side join into the actual 512-byte CACHE window without +added NOP padding. This is a bounded hot-loop layout, not a claim that the +whole collector function fits in CACHE. Scene byte/cycle measurements and +their provenance belong in the [example README](../examples/snes/gem_chess/README.md). + ## Verification and remaining work Existing O0 tests remain alongside O1 call, pointer/far, numeric, volatile, diff --git a/docs/testing.md b/docs/testing.md index 55ead1c..ebcc2c4 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -337,6 +337,31 @@ seed-3/result-2019 snapshot contract, not the function-call benchmark source. ## GSU optimization regressions +Focused no-spill backend tests cover final IR-value and auxiliary DivMod homes, +spill-free explicit scalar-local storage and ABI arguments, and rejection of +genuine spilling address intermediates. Os candidate tests distinguish the +ordinary 121-byte spilling recurrence from the strict 147-byte LOOP candidate; +81 boundary-seed pairs execute both candidates through direct and assembled +paths, preserving results and ABI state. The +[`--require-no-spills` contract](optimization.md#requiring-spill-free-allocation) +does not exclude compiler-generated intermediates merely because their source +object is a local array. Existing ordinary compilation remains the control. + +The stack-traffic pass additionally verifies 6,212 independent observations, +65 strict-option/API/output-preservation gates and all 36 ordinary native +benchmark cases. Fresh MinGW/GCC Debug and Release runs each pass 379/379 CTest +tests without skips. A real volatile comparison check replaces a stale BEQ +assertion on a constant-folded fixture, preserving existing opcode-family, +assembly roundtrip and R6 assertions. The permanent matrix executes six value and six +branch predicates, signed/unsigned, on 9×9 boundary pairs at O0/O1/O2/Os through +direct and assembled paths: 648 runs, 1,296 masks and 15,552 truth checks. A +deliberately wrong mask is independently rejected. + +All 36 ordinary benchmark cases also pass optional Mesen verification. Strict +candidate eligibility and its measured size/cycle tradeoff are recorded in the +[optimization contract](optimization.md#requiring-spill-free-allocation); +model instruction counts are a separate metric. + `gsu_optimization` checks default/O0 byte identity, the `-O`/O1 alias, strict TOML boolean validation and CLI precedence, source-import propagation, mixed-level objects, and byte-exact compiler/final assembly exports. @@ -428,7 +453,10 @@ but not after faults. Zero/nonzero stack floors, entry alignment, a minimal O2 array frame, an untaken call, and O2 callers with O0/O1/O2 callees are checked independently. Assembly asserts that the entry guard stays while its two redundant O2 saved-register push checks disappear. Direct build helpers -include the proof test unit. +include the proof test unit. Stack-credit reuse requires straight-line +dominance; every CFG block entry and local-label binding must fence it, even +in call-free loops. The conservative reset repair has 540 stack-boundary cases +covering successful returns and fail-stop behavior. `gsu_size_optimization_unit` checks conditional/remainder-first divmod fusion, matching/dominance/type exclusions, malformed projections, bounded/idempotent @@ -694,6 +722,79 @@ measurements use the optional Mesen harness. An invalid pointer-parameter test retains its preceding volatile witness before the alignment fail-stop. Os must select the smaller software span when LOOP's ABI setup costs more bytes; O2 still requires the profitable automatic CACHE. +Pointer-only promotion tests require repeat-pass idempotence and checked pointer +SSA without counted-loop/initializer conversion. Alias, escape, assignment and +incoming-pointer fault boundaries remain covered; ROM/RAM execution compares +volatile multiplicity, observable RAM and ABI state against O1. +Os direct-divisor and allocated-index copy cases check signed/unsigned results, +witnessed failures and ROM/RAM execution while retaining plot staging and +unsupported-index fallbacks. These structural checks do not establish timing. + +## GSU storage and candidate-planning checks + +`ir_storage_optimizer_unit` checks dead-home reclamation, disjoint CFG lifetime +sharing, retained interference, aggregate alignment, unchanged parameter and +analyzer offsets, malformed-home rejection and repeat-plan idempotence. +Flat numeric aliases, incoming/escaped pointers, volatile/partial/out-of-bounds +accesses and bounded-analysis exhaustion retain conservative storage. Pool +tests check complete little-endian images, disabled capability and mutable or +partial-initializer fallbacks; scalar-bound queries reject invalid IDs safely. + +`gsu_seven_backend_unit` executes conditional snapshots without repeating +volatile reads, nested/transitive/recursive calls and hardware loops, physical +SBK spill updates, and the real numeric stack-alias read/write fixtures at +O1/O2/Os. Results, access counts, stack/LOOP state, banks, fault state and RAM +sentinels remain asserted. O2/Os cases require profitable immutable pools; +an Os pool also exercises the actual linker, compiler assembly and +final-assembly byte-exact reconstruction. +ROM/wrong-bank placement and ordinary DATA-as-RAM relocations remain negative +cases in linker coverage. Strict multi-function Os tests require the exact +selected spill-free candidate and reproduce it on the same backend instance, +even when larger than the ordinary winner. These are functional/encoding +checks, not measured-cycle or physical-hardware claims. +Mixed-candidate coverage also retains the complete legacy payload incumbent, +including cross-function shared DATA, division ownership and alignment, before +adding pointer-only alternatives; backend-instance reuse remains checked. +Extension guardrails check complete-winner total/per-function proxy references, +missing-evidence rejection and conditional strict spill eligibility. Fresh, +reused and post-exception invocations must retain deterministic owned results. + +### Os recovery validation snapshot + +The historical uncommitted Os recovery snapshot passed independent Release +381/381 and Debug 381/381 with no failures, skips or incomplete tests, five C++14 `-Werror` checks +and 10,424 paired cases. Final Mesen functional gates passed without changing +the workloads, hosts, capacity limits or analytic oracles. + +The 46 runtime groups contain five freshly measured groups and 41 +identity-qualified historical references, not 46 fresh executions. There were +36 new Mesen processes: 28 guards and eight workload processes. Three pure +snapshot checkers are not timing samples. The final 572-sample timing dataset +contains 76 fresh samples and 496 references; 404 image pairs comprise 68 fresh +and 336 referenced pairs. Rotation/scaling images pair by fixed phase; shapes +uses required named captures, never dynamic frame ordinals. Original shapes +O0/O1 placement non-fits remain unexecuted. Evidence and identity proofs are +retained under `build/ofr/`; this is emulator validation, not physical hardware +or new sanitizer coverage. This does not validate later source changes or the +current full gem-chess scene. See the [historical recovery and remaining baseline gaps](optimization.md#historical-os-recovery-measurements-2026-10-20). + +### Gem-chess local validation + +The 2026-10-20 local source snapshot passed Release 387/387 and Debug 387/387 +with no failures, skips or incomplete tests, retaining all 381 previous tests +and adding six gem-chess cases. Full prebuilt and direct paths passed, as did +strict C++14 syntax checks for 14 production translation units and two runners. +The actual-source raster, private-raster and paint corpora execute 432, 568 and +536 cases respectively per O2/Os policy, with independent raster expectations +and byte-exact build/assembly paths. This includes the conservative stack-credit +reset repair; the older recovery results above do not validate that repair. + +Current O2 full-scene Mesen positive/negative and ten-capture checks also passed +under the original 120-second CLI and external bounds with unchanged frame caps. +The confirming bounded runs supersede an initial run with a 180-second CLI +limit; that earlier attempt remains preserved. Full-scene Os Mesen execution +is unrun. No physical-hardware, new sanitizer or CI result is implied. Exact +source/tool identities and scope are in the [scene validation snapshot](../examples/snes/gem_chess/README.md#local-validation-snapshot-2026-10-20). ## Optional libFuzzer target diff --git a/examples/snes/gem_chess/README.md b/examples/snes/gem_chess/README.md new file mode 100644 index 0000000..eef5ff5 --- /dev/null +++ b/examples/snes/gem_chess/README.md @@ -0,0 +1,263 @@ +# Gem chess: a complete SuperFX scene + +![Historical Mesen capture of the DiscoC scene](preview.png) + +A full 8×8 board and 32 opaque, faceted gemstone pieces, calculated and drawn +by DiscoC on the GSU. There are six mesh silhouettes: pawn, crenellated rook, +leaning knight, pointed bishop, crowned queen and king with an extruded cross. +The cyan and ruby armies start in the usual chess arrangement. This is a +scene/camera demo, **not a chess engine**; there are no move rules or computer opponent. + +The camera really orbits in 3D, with perspective, elevation and distance. +The image is not a pre-rendered background, a sprite rotation or a wireframe +passed off as a solid scene. Lighting is opaque, quantized directional flat +shading, not transparency, refraction or ray tracing. + +## Build and play + +Build DiscoC following [the normal build instructions](../../../docs/building.md). +Put `wla-65816` and `wlalink` on PATH, then run from the repository root: + +```sh +cmake -DDISCO_TOOLS_DIR=build/release/bin -P examples/snes/gem_chess/build-snes.cmake +``` + +Open **`build/gem-chess-scene/gem-chess.sfc`** in a SuperFX-capable emulator. +This is a complete 64-KiB SNES ROM. Map the player-one SNES controller: + +| Control | Action | +| --- | --- | +| Left / Right | Orbit the camera around the board | +| Up / Down | Raise / lower camera elevation | +| Select + Up / Down | Zoom in / out by changing camera distance | +| A press | Toggle shaded faces / visible-edge wireframe | +| Start | Restore the default camera and shaded mode | + +Yaw wraps through 64 positions; elevation is clamped to phases 5..10 and zoom +to 6..16. Opposing directions cancel. A held down toggles once. The SNES CPU +continues polling input during rendering; each render receives a coherent +snapshot and unchanged input does not trigger unnecessary redraws. + +The default is **O2, 256×192 bitmap, 4bpp, NTSC, CLSR=1 (~21.477 MHz), normal +multiply timing**. The PPU presents this full scene through BG1, not the two +small OBJ panels used by the separate interactive-shapes demo. There is no +emulator overclocking. `-DOPTIMIZATION=s` requests the size-oriented pipeline; +the checked linker must still accept its code/stack placement. Local Mesen +runtime verification covers the current O2 source as recorded below. Os passes +current compile/link/assembly-equivalence checks, but full-scene Os Mesen runtime +has not been run. + +Tool paths and output directories can be explicit; quote paths with spaces: + +```sh +cmake -DDISCO_TOOLS_DIR=/path/to/discoc/bin \ + -DWLA_65816=/path/to/wla-65816 -DWLALINK=/path/to/wlalink \ + -DOUTPUT_DIR=build/my-chess -P examples/snes/gem_chess/build-snes.cmake +``` + +For only the GSU payload and byte-exact assembly, without a SNES host: + +```sh +discc build --config examples/snes/gem_chess/discoc.toml +``` + +## Renderer + +All geometry uses bounded signed 16-bit integers and symmetric Q6 rounding. +The camera looks at `(0,10,0)`, uses focal length 256 and an integer reciprocal +table for perspective. The table is built once per render using an exact +quotient/product recurrence, shared by every vertex, without a division per +depth entry. Out-of-range depth is diagnosed through the host mailbox rather than +indexing an uninitialized table entry. + +Pieces are separated vertical bodies, painted far-to-near using unsigned, +scaled **radial camera distance**, not just Z order. Convex height bands are +painted bottom-to-top. Integer ring rounding can make a four-corner band face +nonplanar: per-band diagonal masks select its convex hull and its triangles +are culled independently. Two visible triangles share a fill only when their +screen projection is convex. Planar faces use the same independent-triangle +culling because integer perspective can fold their projection at grazing angles. +Extra convexity determinants are computed only when both triangles are visible; +hidden triangles need no temporary coordinate copy. Vertex coordinate offsets +and edge differences are reused within each face. +Crowns and the king's cross use separate convex +parts. This is a renderer for this bounded scene, not a general intersecting- +mesh/Z-buffer engine. + +World-space facet direction and slope select a five-tone material ramp. +In-viewport clockwise convex faces seed only their vertex rows. A per-side edge +collector writes interior boundary rows directly and groups horizontal runs, +preserving the midpoint recurrence and its tie rule. Clipped faces and +horizontal/point degeneracies retain the generic extrema collector and interval +initialization. Wireframe retains the checked midpoint line renderer. Cached horizontal +spans use `pixel;` and its hardware R1 increment; the final `flush;` commits +pending pixel-cache data before STOP. Every render explicitly resets POR and +clears the entire framebuffer, so camera changes cannot leave old pieces. + +## Safe presentation and memory + +| Cartridge RAM, bank `$70` | Use | +| --- | --- | +| `$0000..5FFF` | 24-KiB, column-major 4bpp framebuffer | +| `$6000` upward | Fixed-origin GSU code and runtime bootstrap | +| Above payload, below `$EFFE` | Checked descending stack and local renderer arrays | +| `$F000` | Returned primitive count | +| `$F002/4/6/8` | Yaw, elevation, zoom and wireframe input | +| `$F010/12/14/16` | Normalized input echoes | +| `$F018` | Depth-domain error flag, normally zero | + +The host obtains SCBR/SCMR/origin from final linked metadata. It checks STOP, +R6, R0 versus the mailbox, input echoes and stack restoration before uploading. +The ROM declares 64 KiB cartridge RAM and never accesses bank `$71`. + +VRAM contains two 24-KiB character buffers, at bytes `$0000` and `$6000`, plus +a shared row-major tilemap at `$E000`. The SNES CPU uploads the inactive buffer +in **six 4096-byte chunks, one per fresh VBlank**, checking the beam after each. +Only then does it switch BG1's character base. The previously completed view +remains visible throughout rendering and transfer; no partial frame is shown. +Tile zero remains clear and supplies the blank rows below the 192-line scene. + +This prioritizes correctness, not real-time frame rate. A filled default view +took several seconds in the historical measurements below; input polling is +60 Hz, but newly rendered views are not. `gem-chess-timings.csv` reports measured +GSU opcode-to-STOP cycles/seconds separately from host publication clocks. +It excludes DMA/presentation from the GSU sample and is **not an FPS result**. + +### Historical optimization snapshot (2026-10-18) + +This earlier stack-traffic comparison uses the frozen compiler and renderer +hashes listed below, not the current source. It compares against the preceding +renderer, not the older original scene. These are dated uncommitted development +measurements, not current-source validation or release benchmarks: + +| O2 configuration | Payload bytes | Default filled GSU cycles | Default wireframe GSU cycles | +| --- | ---: | ---: | ---: | +| Accepted baseline compiler and renderer | 26,919 | 78,377,218 | 56,312,742 | +| Snapshot compiler, same baseline renderer | 24,777 | 68,270,253 | 49,821,154 | +| Snapshot compiler and revised renderer | 26,567 | 65,452,153 | 47,059,290 | + +Compiler-only changes reduce filled/wireframe cycles by 12.90%/11.53%. +The combined result saves 352 bytes (1.31%) and 16.49%/16.43% of cycles against +the accepted baseline. The revised renderer costs 1,790 bytes versus the final +compiler-only build, trading code size for faster projection and edge collection. +Its 487-byte collector hot body plus frequent side join fit the actual CACHE +window without added padding; the whole function is not claimed to fit. + +Both historical variants match all ten baseline BMPs, all 24,576 framebuffer bytes +and primitive counts. All 18 measured publications improve against baseline; +controller, STOP/ABI, bank/CACHE, VRAM/PPU, VBlank DMA and negative-ROM checks +pass. Measurements use bank `$70` RAM execution, stock NTSC GSU at 100%, +21,477,270 Hz and normal multiply timing. GSU samples exclude host publication +and are neither FPS nor physical-hardware measurements. + +Local ignored evidence is under +`build/stack-traffic-verification/final-compiler-only-O2-runtime-a/` and +`build/stack-traffic-verification/final-combined-B-O2-runtime-a/`; +`final-artifacts-a/` retains the final ROM package. SHA256 provenance: + +- Snapshot `discc`: `0DBED34CCD922E5FD16D7D2C927771842844D9381B4D7562CE13C932DC8369C0`. +- Baseline renderer: `A006B14EFBA752B9BE24594D6EFC8973F2E20F7F4F786560D9CF9D903FCE1BCD`. +- Revised renderer: `CECC4BB448C28FA47C822620CEC10E71F29792AF830A5C26D7FDBC39927C0997`. +- Final compiler-only payload: `4C0012122201A08D46D9724D39467D5CD34DAF33B9BB0FEFF8DA47AD0769F45E`. +- Final combined payload: `BA3A3307AC363191DCFBC38D2B0F98B37B74BE72FDA8F4A8255B9D6F6076959A`. +- Final combined ROM: `21DB4A84BC4677E02C66867C1CEC4F89BE3578D271DB3D48113B19033623413B`. + +The optional [`--require-no-spills`](../../../docs/optimization.md#requiring-spill-free-allocation) +constraint did **not** pass for that snapshot: `project` required +30 mapped IR values in four unique 16-bit spill slots. This is not a current +spill count. The diagnostic leaves +existing output unchanged. Reduced stack traffic is not a spill-free scene or +a stackless ABI guarantee. + +#### Earlier renderer-rewrite snapshot + +The following uncommitted working-tree measurements separate compiler changes +from the renderer rewrite, using frozen tools and retained baseline captures: + +| O2 configuration | Payload bytes | Default filled GSU cycles | Default wireframe GSU cycles | +| --- | ---: | ---: | ---: | +| Original compiler and renderer | 28,308 | 104,811,963 | 75,207,386 | +| Updated compiler, original renderer | 24,535 | 88,041,623 | 62,230,015 | +| Updated compiler and renderer | 26,919 | 78,377,218 | 56,312,742 | + +Compiler-only changes save 13.33% of payload bytes and 16.00%/17.26% of +filled/wireframe cycles. The combined build saves 4.91% of bytes and +25.22%/25.12% of cycles against the original. The renderer trades 2,384 bytes +against compiler-only output for another 10.98%/9.51% cycle reduction. +The final default poses take 3.649310085 s filled and 2.621969273 s wireframe +at 21,477,270 Hz. These are emulator GSU timings, not FPS, physical-hardware +measurements or final-release benchmarks. + +The independent combined O2 run matches all ten baseline BMPs, complete 24-KiB +framebuffers and primitive counts. The negative-ROM check also passes. Local +ignored evidence is under +`build/gem-opt-verification/integration-20261018-a/combined-v6-O2-serial-observation/`: +`run-result.json`, `capture-comparison.json` and `gem-chess-timings.csv`. +Frozen tool hashes are recorded in +`build/gem-opt-backend/checkpoint-tools-20261017-v1/manifest.txt`. +SHA256 provenance for this snapshot: + +- Renderer source: `A006B14EFBA752B9BE24594D6EFC8973F2E20F7F4F786560D9CF9D903FCE1BCD`. +- Combined O2 payload: `E814F1E1112112D40DEDCD379BF718D58BD59FA5492667DA46D1CF145046B055`. + +In that earlier snapshot, Os produces 26,384 bytes and passes compile/link and compiler/final-assembly +roundtrips; Os runtime was not tested. Reproduce with the build command above, +adding `-DOPTIMIZATION=s` for Os and using separate output directories. The +ignored evidence is a local development record, not published release evidence. + +## Verification + +### Local validation snapshot (2026-10-20) + +The current renderer SHA-256 is +`F7E71425B8ACEC372FB62BA646AE8B68018DA586BC39DD41A5557C87A14E7745`; +the tested Release compiler SHA-256 is +`1FB60984D6493FECE006530C22F0E23A1139598F45A9E13B24F03686E9C278DE`. +O2 and Os payloads are 27,675 and 27,260 bytes respectively. Project/direct +builds, compiler assembly and final exports reconstruct identical bytes within +each policy. These are local source-validation results, not release benchmarks +or a new performance comparison with the historical tables. +The Mesen executable SHA-256 is +`8EF403D6B9AF32075416193914D7993CE8480B347FE1F20A0CFD9815374933E7`, +using the deterministic stock NTSC/CLSR=1/normal-multiply profile below. + +The current O2 ROM passed positive and deliberate-failure Mesen runs, matching +all ten accepted baseline BMPs, complete 24,576-byte framebuffers and primitive +counts. Controller, STOP/ABI, VRAM/PPU and VBlank transfer checks passed. The +confirming runs completed in 45.984 s and 2.564 s wall time with both CLI and +external timeouts of 120 s and unchanged frame limits. These are test-process +durations, not rendering throughput or GSU-cycle measurements. + +Twelve strict-allocation output-protection checks passed. The first offending +function, `project`, reports three IR-value spill slots; this is **not** the +whole scene's total spill count. Full-scene Os Mesen runtime remains unrun. +Native raster coverage and complete local suite results are described in +[testing](../../../docs/testing.md#gem-chess-local-validation). + +With Mesen available: + +```sh +cmake -DDISCO_TOOLS_DIR=build/release/bin -DMESEN=/path/to/Mesen \ + -DVERIFY_MESEN=ON -P examples/snes/gem_chess/build-snes.cmake +``` + +The build compares manifest/direct, compiler-assembly and final-export payloads +byte for byte. The Mesen verifier injects actual controller input and checks +normal STOP/ABI, complete framebuffer-to-VRAM equality, both VRAM buffers, +all 1024 tilemap entries, palette, all displayed pixels and VBlank completion. +It visits ten camera/mode states, including a formerly folded queen facet, +a grazing planar wall, the maximum-area plinth pose, zoom/elevation limits, +wireframe and reset. +A separate deliberately wrong-result ROM must display a solid red screen. + +The widened-integer Lua geometry fixtures also check all **4224** bounded +camera combinations, signed area limits, convex-hull diagonal masks and the +planar/nonplanar culling regressions. These complement the real emulator runs; the +tests do not implement a second complete renderer or claim physical-hardware +validation. Captures, timings and verification logs are saved beside the ROM. +Local verification uses MesenCE **2.2.1**, build +`20ba206cef5ba207c21203176d02cb9f43dda9fb`, at the stock NTSC 21 MHz profile. + +**The ROM contains no proprietary Nintendo code, Nintendo SDK components, +commercial game code or proprietary game assets.** Geometry, host and scene +are original. The screenshot is captured from the executing ROM. diff --git a/examples/snes/gem_chess/build-snes.cmake b/examples/snes/gem_chess/build-snes.cmake new file mode 100644 index 0000000..4ac6f86 --- /dev/null +++ b/examples/snes/gem_chess/build-snes.cmake @@ -0,0 +1,64 @@ +cmake_minimum_required(VERSION 3.20) +if(NOT DEFINED OPTIMIZATION) + set(OPTIMIZATION 2) +endif() +if(NOT OPTIMIZATION MATCHES "^[2s]$") + message(FATAL_ERROR "This bounded RAM scene host supports OPTIMIZATION=2 or s") +endif() +set(DEMO_OUTPUT_SUBDIR "build/gem-chess-scene") +include("${CMAKE_CURRENT_LIST_DIR}/../snes_demo_helpers.cmake") +build_gsu_payload("${CMAKE_CURRENT_LIST_DIR}/main.dc" "${CMAKE_CURRENT_LIST_DIR}/discoc.toml") +configure_file("${OUTPUT_DIR}/triangle.bin" "${OUTPUT_DIR}/gem-chess.bin" COPYONLY) +configure_file("${OUTPUT_DIR}/triangle.s" "${OUTPUT_DIR}/gem-chess.s" COPYONLY) +file(SIZE "${OUTPUT_DIR}/gem-chess.bin" payload_size) +if(payload_size GREATER 32768) + message(FATAL_ERROR "Payload does not fit its LoROM source bank") +endif() +file(READ "${OUTPUT_DIR}/gem-chess.s" assembly) +foreach(field IN ITEMS CODE_START_ADDRESS BITMAP_SCBR BITMAP_SCMR) + if(NOT assembly MATCHES "\\.define __DISCO_${field}[ \t]+\\$([0-9A-Fa-f]+)") + message(FATAL_ERROR "Missing linked metadata: ${field}") + endif() + set(${field} "${CMAKE_MATCH_1}") + math(EXPR ${field}_value "0x${${field}}") +endforeach() +if(NOT CODE_START_ADDRESS_value EQUAL 0x706000 OR + NOT BITMAP_SCBR_value EQUAL 0 OR NOT BITMAP_SCMR_value EQUAL 33) + message(FATAL_ERROR "Host requires $70:6000, bitmap 256x192, 4bpp, base zero") +endif() +file(WRITE "${OUTPUT_DIR}/scene-config.inc" + ".DEFINE GSU_LOAD_ADDRESS $${CODE_START_ADDRESS}\n.DEFINE GSU_START_PC $6000\n" + ".DEFINE BITMAP_SCBR $${BITMAP_SCBR}\n.DEFINE BITMAP_SCMR $${BITMAP_SCMR}\n") +string(REGEX MATCH "(ibt|iwt) r6, #\\$0+ [^\n]+\n stop ; CODE\\+\\$([0-9A-F]+)" stop_match "${assembly}") +if(NOT stop_match) + message(FATAL_ERROR "Missing normal main STOP for timing/ABI validation") +endif() +math(EXPR stop_address "${CODE_START_ADDRESS_value} + 0x${CMAKE_MATCH_2}" OUTPUT_FORMAT HEXADECIMAL) +file(WRITE "${OUTPUT_DIR}/scene-test-config.lua" "return {stop=${stop_address},bytes=${payload_size}}\n") +configure_file("${CMAKE_CURRENT_LIST_DIR}/geometry-checks.lua" "${OUTPUT_DIR}/geometry-checks.lua" COPYONLY) +build_snes_host("${CMAKE_CURRENT_LIST_DIR}" gem-chess) +build_snes_host("${CMAKE_CURRENT_LIST_DIR}" gem-chess-negative -D FORCE_BAD_RESULT=1) +if(VERIFY_MESEN) + foreach(suffix IN ITEMS "" "-negative") + file(REMOVE "${OUTPUT_DIR}/gem-chess${suffix}-verification.log") + if(suffix STREQUAL "") + set(script verify.lua) + else() + set(script verify-negative.lua) + endif() + run_command("${MESEN}" --testrunner --timeout=120 --doNotSaveSettings + --snes.region=Ntsc --snes.gsuClockSpeed=100 --snes.enableRandomPowerOnState=false + --snes.ppuExtraScanlinesBeforeNmi=0 --snes.ppuExtraScanlinesAfterNmi=0 + --snes.disableFrameSkipping=true --debug.scriptWindow.allowIoOsAccess=true + "${CMAKE_CURRENT_LIST_DIR}/${script}" "${OUTPUT_DIR}/gem-chess${suffix}.sfc") + if(NOT EXISTS "${OUTPUT_DIR}/gem-chess${suffix}-verification.log") + message(FATAL_ERROR "Mesen did not produce verification evidence") + endif() + file(READ "${OUTPUT_DIR}/gem-chess${suffix}-verification.log" evidence) + if(NOT evidence MATCHES "^PASS gem chess") + message(FATAL_ERROR "${evidence}") + endif() + message(STATUS "${evidence}") + endforeach() +endif() +message(STATUS "ROM: ${OUTPUT_DIR}/gem-chess.sfc (${payload_size}-byte GSU payload)") diff --git a/examples/snes/gem_chess/discoc.toml b/examples/snes/gem_chess/discoc.toml new file mode 100644 index 0000000..c8aacd8 --- /dev/null +++ b/examples/snes/gem_chess/discoc.toml @@ -0,0 +1,22 @@ +[project] +name = "gem-chess" +target = "gsu" +sources = ["main.dc"] + +[compiler] +optimization_level = 2 + +[target.gsu] +memory_mapping = "lorom" +execution_memory = "ram" +origin = 0x706000 +ram_bank = 0 +stack_pointer = 0xEFFE + +[runtime] +initialize = true + +[output] +directory = "../../../build/gem-chess-scene" +binary = "gem-chess.bin" +assembly = "gem-chess.s" diff --git a/examples/snes/gem_chess/display.inc b/examples/snes/gem_chess/display.inc new file mode 100644 index 0000000..b006453 --- /dev/null +++ b/examples/snes/gem_chess/display.inc @@ -0,0 +1,137 @@ +; Two 24-KiB character buffers ($0000/$6000), one shared tilemap at $E000. +; Transfer only the inactive buffer, 4 KiB per fresh VBlank. Switch BG1's +; character base after all six chunks: the visible scene is never torn. +.ACCU 8 +.INDEX 16 +initialize_display: + lda #$80 + sta $2115 + rep #$20 + .ACCU 16 + lda #$7000 + sta $2116 + ldy #0 +MapRow: + tya + ldx #32 +MapColumn: + sta $2118 + clc + adc #24 + dex + bne MapColumn + iny + cpy #24 + bne MapRow + ; Tile 0 is outside every projected geometry envelope and remains clear in + ; both buffers. It also supplies blank rows 192..255 without a third tile. + lda #0 + ldx #256 +MapBlank: + sta $2118 + dex + bne MapBlank + sep #$20 + .ACCU 8 + stz $2121 + ldx #0 +PaletteByte: + lda.l ScenePalette,x + sta $2122 + inx + cpx #32 + bne PaletteByte + lda #1 + sta $2105 + lda #$70 + sta $2107 + stz $210B + stz $210D + stz $210D + lda #$FF + sta $210E + lda #$03 + sta $210E + lda #1 + sta $4300 + lda #$18 + sta $4301 + lda #$70 + sta $4304 + rts + +upload_scene: + rep #$20 + .ACCU 16 + stz DMA_SOURCE + stz DMA_CHUNKS + lda ACTIVE_BUFFER + eor #1 + beq BufferZero + lda #$3000 +BufferZero: + sta DMA_DESTINATION + sep #$20 + .ACCU 8 +UploadChunk: + jsr wait_next_vblank + rep #$20 + .ACCU 16 + lda DMA_DESTINATION + sta $2116 + lda DMA_SOURCE + sta $4302 + lda #$1000 + sta $4305 + sep #$20 + .ACCU 8 + lda #1 + sta $420B + lda $4212 + bpl UploadOverrun + lda $213F + lda $2137 + lda $213D + sta.l LAST_DMA_LINE + lda $213D + and #1 + sta.l LAST_DMA_LINE+1 + rep #$20 + .ACCU 16 + inc DMA_CHUNKS + lda DMA_SOURCE + clc + adc #$1000 + sta DMA_SOURCE + lda DMA_DESTINATION + clc + adc #$0800 + sta DMA_DESTINATION + lda DMA_SOURCE + cmp #$6000 + sep #$20 + .ACCU 8 + bne UploadChunk + rep #$20 + .ACCU 16 + lda ACTIVE_BUFFER + eor #1 + sta ACTIVE_BUFFER + sep #$20 + .ACCU 8 + beq ShowZero + lda #3 +ShowZero: + sta $210B + lda #1 + sta $212C + rts +UploadOverrun: + lda #2 + jmp Failure + +ScenePalette: + ; Midnight backdrop, slate/ivory board, gold bevel, cyan/ruby gemstone ramps. + .DW $0C41,$2CE5,$5692,$1616 + .DW $20A0,$4580,$6282,$7B88,$7FF8 + .DW $1027,$206F,$34B8,$499F,$66FF,$7FFF,$0841 diff --git a/examples/snes/gem_chess/geometry-checks.lua b/examples/snes/gem_chess/geometry-checks.lua new file mode 100644 index 0000000..1a0c9c9 --- /dev/null +++ b/examples/snes/gem_chess/geometry-checks.lua @@ -0,0 +1,90 @@ +-- Independent widened-integer geometry checks. No generated assembly is used. +local quarter={0,6,12,19,24,30,36,41,45,49,53,56,59,61,63,64,64} +local ringX,ringZ={64,32,-32,-64,-32,32},{0,55,55,0,-55,-55} +local radii={{5,6,3,2,4,0},{6,6,3,3,6,6},{6,6,3,2,4,2}, + {6,6,3,2,5,0},{6,6,3,2,6,3},{6,6,3,2,5,2}} +local heights={{0,2,4,10,15,21},{0,2,4,16,18,23},{0,2,4,13,18,22}, + {0,2,4,15,22,29},{0,2,4,17,23,29},{0,2,4,19,25,29}} +local masks={{36,9,36,36,0},{0,9,0,36,0},{0,9,36,36,9}, + {0,9,36,0,0},{0,9,36,36,9},{0,9,36,0,0}} +local function q(v)return (v+32+(v<0 and -1 or 0))//64 end +local function sin(phase) + phase=phase&63 + local i=phase&15;if (phase&16)~=0 then i=16-i end + local v=quarter[i+1];return (phase&32)~=0 and -v or v +end +local function subtract(a,b)return {a[1]-b[1],a[2]-b[2],a[3]-b[3]} end +local function cross(a,b)return {a[2]*b[3]-a[3]*b[2],a[3]*b[1]-a[1]*b[3],a[1]*b[2]-a[2]*b[1]} end +local function dot(a,b)return a[1]*b[1]+a[2]*b[2]+a[3]*b[3] end +local function area(a,b,c)return (b[1]-a[1])*(c[2]-a[2])-(b[2]-a[2])*(c[1]-a[1]) end +local function vertex(kind,ring,side,facing) + local offset=kind==3 and ring>=3 and (ring-3)*facing or 0 + return {q(radii[kind][ring+1]*ringX[side+1]),heights[kind][ring+1], + offset+q(radii[kind][ring+1]*ringZ[side+1])} +end +local function project(v,yaw,elevation,zoom) + local sy,cy,sp,cp=sin(yaw),sin(yaw+16),sin(elevation),sin(elevation+16) + local rx=q(v[1]*cy+v[3]*sy);local rz=q(v[3]*cy-v[1]*sy) + local vertical=q((v[2]-10)*cp-rz*sp) + local depth=400-zoom*8-q((v[2]-10)*sp+rz*cp) + assert(depth>=128 and depth<512,"depth table bounds") + local factor=16384//depth + assert(math.abs(rx*factor)<32768 and math.abs(vertical*factor)<32768,"projection word overflow") + local p={128+q(rx*factor),84-q(vertical*factor)} + assert(p[1]>=0 and p[1]<256 and p[2]>=0 and p[2]<192,"escaped viewport") + return p +end +return function() + local triangles=0 + for kind=1,6 do for _,facing in ipairs({1,-1}) do + for band=0,4 do for side=0,5 do + local next=(side+1)%6 + local a=vertex(kind,band,next,facing);local b=vertex(kind,band,side,facing) + local c=vertex(kind,band+1,side,facing);local d=vertex(kind,band+1,next,facing) + local determinant=dot(subtract(b,a),cross(subtract(c,a),subtract(d,a))) + local bd=(masks[kind][band+1]&(1<0),"mesh diagonal fixture disagrees with convex hull") + local chosen=bd and {{a,b,d},{b,c,d}} or {{a,b,c},{a,c,d}} + for _,tri in ipairs(chosen) do + local normal=cross(subtract(tri[2],tri[1]),subtract(tri[3],tri[1])) + for r=band,band+1 do for s=0,5 do + assert(dot(normal,subtract(vertex(kind,r,s,facing),tri[1]))<=0,"non-convex band plane") + end end + triangles=triangles+1 + end + end end + end end + -- Regression: the former AC fan combines a visible and hidden triangle. + local folded={} + for _,v in ipairs({vertex(5,2,0,1),vertex(5,2,5,1),vertex(5,3,5,1),vertex(5,3,0,1)}) do + folded[#folded+1]=project({v[1]-8,v[2],v[3]+56},61,7,16) + end + assert(area(folded[1],folded[2],folded[3])==-21 and + area(folded[1],folded[3],folded[4])==18,"nonplanar culling regression fixture") + -- A planar plinth wall also folds after integer projection at a grazing + -- angle. Its visible second triangle must not be discarded with the first. + local wall={} + for _,v in ipairs({{-70,-5,70},{70,-5,70},{70,0,70},{-70,0,70}}) do + wall[#wall+1]=project(v,11,10,13) + end + assert(area(wall[1],wall[2],wall[3])==106 and + area(wall[1],wall[3],wall[4])==-94,"planar projection culling regression fixture") + local poses,maxArea=0,0 + for yaw=0,63 do for elevation=5,10 do for zoom=6,16 do + for _,x in ipairs({-70,70}) do for _,z in ipairs({-70,70}) do + project({x,-5,z},yaw,elevation,zoom);project({x,0,z},yaw,elevation,zoom) + end end + for _,x in ipairs({-62,62}) do for _,z in ipairs({-62,62}) do + project({x,0,z},yaw,elevation,zoom);project({x,38,z},yaw,elevation,zoom) + end end + local a=project({-70,0,70},yaw,elevation,zoom) + local b=project({70,0,70},yaw,elevation,zoom) + local c=project({70,0,-70},yaw,elevation,zoom) + local d=project({-70,0,-70},yaw,elevation,zoom) + local first,second=area(a,b,c),area(a,c,d) + assert(first<0 and second<0 and first+second>=-32768,"plinth signed area overflow/culling") + maxArea=math.max(maxArea,-first-second);poses=poses+1 + end end end + assert(poses==4224 and maxArea==28380,"camera envelope regression") + return string.format("%d camera envelopes, %d outward band triangles, planar/nonplanar culling regressions",poses,triangles) +end diff --git a/examples/snes/gem_chess/main.dc b/examples/snes/gem_chess/main.dc new file mode 100644 index 0000000..031c944 --- /dev/null +++ b/examples/snes/gem_chess/main.dc @@ -0,0 +1,700 @@ +// Original geometry, transformed and rasterized on the GSU, not an image asset. +bitmap scene { + mode bitmap; + size 256x192; + depth 4bpp; + base 0; +} + +word q6(word value) { + return (value + 32 + (value >> 15)) >> 6; +} + +word sine(word* quarter, word phase) { + phase &= 63; + word index = phase & 15; + if ((phase & 16) != 0) index = 16 - index; + word value = quarter[index]; + if ((phase & 32) != 0) value = -value; + return value; +} + +// camera = sin(yaw), cos(yaw), sin(elevation), cos(elevation), distance. +// The bounded orbit keeps every depth in 128..511. One reciprocal per integer +// depth is shared by the whole frame; projection needs no per-vertex division. +void project(word x, word height, word z, word* camera, word* inverse, word* out) { + word rx = q6(x * camera[1] + z * camera[0]); + word rz = q6(z * camera[1] - x * camera[0]); + word h = height - 10; + word vertical = q6(h * camera[3] - rz * camera[2]); + word depth = camera[4] - q6(h * camera[2] + rz * camera[3]); + if (depth < 128 || depth >= 512) { + *(volatile word*)0xF018 = 1; + out[0]=0; out[1]=0; + return; + } + word factor = inverse[depth]; + out[0] = 128 + q6(rx * factor); + out[1] = 84 - q6(vertical * factor); +} + +// Mesh instances combine unrounded yaw products modulo one word. Round only +// after adding the center, ring offset and knight lean, just as project() does. +void project_raw(word raw_x, word height, word raw_z, word* camera, word* inverse, word* out) { + word rx = q6(raw_x); + word rz = q6(raw_z); + word h = height - 10; + word vertical = q6(h * camera[3] - rz * camera[2]); + word depth = camera[4] - q6(h * camera[2] + rz * camera[3]); + if (depth < 128 || depth >= 512) { + *(volatile word*)0xF018 = 1; + out[0]=0; out[1]=0; + return; + } + word factor = inverse[depth]; + out[0] = 128 + q6(rx * factor); + out[1] = 84 - q6(vertical * factor); +} + +// General clipped midpoint fallback. It samples the same canonical edge pixels +// as line(), but accumulates extrema instead of plotting them. +void collect_clipped(word x0, word y0, word x1, word y1, word* spans) { + word dx=x1-x0; word dy=y1-y0; + if (dx<0) dx=-dx; + if (dy<0) dy=-dy; + bool horizontal=dx>=dy; + if ((horizontal && x0>x1) || (!horizontal && y0>y1)) { + word t=x0; x0=x1; x1=t; t=y0; y0=y1; y1=t; + } + word direction=1; + if ((horizontal && y1>1; + @cache for (word k=0; k<=major; k++) { + if (y0>=0 && y0<192) { + word* left=spans+y0; word* right=left+192; + if (x0<*left) *left=x0; + if (x0>*right) *right=x0; + } + error+=minor; + if (error>=major) { + error-=major; + if (horizontal) y0+=direction; else x0+=direction; + } + if (horizontal) x0++; else y0++; + } +} + +// Private fast path: clockwise convex faces, all vertices inside the viewport. +// These faces have a downward left chain and upward right chain. +// The walk starts on one shared vertex and stops on the other, so only those +// two rows merge. Interior rows belong to one edge and are stored once. +// Each side and axis is a straight walk: the minor step is inc or dec, never +// a value reloaded from the frame on every row. +void collect_horizontal_left(word x0, word x1, word dx, word dy, word* span, bool down) { + word error = dx >> 1; + word first = x0; + // Do not test a run boundary or touch RAM for every edge pixel. + // This is the original error recurrence, including its >= tie. + @cache while (true) { + error += dy; + if (error >= dx || x0 == x1) break; + x0++; + } + if (first < *span) *span = first; + if (x0 == x1) return; + if (down) { + @cache while (true) { + error -= dx; + x0++; + span--; + first = x0; + while (true) { + error += dy; + if (error >= dx || x0 == x1) break; + x0++; + } + if (x0 == x1) { + if (first < *span) *span = first; + return; + } + *span = first; + } + } else { + @cache while (true) { + error -= dx; + x0++; + span++; + first = x0; + while (true) { + error += dy; + if (error >= dx || x0 == x1) break; + x0++; + } + if (x0 == x1) { + if (first < *span) *span = first; + return; + } + *span = first; + } + } +} + +void collect_horizontal_right(word x0, word x1, word dx, word dy, word* span, bool down) { + word error = dx >> 1; + @cache while (true) { + error += dy; + if (error >= dx || x0 == x1) break; + x0++; + } + if (x0 > *span) *span = x0; + if (x0 == x1) return; + if (down) { + @cache while (true) { + error -= dx; + x0++; + span--; + while (true) { + error += dy; + if (error >= dx || x0 == x1) break; + x0++; + } + if (x0 == x1) { + if (x0 > *span) *span = x0; + return; + } + *span = x0; + } + } else { + @cache while (true) { + error -= dx; + x0++; + span++; + while (true) { + error += dy; + if (error >= dx || x0 == x1) break; + x0++; + } + if (x0 == x1) { + if (x0 > *span) *span = x0; + return; + } + *span = x0; + } + } +} + +void collect_vertical_left(word x0, word y0, word y1, word dx, word dy, word* span, bool leftward) { + word error = dy >> 1; + word* last = span + (y1 - y0); + if (x0 < *span) *span = x0; + if (span == last) return; + error += dx; + if (error >= dy) { + error -= dy; + if (leftward) x0--; else x0++; + } + span++; + if (leftward) { + @cache while (span != last) { + *span = x0; + error += dx; + if (error >= dy) { error -= dy; x0--; } + span++; + } + } else { + @cache while (span != last) { + *span = x0; + error += dx; + if (error >= dy) { error -= dy; x0++; } + span++; + } + } + if (x0 < *span) *span = x0; +} + +void collect_vertical_right(word x0, word y0, word y1, word dx, word dy, word* span, bool leftward) { + word error = dy >> 1; + word* last = span + (y1 - y0); + if (x0 > *span) *span = x0; + if (span == last) return; + error += dx; + if (error >= dy) { + error -= dy; + if (leftward) x0--; else x0++; + } + span++; + if (leftward) { + @cache while (span != last) { + *span = x0; + error += dx; + if (error >= dy) { error -= dy; x0--; } + span++; + } + } else { + @cache while (span != last) { + *span = x0; + error += dx; + if (error >= dy) { error -= dy; x0++; } + span++; + } + } + if (x0 > *span) *span = x0; +} + +void collect(word x0, word y0, word x1, word y1, word* spans) { + word dx = x1 - x0; word dy = y1 - y0; + if (dx < 0) dx = -dx; + if (dy < 0) dy = -dy; + if (dy==0) { + if (x0>x1) { word t=x0; x0=x1; x1=t; } + word* left=spans+y0; word* right=left+192; + if (x0<*left) *left=x0; + if (x1>*right) *right=x1; + return; + } + bool leftside = y1 > y0; + bool horizontal = dx >= dy; + if ((horizontal && x0 > x1) || (!horizontal && y0 > y1)) { + word t = x0; x0 = x1; x1 = t; + t = y0; y0 = y1; y1 = t; + } + word* span = spans + y0; + if (!leftside) span += 192; + if (horizontal) { + if (leftside) collect_horizontal_left(x0, x1, dx, dy, span, y1 < y0); + else collect_horizontal_right(x0, x1, dx, dy, span, y1 < y0); + } else if (leftside) { + collect_vertical_left(x0, y0, y1, dx, dy, span, x1 < x0); + } else { + collect_vertical_right(x0, y0, y1, dx, dy, span, x1 < x0); + } +} + +void line(word x0, word y0, word x1, word y1, word ink) { + word dx = x1 - x0; word dy = y1 - y0; + if (dx < 0) dx = -dx; + if (dy < 0) dy = -dy; + bool horizontal = dx >= dy; + if ((horizontal && x0 > x1) || (!horizontal && y0 > y1)) { + word t = x0; x0 = x1; x1 = t; + t = y0; y0 = y1; y1 = t; + } + word direction = 1; + if ((horizontal && y1 < y0) || (!horizontal && x1 < x0)) direction = -1; + word major = dy; word minor = dx; + if (horizontal) { major = dx; minor = dy; } + word error = major >> 1; + plot { + color ink; + @cache for (word k = 0; k <= major; k++) { + if (x0 >= 0 && x0 < 256 && y0 >= 0 && y0 < 192) { + at (x0, y0); + pixel; + } + error += minor; + if (error >= major) { + error -= major; + if (horizontal) y0 += direction; else x0 += direction; + } + if (horizontal) x0++; else y0++; + } + } +} + +// Already-culled convex polygons; clip spans without moving their vertices. +word paint(word* xs, word* ys, word vertices, word ink, bool wire, word* spans) { + if (wire) { + for (word i = 0; i < vertices; i++) { + word next = i+1; if (next==vertices) next=0; + line(xs[i], ys[i], xs[next], ys[next], ink); + } + return 1; + } + word top = 191; word bottom = 0; + bool clipped=false; + for (word i = 0; i < vertices; i++) { + word x=xs[i]; word y=ys[i]; + if (y < top) top = y; + if (y > bottom) bottom = y; + if (x<0 || x>255) clipped=true; + if (y<0 || y>191) clipped=true; + else { spans[y]=x; spans[192+y]=x; } + } + if (top < 0) top = 0; + if (bottom > 191) bottom = 191; + if (top > bottom) return 0; + word* left = spans + top; word* right = spans + 192 + top; + if (clipped || top==bottom) { + // Keep fully clipped and horizontal/point degeneracy behavior, even + // outside the scene's proven in-viewport camera envelope. + @cache for (word y = top; y <= bottom; y++) { *left=256; *right=-1; left++; right++; } + for (word i=0; i 255) end = 255; + word width = end - start + 1; + if (width > 0) { + at (start, y); + @cache for (word x = 0; x < width; x++) pixel; + } + left++; right++; + } + } else { + // In-viewport convex edges stay inside 0..255, so the span walk + // does not clip. One cache window covers every scanline's plot. + @cache for (word y = top; y <= bottom; y++) { + word start = *left; word end = *right; + word width = end - start + 1; + if (width > 0) { + at (start, y); + for (word x = 0; x < width; x++) pixel; + } + left++; right++; + } + } + } + return 1; +} + +// Integer ring coordinates are not necessarily homothetic. The chosen diagonal +// follows the band's convex hull. Even planar quads can fold after quantized +// perspective projection, so independently cull the two selected triangles. +// Fuse their fill only when both are visible AND their projection is convex. +word quad_face(word* points, word a, word b, word c, word d, + word ink, bool wire, word* spans, bool bd) { + // Coordinates are loaded once. Their products finish before the paint + // arrays exist, and a culled quad never builds those arrays. + word ax = points[a+a]; word ay = points[a+a+1]; + word bx = points[b+b]; word by = points[b+b+1]; + word cx = points[c+c]; word cy = points[c+c+1]; + word dx = points[d+d]; word dy = points[d+d+1]; + word abx = bx-ax; word aby = by-ay; + word acx = cx-ax; word acy = cy-ay; + word adx = dx-ax; word ady = dy-ay; + word first; word second; + if (bd) { + first = abx*ady-aby*adx; + second = (acx-abx)*(ady-aby)-(acy-aby)*(adx-abx); + } else { + first = abx*acy-aby*acx; + second = acx*ady-acy*adx; + } + if (first>=0 && second>=0) return 0; + bool fused = false; + if (first<0 && second<0) { + word other; + if (bd) other = abx*acy-aby*acx; + else other = abx*ady-aby*adx; + // Both diagonals have the same total determinant. Word arithmetic + // preserves the original individual determinants, including their sign. + word remaining = first+second-other; + if (other<=0 && remaining<=0) fused = true; + } + word xs[4] = {ax, bx, cx, dx}; + word ys[4] = {ay, by, cy, dy}; + if (fused) return paint(&xs[0], &ys[0], 4, ink, wire, spans); + // Reuse the polygon's storage; a hidden triangle needs no temporary copy. + if (bd) { + word t=xs[2]; xs[2]=xs[3]; xs[3]=t; + t=ys[2]; ys[2]=ys[3]; ys[3]=t; + } + word count=0; + if (first<0) count=paint(&xs[0],&ys[0],3,ink,wire,spans); + if (second<0) { + if (bd) { xs[0]=xs[1]; ys[0]=ys[1]; xs[1]=xs[3]; ys[1]=ys[3]; } + else { xs[1]=xs[2]; ys[1]=ys[2]; xs[2]=xs[3]; ys[2]=ys[3]; } + count+=paint(&xs[0],&ys[0],3,ink,wire,spans); + } + return count; +} + +// Planar board/box faces use AC. Repeated C/D indices represent a triangle. +word face(word* points, word a, word b, word c, word d, + word ink, bool wire, word* spans) { + if (c!=d) return quad_face(points,a,b,c,d,ink,wire,spans,false); + word ax = points[a+a]; word ay = points[a+a+1]; + word bx = points[b+b]; word by = points[b+b+1]; + word cx = points[c+c]; word cy = points[c+c+1]; + word area = (bx-ax)*(cy-ay) - (by-ay)*(cx-ax); + if (area >= 0) return 0; + word xs[3] = {ax, bx, cx}; + word ys[3] = {ay, by, cy}; + return paint(&xs[0],&ys[0],3,ink,wire,spans); +} + +// A bounded dot product with a fixed world-space light. L1 normalization is +// quantized using comparisons, avoiding sqrt/division for every tiny facet. +word shade(word dh, word dr, word horizontal, word material) { + word slope = dr; if (slope < 0) slope = -slope; + word denominator = dh + slope; + word light = horizontal * dh + 48 * dr; + word level = 4; + if (light < 40 * denominator) level = 3; + if (light < 16 * denominator) level = 2; + if (light < -8 * denominator) level = 1; + if (light < -32 * denominator) level = 0; + return material + level; +} + +word box(word x, word y, word z, word width, word height, word depth, + word material, bool wire, word* camera, word* inverse, word* spans) { + word points[16]; + for (word i = 0; i < 8; i++) { + word xx = -width; if ((i & 1) != 0) xx = width; + word yy = 0; if ((i & 2) != 0) yy = height; + word zz = -depth; if ((i & 4) != 0) zz = depth; + project(x+xx, y+yy, z+zz, camera, inverse, &points[i+i]); + } + word faces[24] = {0,2,3,1, 4,5,7,6, 0,4,6,2, 1,3,7,5, 0,1,5,4, 2,6,7,3}; + word count = 0; + for (word i = 0; i < 6; i++) { + word ink = material+2; + if (i == 0 || i == 3) ink = material+1; + if (i == 5) ink = material+4; + count += face(&points[0], faces[i*4], faces[i*4+1], faces[i*4+2], faces[i*4+3], ink, wire, spans); + } + return count; +} + +word main() { + use bitmap scene; + word yaw = *(volatile word*)0xF002 & 63; + word elevation = *(volatile word*)0xF004; + word zoom = *(volatile word*)0xF006; + bool wire = (*(volatile word*)0xF008 & 1) != 0; + *(volatile word*)0xF018 = 0; + if (elevation < 5) elevation = 5; + if (elevation > 10) elevation = 10; + if (zoom < 6) zoom = 6; + if (zoom > 16) zoom = 16; + word quarter[17] = {0,6,12,19,24,30,36,41,45,49,53,56,59,61,63,64,64}; + word camera[5] = {sine(&quarter[0], yaw), sine(&quarter[0], yaw+16), + sine(&quarter[0], elevation), sine(&quarter[0], elevation+16), 400-zoom*8}; + word inverse[512]; + // At entry to each iteration, product is quotient*i. Across this bounded + // range floor(16384/i) falls by at most one, so one correction is exact. + word quotient=128; word product=16384; + @cache for (word i = 128; i < 512; i++) { + if (product>16384) { quotient--; product-=i; } + inverse[i]=quotient; + product+=quotient; + } + word spans[384]; + word count = 0; + + plot { + // Zero is opaque in this plot context: every frame clears the complete + // framebuffer, not just the previous geometry's bounding box. + options; + color 0; + for (word y = 0; y < 192; y++) { + at (0, y); + @cache for (word x = 0; x < 256; x++) pixel; + } + } + + // Beveled plinth: lower/upper four-corner rings, then a gold top frame. + word border[16]; + for (word i = 0; i < 4; i++) { + word xx = -70; if (i == 1 || i == 2) xx = 70; + word zz = -70; if (i >= 2) zz = 70; + project(xx, -5, zz, &camera[0], &inverse[0], &border[i+i]); + project(xx, 0, zz, &camera[0], &inverse[0], &border[8+i+i]); + } + for (word i = 0; i < 4; i++) { + word next = (i+1) & 3; + count += face(&border[0], next, i, i+4, next+4, 3, false, &spans[0]); + } + count += face(&border[0], 7,6,5,4, 3,false,&spans[0]); + + word grid[162]; + for (word z = 0; z <= 8; z++) { + for (word x = 0; x <= 8; x++) { + word vertex = (z*9+x)*2; + project(x*16-64, 0, z*16-64, &camera[0], &inverse[0], &grid[vertex]); + } + } + for (word z = 0; z < 8; z++) { + for (word x = 0; x < 8; x++) { + word a = z*9+x; + count += face(&grid[0], a+9,a+10,a+1,a, 1+((x^z)&1),false,&spans[0]); + } + } + + // Symmetric profiles are shared by every instance. Types: pawn, rook, + // knight, bishop, queen, king. The knight's head leans toward the enemy. + word radii[36] = {5,6,3,2,4,0, 6,6,3,3,6,6, 6,6,3,2,4,2, + 6,6,3,2,5,0, 6,6,3,2,6,3, 6,6,3,2,5,2}; + word heights[36] = {0,2,4,10,15,21, 0,2,4,16,18,23, 0,2,4,13,18,22, + 0,2,4,15,22,29, 0,2,4,17,23,29, 0,2,4,19,25,29}; + // Convex-hull BD-diagonal masks, five six-sided bands per mesh type. + word diagonals[30] = {36,9,36,36,0, 0,9,0,36,0, 0,9,36,36,9, + 0,9,36,0,0, 0,9,36,36,9, 0,9,36,0,0}; + word back_rank[8] = {1,2,3,4,5,3,2,1}; + word ring_x[6] = {64,32,-32,-64,-32,32}; + word ring_z[6] = {0,55,55,0,-55,-55}; + // Keep rounding on the complete original radius product, not on a split + // projection. Every instance shares these exact 7*6 coordinate pairs. + word ring_offsets[84]; + for (word radius=0; radius<=6; radius++) { + for (word s=0; s<6; s++) { + word vertex=(radius*6+s)*2; + ring_offsets[vertex]=q6(radius*ring_x[s]); + ring_offsets[vertex+1]=q6(radius*ring_z[s]); + } + } + word normal_z[6] = {32,64,32,-32,-64,-32}; + word light[6] = {-41,-20,21,41,20,-21}; + word order[32]; unsigned word keys[32]; + word eye_ground = q6(camera[4] * camera[3]); + + // Order disjoint vertical footprints by horizontal eye distance, far first. + // Half-sized coordinates keep the squared key within unsigned 16 bits. + for (word i = 0; i < 32; i++) { + word team = i >> 4; word slot = i & 15; + word xx = (slot & 7)*16-56; + word zz = -56; if (slot >= 8) zz = -40; + if (team != 0) zz = -zz; + word rx = q6(xx*camera[1]+zz*camera[0]); + word rz = q6(zz*camera[1]-xx*camera[0]); + word half_x=rx>>1; word half_d=(eye_ground-rz)>>1; + unsigned word key=(unsigned word)(half_x*half_x) + + (unsigned word)half_d*(unsigned word)half_d; + word j = i; + while (j > 0 && keys[j-1] < key) { keys[j]=keys[j-1]; order[j]=order[j-1]; j--; } + keys[j] = key; order[j] = i; + } + + word points[74]; + // All shadows lie on the board. Paint them before any raised piece. + for (word i = 0; i < 32; i++) { + word team=i>>4; word slot=i&15; + word xx=(slot&7)*16-56; word zz=-56; + if (slot>=8) zz=-40; + if (team!=0) zz=-zz; + for (word s=0; s<6; s++) + project(xx+2+ring_offsets[72+s+s],0,zz+2+ring_offsets[73+s+s],&camera[0],&inverse[0],&points[s+s]); + count += face(&points[0],3,2,1,0,15,false,&spans[0]); + count += face(&points[0],0,5,4,3,15,false,&spans[0]); + } + + // Shadows consumed the world offsets. Reuse their 84 words for the raw + // rotated offsets shared by every mesh, without component-wise rounding. + for (word vertex=0; vertex<84; vertex+=2) { + word x=ring_offsets[vertex]; word z=ring_offsets[vertex+1]; + ring_offsets[vertex]=x*camera[1]+z*camera[0]; + ring_offsets[vertex+1]=z*camera[1]-x*camera[0]; + } + + for (word item = 0; item < 32; item++) { + word piece = order[item]; word team = piece >> 4; word slot = piece & 15; + word xx = (slot & 7)*16-56; word zz = -56; + word piece_type = back_rank[slot & 7]; + if (slot >= 8) { zz = -40; piece_type = 0; } + if (team != 0) zz = -zz; + word material = 4; word facing = 1; + if (team != 0) { material=9; facing=-1; } + word raw_x=xx*camera[1]+zz*camera[0]; + word raw_z=zz*camera[1]-xx*camera[0]; + word profile = piece_type*6; + // Pawn/bishop final rings and their caps are the same unleaned apex. + // Keep all six ring indices for the real conical band below them. + bool collapsed = piece_type == 0 || piece_type == 3; + word rings = 6; if (collapsed) rings = 5; + for (word ring = 0; ring < rings; ring++) { + word radius = radii[profile+ring]; word height = heights[profile+ring]; + word lean_x=0; word lean_z=0; + if (piece_type == 2 && ring >= 3) { + word offset=(ring-3)*facing; + lean_x=offset*camera[0]; lean_z=offset*camera[1]; + } + for (word s = 0; s < 6; s++) { + word vertex = (ring*6+s)*2; + word radial=(radius*6+s)*2; + project_raw(raw_x+lean_x+ring_offsets[radial], height, raw_z+lean_z+ring_offsets[radial+1], + &camera[0],&inverse[0],&points[vertex]); + } + } + word cap = heights[profile+5]; + if (piece_type == 4) cap += 3; + project_raw(raw_x,cap,raw_z,&camera[0],&inverse[0],&points[72]); + if (collapsed) { + word apex_x=points[72]; word apex_y=points[73]; + for (word s=0; s<6; s++) { + points[60+s+s]=apex_x; points[61+s+s]=apex_y; + } + } + for (word band = 0; band < 5; band++) { + word dh = heights[profile+band+1]-heights[profile+band]; + word dr = radii[profile+band]-radii[profile+band+1]; + word mask=diagonals[piece_type*5+band]; word bit=1; + for (word s = 0; s < 6; s++) { + word next = s+1; if (next==6) next=0; + word a=band*6+s; word b=band*6+next; + word slope=dr; + if (piece_type==2 && band>=3) slope-=q6(normal_z[s]*facing); + word ink=shade(dh,slope,light[s],material); + count += quad_face(&points[0],b,a,a+6,b+6,ink,wire,&spans[0],(mask&bit)!=0); + bit+=bit; + } + } + if (!collapsed) { + for (word s = 0; s < 6; s++) { + word next=s+1; if(next==6) next=0; + word ink=shade(cap-heights[profile+5],radii[profile+5],light[s],material); + count += face(&points[0],30+next,30+s,36,36,ink,wire,&spans[0]); + } + } + if (piece_type == 5) { + // A real extruded cross, three nonintersecting convex solids. + count += box(xx,29,zz,1,4,1,material,wire,&camera[0],&inverse[0],&spans[0]); + count += box(xx,33,zz,4,2,1,material,wire,&camera[0],&inverse[0],&spans[0]); + count += box(xx,35,zz,1,3,1,material,wire,&camera[0],&inverse[0],&spans[0]); + } + if (piece_type == 1) { + // Four separate merlons: do not fill a concave crown as one polygon. + word teeth[4] = {0,1,2,3}; word tooth_keys[4]; + for (word t=0; t<4; t++) { + word tx=-4; word tz=0; + if (t==1) { tx=0; tz=-3; } + if (t==2) tx=4; + if (t==3) { tx=0; tz=3; } + word key=tz*camera[1]-tx*camera[0]; + word j=t; + while(j>0 && tooth_keys[j-1]>key) { tooth_keys[j]=tooth_keys[j-1]; teeth[j]=teeth[j-1]; j--; } + tooth_keys[j]=key; teeth[j]=t; + } + for (word t=0; t<4; t++) { + word tooth=teeth[t]; word tx=-4; word tz=0; + if (tooth==1) { tx=0; tz=-3; } + if (tooth==2) tx=4; + if (tooth==3) { tx=0; tz=3; } + count += box(xx+tx,23,zz+tz,1,4,1,material,wire,&camera[0],&inverse[0],&spans[0]); + } + } + } + flush; + if (*(volatile word*)0xF018 != 0) count=0; + *(volatile word*)0xF000 = count; + *(volatile word*)0xF010 = yaw; + *(volatile word*)0xF012 = elevation; + *(volatile word*)0xF014 = zoom; + *(volatile word*)0xF016 = (word)wire; + return count; +} diff --git a/examples/snes/gem_chess/preview.png b/examples/snes/gem_chess/preview.png new file mode 100644 index 0000000000000000000000000000000000000000..f3e0d1c6fd993f47ba61c5002a99330b6bc14797 GIT binary patch literal 4467 zcmchb`8(8$7so%JSs3He+$5d=lpP9=bnv~sSx@w8UO$xGlJ0t004O| zFo}ZktlM#AegODW$IQskE(~0Kb~;ZxjM3j|He2 z7~m}*3>d}(lZR1&BOHXhuz;V?|3UOVOQP_%j=H*IshW=56?x{=>J%lLWG@^GjNamFva{Ms6%E3F25ZE&;X(|g3>Bu}${CE^qDT2i#^PPu<3(I=zlWjJQU^Vp`+*4Qp zJoh}t{S^W&7ChFip3Tl@4nHATBo2cxa+(;_6*1PgAZidzMogYe`OyRmrz0{(^j z6G;TqrxfVP?>Sh8Qfyl+Ai9Qw!bm328bxn0mwU?+yt}P+PnR!D1e14Qyh%)ccsT5Q z?||;vt_I***$#&5eT0*KnwRWvp> z-3Lp|HX)EO_%CuDpl78vW-|8W=io;Zt^e5A0?ifI2nG|3PC>Sx$e~1oQH?*u-uJ~$ z*$UxrhXSDF;*}D4DqU5|ckH1hTMGO_+x7M_sf|f|+n|jZ7gmP&lHj0Zp-fT!1Z_Zk zTpk^b_AvpnRyL$t=q>t!&E{m(*S@u^UWIr*^C>$>|9$prMk9jc=up?m z&ecy@q%Kb78?+429CQv`*B=nl_}3X#ApN{&s<1!B;lr_={jN2%ffJ8wU{Seo-v^6h zo1m#&OU7-&SJuhC1inrG6Zj%p`bx0A!|A3WEo86PyD8+fs&)9PNPFC-hBaRwHjXbI z4>S&6(H?m5S}3B(ONoxY`6a$`V>%(97Rz_>tzv9Ydb{3(NBgNGLjFR*$<3*@vUXyu zDOKh9#$#P44`-asGsKl6dmSQJtvJ#9sug}0;`M(8`p_P^o?lF86*IvV7{;K~ys>y^VZ+ETP%YA65|;Ss@gNgs=mnDnt% za{T-=IzQ68TUwrA)W_(+E9bDg% z)-D82M;g(->S&G^?+fV@3~O$QQ!>6%6Vz()Bveh;%NCxyG*qWi8sMF*aP-0pLzw~c z&RSeEdwcEE+JqL;)nE8Hp1bFDW_Y2Xgk`o*{??%MSQ>JSA;$fIsDYPgIK?D+Dn1GJ zaxn-e=N!{oips8%92Z9^WAs;Y-B4Jiz2TiI*6?&oMTE&tK*P0m`oXd#rZ$g%};ywRt}z{ORef&eS<3v+;5Nph%CmA!r9&izLacg3P9QmIifO9QHtf9LskB9I#N3@>~~sB-CNI~ zjic$fh=h+CH*Zw<(58e;5&uMRs7@fO9Z2n^cQLfDAyfTxZ1ue0{aP-k=xlCGNn3UB@Q@piaT zdV~A8+Q)~@DW&4>EL?Wsm?Cyc!aaB91#k_(yJkwIEIpKX>oLP} z(mq;}Jg)O1r(&bA$~m&$#+DM@a)FAuqCflf{k5?i0wCRDDvQuw{BIuh<-R|nAyIPufaYX$||=0%CMf=*bSQ z_I+?byeTPRctfC|wX1ymGZroRD(McuQZkJ0ceg%_hvZly2#lrV-zrUM8~(L9U3GTy zl-)0emsI`d+*D?^BousXuWsJc{)LkxseE5oJyPcf5iKXOa zT_T}41gk)A@2559i-VF63QUhMiJYIB)`Qm&1moO=u$s=}nwzuy7pf@=t;fh03oacZ z80$&7JNJ~-Jxp?0+AS|ZJD_)dW=Nd5JtmN9P0IfE$S+d=*@qvU>Ed#ln!_Ic^=ntk z!fq1zDKA4dHpe1;L|W;x4J7_#X&@9Uay>BF9Ns;Y`hBkK{)jLGh&Xt}L|o9pA3j*N z^J^uX5Pn2iG6{#5VPjnLfoc6bPvu-ZNR!ya{0oZYlc#QV^w2uPY5$a(jMgO+u{*CY? z`XCGXa<3#aZVF>Rvd>Y7L5H90DK~Jh6z5)$^pVeGN|mqRasW$r+yS{OqpvLe%#tV8 zMceRdKSHZayxz-PTuvN~N3rO1PE&47ovAhVA)-Uqm6%Gim3hdJ)aEA1APHK(AH<)s zg|ylEJyWwJiR|LJJ20}Yjik|^?O*wNj*hm=+WjlpV;^8!Jl>Idb#;17a*B%UC+UGE z5<7N+NYe#Qb%B~PbiTD1*{eIQ8X5W1|1(LQ)qajEjSNeGH@=7WdS9gEy7ak3iZlll zhgpL8$Jf4x+$8318WxaT!E@>vnAWe$J0Gi>I^CLbPgg(qx&O`A&4-S3QCJNzFne+9 zsVckupldUn9vblK)BS@n*%_dhSp*&7MtrTMjPKmanJMC~!7M`ZK5zZKc|J$4l2}$g zsY^795qA(J>`%T-?`G;0xWWCGNN@7F-Z1rFwsC39;{2wO%YkS`k+OGBqn2(bN#_Q; z3)nPPuOgOwtu{mB1SRBB@{%`ZV+THEi8fhOew`Op_Y_W@ zOrhV|8lYmkl$j($o21e;ih!jM*%G=myStNfVNdsHMxdy&c%*$KZiyXrP_eC}Zr2pi zv8B^uzm8#3OAx5P7OAtxnoGkkh)#SKcbcCuuricd{r>k>V{PxKjPT0xk0cHyGV{ky!$q23%z`PYjxR7z~)W$DtXDbM**+a5*t$XFxNki zScb{7rz!V6I2=>4__f8{d35`=l>sFo$UC8WfvF{XFfVf!Zx&}OOir;3#zk&DY4~EU ztr7kfe!gI{!2DFO`&RHfOH!Z@m<8h}+n>k%5ydd&opMgIs zdqA+4j8i$q4pb?wypSS0?Rm^)SwRdE)4Nfz$<&fWc#bboXB!i6@j_B*g3jw>P+ien z9rMJ!)wY~;UB`CgjES&*uLxlxwW3yQxS zUX2#(<1K2T6uXL~`0+ue zmfFNBI%Lb2vpI1Pwbnf?%v`_b_?z|YZV~7RxpXrk;{2@{|0sQ*uG_Va3B*!wyGy(+ zwM2`9trA!ib_hKyYseCV!J002#SV?U7;qqwUeLH~C$Hz&lTf05zY7YHn6ZR6r_I}o zF#^CZGf9rnaP+6Ei^i-{2uPz<4fPUO0}JObz-&Zs93`3svDEe_$8fW`cS4Z}m6yTT zhJE0s4+r7i+?Lf#)+``Jn~ah(_Zw`A z2Ng@{M$N;)`-i$S-wH6-)y%VY7#FQX^0q#vFrV-!t0aKOw*3kW z%%Xv>h^y>6*FTiqw+JZasLDQ*Drej literal 0 HcmV?d00001 diff --git a/examples/snes/gem_chess/snes_host.asm b/examples/snes/gem_chess/snes_host.asm new file mode 100644 index 0000000..2691537 --- /dev/null +++ b/examples/snes/gem_chess/snes_host.asm @@ -0,0 +1,453 @@ +; Freestanding original host. No Nintendo SDK, BIOS, game code or game assets. +.MEMORYMAP + DEFAULTSLOT 0 + SLOTSIZE $8000 + SLOT 0 $8000 +.ENDME +.ROMBANKMAP + BANKSTOTAL 2 + BANKSIZE $8000 + BANKS 2 +.ENDRO +.EMPTYFILL $FF +.SNESHEADER + ID "DSCO" + NAME "DISCOC GEM CHESS" + LOROM + SLOWROM + CARTRIDGETYPE $14 + ROMSIZE $06 + SRAMSIZE $06 + COUNTRY $01 + LICENSEECODE $00 + VERSION $00 +.ENDSNES +.SNESNATIVEVECTOR + COP InterruptReturn + BRK InterruptReturn + ABORT InterruptReturn + NMI ReadController + IRQ InterruptReturn +.ENDNATIVEVECTOR +.SNESEMUVECTOR + COP InterruptReturn + ABORT InterruptReturn + NMI InterruptReturn + RESET Reset + IRQBRK InterruptReturn +.ENDEMUVECTOR + +.INCLUDE "scene-config.inc" +.DEFINE TEST_STATUS $7E0000 +.DEFINE TEST_RESULT $7E0002 +.DEFINE TEST_SP $7E0004 +.DEFINE TEST_PBR $7E0006 +.DEFINE TEST_RAMBR $7E0007 +.DEFINE TEST_SFR $7E0008 +.DEFINE TEST_RAM_RESULT $7E000A +.DEFINE TEST_YAW $7E000C +.DEFINE TEST_PITCH $7E000E +.DEFINE TEST_ZOOM $7E0010 +.DEFINE TEST_MODE $7E0012 +.DEFINE TEST_FRAME $7E0014 +.DEFINE DMA_SOURCE $0016 +.DEFINE DMA_DESTINATION $0018 +.DEFINE LAST_DMA_LINE $7E001A +.DEFINE TEST_CBR $7E001C +.DEFINE INPUT_VERSION $0020 +.DEFINE INPUT_HELD $0022 +.DEFINE INPUT_YAW $0024 +.DEFINE INPUT_PITCH $0026 +.DEFINE INPUT_ZOOM $0028 +.DEFINE INPUT_MODE $002A +.DEFINE INPUT_PREVIOUS $002C +.DEFINE INPUT_POLLS $002E +.DEFINE ACTIVE_BUFFER $0030 +.DEFINE DMA_CHUNKS $0032 +.DEFINE GSU_RESULT $70F000 +.DEFINE FRAMEBUFFER_SIZE $6000 + +.BANK 0 SLOT 0 +.ORG 0 +.SECTION "Gem chess SNES host" FORCE +.ACCU 8 +.INDEX 8 +Reset: + sei + clc + xce + rep #$30 + .ACCU 16 + .INDEX 16 + ldx #$1FFF + txs + lda #0 + tcd + sep #$20 + .ACCU 8 + pha + plb + stz $4200 + stz $420B + stz $420C + lda #$80 + sta $4201 + sta $2100 + stz $212C + stz $212D + stz $2130 + stz $2131 + stz $2133 + stz $2106 + stz $2123 + stz $2124 + stz $2125 + stz $212E + stz $212F + stz $3030 + stz $3031 + stz $303A + rep #$20 + .ACCU 16 + lda #0 + ldx #0 +ClearWorkRam: + sta $0000,x + inx + inx + cpx #$0040 + bne ClearWorkRam + ldx #0 +ClearFramebuffer: + sta.l $700000,x + inx + inx + cpx #FRAMEBUFFER_SIZE + bne ClearFramebuffer + lda #8 + sta INPUT_YAW + lda #7 + sta INPUT_PITCH + lda #12 + sta INPUT_ZOOM + sep #$20 + .ACCU 8 + jsr copy_payload + jsr initialize_display + lda $4210 + lda #$80 + sta $4200 + lda #$0F + sta $2100 + +RenderNextFrame: + lda #0 + sta.l TEST_STATUS + rep #$20 + .ACCU 16 +InputSnapshot: + lda INPUT_VERSION + and #1 + bne InputSnapshot + ldx INPUT_VERSION + lda INPUT_YAW + sta.l TEST_YAW + lda INPUT_PITCH + sta.l TEST_PITCH + lda INPUT_ZOOM + sta.l TEST_ZOOM + lda INPUT_MODE + sta.l TEST_MODE + txa + cmp INPUT_VERSION + bne InputSnapshot + lda.l TEST_YAW + sta.l $70F002 + lda.l TEST_PITCH + sta.l $70F004 + lda.l TEST_ZOOM + sta.l $70F006 + lda.l TEST_MODE + sta.l $70F008 + sep #$20 + .ACCU 8 + lda #BITMAP_SCBR + sta $3038 + lda #$70 + sta $3034 + lda #$80 + sta $3037 + lda #1 + sta $3039 ; ~21.477 MHz NTSC, normal multiply timing + lda #(BITMAP_SCMR | $08) + sta $303A ; GSU RAM / SNES CPU ROM access + rep #$20 + .ACCU 16 + lda #GSU_START_PC + sta $301E + sep #$20 + .ACCU 8 + ldy #$0100 +WaitRound: + ldx #$FFFF +WaitGsu: + lda $3030 + and #$20 + beq GsuStopped + dex + bne WaitGsu + dey + bne WaitRound + stz $3030 + stz $303A + lda #3 + jmp Failure +GsuStopped: + lda $3031 + stz $303A + lda $3034 + sta.l TEST_PBR + lda $303C + sta.l TEST_RAMBR + lda $3030 + sta.l TEST_SFR + rep #$20 + .ACCU 16 + lda $3014 + sta.l TEST_SP + lda $303E + sta.l TEST_CBR + lda.l GSU_RESULT + sta.l TEST_RAM_RESULT + lda $3000 + sta.l TEST_RESULT + bra CheckResult +WrongResult: + sep #$20 + .ACCU 8 + lda #2 + jmp Failure +CheckResult: + .ACCU 16 + .IFDEF FORCE_BAD_RESULT + inc a + .ENDIF + cmp.l TEST_RAM_RESULT + bne WrongResult + cmp #0 + beq WrongResult + lda $300C + bne WrongResult + lda $3014 + cmp #$EFFA + bne WrongResult + lda.l $70F010 + cmp.l TEST_YAW + bne WrongResult + lda.l $70F012 + cmp.l TEST_PITCH + bne WrongResult + lda.l $70F014 + cmp.l TEST_ZOOM + bne WrongResult + lda.l $70F016 + cmp.l TEST_MODE + bne WrongResult + sep #$20 + .ACCU 8 + jsr upload_scene + rep #$20 + .ACCU 16 + lda.l TEST_FRAME + inc a + sta.l TEST_FRAME + sep #$20 + .ACCU 8 + lda #1 + sta.l TEST_STATUS +WaitForChangedInput: + rep #$20 + .ACCU 16 + lda INPUT_YAW + cmp.l TEST_YAW + bne InputChanged + lda INPUT_PITCH + cmp.l TEST_PITCH + bne InputChanged + lda INPUT_ZOOM + cmp.l TEST_ZOOM + bne InputChanged + lda INPUT_MODE + cmp.l TEST_MODE + bne InputChanged + sep #$20 + .ACCU 8 + wai + bra WaitForChangedInput +InputChanged: + sep #$20 + .ACCU 8 + jmp RenderNextFrame +Failure: + sta.l TEST_STATUS + stz $212C + stz $2121 + lda #$1F + sta $2122 + lda #0 + sta $2122 + lda #$0F + sta $2100 +Finished: + bra Finished + +copy_payload: + ldx #0 +CopyByte: + lda.l payload_start,x + sta.l GSU_LOAD_ADDRESS,x + inx + cpx #(payload_end-payload_start) + bne CopyByte + rts + +wait_next_vblank: +WaitVisible: + lda $4212 + bmi WaitVisible +WaitBlank: + lda $4212 + bpl WaitBlank + rts + +ReadController: + php + rep #$30 + .ACCU 16 + .INDEX 16 + pha + phx + phy + phb + sep #$20 + .ACCU 8 + lda #0 + pha + plb + lda $4210 + lda #1 + sta $4016 + stz $4016 + rep #$20 + .ACCU 16 + inc INPUT_VERSION + stz INPUT_HELD + ldx #16 +ReadButton: + lda $4016 + and #1 + lsr a + rol INPUT_HELD + dex + bne ReadButton + lda INPUT_HELD + and #$1000 + beq NoReset + lda #8 + sta INPUT_YAW + lda #7 + sta INPUT_PITCH + lda #12 + sta INPUT_ZOOM + stz INPUT_MODE + jmp InputDone +NoReset: + lda INPUT_PREVIOUS + eor INPUT_HELD + and INPUT_HELD + and #$0080 + beq NoToggle + lda INPUT_MODE + eor #1 + sta INPUT_MODE +NoToggle: + lda INPUT_HELD + and #$0300 + cmp #$0100 + bne NotRight + lda INPUT_YAW + inc a + bra StoreYaw +NotRight: + cmp #$0200 + bne Vertical + lda INPUT_YAW + dec a +StoreYaw: + and #63 + sta INPUT_YAW +Vertical: + lda INPUT_HELD + and #$2000 + beq TiltCamera + lda INPUT_HELD + and #$0C00 + cmp #$0800 + bne NotGrow + lda INPUT_ZOOM + cmp #16 + beq InputDone + inc a + sta INPUT_ZOOM + bra InputDone +NotGrow: + cmp #$0400 + bne InputDone + lda INPUT_ZOOM + cmp #6 + beq InputDone + dec a + sta INPUT_ZOOM + bra InputDone +TiltCamera: + lda INPUT_HELD + and #$0C00 + cmp #$0800 + bne NotUp + lda INPUT_PITCH + cmp #10 + beq InputDone + inc a + sta INPUT_PITCH + bra InputDone +NotUp: + cmp #$0400 + bne InputDone + lda INPUT_PITCH + cmp #5 + beq InputDone + dec a + sta INPUT_PITCH +InputDone: + lda INPUT_HELD + sta INPUT_PREVIOUS + inc INPUT_POLLS + inc INPUT_VERSION + plb + ply + plx + pla + plp + rti + +.INCLUDE "display.inc" +InterruptReturn: + rti +.ENDS +.SECTION ".rodata_gem_chess_payload" SUPERFREE +payload_start: + .INCBIN "gem-chess.bin" +payload_end: +.ENDS +.ASSERT payload_end-payload_start > 0, LDERROR, "Empty payload" +.ASSERT payload_end-payload_start <= $8000, LDERROR, "Payload must fit its LoROM source bank" diff --git a/examples/snes/gem_chess/verify-negative.lua b/examples/snes/gem_chess/verify-negative.lua new file mode 100644 index 0000000..8b9592a --- /dev/null +++ b/examples/snes/gem_chess/verify-negative.lua @@ -0,0 +1,15 @@ +local folder=emu.getRomInfo().path:match("^(.*[/\\])") or "" +local frames,red=0,0 +emu.addEventCallback(function() + frames=frames+1 + local status=emu.read(0,emu.memType.snesWorkRam) + if status==0 and frames<1800 then return end + if status==2 then red=red+1 end + if status==2 and red<5 then return end + local pass=status==2 and emu.read16(2,emu.memType.snesWorkRam)>64 and + emu.read16(0,emu.memType.snesCgRam)==0x001F and (emu.getPixel(128,96)&0xFFFFFF)==0xFF0000 + local message=(pass and "PASS" or "FAIL").." gem chess negative: deliberate result mismatch produces a red screen" + local file=assert(io.open(folder.."gem-chess-negative-verification.log","w")) + file:write(message.."\n");file:close() + emu.log(message);emu.stop(pass and 0 or 1) +end,emu.eventType.endFrame) diff --git a/examples/snes/gem_chess/verify.lua b/examples/snes/gem_chess/verify.lua new file mode 100644 index 0000000..f9a4cee --- /dev/null +++ b/examples/snes/gem_chess/verify.lua @@ -0,0 +1,181 @@ +-- Real port-one input, actual GSU STOPs, complete VRAM and visible-image checks. +-- This is a presentation/ABI/control test, not a second polygon renderer. +local folder=emu.getRomInfo().path:match("^(.*[/\\])") or "" +local config=dofile(folder.."scene-test-config.lua") +local geometry=dofile(folder.."geometry-checks.lua")() +local palette={0x0C41,0x2CE5,0x5692,0x1616,0x20A0,0x4580,0x6282,0x7B88, + 0x7FF8,0x1027,0x206F,0x34B8,0x499F,0x66FF,0x7FFF,0x0841} +local goals={ + {yaw=8,pitch=7,zoom=12,mode=0}, + {yaw=16,pitch=10,zoom=16,mode=0}, + {yaw=0,pitch=5,zoom=6,mode=0}, + {yaw=32,pitch=7,zoom=12,mode=0}, + {yaw=56,pitch=10,zoom=16,mode=0}, + {yaw=61,pitch=7,zoom=16,mode=0}, + {yaw=2,pitch=10,zoom=16,mode=0}, + {yaw=11,pitch=10,zoom=13,mode=0}, + {yaw=8,pitch=7,zoom=12,mode=1}, + {yaw=8,pitch=7,zoom=12,mode=0,reset=true} +} +local expected={yaw=8,pitch=7,zoom=12,mode=0,previous=0} +local stage,frames,polls,publications=1,0,0,0 +local failed,latest=false,nil +local captureAfter=0 +local active,pending=nil,nil +local timing={"publication,yaw,elevation,zoom,wire,gsu_cycles,gsu_seconds,host_publication_clock"} +local function save(name,bytes) + local file=assert(io.open(folder..name,"wb")) + assert(file:write(bytes));assert(file:close()) +end +local function finish(code,message) + save("gem-chess-verification.log",message.."\n") + emu.log(message);emu.stop(code) +end +local function guard(fn) + return function(...) + if failed then return end + local ok,why=pcall(fn,...) + if not ok then failed=true;finish(1,"FAIL gem chess: "..tostring(why)) end + end +end +local function same(a,b) + return a.yaw==b.yaw and a.pitch==b.pitch and a.zoom==b.zoom and a.mode==b.mode +end +emu.addEventCallback(guard(function() + local goal=goals[stage] + local input={a=false,start=false,left=false,right=false,up=false,down=false,select=false} + if goal then + if goal.reset then input.start=true + else + local delta=(goal.yaw-expected.yaw)&63 + if delta~=0 then if delta<=32 then input.right=true else input.left=true end end + if expected.pitch~=goal.pitch then + if expected.pitch>2) end + return (channel(value&31)<<16)|(channel((value>>5)&31)<<8)|channel((value>>10)&31) +end +local function capture(screen,name) + local rows={"BM"..string.pack(">8)&255,(value>>16)&255) + end + rows[#rows+1]=table.concat(row) + end + save(name,table.concat(rows)) +end +emu.addMemoryCallback(guard(function() + assert(not active and not pending,"reentered payload") + active=assert(emu.getState()["cart.coprocessor.cycleCount"],"missing GSU clock") +end),emu.callbackType.exec,0x706000,0x706000,emu.cpuType.gsu,emu.memType.gsuMemory) +emu.addMemoryCallback(guard(function(address,opcode) + local state=emu.getState() + assert(active and opcode==0 and state["cart.coprocessor.r6"]==0,"unexpected STOP") + local offset=((address+1)-state["cart.coprocessor.cacheBase"])&0xFFFF + local tail=5 + if offset<512 then tail=state["cart.coprocessor.cacheValid"..(offset>>4)] and 1 or 81 end + pending=state["cart.coprocessor.cycleCount"]-active+tail;active=nil +end),emu.callbackType.exec,config.stop,config.stop,emu.cpuType.gsu,emu.memType.gsuMemory) +emu.addMemoryCallback(guard(function(_,status) + assert(status<2,"host failure status="..status) + if status~=1 then return end + local machine=emu.getState() + assert(machine["cart.coprocessor.clockSelect"]==true and machine["cart.coprocessor.highSpeedMode"]==false,"GSU clock profile") + assert(emu.read16(4,emu.memType.snesWorkRam)==0xEFFA and emu.read(6,emu.memType.snesWorkRam)==0x70 and + emu.read(7,emu.memType.snesWorkRam)==0 and (emu.read(8,emu.memType.snesWorkRam)&0x20)==0,"STOP/ABI/banks") + local count=emu.read16(2,emu.memType.snesWorkRam) + assert(count>64 and count==emu.read16(0xF000,emu.memType.gsuWorkRam) and + emu.read16(0xF018,emu.memType.gsuWorkRam)==0,"primitive result/projection bounds") + assert(emu.read16(0x32,emu.memType.snesWorkRam)==6,"incomplete DMA chunks") + local line=emu.read16(0x1A,emu.memType.snesWorkRam) + assert(line>=225 and line<=261,"DMA outside NTSC VBlank: "..line) + assert(emu.read16(0x1C,emu.memType.snesWorkRam)>=0x6000,"CACHE outside code") + local buffer=emu.read16(0x30,emu.memType.snesWorkRam) + assert(buffer==0 or buffer==1,"invalid VRAM buffer") + local ram={} + for address=0,0x5FFF do + local value=emu.read(address,emu.memType.gsuWorkRam);ram[address+1]=value + assert(emu.read(buffer*0x6000+address,emu.memType.snesVideoRam)==value,string.format("VRAM byte $%04X",address)) + end + for address=1,32 do assert(ram[address]==0,"shared blank tile was drawn") end + for y=0,31 do for x=0,31 do + assert(emu.read16(0xE000+(y*32+x)*2,emu.memType.snesVideoRam)==(y<24 and x*24+y or 0),"tilemap") + end end + for i,value in ipairs(palette) do assert(emu.read16((i-1)*2,emu.memType.snesCgRam)==value,"palette") end + local image,histogram={},{} + for i=0,15 do histogram[i]=0 end + for y=0,191 do for x=0,255 do + local address=((x>>3)*24+(y>>3))*32+(y&7)*2 + local bit=7-(x&7) + local ink=0 + for plane=0,3 do ink=ink|(((ram[address+(plane>>1)*16+(plane&1)+1]>>bit)&1)<30 and ruby>30 and histogram[1]>1000 and histogram[2]>1000,"missing board or army") + latest={yaw=emu.read16(0xC,emu.memType.snesWorkRam),pitch=emu.read16(0xE,emu.memType.snesWorkRam), + zoom=emu.read16(0x10,emu.memType.snesWorkRam),mode=emu.read16(0x12,emu.memType.snesWorkRam),image=image,count=count} + publications=publications+1;captureAfter=frames+3 + assert(pending,"missing GSU timing sample") + timing[#timing+1]=string.format("%d,%d,%d,%d,%d,%d,%.9f,%d",publications,latest.yaw,latest.pitch,latest.zoom, + latest.mode,pending,pending/21477270,machine["memoryManager.masterClock"]) + save("gem-chess-timings.csv",table.concat(timing,"\n").."\n");pending=nil +end),emu.callbackType.write,0x7E0000,0x7E0000,emu.cpuType.snes,emu.memType.snesMemory) + +emu.addEventCallback(guard(function() + frames=frames+1 + assert(frames<7200,"scene/controller timeout") + local goal=goals[stage] + if not goal or not latest or frames#goals then finish(0,string.format("PASS gem chess: %d camera views, %d publications, %d real controller polls; full framebuffer/VRAM/PPU, both buffers, STOP/ABI, wireframe, reset, zoom/elevation and VBlank DMA; %s",#goals,publications,polls,geometry)) end +end),emu.eventType.endFrame) diff --git a/src/ABI.hpp b/src/ABI.hpp index 14cce8f..9fb88f7 100644 --- a/src/ABI.hpp +++ b/src/ABI.hpp @@ -15,6 +15,9 @@ constexpr std::uint8_t FarBankRegister = 4; constexpr std::uint8_t AddressFaultRegister = 6; constexpr const char* NearRamBankSymbol = "__disco_near_ram_bank"; constexpr const char* NearRomBankSymbol = "__disco_near_rom_bank"; +// Private DATA in a payload copied to the selected near RAM execution bank. +// Linkers must reject these RAM relocations for ROM or mismatched placement. +constexpr const char* ImmutablePoolPrefix = "\x01" "__disco_pool_"; constexpr std::uint8_t FramePointerRegister = 9; constexpr std::uint8_t StackPointerRegister = 10; constexpr std::uint8_t LinkRegister = 11; diff --git a/src/AssemblyGenerator.cpp b/src/AssemblyGenerator.cpp index 10c1f5f..8fc05d6 100644 --- a/src/AssemblyGenerator.cpp +++ b/src/AssemblyGenerator.cpp @@ -1,5 +1,6 @@ #include "AssemblyGenerator.hpp" #include "GSUCodeLayout.hpp" +#include "ABI.hpp" #include #include #include @@ -109,7 +110,14 @@ std::string AssemblyGenerator::generate() const { if (symbol.name.empty()) throw std::runtime_error("Assembly export: empty symbol name."); if (symbol.name == GSUCodeLayout::CacheAlignmentSymbol) continue; const bool local = symbol.name.front() == '\x01'; - const auto name = local ? uniqueName() : symbol.name; + auto name = local ? uniqueName() : symbol.name; + if (local && symbol.name.compare(0, std::string(GSUAbi::ImmutablePoolPrefix).size(), GSUAbi::ImmutablePoolPrefix) == 0) { + // Keep the guarded relocation namespace through .s round trips. + // The assembler reinstates the private marker for nonexports. + name = symbol.name.substr(1); + while (names.count(name)) name += "_"; + names.insert(name); + } renamed.emplace(symbol.name, name); auto& labels = symbol.section == SymbolSection::CODE ? code_labels : symbol.section == SymbolSection::DATA ? data_labels : ram_labels; const auto size = symbol.section == SymbolSection::CODE ? m_object.code_section.size() : symbol.section == SymbolSection::DATA ? m_object.data_section.size() : m_object.ram_section.size(); diff --git a/src/CompilerDriver.cpp b/src/CompilerDriver.cpp index 0e266f1..1d02f3f 100644 --- a/src/CompilerDriver.cpp +++ b/src/CompilerDriver.cpp @@ -24,6 +24,8 @@ void usage() { " --project Alias for build --config\n" " --target Select target (default gsu)\n" " --check Analyze and verify IR without emitting code\n" + " --require-no-spills Reject GSU compiler-generated spill homes, including in --check/--emit-ir\n" + " Source units only, not prebuilt objects; local/ABI storage is allowed\n" " -O | -O1 Optimize GSU code (default -O0 baseline)\n" " -O0 Retain baseline GSU code generation\n" " -O2 CFG/SSA, global registers, loops and bounded inlining\n" @@ -88,6 +90,7 @@ int runCompiler(std::vector arguments, ModuleLoader* modules) { else if (argument == "--emit-ir") emit_ir = true; else if (argument == "--emit-asm") emit_asm = true; else if (argument == "--check") check = true; + else if (argument == "--require-no-spills") config.require_no_spills = true; else if (argument == "-O" || argument == "-O1") config.optimization = OptimizationLevel::O1; else if (argument == "-O0") config.optimization = OptimizationLevel::Baseline; else if (argument == "-O2") config.optimization = OptimizationLevel::O2; @@ -109,6 +112,10 @@ int runCompiler(std::vector arguments, ModuleLoader* modules) { } } if (input.empty()) throw std::runtime_error("No input file specified."); + if (config.require_no_spills && emit_ast) + throw std::runtime_error("--require-no-spills is unsupported with --emit-ast; use --check or --emit-ir for allocation validation."); + if (config.require_no_spills && config.target != TargetKind::GSU) + throw std::runtime_error("Target 'spc700' does not support --require-no-spills: no code-generation backend is available."); applyPlacement(config, placement); ModuleLoader loader; if (!modules) { @@ -159,6 +166,12 @@ int runCompiler(std::vector arguments, ModuleLoader* modules) { IRLowerer lowerer(config.target); auto ir = lowerer.lower(program); ir.bitmap = analyzer.getBitmapConfig(); IRVerifier::verify(ir); + if (config.require_no_spills && (emit_ir || check)) { + // Validate the same original IR and final backend candidate as an + // ordinary emission, before inspection-only optimization changes it. + IRCodeGenerator generator(analyzer.getAllLocalSymbols(), analyzer.getFunctionSymbols(), data, config); + generator.generate(ir); + } if ((emit_ir || check) && isGlobalOptimization(config.optimization)) IRGlobalOptimizer::run(ir, analyzer.getAllLocalSymbols(), config.optimization); if (emit_ir) { diff --git a/src/GSUAddressProof.cpp b/src/GSUAddressProof.cpp index ad53cb7..5bae15d 100644 --- a/src/GSUAddressProof.cpp +++ b/src/GSUAddressProof.cpp @@ -415,6 +415,7 @@ void GSUAddressProof::run(const IRFunction& function, const Analyzer::LocalSymbo parameters += isFarPointer(parameter.type) ? 4 : 2; if (parameters > 65526) return; } + m_block_count = function.blocks.size(); m_checked_frame = checked_frame; // The linker permits stack_floor=0. Keep the bottom empty word out of // the proof: FP-frame_bytes itself could be the null address. @@ -465,6 +466,16 @@ bool GSUAddressProof::provesAccess(IRValueId value, const Type& pointer, int wid return false; } +bool GSUAddressProof::scalarBounds(IRValueId value, IRBlockId block, + std::int64_t& low, std::int64_t& high) const { + if (!value.isValid() || value.value >= m_facts.size() || + !block.isValid() || block.value >= m_block_count) return false; + const auto fact = get(value, block.value); + if (fact.kind != Kind::Scalar || fact.low > fact.high) return false; + low = fact.low; high = fact.high; + return true; +} + bool GSUAddressProof::provesOffset(const IRInstruction& instruction) const { if (instruction.opcode != IROpcode::PointerOffset || instruction.operands.size() != 2 || (instruction.operation != "+" && instruction.operation != "-") || @@ -478,3 +489,17 @@ bool GSUAddressProof::provesOffset(const IRInstruction& instruction) const { provesAccess(instruction.operands[0], instruction.type, static_cast(instruction.immediate)) && provesAccess(instruction.result, instruction.type, static_cast(instruction.immediate)); } + +GSUAddressProof::MultiplyKind GSUAddressProof::multiplyKind(const IRInstruction& instruction) const { + if (instruction.opcode != IROpcode::Binary || instruction.operation != "*" || instruction.operands.size() != 2) + return MultiplyKind::FullWord; + const auto block = instruction.result.value < m_definition_blocks.size() ? + m_definition_blocks[instruction.result.value] : static_cast(IRBlockId::Invalid); + const auto a = get(instruction.operands[0], block), b = get(instruction.operands[1], block); + // The hardware truncates BOTH inputs, not their product. A small result + // alone (including multiplication by zero) is never a byte-operand proof. + if (a.kind != Kind::Scalar || b.kind != Kind::Scalar) return MultiplyKind::FullWord; + if (a.low >= -128 && a.high <= 127 && b.low >= -128 && b.high <= 127) return MultiplyKind::SignedByte; + if (a.low >= 0 && a.high <= 255 && b.low >= 0 && b.high <= 255) return MultiplyKind::UnsignedByte; + return MultiplyKind::FullWord; +} diff --git a/src/GSUAddressProof.hpp b/src/GSUAddressProof.hpp index b924906..bf847cd 100644 --- a/src/GSUAddressProof.hpp +++ b/src/GSUAddressProof.hpp @@ -17,6 +17,12 @@ class GSUAddressProof { std::size_t frame_bytes, bool checked_frame); bool provesAccess(IRValueId value, const Type& pointer, int width) const; bool provesOffset(const IRInstruction& instruction) const; + // Numeric facts only; the caller must independently prove provenance, + // object extent, mutability and lifetime. Failure leaves outputs intact. + bool scalarBounds(IRValueId value, IRBlockId block, + std::int64_t& low, std::int64_t& high) const; + enum class MultiplyKind { FullWord, SignedByte, UnsignedByte }; + MultiplyKind multiplyKind(const IRInstruction& instruction) const; private: enum class Kind { Unknown, Scalar, Frame, Absolute }; @@ -45,4 +51,5 @@ class GSUAddressProof { std::vector> m_constraints; std::int64_t m_frame_low = 0, m_frame_end = 0; bool m_checked_frame = false; + std::size_t m_block_count = 0; }; diff --git a/src/GSUCostModel.hpp b/src/GSUCostModel.hpp index df50359..97dc4c0 100644 --- a/src/GSUCostModel.hpp +++ b/src/GSUCostModel.hpp @@ -42,6 +42,15 @@ class GSUCostModel { return {literalBytes(static_cast(displacement)) + (far ? 13u : 4u), far ? 4u : 2u}; } static GSUCost preserveCall() { return {8, 4}; } // checked pushes/guard costs may add more. + static std::uint64_t scalarCallBytes(std::size_t arguments) { + // Real canonical LINK/IWT/NOP, scalar STW/DEC/DEC arguments and + // caller cleanup. Guards, materialization and live saves can only add + // bytes, so this is a conservative emitted call-site lower bound. + const auto area = GSUCost::saturatedMultiply(arguments, 2); + const auto cleanup = area == 0 ? 0u : area <= 15 ? 3u : + literalBytes(static_cast(std::min(65535, area))) + 2u; + return GSUCost::saturatedAdd(5u + cleanup, GSUCost::saturatedMultiply(arguments, 3)); + } static bool scalar(const Type& type) { return !type.pointer_level && !type.array_size && (type.base == BaseType::WORD || type.base == BaseType::BYTE || type.base == BaseType::BOOL); @@ -78,8 +87,15 @@ class GSUCostModel { if (dest && !dest->rematerializable) result.add(dest->has_register ? copy() : spillStore(displacement(*dest), isFarPointer(i.type)), edge_weight); } } else { - for (const auto v : i.operands) result.add(read(v), weight); - if (dest && !dest->rematerializable) result.add(dest->has_register ? copy() : spillStore(displacement(*dest), isFarPointer(i.type)), weight); + const bool tied = dest && dest->has_register && !i.operands.empty() && + sameLocation(i.result, i.operands.front()) && + ((i.opcode == IROpcode::Unary && (i.operation == "~" || i.operation == "!" || i.operation == "-")) || + (i.opcode == IROpcode::Binary && (i.operation == "+" || i.operation == "-" || + i.operation == "&" || i.operation == "|" || i.operation == "^"))); + for (std::size_t n = 0; n < i.operands.size(); ++n) + if (!tied || n) result.add(read(i.operands[n]), weight); + if (dest && !dest->rematerializable && !tied) + result.add(dest->has_register ? copy() : spillStore(displacement(*dest), isFarPointer(i.type)), weight); if (i.opcode == IROpcode::Call) for (const auto& entry : allocator.locations()) if (entry.second.has_register && allocator.liveAcrossCall(IRValueId{entry.first}, position)) result.add(preserveCall(), weight); } diff --git a/src/GSUMachineScheduler.cpp b/src/GSUMachineScheduler.cpp index 68e4041..962fdaa 100644 --- a/src/GSUMachineScheduler.cpp +++ b/src/GSUMachineScheduler.cpp @@ -14,6 +14,7 @@ struct Instruction { std::vector bytes; bool plain = false; bool delay_slot = false; + bool operand_fragment = false; unsigned alt = 4; unsigned source = 16, destination = 16; std::size_t target = Missing; @@ -80,7 +81,7 @@ bool increment(const Instruction& i) { return i.bytes.size() == 1 && ((op >= 0xd0 && op <= 0xdd) || (op >= 0xe0 && op <= 0xed)); } bool oneByte(const Instruction& i, std::uint8_t opcode) { - return i.bytes.size() == 1 && i.bytes[0] == opcode; + return !i.operand_fragment && i.bytes.size() == 1 && i.bytes[0] == opcode; } enum ConditionFlag : unsigned { Sign = 1, Zero = 2, Carry = 4, Overflow = 8 }; unsigned branchFlags(std::uint8_t opcode) { @@ -173,6 +174,167 @@ bool assemble(const std::vector& code, ObjectFile& object) { } object.code_section = std::move(bytes); return true; } +bool publicCodeSymbol(const ObjectFile& object, std::size_t offset) { + for (const auto& symbol : object.symbol_table) { + if (symbol.section != SymbolSection::CODE || symbol.offset != offset) continue; + if (symbol.name == GSUCodeLayout::CacheAlignmentSymbol) continue; + if (symbol.name.empty() || symbol.name.front() != '\x01') return true; + } + return false; +} +bool unconditionalExit(const Instruction& instruction) { + if (instruction.bytes.empty()) return false; + const auto op = instruction.bytes[0]; + // BRA and STOP never fall through. JMP and IWT R15 do so only as those + // opcodes; an unknown or ALT-prefixed encoding is a different instruction. + if ((op == 0 && instruction.bytes.size() == 1) || (op == 5 && instruction.bytes.size() == 2)) return true; + if (instruction.alt != 0) return false; + return (op >= 0x98 && op <= 0x9d && instruction.bytes.size() == 1) || + (op == 0xff && instruction.bytes.size() == 3); +} +bool splitImmediate(const Instruction& instruction) { + if (instruction.delay_slot || instruction.bytes.size() < 2) return false; + const auto op = instruction.bytes[0]; + return (op >= 0xa0 && op <= 0xae && instruction.bytes.size() == 2) || + (op >= 0xf0 && op <= 0xfe && instruction.bytes.size() == 3); +} +void planEntryIncrements(std::vector& code, const std::set& entries, + const std::set& relocation_offsets, + const std::set& protected_offsets, const ObjectFile& object, + const std::map& jump_targets, + GSUMachineScheduler::Statistics& stats) { + std::map incoming; + for (const auto& instruction : code) if (instruction.target != Missing) ++incoming[instruction.target]; + for (const auto& jump : jump_targets) ++incoming[jump.second]; + for (std::size_t n = 1; n + 1 < code.size(); ++n) { + auto& previous = code[n - 1]; + auto& branch = code[n]; + auto& slot = code[n + 1]; + // The ordinary filler cannot cross an entry. A one-byte INC/DEC that + // is itself the entry becomes the following BRA's slot, and the + // zero-byte anchor keeps the label on the BRA. + if (previous.bytes.empty() || previous.delay_slot || !increment(previous)) continue; + if (branch.bytes.size() != 2 || branch.bytes[0] != 5 || !oneByte(slot, 1)) continue; + if (!entries.count(previous.old) || entries.count(branch.old) || incoming[branch.old]) continue; + if (publicCodeSymbol(object, previous.old) || publicCodeSymbol(object, branch.old)) continue; + if (!freeRange(code, n - 1, n, relocation_offsets) || + !freeRange(code, n + 1, n + 2, protected_offsets)) continue; + slot.bytes = previous.bytes; + slot.delay_slot = true; + previous.bytes.clear(); + previous.target = Missing; + ++stats.delay_slots; + ++stats.entry_slots; + } +} +void planImmediateSlots(std::vector& code, const ObjectFile& object, + const std::map& jump_targets, + const std::set& relocation_offsets, + const std::set& protected_offsets, + GSUMachineScheduler::Statistics& stats) { + std::map at; + std::map incoming; + for (std::size_t n = 0; n < code.size(); ++n) { + const auto& instruction = code[n]; + if (instruction.old != Missing && !instruction.bytes.empty()) at[instruction.old] = n; + if (instruction.target != Missing) ++incoming[instruction.target]; + else if (instruction.bytes.size() == 3 && instruction.bytes[0] == 0xff) { + const auto jump = jump_targets.find(instruction.old); + if (jump != jump_targets.end()) ++incoming[jump->second]; + } + } + for (std::size_t n = 0; n + 1 < code.size(); ++n) { + const auto& branch = code[n]; + if (branch.delay_slot || !oneByte(code[n + 1], 1)) continue; + if (n && !code[n - 1].bytes.empty()) { + const auto link = code[n - 1].bytes[0]; + if (link >= 0x91 && link <= 0x94) continue; + } + std::size_t destination = Missing; + if (branch.bytes.size() == 2 && branch.bytes[0] == 5 && branch.plain) destination = branch.target; + else if (branch.bytes.size() == 3 && branch.bytes[0] == 0xff && branch.alt == 0) { + const auto jump = jump_targets.find(branch.old); + if (jump != jump_targets.end()) destination = jump->second; + } + if (destination == Missing || incoming[destination] != 1) continue; + const auto found = at.find(destination); + if (found == at.end() || !splitImmediate(code[found->second])) continue; + const auto target = found->second; + if (publicCodeSymbol(object, code[target].old)) continue; + if (!freeRange(code, target, target + 1, relocation_offsets) || + !freeRange(code, n + 1, n + 2, protected_offsets)) continue; + std::size_t predecessor = target; + while (predecessor > 0 && code[predecessor - 1].bytes.empty()) --predecessor; + const bool slot_precedes = predecessor > 0 && predecessor - 1 == n + 1; + if (predecessor > 0 && !slot_precedes && !unconditionalExit(code[predecessor - 1])) continue; + // The branch preserves clear selectors. Only its opcode byte executes + // in the slot; the immediate stays at the unique target PC. + code[n + 1].bytes = {code[target].bytes[0]}; + code[n + 1].delay_slot = true; + code[target].bytes.erase(code[target].bytes.begin()); + // The remaining byte(s) are fetched by the opcode in the old-flow + // slot. Their numeric value is not an executable CACHE/STOP/etc. + code[target].operand_fragment = true; + ++stats.immediate_slots; + } +} +void dropNestedCaches(std::vector& code, const ObjectFile& object, + const std::set& entries, + GSUMachineScheduler::Statistics& stats) { + // Without a required final alignment, two different PCs can cross a + // cache-line boundary after relinking. Entry-CACHE padding is applied + // later, so it also prevents an exact position proof in this pass. + if (GSUCodeLayout::alignment(object) != 16) return; + for (const auto& instruction : code) + if (oneByte(instruction, 2) && publicCodeSymbol(object, instruction.old)) return; + bool have_window = false; + bool selectors_clear = false; + std::size_t anchor = 0, position = 0; + for (auto& instruction : code) { + // A linear predecessor's CACHE need not execute on another entry. + // Calls may also replace CBR before returning. Restrict the proof to + // a straight-line basic-block segment, including zero-byte anchors. + if (entries.count(instruction.old)) { have_window = false; selectors_clear = false; } + const auto size = instruction.bytes.size(); + if (instruction.bytes.empty() || instruction.operand_fragment) { position += size; continue; } + const auto op = instruction.bytes[0]; + if (instruction.delay_slot) { + // CACHE here observes the transfer's effective PC, not this + // byte's linear position. Keep it and lose CBR knowledge unless + // both paths' effective windows are independently proved. + have_window = false; + selectors_clear = false; + position += size; + continue; + } + if (transfer(op) || op == 0 || (op >= 0x91 && op <= 0x94)) { + have_window = false; + selectors_clear = false; + position += size; + continue; + } + if (!oneByte(instruction, 2)) { + // Conservatively forget even a complete WITH/TO copy pair. An + // ordinary consumer/literal resets all selectors; prefixes do not. + selectors_clear = !((op >= 0x10 && op <= 0x2f) || (op >= 0xb0 && op <= 0xbf) || + (op >= 0x3d && op <= 0x3f)); + position += size; + continue; + } + const auto next_pc = position + size; + // Equality of CBR, not just membership in its 512-byte window, keeps + // all later coverage unchanged. CACHE also resets ALT/WITH/FROM/TO. + if (have_window && selectors_clear && (next_pc & ~std::size_t{15}) == (anchor & ~std::size_t{15})) { + instruction.bytes.clear(); instruction.target = Missing; + ++stats.nested_caches; + } else { + anchor = next_pc; + have_window = true; + position += size; + } + selectors_clear = true; + } +} } GSUMachineScheduler::Statistics GSUMachineScheduler::run(ObjectFile& object) { @@ -192,6 +354,50 @@ GSUMachineScheduler::Statistics GSUMachineScheduler::run(ObjectFile& object) { relocation_offsets.insert(r.patch_offset + b); } + // A private one-PLOT loop may put PLOT in LOOP's always-executed slot, + // including its first/final iteration and the raw zero-count wrap. Keep + // the old PLOT entry anchor on the new LOOP; generic scheduling cannot + // erase that entry or infer its incoming selector state from linear code. + std::map pixel_symbols; + std::set private_pixels, blocked_pixels, r13_literals; + for (const auto& i : code) { + if (i.bytes.size() == 3 && i.bytes[0] == 0xfd && i.alt == 0 && !i.delay_slot) + r13_literals.insert(i.old); + if (i.target != Missing) blocked_pixels.insert(i.target); + } + for (const auto& s : object.symbol_table) if (s.section == SymbolSection::CODE) { + pixel_symbols.emplace(s.name, s.offset); + if (!s.name.empty() && s.name.front() == '\x01') private_pixels.insert(s.offset); + else blocked_pixels.insert(s.offset); + } + std::set r13_pixels; + for (const auto& r : object.relocation_table) { + const auto target = pixel_symbols.find(r.target_symbol_name); + if (target == pixel_symbols.end()) continue; + const auto patch = static_cast(r.patch_offset); + if (r.section_to_patch == SymbolSection::CODE && r.type == RelocationType::ADDR16_IWT && + r13_literals.count(patch)) + r13_pixels.insert(target->second); + else blocked_pixels.insert(target->second); + } + for (std::size_t n = 1; n + 2 < code.size(); ++n) { + const auto entry = code[n].old; + if (!oneByte(code[n], 0x4c) || !oneByte(code[n + 1], 0x3c) || !oneByte(code[n + 2], 1) || + code[n].delay_slot || !private_pixels.count(entry) || !r13_pixels.count(entry) || blocked_pixels.count(entry) || + !freeRange(code, n + 1, n + 3, entries) || !freeRange(code, n, n + 3, relocation_offsets)) continue; + const auto& before = code[n - 1]; + // LOOP resets prefixes on every backedge. The only remaining entry + // is fallthrough, which must also be a known reset, never LINK's or + // another transfer's delay slot, WITH, FROM, TO or ALT. + if (before.delay_slot || !(oneByte(before, 1) || oneByte(before, 2) || + (before.bytes.size() == 3 && before.bytes[0] == 0xfd && before.alt == 0))) continue; + code[n].bytes = {0x3c}; code[n].plain = true; code[n].alt = code[n].source = code[n].destination = 0; + code[n + 1].bytes = {0x4c}; code[n + 1].delay_slot = true; + code[n + 1].plain = true; code[n + 1].alt = code[n + 1].source = code[n + 1].destination = 0; + code.erase(code.begin() + static_cast(n + 2)); + ++stats.delay_slots; ++stats.private_pixel_loops; + } + // Hide ROM-buffer latency with work already following the read. GETC has no // register/condition-code result; plain GETB writes only its selected DREG. // Never cross a label, relocation, bank switch, load/store, trap or branch. @@ -342,39 +548,99 @@ GSUMachineScheduler::Statistics GSUMachineScheduler::run(ObjectFile& object) { code[n + 3].bytes.clear(); ++stats.compact_branches; } - auto scheduled = object; - if (!assemble(code, scheduled)) return Statistics{}; - object = std::move(scheduled); - - // Size policy keeps explicit CACHE opcodes but adds no alignment bytes or - // linker padding hints. Delay-slot/ROM scheduling above is non-increasing. - if (object.config.optimization == OptimizationLevel::Size) return stats; - - // Align function-entry CACHE's *prefetched next byte*, not its opcode. - // Padding is outside the entry symbol, hence not paid on calls. Deliberately - // do not pad loop CACHE directives: measured rotation timing regressed when - // those NOPs executed on each scanline. Hot loop layout is handled in IR. - entries.clear(); - for (const auto& s : object.symbol_table) if (s.section == SymbolSection::CODE) entries.insert(s.offset); - code = decode(object, entries); - std::set cache_entries; - for (const auto& s : object.symbol_table) - if (s.section == SymbolSection::CODE && !s.name.empty() && s.name.front() != '\x01' && - s.offset < object.code_section.size() && object.code_section[s.offset] == 2) cache_entries.insert(s.offset); - std::vector aligned; - std::size_t bytes = 0; - unsigned padding = 0; - for (auto i : code) { - if (cache_entries.count(i.old)) while ((bytes + 1) % 16) { - Instruction nop; nop.bytes = {1}; aligned.push_back(std::move(nop)); ++bytes; ++padding; + // Relax symbol-resolved, non-call PC literals after useful-slot/layout + // compaction. Never infer a destination from an unrelocated numeric word. + // Plain selectors make BRA's preserved prefix state identical to IWT's + // reset state, including for a useful opcode in the existing delay slot. + std::map jump_targets; + for (const auto& r : object.relocation_table) { + const auto target = symbols.find(r.target_symbol_name); + if (r.section_to_patch == SymbolSection::CODE && r.type == RelocationType::ADDR16_IWT && target != symbols.end()) + jump_targets.emplace(r.patch_offset, target->second); + } + std::set shortened; + for (unsigned round = 0; round < 8 && !jump_targets.empty(); ++round) { + std::vector positions(object.code_section.size() + 1, Missing); + std::size_t length = 0; + for (const auto& i : code) { + if (i.old != Missing) positions.at(i.old) = length; + length += i.bytes.size(); + } + positions.back() = length; + bool changed = false; + for (std::size_t n = 0; n < code.size(); ++n) { + auto& i = code[n]; + const auto target = jump_targets.find(i.old); + if (target == jump_targets.end() || i.bytes.size() != 3 || i.bytes[0] != 0xff || + !i.plain || i.delay_slot || (n && code[n - 1].bytes.size() == 1 && + code[n - 1].bytes[0] >= 0x91 && code[n - 1].bytes[0] <= 0x94)) continue; + if (entries.count(i.old + 1) || entries.count(i.old + 2) || + relocation_offsets.count(i.old + 3)) continue; + const auto here = positions.at(i.old), destination = positions.at(target->second); + if (here == Missing || destination == Missing) continue; + // This round's distances can only decrease as other jumps shrink. + const auto distance = static_cast(destination) - + static_cast(here + 2) - (destination > here ? 1 : 0); + if (distance < -128 || distance > 127) continue; + i.bytes = {5, 0}; i.target = target->second; + shortened.insert(i.old); changed = true; ++stats.short_jumps; } - bytes += i.bytes.size(); aligned.push_back(std::move(i)); + if (!changed) break; } - auto candidate = object; - if (!cache_entries.empty() && bytes <= 65536 && assemble(aligned, candidate)) { - if (GSUCodeLayout::alignment(candidate) != 16) + // Slot planning stays on this instruction list. Re-decoding afterwards + // would treat a split immediate opcode as a full instruction. + const auto without_plans = code; + const auto without_plan_stats = stats; + planEntryIncrements(code, entries, relocation_offsets, protected_offsets, object, jump_targets, stats); + planImmediateSlots(code, object, jump_targets, relocation_offsets, protected_offsets, stats); + if (object.config.optimization != OptimizationLevel::Size) + dropNestedCaches(code, object, entries, stats); + + const auto publish = [&](const std::vector& seq, bool hint, unsigned padding) { + auto candidate = object; + candidate.relocation_table.erase(std::remove_if(candidate.relocation_table.begin(), candidate.relocation_table.end(), + [&](const RelocationEntry& r) { return r.section_to_patch == SymbolSection::CODE && shortened.count(r.patch_offset); }), + candidate.relocation_table.end()); + if (!assemble(seq, candidate)) return false; + if (hint && GSUCodeLayout::alignment(candidate) != 16) candidate.symbol_table.push_back({GSUCodeLayout::CacheAlignmentSymbol, SymbolSection::CODE, 0}); - object = std::move(candidate); stats.cache_padding = padding; + object = std::move(candidate); + if (hint) stats.cache_padding = padding; + return true; + }; + std::set cache_entries; + // Size policy keeps explicit CACHE opcodes but adds no alignment bytes or + // linker padding hints. Align a function-entry CACHE's prefetched next + // byte, not its opcode. Do not pad loop CACHE directives. + if (object.config.optimization != OptimizationLevel::Size) { + for (const auto& symbol : object.symbol_table) + if (symbol.section == SymbolSection::CODE && !symbol.name.empty() && symbol.name.front() != '\x01') + cache_entries.insert(symbol.offset); + } + const auto layout = [&](const std::vector& seq) { + unsigned padding = 0; + std::vector aligned; + std::size_t bytes = 0; + bool can_hint = false; + if (!cache_entries.empty()) { + for (const auto& instruction : seq) { + if (oneByte(instruction, 2) && cache_entries.count(instruction.old)) { + can_hint = true; + while ((bytes + 1) % 16) { + Instruction nop; nop.bytes = {1}; aligned.push_back(std::move(nop)); ++bytes; ++padding; + } + } + bytes += instruction.bytes.size(); + aligned.push_back(instruction); + } + if (bytes > 65536) can_hint = false; + } + if (can_hint && publish(aligned, true, padding)) return true; + return publish(seq, false, 0); + }; + if (!layout(code)) { + stats = without_plan_stats; + if (!layout(without_plans)) return Statistics{}; } return stats; } diff --git a/src/GSUMachineScheduler.hpp b/src/GSUMachineScheduler.hpp index a550c47..4ae28e7 100644 --- a/src/GSUMachineScheduler.hpp +++ b/src/GSUMachineScheduler.hpp @@ -12,6 +12,11 @@ class GSUMachineScheduler { unsigned rom_operations = 0; unsigned cache_padding = 0; unsigned compact_branches = 0; + unsigned short_jumps = 0; + unsigned private_pixel_loops = 0; + unsigned entry_slots = 0; + unsigned immediate_slots = 0; + unsigned nested_caches = 0; }; static Statistics run(ObjectFile& object); }; diff --git a/src/IR.cpp b/src/IR.cpp index 3976d5b..31364c1 100644 --- a/src/IR.cpp +++ b/src/IR.cpp @@ -135,6 +135,130 @@ bool sameValueType(const Type& left, const Type& right) { (samePointerLayers(left, right) && left.space == right.space)); } +// Independent content-liveness check for overlapping homes. Metadata does not +// authorize a new alias rule: unknown RAM paths and observable identities are +// rejected whenever a plan pools or shares storage. +void verifyStorageInterference(const IRFunction& function) { + if (!function.storage_layout_valid) return; + using Live = std::set; + std::set> overlapping; + for (auto a = function.storage_layout.begin(); a != function.storage_layout.end(); ++a) { + if (a->second.stackOffset >= 0) continue; + const auto size_a = ((a->second.type.sizeInBytes * std::max(1, a->second.type.array_size) + 1) / 2) * 2 + + std::max(std::max(2, a->second.type.alignment), storageAlignment(a->second.type)) - 2; + for (auto b = std::next(a); b != function.storage_layout.end(); ++b) { + if (b->second.stackOffset >= 0) continue; + const auto size_b = ((b->second.type.sizeInBytes * std::max(1, b->second.type.array_size) + 1) / 2) * 2 + + std::max(std::max(2, b->second.type.alignment), storageAlignment(b->second.type)) - 2; + if (a->second.stackOffset < b->second.stackOffset + size_b && b->second.stackOffset < a->second.stackOffset + size_a) + overlapping.emplace(a->first, b->first); + } + } + if (overlapping.empty() && function.immutable_local_arrays.empty()) return; + const Token source(TokenType::UNKNOWN, function.name, 1, 1); + const auto reject = [&]() { throw CompilerError("IR verifier: pooled/shared storage has an alias or lifetime conflict.", source); }; + if (function.value_count > 8192 || function.blocks.size() > 256) reject(); + std::vector owners(static_cast(function.value_count) + 1); + std::vector values(owners.size(), nullptr); + std::vector zero(owners.size(), false); + std::size_t count = 0; + for (const auto& parameter : function.parameters) + if (parameter.type.pointer_level && parameter.type.space == AddressSpace::RAM) reject(); + for (const auto& block : function.blocks) for (const auto& instruction : block.instructions) { + if (++count > 16000) reject(); + if (instruction.result.isValid()) values.at(instruction.result.value) = &instruction; + if (instruction.opcode == IROpcode::Call || instruction.opcode == IROpcode::Load || instruction.opcode == IROpcode::Store) reject(); + if (instruction.opcode == IROpcode::Address && instruction.operation.empty() && instruction.symbol_id.isValid()) { + owners.at(instruction.result.value) = instruction.symbol_id; zero.at(instruction.result.value) = true; + const auto pool = function.immutable_local_arrays.find(instruction.symbol_id); + if (pool != function.immutable_local_arrays.end()) { + auto element = pool->second.type; element.array_size = 0; + if (!sameValueType(element, pointeeType(instruction.type))) reject(); + } + } + } + for (unsigned round = 0; round < 64; ++round) { + bool changed = false; + for (const auto& block : function.blocks) for (const auto& instruction : block.instructions) { + if (!instruction.result.isValid() || owners.at(instruction.result.value).isValid() || instruction.operands.empty()) continue; + const auto owner = owners.at(instruction.operands[0].value); + if (!owner.isValid()) continue; + if ((instruction.opcode == IROpcode::Address && instruction.operation == "member") || + instruction.opcode == IROpcode::PointerOffset) { + owners.at(instruction.result.value) = owner; + bool same = instruction.opcode == IROpcode::Address && instruction.immediate == 0; + if (instruction.opcode == IROpcode::PointerOffset) { + const auto* index = values.at(instruction.operands[1].value); + same = instruction.operands.size() == 2 && index && index->opcode == IROpcode::Constant && index->immediate == 0; + } + zero.at(instruction.result.value) = same && zero.at(instruction.operands[0].value); changed = true; + } + } + if (!changed) break; + } + struct Event { SymbolId symbol; bool read = false, write = false, kill = false; }; + std::vector> events(function.blocks.size()); + std::vector> successors(function.blocks.size()); + std::vector uses(function.blocks.size()), defs(function.blocks.size()), in(function.blocks.size()), out(function.blocks.size()); + for (const auto& block : function.blocks) for (const auto& instruction : block.instructions) { + if (instruction.opcode != IROpcode::Phi) + for (const auto target : instruction.targets) successors.at(block.id.value).insert(target.value); + for (std::size_t operand = 0; operand < instruction.operands.size(); ++operand) { + const auto owner = owners.at(instruction.operands[operand].value); + if (!owner.isValid()) continue; + const bool derived = operand == 0 && instruction.result.isValid() && owners.at(instruction.result.value).isValid() && + (instruction.opcode == IROpcode::Address || instruction.opcode == IROpcode::PointerOffset); + const bool read = operand == 0 && instruction.opcode == IROpcode::LoadIndirect; + const bool write = operand == 0 && (instruction.opcode == IROpcode::StoreIndirect || instruction.opcode == IROpcode::MemoryInitialize); + if ((!derived && !read && !write) || (write && function.immutable_local_arrays.count(owner))) reject(); + } + Event event; + if (instruction.opcode == IROpcode::LoadIndirect || instruction.opcode == IROpcode::StoreIndirect || instruction.opcode == IROpcode::MemoryInitialize) { + event.symbol = owners.at(instruction.operands[0].value); + const auto* pointer = values.at(instruction.operands[0].value); + if (!event.symbol.isValid() && pointer && pointer->type.space == AddressSpace::RAM) reject(); + const auto local = function.storage_layout.find(event.symbol); + if (local != function.storage_layout.end() && local->second.stackOffset < 0) { + event.read = instruction.opcode == IROpcode::LoadIndirect; event.write = !event.read; + const auto width = static_cast(instruction.type.sizeInBytes) * + (instruction.opcode == IROpcode::MemoryInitialize ? static_cast(instruction.initialization_values.size()) : 1); + event.kill = event.write && zero.at(instruction.operands[0].value) && + width == static_cast(local->second.type.sizeInBytes) * std::max(1, local->second.type.array_size); + if (event.read && !defs[block.id.value].count(event.symbol)) uses[block.id.value].insert(event.symbol); + if (event.kill) defs[block.id.value].insert(event.symbol); + } + } + events[block.id.value].push_back(event); + } + std::size_t work = 0; bool changed = true; + while (changed) { + changed = false; + for (std::size_t reverse = function.blocks.size(); reverse > 0; --reverse) { + const auto block = reverse - 1; Live next_out; + for (const auto next : successors[block]) next_out.insert(in[next].begin(), in[next].end()); + auto next_in = uses[block]; + for (const auto value : next_out) if (!defs[block].count(value)) next_in.insert(value); + work += next_in.size() + next_out.size() + 1; + if (work > 2000000) reject(); + if (next_in != in[block] || next_out != out[block]) { in[block] = std::move(next_in); out[block] = std::move(next_out); changed = true; } + } + } + const auto conflict = [&](SymbolId a, SymbolId b) { + if (b < a) std::swap(a, b); + if (a != b && overlapping.count({a, b})) reject(); + }; + for (const auto& block : function.blocks) { + auto live = out[block.id.value]; + for (const auto a : live) for (const auto b : live) conflict(a, b); + const auto& code = events[block.id.value]; + for (auto event = code.rbegin(); event != code.rend(); ++event) { + if (event->write) for (const auto other : live) conflict(event->symbol, other); + if (event->kill) live.erase(event->symbol); + if (event->read) { for (const auto other : live) conflict(event->symbol, other); live.insert(event->symbol); } + } + } +} + } // namespace bool IRInstruction::isTerminator() const { @@ -161,6 +285,8 @@ void IRVerifier::verify(const IRModule& module) { fail("IR verifier: target lacks far-data capability.", source); }; for (const auto& function : module.functions) { + if (function.storage_layout_valid && module.target != TargetKind::GSU) + fail("IR verifier: owned storage planning requires the GSU target.", Token(TokenType::UNKNOWN, function.name, 1, 1)); check_type(function.return_type, Token(TokenType::UNKNOWN, function.name, 1, 1)); for (const auto& parameter : function.parameters) check_type(parameter.type, parameter.name); if (function.is_cached && !supportsCapability(module.target, TargetCapability::InstructionCache)) @@ -213,6 +339,58 @@ void IRVerifier::verifyFunction(const IRFunction& function) { for (const auto& parameter : function.parameters) verifyPointerType(parameter.type, Token(TokenType::UNKNOWN, "", 0, 0), MaxPointerDepth); + const Token storage_source(TokenType::UNKNOWN, function.name, 1, 1); + if (!function.storage_layout_valid && (!function.storage_layout.empty() || !function.immutable_local_arrays.empty())) + fail("IR verifier: storage metadata requires a valid owned layout.", storage_source); + if (function.storage_layout_valid) { + if (function.total_local_alloc_size < 0 || function.total_local_alloc_size > 65530 || + function.total_local_alloc_size % 2 || function.storage_layout.size() > 256 || + function.immutable_local_arrays.size() > 256) + fail("IR verifier: invalid bounded local storage layout.", storage_source); + const auto object_bytes = [&](const Type& type) { + if (type.sizeInBytes <= 0 || type.sizeInBytes > 65528 || type.array_size < 0 || + type.array_size > 65528 / type.sizeInBytes) + fail("IR verifier: invalid local object extent.", storage_source); + return type.sizeInBytes * std::max(1, type.array_size); + }; + for (const auto& local : function.storage_layout) { + const auto& symbol = local.second; + verifyPointerType(symbol.type, storage_source, MaxPointerDepth); + const auto bytes = object_bytes(symbol.type); + const auto alignment = std::max(std::max(2, symbol.type.alignment), storageAlignment(symbol.type)); + if (!local.first.isValid() || local.first != symbol.id || !symbol.stackOffset || + symbol.stackOffset % 2 || alignment > 128 || (alignment & (alignment - 1)) || + function.immutable_local_arrays.count(local.first)) + fail("IR verifier: invalid local storage identity or alignment.", storage_source); + if (symbol.stackOffset < 0 && + (symbol.stackOffset < -function.total_local_alloc_size + 2 || + static_cast(symbol.stackOffset) + ((bytes + 1) / 2) * 2 + alignment - 2 > 0)) + fail("IR verifier: local storage exceeds the checked frame.", storage_source); + } + int parameter_offset = 6; + for (const auto& parameter : function.parameters) { + const auto local = function.storage_layout.find(parameter.symbol_id); + if (local == function.storage_layout.end() || local->second.stackOffset != parameter_offset || + !sameValueType(local->second.type, parameter.type)) + fail("IR verifier: storage layout changed parameter ABI offsets.", parameter.name); + parameter_offset += isFarPointer(parameter.type) ? 4 : 2; + } + std::size_t pool_bytes = 0; + for (const auto& entry : function.immutable_local_arrays) { + const auto& pool = entry.second; const auto& type = pool.type; + if (!entry.first.isValid() || !isIntegerType(type) || type.pointer_level || type.array_size < 4 || + type.array_size > 256 || (type.sizeInBytes != 1 && type.sizeInBytes != 2) || + type.sizeInBytes != (type.base == BaseType::WORD ? 2 : 1) || + type.is_volatile || type.alignment > 2 || pool.bytes.size() != static_cast(object_bytes(type)) || + pool.bytes.size() > 65528 - pool_bytes) + fail("IR verifier: invalid immutable local array extent.", storage_source); + if (type.base == BaseType::BOOL && std::any_of(pool.bytes.begin(), pool.bytes.end(), + [](std::uint8_t byte) { return byte > 1; })) + fail("IR verifier: immutable bool array has an invalid representation.", storage_source); + pool_bytes += pool.bytes.size(); + } + } + struct Definition { std::size_t block = 0; std::size_t instruction = 0; @@ -654,6 +832,9 @@ void IRVerifier::verifyFunction(const IRFunction& function) { instruction.immediate < -function.total_local_alloc_size || instruction.immediate % 2 != 0)) || (!instruction.operation.empty() && instruction.operation != "member" && instruction.operation != "temporary")) fail("IR verifier: address has an invalid operation or operands.", instruction.source); + if (function.storage_layout_valid && instruction.operation.empty() && instruction.symbol_id.isValid() && + !function.storage_layout.count(instruction.symbol_id) && !function.immutable_local_arrays.count(instruction.symbol_id)) + fail("IR verifier: local address is absent from its owned storage layout.", instruction.source); break; case IROpcode::PointerCompare: if (instruction.operands.size() != 2 || instruction.type.base != BaseType::BOOL || instruction.type.pointer_level != 0 || @@ -802,6 +983,7 @@ void IRVerifier::verifyFunction(const IRFunction& function) { } } } + verifyStorageInterference(function); } IRFunction& IRLowerer::currentFunction() { @@ -917,6 +1099,39 @@ IRValueId IRLowerer::lowerExpression(Expr& expr) { return m_last_value; } +void IRLowerer::lowerCondition(Expr& expr, IRBlockId true_target, IRBlockId false_target) { + // The owning AST bounds expression depth. Keep only stable block IDs across + // recursive lowering, which may grow the function's block vector. + if (auto* binary = dynamic_cast(&expr)) { + if (binary->token.type == TokenType::AND_AND || binary->token.type == TokenType::OR_OR) { + const auto rhs = createBlock("condition.rhs"); + if (binary->token.type == TokenType::AND_AND) + lowerCondition(*binary->left, rhs, false_target); + else + lowerCondition(*binary->left, true_target, rhs); + m_current_block = rhs; + lowerCondition(*binary->right, true_target, false_target); + return; + } + } + if (auto* unary = dynamic_cast(&expr)) { + if (unary->token.type == TokenType::BANG) { + lowerCondition(*unary->right, false_target, true_target); + return; + } + } + if (auto* cast = dynamic_cast(&expr)) { + // Converting an integer to bool preserves exactly its zero/nonzero + // condition. Other casts (notably byte narrowing) must still execute. + if (expr.result_type.base == BaseType::BOOL && expr.result_type.pointer_level == 0 && + isIntegerType(cast->expression->result_type)) { + lowerCondition(*cast->expression, true_target, false_target); + return; + } + } + emitConditionalBranch(lowerExpression(expr), true_target, false_target, expr.token); +} + IRValueId IRLowerer::lowerAddress(Expr& expr) { Type address_type = expr.address_type.pointer_level > 0 ? expr.address_type : pointerTo(expr.result_type, expr.result_type.space); @@ -1266,12 +1481,10 @@ void IRLowerer::visit(FunctionDeclStmt& stmt) { void IRLowerer::visit(IfStmt& stmt) { requireFunction(stmt.token); const bool incoming_plot_context = m_plot_context; - const auto condition = lowerExpression(*stmt.condition); const auto then_block = createBlock("if.then"); const auto else_block = stmt.elseBranch ? createBlock("if.else") : IRBlockId{}; const auto end_block = createBlock("if.end"); - emitConditionalBranch(condition, then_block, stmt.elseBranch ? else_block : end_block, - stmt.token); + lowerCondition(*stmt.condition, then_block, stmt.elseBranch ? else_block : end_block); m_current_block = then_block; stmt.thenBranch->accept(*this); @@ -1315,7 +1528,7 @@ void IRLowerer::visit(ForStmt& stmt) { emitBranch(condition_block, stmt.token); m_current_block = condition_block; if (stmt.is_cached) { IRInstruction cache; cache.opcode = IROpcode::Cache; cache.source = stmt.token; emitInstruction(std::move(cache)); } - emitConditionalBranch(lowerExpression(*stmt.condition), body_block, end_block, stmt.token); + lowerCondition(*stmt.condition, body_block, end_block); m_current_block = body_block; m_break_targets.push_back(end_block); m_continue_targets.push_back(increment_block); stmt.body->accept(*this); @@ -1349,8 +1562,7 @@ void IRLowerer::visit(WhileStmt& stmt) { m_current_block = condition_block; if (stmt.is_cached) { IRInstruction cache; cache.opcode = IROpcode::Cache; cache.source = stmt.token; emitInstruction(std::move(cache)); } - const auto condition = lowerExpression(*stmt.condition); - emitConditionalBranch(condition, body_block, end_block, stmt.token); + lowerCondition(*stmt.condition, body_block, end_block); m_break_targets.push_back(end_block); m_continue_targets.push_back(condition_block); @@ -1578,6 +1790,13 @@ std::string dumpIR(const IRModule& module) { for (const auto& function : module.functions) { output << "function " << function.name << "() -> " << typeName(function.return_type) << " {\n"; + if (function.storage_layout_valid) { + output << " storage.frame " << function.total_local_alloc_size << '\n'; + for (const auto& local : function.storage_layout) + output << " storage.local #" << local.first.value << " offset=" << local.second.stackOffset << '\n'; + for (const auto& pool : function.immutable_local_arrays) + output << " storage.immutable #" << pool.first.value << " bytes=" << pool.second.bytes.size() << '\n'; + } for (const auto& block : function.blocks) { output << " " << block.label << " (b" << block.id.value << "):\n"; for (const auto& instruction : block.instructions) { diff --git a/src/IR.hpp b/src/IR.hpp index 90dc006..df5e37a 100644 --- a/src/IR.hpp +++ b/src/IR.hpp @@ -145,6 +145,13 @@ struct IRBasicBlock { std::vector instructions; }; +struct IRImmutableLocalArray { + Type type; + // Little-endian RAM image. Owned by this candidate, never borrowed from + // the analyzer, an instruction, or a different candidate's optimized IR. + std::vector bytes; +}; + struct IRFunction { std::string name; std::string link_name; @@ -156,6 +163,11 @@ struct IRFunction { std::vector blocks; IRBlockId entry; std::uint32_t value_count = 0; + // Final storage planning runs after IR optimization. Backends must use + // this owned table, when valid, for both emission and address proofs. + bool storage_layout_valid = false; + Analyzer::LocalSymbolTable storage_layout; + std::map immutable_local_arrays; }; struct IRModule { @@ -235,6 +247,7 @@ class IRLowerer : public Visitor { IRBlockId createBlock(const std::string& label); IRValueId createValue(); IRValueId lowerExpression(Expr& expr); + void lowerCondition(Expr& expr, IRBlockId true_target, IRBlockId false_target); IRValueId lowerAddress(Expr& expr); IRValueId lowerLogical(BinaryExpr& expr); IRValueId emitValue(IROpcode opcode, const Type& type, const Token& source, diff --git a/src/IRBackendOptimization.cpp b/src/IRBackendOptimization.cpp new file mode 100644 index 0000000..159365b --- /dev/null +++ b/src/IRBackendOptimization.cpp @@ -0,0 +1,528 @@ +#include "IRCodeGenerator.hpp" + +#include +#include + +#include "ABI.hpp" +#include "GSUCostModel.hpp" +#include "IRControlFlow.hpp" + +namespace { +std::string functionSymbol(const IRFunction& f) { return f.link_name.empty() ? f.name : f.link_name; } +std::string blockSymbol(const IRFunction& f, IRBlockId b) { + return std::string(1, '\x01') + f.name + "#" + std::to_string(b.value); +} +std::size_t symbolOffset(const ObjectFile& object, const std::string& name) { + for (const auto& s : object.symbol_table) + if (s.section == SymbolSection::CODE && s.name == name) return s.offset; + return object.code_section.size(); +} +std::size_t functionEnd(const IRModule& module, const ObjectFile& object, const IRFunction& f) { + const auto begin = symbolOffset(object, functionSymbol(f)); + auto end = object.code_section.size(); + for (const auto& other : module.functions) { + const auto offset = symbolOffset(object, functionSymbol(other)); + if (offset > begin) end = std::min(end, offset); + } + for (const auto& s : object.symbol_table) + if (s.section == SymbolSection::CODE && s.offset > begin && + s.name.compare(0, 19, std::string(1, '\x01') + "__disco_os_divmod_") == 0) + end = std::min(end, s.offset); + return end; +} +} + +const Analyzer::LocalSymbolTable& IRCodeGenerator::localSymbols(const IRFunction& function) const { + if (function.storage_layout_valid) return function.storage_layout; + const auto found = m_all_local_symbols.find(function.name); + static const Analyzer::LocalSymbolTable empty; + return found == m_all_local_symbols.end() ? empty : found->second; +} + +void IRCodeGenerator::planCallClobbers(const IRModule& module) { + m_call_clobbers.clear(); + // Exact allocated homes plus a conservative fixed-register envelope for + // each internal callee. The envelope includes transitive hardware use; + // an absent LOOP in this function is not evidence about its callees. + constexpr std::uint16_t unknown = 0x7fff; + if (module.functions.size() > 256) return; + std::map> calls; + std::size_t work = 0; + for (const auto& f : module.functions) { + if (f.value_count > 4096 || f.blocks.size() > 256) { m_call_clobbers.clear(); return; } + const auto key = functionSymbol(f); + std::uint16_t mask = (1u << 0) | (1u << 1) | (1u << 3) | (1u << 6) | (1u << 11); + LinearScanAllocator allocator; + const auto policy = m_function_policies.find(f.name); + allocator.runGlobal(f, allocatableRegisters(f), policy == m_function_policies.end() ? m_allocation_policy : policy->second); + for (const auto& location : allocator.locations()) if (location.second.has_register) + mask |= static_cast(1u << location.second.physical_register); + std::map definitions; + for (const auto& b : f.blocks) for (const auto& i : b.instructions) + if (i.result.isValid()) definitions.emplace(i.result.value, &i); + for (const auto& b : f.blocks) for (const auto& i : b.instructions) { + if (++work > 200000) { m_call_clobbers.clear(); return; } + mask |= i.hardwareEffects().writes_registers; + if (i.opcode == IROpcode::Call) calls[key].insert(i.symbol); + if (isFarPointer(i.type) || i.type.pointer_level || i.opcode == IROpcode::PointerOffset || + i.opcode == IROpcode::HardwareLoop || + (i.opcode == IROpcode::Binary && i.operation == "*")) mask |= 1u << 4; + if (i.opcode == IROpcode::DivMod || i.opcode == IROpcode::DivModResult || + (i.opcode == IROpcode::Binary && (i.operation == "/" || i.operation == "%"))) + mask |= (1u << 2) | (1u << 4); + if ((i.opcode == IROpcode::LoadIndirect || i.opcode == IROpcode::Load) && !i.operands.empty() && + definitions.at(i.operands[0].value)->type.space == AddressSpace::ROM) mask |= 1u << 14; + if (i.opcode == IROpcode::MemoryInitialize && i.initialization_values.size() >= 32) + mask |= (1u << 12) | (1u << 13); + } + m_call_clobbers.emplace(key, mask); + } + // Monotone finite bitset propagation also covers recursive SCCs. A call + // to an unresolved/external name is always the full volatile envelope. + for (std::size_t round = 0; round <= module.functions.size(); ++round) { + bool changed = false; + for (const auto& edge : calls) { + auto& mask = m_call_clobbers.at(edge.first); + const auto before = mask; + for (const auto& name : edge.second) { + if (++work > 1000000) { m_call_clobbers.clear(); return; } + const auto callee = m_call_clobbers.find(name); + mask |= callee == m_call_clobbers.end() ? unknown : callee->second; + } + changed = changed || before != mask; + } + if (!changed) return; + } + m_call_clobbers.clear(); +} + +std::uint16_t IRCodeGenerator::callClobbers(const IRInstruction& instruction) const { + const auto found = m_call_clobbers.find(instruction.symbol); + return globallyOptimized() && found != m_call_clobbers.end() ? found->second : 0x7fff; +} + +bool IRCodeGenerator::phiCopiesEmpty(std::uint32_t from, IRBlockId target) const { + for (const auto& phi : m_current_function->blocks.at(target.value).instructions) { + if (phi.opcode != IROpcode::Phi) break; + const auto uses = m_use_counts.find(phi.result.value); + if (uses == m_use_counts.end() || !uses->second) continue; + for (std::size_t e = 0; e < phi.targets.size(); ++e) { + if (phi.targets[e].value != from || phi.result.value == phi.operands[e].value) continue; + const auto* a = m_register_allocator.find(phi.result); + const auto* b = m_register_allocator.find(phi.operands[e]); + if (a && b && a->has_register && b->has_register && a->physical_register == b->physical_register) continue; + const auto x = m_spill_offsets.find(phi.result.value), y = m_spill_offsets.find(phi.operands[e].value); + if (x != m_spill_offsets.end() && y != m_spill_offsets.end() && x->second == y->second) continue; + return false; + } + } + return true; +} + +bool IRCodeGenerator::phiCopiesUseR6(std::uint32_t from, IRBlockId target) const { + std::vector> moves; + for (const auto& phi : m_current_function->blocks.at(target.value).instructions) { + if (phi.opcode != IROpcode::Phi) break; + const auto uses = m_use_counts.find(phi.result.value); + if (uses == m_use_counts.end() || !uses->second) continue; + for (std::size_t e = 0; e < phi.targets.size(); ++e) { + if (phi.targets[e].value != from || phi.result.value == phi.operands[e].value) continue; + const auto* a = m_register_allocator.find(phi.result); + const auto* b = m_register_allocator.find(phi.operands[e]); + if (a && b && a->has_register && b->has_register && a->physical_register == b->physical_register) continue; + const auto x = m_spill_offsets.find(phi.result.value), y = m_spill_offsets.find(phi.operands[e].value); + if (x != m_spill_offsets.end() && y != m_spill_offsets.end() && x->second == y->second) continue; + // Mixed-memory cycles use checked stack saves, not R6. Their + // scalar stores/reloads and rematerialized constants use R0/R3. + if (!a || !b || !a->has_register || !b->has_register) return false; + moves.push_back({a->physical_register, b->physical_register}); + } + } + if (moves.size() > 64) return true; + while (!moves.empty()) { + auto ready = moves.end(); + for (auto i = moves.begin(); i != moves.end(); ++i) { + bool needed = false; + for (auto j = moves.begin(); j != moves.end(); ++j) + if (i != j && i->first == j->second) needed = true; + if (!needed) { ready = i; break; } + } + if (ready == moves.end()) return true; + moves.erase(ready); + } + return false; +} + +IRBlockId IRCodeGenerator::threadedLoopEntry(IRBlockId target) const { + if (!globallyOptimized()) return target; + std::set visited; + for (unsigned depth = 0; depth < 32 && visited.insert(target.value).second; ++depth) { + const auto& b = m_current_function->blocks.at(target.value); + if (!m_register_allocator.boundaryStores(target.value).empty() || + !m_register_allocator.boundaryReloads(target.value).empty()) break; + auto i = b.instructions.begin(); + while (i != b.instructions.end() && i->opcode == IROpcode::Phi) ++i; + if (i == b.instructions.end() || i->opcode != IROpcode::Branch || i->targets.size() != 1 || + i + 1 != b.instructions.end() || !phiCopiesEmpty(target.value, i->targets[0])) break; + target = i->targets[0]; + } + return target; +} + +void IRCodeGenerator::planRegionalLoopValues() { + m_regional_loop_values.clear(); + const auto& f = *m_current_function; + if (f.blocks.size() > 256 || f.value_count > 4096) return; + const IRControlFlow cfg(f); + std::map definition_blocks; + for (const auto& b : f.blocks) for (const auto& i : b.instructions) + if (i.result.isValid()) definition_blocks.emplace(i.result.value, b.id.value); + std::size_t work = 0; + for (const auto& setup_block : f.blocks) { + const auto& setup = setup_block.instructions.back(); + if (setup.opcode != IROpcode::HardwareLoop || setup.targets.size() != 1 || !setup.loop_target.isValid() || + m_register_hardware_loops.count(setup.loop_id)) continue; + const IRControlFlow::Loop* scope = nullptr; + for (const auto& loop : cfg.loops) if (loop.blocks.count(setup.loop_target.value)) { + if (!scope || loop.blocks.size() < scope->blocks.size()) scope = &loop; + } + if (!scope || scope->blocks.size() > 64) continue; + auto region = scope->blocks; + // PHI splitting may put a private initial-copy edge between setup + // and the natural-loop header. Include that bounded one-way chain in + // the same fixed-register/slot proof: setup values must survive it too. + auto entry = setup.targets.front().value; + std::set entry_chain; + while (!region.count(entry) && entry_chain.size() < 32 && entry_chain.insert(entry).second) { + const auto& code = f.blocks.at(entry).instructions; + auto i = code.begin(); + while (i != code.end() && i->opcode == IROpcode::Phi) ++i; + if (i == code.end() || i->opcode != IROpcode::Branch || i + 1 != code.end()) break; + entry = i->targets.front().value; + } + if (!region.count(entry) || region.size() + entry_chain.size() > 64) continue; + region.insert(entry_chain.begin(), entry_chain.end()); + unsigned available = (1u << 4) | (1u << 6); + bool closed = true, latch = false; + std::map reads; + std::set written_slots; + for (const auto b : region) { + for (const auto p : cfg.predecessors.at(b)) { + if (!region.count(p) && p != setup_block.id.value) closed = false; + if (phiCopiesUseR6(p, IRBlockId{b})) available &= ~(1u << 6); + } + for (const auto& i : f.blocks[b].instructions) { + if (++work > 1000000) { m_regional_loop_values.clear(); return; } + if (i.result.isValid()) { + const auto spill = m_spill_offsets.find(i.result.value); + if (spill != m_spill_offsets.end()) written_slots.insert(spill->second); + } + for (const auto v : i.operands) { + ++reads[v.value]; + // A scalar result does not imply scalar materialization: + // e.g. Cast can reload an external far pair into R0/R4. + // Validated IR is supported independently of the frontend. + if (isFarPointer(producer(v, i.source).type)) closed = false; + } + if (isFarPointer(i.type)) { closed = false; continue; } + switch (i.opcode) { + case IROpcode::Phi: + break; + case IROpcode::Constant: + case IROpcode::Address: + if (i.type.pointer_level && (isFarPointer(i.type) || i.type.space != AddressSpace::RAM)) closed = false; + break; + case IROpcode::LoadIndirect: { + const auto& address = producer(i.operands[0], i.source); + if (!GSUCostModel::scalar(i.type) || isFarPointer(address.type) || address.type.space != AddressSpace::RAM || + !provenAddress(i.operands[0], address.type, i.type.sizeInBytes)) closed = false; + break; + } + case IROpcode::StoreIndirect: { + const auto& address = producer(i.operands[0], i.source); + // Flat numeric pointers can alias any frame word. + // Only a direct, disjoint compiler-planned local has + // a strong enough identity for these invariant homes. + if (address.opcode != IROpcode::Address || !address.operation.empty() || + !address.symbol_id.isValid() || !localSymbols(f).count(address.symbol_id) || + !GSUCostModel::scalar(i.type)) closed = false; + if (!usesRegisterAllocation(i.operands[1])) available &= ~(1u << 6); + break; + } + case IROpcode::Binary: { + const auto* left = m_register_allocator.find(i.operands[0]); + const auto* right = m_register_allocator.find(i.operands[1]); + std::int64_t literal = 0; + const bool constant = constantValue(i.operands[1], literal); + const bool simple = i.operation == "+" || i.operation == "-" || i.operation == "&" || + i.operation == "|" || i.operation == "^"; + const bool compare = i.operation == "==" || i.operation == "!=" || i.operation == "<" || + i.operation == ">" || i.operation == "<=" || i.operation == ">="; + const bool shift = (i.operation == "<<" || i.operation == ">>") && constant && literal >= 0 && literal < 16; + if (simple) { + const bool frame_right = (i.operation == "+" || i.operation == "-") && + usesRegisterAllocation(i.result) && left && left->has_register && + m_spill_offsets.count(i.operands[1].value) && i.type.base == BaseType::WORD; + if (!constant && !(right && right->has_register) && !frame_right) available &= ~(1u << 6); + } else if (compare) { + if (!(left && left->has_register) && !(right && right->has_register)) available &= ~(1u << 6); + } else if (!shift) available = 0; + break; + } + case IROpcode::Unary: + case IROpcode::BitExtract: + case IROpcode::Cast: + if (!GSUCostModel::scalar(i.type)) closed = false; + break; + case IROpcode::PointerOffset: + if (i.operands.size() != 2 || !m_address_proof.provesOffset(i) || + producer(i.operands[1], i.source).opcode != IROpcode::Constant || + i.type.space != AddressSpace::RAM) closed = false; + break; + case IROpcode::SetColor: + if (i.operation == "rom.byte") closed = false; + break; + case IROpcode::PlotCoordinateRead: + case IROpcode::PlotCoordinateWrite: + case IROpcode::Plot: + case IROpcode::PlotBegin: + case IROpcode::PlotEnd: + case IROpcode::CMode: + case IROpcode::Cache: + case IROpcode::Branch: + case IROpcode::CondBranch: + break; + case IROpcode::HardwareLoopEnd: + latch = latch || i.loop_id == setup.loop_id; + if (i.loop_id != setup.loop_id) closed = false; + break; + default: closed = false; break; + } + } + } + if (!closed || !latch || !available) continue; + std::vector candidates; + for (const auto& use : reads) { + const auto spill = m_spill_offsets.find(use.first); + if (spill == m_spill_offsets.end() || region.count(definition_blocks.at(use.first)) || + written_slots.count(spill->second) || !GSUCostModel::scalar(m_values.at(use.first)->type)) continue; + // The definition must dominate the setup, not just be located in + // an earlier emitted block. Its frame home is already initialized. + if (!cfg.dominates(definition_blocks.at(use.first), setup_block.id.value)) continue; + candidates.push_back(use.first); + } + std::sort(candidates.begin(), candidates.end(), [&](std::uint32_t a, std::uint32_t b) { + if (reads.at(a) != reads.at(b)) return reads.at(a) > reads.at(b); + return m_spill_offsets.at(a) < m_spill_offsets.at(b); + }); + RegionalLoopValues selected; selected.blocks = region; + for (const auto reg : {std::uint8_t{4}, std::uint8_t{6}}) { + if (!(available & (1u << reg)) || candidates.empty()) continue; + selected.values.push_back({IRValueId{candidates.front()}, reg}); + candidates.erase(candidates.begin()); + } + if (!selected.values.empty()) m_regional_loop_values.emplace(setup.loop_id, std::move(selected)); + } +} + +int IRCodeGenerator::regionalRegister(IRValueId value) const { + for (const auto& loop : m_regional_loop_values) if (loop.second.blocks.count(static_cast(m_current_block_index))) + for (const auto& entry : loop.second.values) if (entry.first.value == value.value) return entry.second; + return -1; +} + +void IRCodeGenerator::emitRegionalLoopValues(std::uint32_t loop_id) { + const auto found = m_regional_loop_values.find(loop_id); + if (found == m_regional_loop_values.end()) return; + auto values = found->second.values; + std::sort(values.begin(), values.end(), [&](const std::pair& a, + const std::pair& b) { + return m_spill_offsets.at(a.first.value) < m_spill_offsets.at(b.first.value); + }); + int previous = 0; bool have_address = false; + for (const auto& entry : values) { + const auto offset = m_spill_offsets.at(entry.first.value); + if (have_address && offset == previous + 2) { emitByte(0xd3); emitByte(0xd3); } + else { + emitRegisterLiteral(3, static_cast(offset)); + emitByte(0xb9); emitByte(0x13); emitByte(0x53); + } + emitByte(static_cast(0x10 | entry.second)); emitByte(0x43); + m_last_ram_word_address.rememberFrame(offset); + previous = offset; have_address = true; + } + m_accumulator_value = IRValueId{}; +} + +std::uint64_t IRCodeGenerator::candidateSpeed(const IRModule& module, const ObjectFile& object, + const IRFunction* only) const { + std::uint64_t score = 0; + for (const auto& f : module.functions) { + if (only && only->name != f.name) continue; + const auto begin = symbolOffset(object, functionSymbol(f)), end = functionEnd(module, object, f); + if (begin >= end) continue; + IRControlFlow cfg(f); + score = GSUCost::saturatedAdd(score, end - begin); + for (std::size_t n = 0; n < f.blocks.size(); ++n) { + const auto start = symbolOffset(object, blockSymbol(f, f.blocks[n].id)); + const auto next = n + 1 < f.blocks.size() ? symbolOffset(object, blockSymbol(f, f.blocks[n + 1].id)) : end; + if (next < start || next > end) continue; + // Actual scheduled bytes weighted by structural loop frequency. + // This is only a deterministic speed tie-break, never a timing claim. + score = GSUCost::saturatedAdd(score, GSUCost::saturatedMultiply(next - start, + GSUCostModel::blockWeight(cfg.loop_depth.at(f.blocks[n].id.value)) - 1)); + } + } + return score; +} + +void IRCodeGenerator::resetCandidateEmissionState() noexcept { + m_current_function = nullptr; m_values.clear(); + m_collect_function_candidates = false; m_share_division = false; m_pool_enabled = true; + m_allow_auto_cache = true; m_force_long_branches = false; + m_function_policies.clear(); m_function_division_policies.clear(); m_function_pool_policies.clear(); + m_division_candidates.clear(); m_division_helpers.clear(); + m_long_branches.clear(); m_long_branch_functions.clear(); m_relaxation_pending.clear(); +} + +void IRCodeGenerator::recordCandidateMetrics(const IRModule& module, GeneratedCandidate& candidate) const { + candidate.function_metrics.clear(); + if (module.functions.size() > 64) return; + std::size_t instructions = 0; + for (const auto& f : module.functions) for (const auto& b : f.blocks) { + if (b.instructions.size() > 16000 - instructions) return; + instructions += b.instructions.size(); + } + const auto present = [&](const std::string& name) { + return std::any_of(candidate.object.symbol_table.begin(), candidate.object.symbol_table.end(), + [&](const SymbolEntry& s) { return s.section == SymbolSection::CODE && s.name == name; }); + }; + for (const auto& f : module.functions) { + const auto begin = symbolOffset(candidate.object, functionSymbol(f)); + const auto end = functionEnd(module, candidate.object, f); + if (!present(functionSymbol(f)) || begin >= end || end > candidate.object.code_section.size()) { + candidate.function_metrics.clear(); return; + } + bool valid = true; + for (std::size_t n = 0; n < f.blocks.size(); ++n) { + const auto name = blockSymbol(f, f.blocks[n].id); + const auto start = symbolOffset(candidate.object, name); + const auto next = n + 1 < f.blocks.size() ? symbolOffset(candidate.object, blockSymbol(f, f.blocks[n + 1].id)) : end; + if (!present(name) || start < begin || start > next || next > end) { valid = false; break; } + } + // Missing/invalid evidence must never become a partial or zero score. + if (!valid) { candidate.function_metrics.clear(); return; } + FunctionMetric metric; + metric.speed_score = candidateSpeed(module, candidate.object, &f); + metric.no_spills = std::none_of(candidate.issues.begin(), candidate.issues.end(), + [&](const SpillIssue& issue) { return issue.function == f.name; }); + candidate.function_metrics.emplace(f.name, metric); + } +} + +bool IRCodeGenerator::admissibleMetric(const FunctionMetric& proposed, const FunctionMetric& legacy) const { + if (m_config.require_no_spills && proposed.no_spills != legacy.no_spills) return proposed.no_spills; + return proposed.speed_score <= legacy.speed_score; +} + +bool IRCodeGenerator::admissibleExtension(const GeneratedCandidate& proposed, const CandidateReference& legacy) const { + if (legacy.function_metrics.empty() || proposed.function_metrics.size() != legacy.function_metrics.size()) return false; + for (const auto& entry : legacy.function_metrics) if (!proposed.function_metrics.count(entry.first)) return false; + // Complete evidence comes first; global strict eligibility then outranks + // local proxy differences, just as it outranks payload bytes in selection. + if (m_config.require_no_spills && proposed.issues.empty() != legacy.no_spills) return proposed.issues.empty(); + for (const auto& entry : legacy.function_metrics) { + const auto metric = proposed.function_metrics.find(entry.first); + if (metric == proposed.function_metrics.end() || !admissibleMetric(metric->second, entry.second)) return false; + } + return admissibleMetric({proposed.speed_score, proposed.issues.empty()}, {legacy.speed_score, legacy.no_spills}); +} + +void IRCodeGenerator::considerFunctionCandidates(const IRModule& module, const GeneratedCandidate& candidate, + const CandidateReference* legacy) { + if (!m_collect_function_candidates) return; + for (const auto& f : module.functions) { + if (legacy) { + const auto proposed = candidate.function_metrics.find(f.name), reference = legacy->function_metrics.find(f.name); + if (proposed == candidate.function_metrics.end() || reference == legacy->function_metrics.end() || + !admissibleMetric(proposed->second, reference->second)) continue; + } + const auto begin = symbolOffset(candidate.object, functionSymbol(f)); + const auto end = functionEnd(module, candidate.object, f); + if (end < begin) continue; + std::map data_ranges; + for (const auto& array : f.immutable_local_arrays) { + const auto label = std::string(GSUAbi::ImmutablePoolPrefix) + f.name + "." + std::to_string(array.first.value); + for (const auto& symbol : candidate.object.symbol_table) if (symbol.section == SymbolSection::DATA && symbol.name == label) + data_ranges[symbol.offset] = std::max(data_ranges[symbol.offset], array.second.bytes.size()); + } + std::size_t data_bytes = 0, previous_end = 0; + bool first = true; + for (const auto& range : data_ranges) { + // Count the actual owned DATA ranges (deduplicated aliases once), + // including alignment holes between adjacent arrays. Cross-function + // sharing and final CODE/DATA padding are measured by re-emission. + if (!first && range.first >= previous_end && range.first - previous_end <= 1) + data_bytes += range.first - previous_end; + data_bytes += range.second; previous_end = range.first + range.second; first = false; + } + const auto bytes = end - begin + data_bytes; + const bool no_spills = std::none_of(candidate.issues.begin(), candidate.issues.end(), + [&](const SpillIssue& issue) { return issue.function == f.name; }); + const auto speed = candidateSpeed(module, candidate.object, &f); + const auto current = m_function_candidates.find(f.name); + if (current != m_function_candidates.end()) { + const auto& old = current->second; + if (m_config.require_no_spills && old.no_spills != no_spills) { if (old.no_spills) continue; } + else if (bytes > old.bytes || (bytes == old.bytes && speed >= old.speed_score)) continue; + } + FunctionCandidate best; + best.function = f; best.policy = m_allocation_policy; best.shared_division = m_share_division; + best.pool_enabled = m_pool_enabled; + best.no_spills = no_spills; best.bytes = bytes; best.speed_score = speed; + m_function_candidates[f.name] = std::move(best); + } +} + +IRCodeGenerator::GeneratedCandidate IRCodeGenerator::generateMixedCandidate(const IRModule& module) { + struct Cleanup { + IRCodeGenerator& generator; + ~Cleanup() { generator.resetCandidateEmissionState(); } + } cleanup{*this}; + auto mixed = module; + m_function_policies.clear(); m_function_division_policies.clear(); m_function_pool_policies.clear(); m_division_candidates.clear(); + std::map divisions; + for (auto& f : mixed.functions) { + const auto chosen = m_function_candidates.find(f.name); + if (chosen == m_function_candidates.end()) fail("IR codegen: missing bounded function candidate.", Token(TokenType::UNKNOWN, f.name, 1, 1)); + f = chosen->second.function; + m_function_policies[f.name] = chosen->second.policy; + m_function_division_policies[f.name] = chosen->second.shared_division; + m_function_pool_policies[f.name] = chosen->second.pool_enabled; + if (chosen->second.shared_division) for (const auto& b : f.blocks) for (const auto& i : b.instructions) + if (i.opcode == IROpcode::DivMod || (i.opcode == IROpcode::Binary && (i.operation == "/" || i.operation == "%"))) + ++divisions[i.type.is_unsigned]; + } + for (const auto& count : divisions) if (count.second >= 2) m_division_candidates.insert(count.first); + IRVerifier::verify(mixed); + m_force_long_branches = false; m_long_branches.clear(); m_long_branch_functions.clear(); + const auto emit = [&]() { + for (unsigned pass = 0; pass < 8; ++pass) { + try { return generateInternal(mixed); } + catch (const NeedsLongBranch&) { continue; } + } + m_long_branch_functions.insert(m_relaxation_pending.begin(), m_relaxation_pending.end()); + return generateInternal(mixed); + }; + m_allow_auto_cache = true; + auto result = emit(); + if (m_had_cache_probe) { + m_allow_auto_cache = false; + m_long_branches.clear(); m_long_branch_functions.clear(); + auto uncached = emit(); + if (preferCandidate(uncached, result)) result = std::move(uncached); + } + m_allow_auto_cache = true; m_force_long_branches = false; + m_function_policies.clear(); m_function_division_policies.clear(); m_function_pool_policies.clear(); + return result; +} diff --git a/src/IRCodeGenerator.cpp b/src/IRCodeGenerator.cpp index 5c425ed..08da8ff 100644 --- a/src/IRCodeGenerator.cpp +++ b/src/IRCodeGenerator.cpp @@ -14,11 +14,10 @@ #include "IRControlFlow.hpp" #include "GSUMachineScheduler.hpp" #include "GSUCostModel.hpp" +#include "IRStorageOptimizer.hpp" namespace { -struct NeedsLongBranch {}; - std::string internalBlockSymbol(const std::string& function, IRBlockId block) { return std::string(1, '\x01') + function + "#" + std::to_string(block.value); } @@ -93,7 +92,9 @@ void IRCodeGenerator::emitPush(std::uint8_t reg) { emitByte(0x3A); emitByte(0xEA); emitByte(0xEA); - if (globallyOptimized()) m_stack_check_credit.decrease(2); + if (globallyOptimized()) { + m_stack_check_credit.decrease(2); + } } void IRCodeGenerator::emitPop(std::uint8_t reg) { @@ -105,12 +106,22 @@ void IRCodeGenerator::emitPop(std::uint8_t reg) { emitByte(static_cast(0x20 | reg)); } emitByte(0x4A); - if (globallyOptimized()) m_stack_check_credit.increase(2); + if (globallyOptimized()) { + m_stack_check_credit.increase(2); + } } void IRCodeGenerator::emitStore(Register address, std::uint8_t value_reg, bool byte) { - const auto address_reg = static_cast(address); + auto address_reg = static_cast(address); m_last_ram_word_address = IRValueId{}; + // STB/STW encode only (R0)-(R11). A pointer parked in R14 is copied into + // an encodable address register before the value prefix is selected. + if (address_reg > 11) { + const auto temp = value_reg == 3 ? std::uint8_t{0} : std::uint8_t{3}; + emitMove(static_cast(temp), address_reg); + if (temp == 0) m_accumulator_value = IRValueId{}; + address_reg = temp; + } if (value_reg != 0) { emitByte(static_cast(0x20 | value_reg)); } @@ -121,9 +132,9 @@ void IRCodeGenerator::emitStore(Register address, std::uint8_t value_reg, bool b } bool IRCodeGenerator::matchesLastRamWordAddress(IRValueId address) const { - if (!m_last_ram_word_address.isValid()) return false; - if (address.value == m_last_ram_word_address.value) return true; - const auto& previous = producer(m_last_ram_word_address, Token(TokenType::UNKNOWN, "", 0, 0)); + if (!m_last_ram_word_address.source.isValid()) return false; + if (address.value == m_last_ram_word_address.source.value) return true; + const auto& previous = producer(m_last_ram_word_address.source, Token(TokenType::UNKNOWN, "", 0, 0)); const auto& current = producer(address, Token(TokenType::UNKNOWN, "", 0, 0)); // Fresh Address nodes for the same declaration are equivalent. Never // equate distinct pointer loads: an alias may have changed the pointer. @@ -200,8 +211,63 @@ std::uint8_t IRCodeGenerator::scratchRegister() const { return m_isInPlottingContext ? 3 : 1; } +std::vector IRCodeGenerator::allocatableRegisters(const IRFunction& function) const { + // Calls already save every allocated register that is live across them, + // so widening this set does not change the callee-preserved ABI. R6 stays + // the temporary used across materialize; R1 stays the outside-plot scratch. + // R14 is the ROM pointer: every write starts a ROM byte fetch, so it is + // not a general register even in a function that never reads ROM. + std::vector regs{5, 7, 8}; + bool plot = false, division = false, multiply = false, far = false; + bool hardware_loop = false, wide_pointer = false, near_rom_pointer = false; + const auto consider = [&](const Type& type) { + if (isFarPointer(type)) far = true; + // Checked near-ROM values also load the link-time bank into R4. + // Inspect every address-producing result, not just a load's scalar + // result type; parameters/returns can carry the same pointer type. + if (type.pointer_level > 0 && type.space == AddressSpace::ROM) near_rom_pointer = true; + }; + for (const auto& parameter : function.parameters) consider(parameter.type); + consider(function.return_type); + for (const auto& block : function.blocks) for (const auto& instruction : block.instructions) { + consider(instruction.type); + if (instruction.in_plot_context) plot = true; + switch (instruction.opcode) { + case IROpcode::Plot: + case IROpcode::PlotBegin: + case IROpcode::PlotEnd: + case IROpcode::PlotCoordinateRead: + case IROpcode::PlotCoordinateWrite: + case IROpcode::Rpix: + plot = true; + break; + case IROpcode::DivMod: + division = true; + break; + case IROpcode::HardwareLoop: + hardware_loop = true; + break; + case IROpcode::PointerOffset: + if (instruction.operands.size() == 3) wide_pointer = true; + break; + case IROpcode::Binary: + if (instruction.operation == "*") multiply = true; + else if (instruction.operation == "/" || instruction.operation == "%") division = true; + break; + default: + break; + } + } + if (!plot && !division) regs.push_back(2); + if (!far && !near_rom_pointer && !division && !multiply && !hardware_loop && !wide_pointer) regs.push_back(4); + return regs; +} + void IRCodeGenerator::buildRegisterAllocation(const IRFunction& function) { - if (globallyOptimized()) { m_register_allocator.runGlobal(function, {5, 7, 8}, m_allocation_policy); return; } + if (globallyOptimized()) { + m_register_allocator.runGlobal(function, allocatableRegisters(function), m_allocation_policy); + return; + } // R0 is the expression accumulator, R1/R3 are backend temporaries, R9 is // the frame pointer, and R10-R15 have ABI or hardware roles. R5, R7, and // R8 are general-purpose registers for the current backend operations. @@ -236,7 +302,8 @@ void IRCodeGenerator::saveLiveRegistersForCall( (instruction.result.isValid() && pair.first == instruction.result.value)) { continue; } - unique_registers.insert(location.physical_register); + if (callClobbers(instruction) & (1u << location.physical_register)) + unique_registers.insert(location.physical_register); } saved_registers.assign(unique_registers.begin(), unique_registers.end()); for (const auto reg : saved_registers) emitPush(reg); @@ -316,10 +383,17 @@ void IRCodeGenerator::emitAddress(const IRInstruction& instruction) { addRelocation(m_data_manager.getEntries().at(instruction.symbol).link_name, patch, RelocationType::ADDR16_RAM); return; } - const auto function_symbols = m_all_local_symbols.find(m_current_function->name); - if (function_symbols != m_all_local_symbols.end() && instruction.symbol_id.isValid()) { - const auto symbol = function_symbols->second.find(instruction.symbol_id); - if (symbol != function_symbols->second.end()) { + if (m_current_function->immutable_local_arrays.count(instruction.symbol_id)) { + const auto patch = m_object_file.code_section.size(); + emitWordLiteral(Register::R0, 0); + addRelocation(std::string(GSUAbi::ImmutablePoolPrefix) + m_current_function->name + "." + + std::to_string(instruction.symbol_id.value), patch, RelocationType::ADDR16_RAM); + return; + } + const auto& function_symbols = localSymbols(*m_current_function); + if (instruction.symbol_id.isValid()) { + const auto symbol = function_symbols.find(instruction.symbol_id); + if (symbol != function_symbols.end()) { emitMove(Register::R0, 9); if (symbol->second.stackOffset > 0) { emitImmediateArithmetic(OpCode::ADD_R, symbol->second.stackOffset, instruction.source); @@ -375,6 +449,16 @@ void IRCodeGenerator::emitLoadIndirect(const IRInstruction& instruction) { if (optimized() && address.type.space == AddressSpace::RAM && !far_address && !isFarPointer(instruction.type)) { // LOAD samples its address register before writing the destination. // R0 can therefore carry both without copying it through R3. + const bool plain_scalar = instruction.type.pointer_level == 0 && + instruction.type.base != BaseType::BOOL && instruction.type.base != BaseType::STRUCT; + std::uint8_t direct = 0; + if (globallyOptimized() && plain_scalar && usesRegisterAllocation(instruction.result)) { + direct = m_register_allocator.find(instruction.result)->physical_register; + if (direct != 0) { + m_spill_cache.clobber(direct); + emitByte(static_cast(0x10 | direct)); + } + } if (usesByteStorage(instruction.type)) emitByte(0x3d); emitByte(0x40); if (instruction.type.pointer_level > 0) { @@ -383,6 +467,7 @@ void IRCodeGenerator::emitLoadIndirect(const IRInstruction& instruction) { } else if (instruction.type.base == BaseType::BOOL) emitBoolNormalization(); if (!usesByteStorage(instruction.type) && instruction.type.sizeInBytes == 2) m_last_ram_word_address = instruction.operands[0]; + if (direct) m_direct_register_load = true; return; } emitMove(Register::R3, 0); @@ -441,6 +526,89 @@ void IRCodeGenerator::emitBinaryOperands(const IRInstruction& instruction) { } } +void IRCodeGenerator::emitScalarComparison(const IRInstruction& instruction, bool reverse) { + const auto left = instruction.operands.at(0), right = instruction.operands.at(1); + const auto home = [&](IRValueId value) { + std::int64_t literal = 0; + return producer(value, instruction.source).type.pointer_level == 0 && + (constantValue(value, literal) || usesRegisterAllocation(value) || m_spill_offsets.count(value.value)); + }; + std::uint8_t source = 0, operand = scratchRegister(); + if (globallyOptimized() && home(left) && home(right)) { + const auto* a = usesRegisterAllocation(left) ? m_register_allocator.find(left) : nullptr; + const auto* b = usesRegisterAllocation(right) ? m_register_allocator.find(right) : nullptr; + if (a && b) { source = a->physical_register; operand = b->physical_register; } + else if (a) { materialize(right); source = a->physical_register; operand = 0; } + else if (b) { materialize(left); operand = b->physical_register; } + else { + // Both scalar homes are already evaluated. Retain the existing + // right-before-left reload order, but compare R6 directly rather + // than copying it through another backend scratch register. + materialize(right); emitMove(Register::R6_LMULT_HI, 0); + materialize(left); operand = 6; + } + } else { + // Ephemeral/effectful operands keep their existing materialization + // order. Reversing only the final CMP never reorders a checked fault. + emitBinaryOperands(instruction); + } + if (reverse) std::swap(source, operand); + if (source) emitByte(static_cast(0xb0 | source)); + emitByte(0x3f); emitByte(static_cast(0x60 | operand)); +} + +bool IRCodeGenerator::emitAllocatedMultiply(const IRInstruction& instruction, std::uint8_t destination) { + if (!globallyOptimized() || instruction.opcode != IROpcode::Binary || instruction.operation != "*") return false; + const auto kind = m_address_proof.multiplyKind(instruction); + if (kind == GSUAddressProof::MultiplyKind::FullWord) return false; + const auto left = instruction.operands[0], right = instruction.operands[1]; + const auto* source = m_register_allocator.find(left); + const auto* operand = m_register_allocator.find(right); + std::int64_t literal = 0; + const bool immediate = constantValue(right, literal) && literal >= 0 && literal <= 15; + if (!source || !source->has_register || (!immediate && (!operand || !operand->has_register)) || + m_accumulator_value.value == left.value) return false; + m_spill_cache.clobber(destination); + if (source->physical_register == destination) emitByte(static_cast(0x20 | destination)); + else { emitByte(static_cast(0x10 | destination)); emitByte(static_cast(0xb0 | source->physical_register)); } + const bool unsigned_product = kind == GSUAddressProof::MultiplyKind::UnsignedByte; + if (immediate) emitByte(unsigned_product ? 0x3f : 0x3e); + else if (unsigned_product) emitByte(0x3d); + emitByte(static_cast(0x80 | (immediate ? static_cast(literal) : operand->physical_register))); + return true; +} + +void IRCodeGenerator::emitMultiply(const IRInstruction& instruction) { + const auto kind = globallyOptimized() ? m_address_proof.multiplyKind(instruction) : GSUAddressProof::MultiplyKind::FullWord; + const bool short_product = kind != GSUAddressProof::MultiplyKind::FullWord; + const bool unsigned_product = kind == GSUAddressProof::MultiplyKind::UnsignedByte; + const auto left = instruction.operands[0], right = instruction.operands[1]; + const auto* place = m_register_allocator.find(right); + std::int64_t literal = 0; + if (short_product && constantValue(right, literal) && literal >= 0 && literal <= 15) { + materialize(left); emitByte(unsigned_product ? 0x3f : 0x3e); + emitByte(static_cast(0x80 | literal)); + return; + } + if (short_product && place && place->has_register) { + materialize(left); + if (unsigned_product) emitByte(0x3d); + emitByte(static_cast(0x80 | place->physical_register)); + return; + } + // Eager SSA operands are already defined; scalar spill materialization + // uses R3, so R6 safely retains the right input while loading the left. + if (place && place->has_register) emitMove(Register::R6_LMULT_HI, place->physical_register); + else { materialize(right); emitMove(Register::R6_LMULT_HI, 0); } + materialize(left); + if (short_product) { + if (unsigned_product) emitByte(0x3d); + emitByte(0x86); + } else { + emitByte(0x3d); emitByte(0x9f); emitMove(Register::R0, 4); + } +} + void IRCodeGenerator::emitBinary(const IRInstruction& instruction) { if (instruction.operands.size() != 2) { fail("IR codegen: binary instruction must have two operands.", instruction.source); @@ -448,6 +616,7 @@ void IRCodeGenerator::emitBinary(const IRInstruction& instruction) { const auto left = instruction.operands[0]; const auto right = instruction.operands[1]; if (optimized() && emitConstantArithmetic(instruction)) return; + if (optimized() && instruction.operation == "*") { emitMultiply(instruction); return; } std::int64_t immediate = 0; const bool right_immediate = constantValue(right, immediate) && immediate >= 0 && immediate <= 15; const auto right_literal = immediate; @@ -498,19 +667,20 @@ void IRCodeGenerator::emitBinary(const IRInstruction& instruction) { } } - emitBinaryOperands(instruction); + const bool comparison = instruction.operation == ">" || instruction.operation == "<" || + instruction.operation == ">=" || instruction.operation == "<=" || + instruction.operation == "==" || instruction.operation == "!="; + const bool reverse = globallyOptimized() && (instruction.operation == "<=" || instruction.operation == ">"); + if (comparison) emitScalarComparison(instruction, reverse); + else emitBinaryOperands(instruction); if (instruction.operation == "&" || instruction.operation == "|" || instruction.operation == "^" || instruction.operation == "<<" || instruction.operation == ">>" || instruction.operation == "/" || instruction.operation == "%") { emitIntegerOperation(instruction); return; } - if (instruction.operation == ">" || instruction.operation == "<" || - instruction.operation == ">=" || instruction.operation == "<=" || - instruction.operation == "==" || instruction.operation == "!=") { - emitByte(static_cast(OpCode::ALT3)); - emitByte(static_cast(0x60 | scratchRegister())); - + if (comparison) { + const auto operation = reverse ? (instruction.operation == "<=" ? ">=" : "<") : instruction.operation; std::vector labels; std::vector fixups; const auto new_label = [&labels]() { @@ -536,20 +706,20 @@ void IRCodeGenerator::emitBinary(const IRInstruction& instruction) { unsigned_comparison ? OpCode::BCS : OpCode::BGE); const auto false_label = new_label(); - if (instruction.operation == "==") { + if (operation == "==") { emit_local_branch(equal, true_label); - } else if (instruction.operation == "!=") { + } else if (operation == "!=") { emit_local_branch(not_equal, true_label); - } else if (instruction.operation == "<") { + } else if (operation == "<") { emit_local_branch(less, true_label); - } else if (instruction.operation == "<=") { + } else if (operation == "<=") { emit_local_branch(less, true_label); emit_local_branch(equal, true_label); - } else if (instruction.operation == ">") { + } else if (operation == ">") { emit_local_branch(equal, false_label); emit_local_branch(greater_equal, true_label); - } else if (instruction.operation == ">=") { - emit_local_branch(less, false_label); + } else if (operation == ">=") { + if (!globallyOptimized()) emit_local_branch(less, false_label); emit_local_branch(greater_equal, true_label); } labels[false_label] = m_object_file.code_section.size(); @@ -612,7 +782,11 @@ void IRCodeGenerator::emitCast(const IRInstruction& instruction) { } else if (!isFarPointer(source.type) && isFarPointer(instruction.type)) { emitNearBank(4, instruction.type.space); } - emitAddressCheck(instruction.type, element.sizeInBytes > 0 ? element.sizeInBytes : 1); + // A far non-null pair can still have offset zero. Only the near + // source branch proves R0 nonzero when narrowing to a near pointer. + emitAddressCheck(instruction.type, element.sizeInBytes > 0 ? element.sizeInBytes : 1, + globallyOptimized() && !isFarPointer(source.type) + ? AddressComponents::NonNull : AddressComponents::Unknown); bindLabel(done); return; } @@ -633,8 +807,13 @@ void IRCodeGenerator::emitCall(const IRInstruction& instruction) { m_last_ram_word_address = IRValueId{}; std::vector saved_registers; saveLiveRegistersForCall(instruction, saved_registers); - if (m_has_hardware_loop) { emitPush(12); emitPush(13); } - if (m_isInPlottingContext) { emitPush(1); emitPush(2); } + const auto clobbers = callClobbers(instruction); + const bool save_loop = m_has_hardware_loop && (clobbers & ((1u << 12) | (1u << 13))); + const bool save_x = m_isInPlottingContext && (clobbers & (1u << 1)); + const bool save_y = m_isInPlottingContext && (clobbers & (1u << 2)); + if (save_loop) { emitPush(12); emitPush(13); } + if (save_x) emitPush(1); + if (save_y) emitPush(2); std::size_t argument_bytes = 0; for (auto argument = instruction.operands.rbegin(); argument != instruction.operands.rend(); ++argument) { @@ -660,8 +839,9 @@ void IRCodeGenerator::emitCall(const IRInstruction& instruction) { fail("IR codegen: call argument area is too large.", instruction.source); } emitAdjustStack(argument_bytes, true, instruction.source); - if (m_isInPlottingContext) { emitPop(2); emitPop(1); } - if (m_has_hardware_loop) { emitPop(13); emitPop(12); } + if (save_y) emitPop(2); + if (save_x) emitPop(1); + if (save_loop) { emitPop(13); emitPop(12); } restoreRegistersAfterCall(saved_registers); } @@ -760,6 +940,118 @@ void IRCodeGenerator::emitStoreIndirect(const IRInstruction& instruction) { emitStore(static_cast(scratchRegister()), 0, byte); } +void IRCodeGenerator::planHardwareLoopPreservation() { + // Match a complete exposed scope, not a source pattern or merely the + // natural loop. No edge may enter the body/leave without its setup, and + // no instruction or PHI copy may borrow R4/R6 while they own the saves. + const auto& blocks = m_current_function->blocks; + std::vector> predecessors(blocks.size()); + for (const auto& block : blocks) for (const auto& instruction : block.instructions) { + if (instruction.opcode == IROpcode::Phi) continue; + for (const auto target : instruction.targets) { + auto& incoming = predecessors.at(target.value); + if (std::find(incoming.begin(), incoming.end(), block.id.value) == incoming.end()) + incoming.push_back(block.id.value); + } + } + for (const auto& block : blocks) { + if (block.instructions.empty()) continue; + const auto& setup = block.instructions.back(); + if (setup.opcode != IROpcode::HardwareLoop || setup.targets.size() != 1 || + setup.targets[0].value != setup.loop_target.value) continue; + std::int64_t count = 0; + const auto* count_location = m_register_allocator.find(setup.operands.front()); + // Loading an already evaluated scalar home does not touch SP or + // introduce faults between the capacity decision and the capture. + if (!constantValue(setup.operands.front(), count) && !m_spill_offsets.count(setup.operands.front().value) && + !(count_location && count_location->has_register)) continue; + const auto body_id = setup.loop_target.value; + std::set visited; + auto current = body_id; + bool plots = false; + // Compaction can retain an empty condition header before its PLOT + // latch. Admit only a small single-entry chain, never arbitrary CFG. + while (visited.size() < 32 && visited.insert(current).second) { + const auto& body = blocks.at(current).instructions; + if (body.empty()) break; + bool closed = true; + for (auto i = body.begin(); i != body.end() - 1; ++i) { + plots = plots || i->opcode == IROpcode::Plot; + closed = closed && (i->opcode == IROpcode::Plot || i->opcode == IROpcode::Cache); + } + if (!closed) break; + const auto& end = body.back(); + if (end.opcode == IROpcode::Branch && end.targets.size() == 1) { + const auto next = end.targets[0].value; + if (predecessors.at(next).size() != 1 || predecessors[next][0] != current) break; + current = next; + continue; + } + if (!plots || end.opcode != IROpcode::HardwareLoopEnd || end.loop_id != setup.loop_id || + end.targets.size() != 2 || end.targets[0].value != body_id || predecessors[body_id].size() != 2 || + std::find(predecessors[body_id].begin(), predecessors[body_id].end(), block.id.value) == predecessors[body_id].end() || + std::find(predecessors[body_id].begin(), predecessors[body_id].end(), current) == predecessors[body_id].end()) break; + const auto exit_id = end.targets[1].value; + const auto& exit = blocks.at(exit_id).instructions; + if (!exit.empty() && predecessors[exit_id].size() == 1 && predecessors[exit_id][0] == current && + exit.front().opcode == IROpcode::HardwareLoopLeave && exit.front().loop_id == setup.loop_id) + m_register_hardware_loops.insert(setup.loop_id); + break; + } + } +} + +void IRCodeGenerator::emitBoundaryTransfer(std::uint32_t value, bool load) { + const auto* location = m_register_allocator.find(IRValueId{value}); + if (!location || !location->boundary_spill || !location->has_register || !m_spill_offsets.count(value)) return; + if (load) { + emitSpill(IRValueId{value}, true); + emitMove(static_cast(location->physical_register), 0); + m_accumulator_value = IRValueId{}; + return; + } + emitWordSpillStore(IRValueId{value}, location->physical_register); +} + +void IRCodeGenerator::emitLoopBoundary(const IRInstruction&, bool reload) { + if (!globallyOptimized()) return; + const auto block = static_cast(m_current_block_index); + if (!m_boundary_stores_emitted.count(block)) { + const auto& stores = m_register_allocator.boundaryStores(block); + if (!stores.empty()) m_boundary_stores_emitted.insert(block); + for (const auto value : stores) emitBoundaryTransfer(value, false); + } + if (!reload) return; + for (const auto value : m_register_allocator.boundaryReloads(block)) emitBoundaryTransfer(value, true); +} + +void IRCodeGenerator::emitHardwareLoopCapacityGuard(const Token& source) { + if (m_stack_check_credit.covers(4)) return; + const auto ready = localLabel(); + const auto patch = m_object_file.code_section.size(); + emitWordLiteral(Register::R3, 0); + addRelocation(std::string(GSUAbi::StackLimitPrefix) + "4", patch, RelocationType::ADDR16_RAM); + emitByte(0xba); emitByte(0x3f); emitByte(0x63); + emitLocalJump(ready, static_cast(OpCode::BCS)); + // Preserve the old failure order, including SP and the first saved word: + // SP < floor+4 either fails the first checked push, or fails the second + // check after saving R12 and decrementing SP. The second store cannot run. + // Every function already requires floor+4 for its frame, so this new + // relocation introduces no additional link-time representability bound. + emitPush(12); + emitStackGuard(2, source); + bindLabel(ready); +} + +void IRCodeGenerator::emitHardwareLoopRestore(std::uint32_t loop_id) { + if (m_register_hardware_loops.count(loop_id)) { + emitMove(Register::R13_LOOP_ADDR, 6); + emitMove(Register::R12_LOOP_COUNT, 4); + } else { + emitPop(13); emitPop(12); + } +} + void IRCodeGenerator::emitHardwareLoop(const IRInstruction& instruction) { m_last_ram_word_address = IRValueId{}; if (instruction.operands.size() != 1) { @@ -768,23 +1060,35 @@ void IRCodeGenerator::emitHardwareLoop(const IRInstruction& instruction) { // The body has an implicit backedge: straight-line state before its first // iteration cannot justify eliding COLOR/CMODE on subsequent iterations. m_known_color = m_known_plot_options = -1; - // Inline IR scopes can nest or call a function that owns another LOOP, - // just like exposed CFG scopes. Preserve both implicit loop registers. - emitPush(12); emitPush(13); + const bool register_save = m_register_hardware_loops.count(instruction.loop_id) != 0; + // Keep all stack-fault decisions before count evaluation, as on the + // general path. Untaken/zero-trip source edges never enter this setup. + if (register_save) emitHardwareLoopCapacityGuard(instruction.source); + else { emitPush(12); emitPush(13); } std::int64_t count = 0; - if (constantValue(instruction.operands[0], count)) { + const bool constant = constantValue(instruction.operands[0], count); + if (register_save) { + if (!constant) materialize(instruction.operands[0]); + // Count materialization can use both the far-bank accumulator R4 and + // volatile arithmetic scratch R6. Capture only after it is complete; + // the proven body and its empty PHI edges cannot touch either save. + emitMove(Register::R4_LMULT_LO, 12); + emitMove(Register::R6_LMULT_HI, 13); + } + if (constant) { emitRegisterLiteral(12, static_cast(count)); } else { - materialize(instruction.operands[0]); + if (!register_save) materialize(instruction.operands[0]); emitMove(Register::R12_LOOP_COUNT, 0); } + emitRegionalLoopValues(instruction.loop_id); // TO executes with R15 at the prefetched first body byte. Copy PC to R13, // never R13 to PC; LOOP executes its prefetched NOP before returning here. if (instruction.targets.empty()) emitMove(Register::R13_LOOP_ADDR, 15); else { const auto patch = m_object_file.code_section.size(); emitWordLiteral(Register::R13_LOOP_ADDR, 0); - addRelocation(loopEntrySymbol(instruction.loop_target, true), patch, RelocationType::ADDR16_IWT); + addRelocation(loopEntrySymbol(threadedLoopEntry(instruction.loop_target), true), patch, RelocationType::ADDR16_IWT); const auto header = m_hardware_headers.find(instruction.loop_id); if (header != m_hardware_headers.end() && m_cache_trips.count(header->second.value) && count >= 2) { m_auto_caches.push_back({m_object_file.code_section.size(), header->second, @@ -800,11 +1104,20 @@ void IRCodeGenerator::emitHardwareLoop(const IRInstruction& instruction) { } void IRCodeGenerator::materialize(IRValueId value) { + m_direct_register_load = false; const auto& instruction = producer(value, Token(TokenType::UNKNOWN, "", 0, 0)); const bool scalar_snapshot = optimized() && ((instruction.type.pointer_level == 0 && (instruction.type.base == BaseType::BYTE || instruction.type.base == BaseType::WORD || instruction.type.base == BaseType::BOOL)) || + (globallyOptimized() && instruction.type.pointer_level > 0 && + instruction.type.space == AddressSpace::RAM && !isFarPointer(instruction.type)) || m_ephemeral_values.count(value.value)); if (scalar_snapshot && value.value != m_emitting_value.value && m_accumulator_value.value == value.value) return; + const auto regional = value.value != m_emitting_value.value ? regionalRegister(value) : -1; + if (regional >= 0) { + emitMove(Register::R0, static_cast(regional)); + if (scalar_snapshot) m_accumulator_value = value; + return; + } if (m_spill_offsets.count(value.value) && value.value != m_emitting_value.value) { const auto copy = globallyOptimized() && scalar_snapshot ? m_spill_cache.find(value) : -1; if (copy >= 0) emitMove(Register::R0, static_cast(copy)); @@ -814,7 +1127,8 @@ void IRCodeGenerator::materialize(IRValueId value) { } const auto* location = m_register_allocator.find(value); if (scalar_snapshot && value.value == m_emitting_value.value && usesRegisterAllocation(value) && - emitAllocatedOperation(instruction, location->physical_register)) { + (emitAllocatedMultiply(instruction, location->physical_register) || + emitAllocatedOperation(instruction, location->physical_register))) { // Eager emission has no R0-result contract: later consumers load the // allocated register. Do not claim an accumulator snapshot here. m_materialized_values.insert(value.value); @@ -919,12 +1233,15 @@ void IRCodeGenerator::materialize(IRValueId value) { instruction.source); } if (usesRegisterAllocation(value)) { - emitMove(static_cast(location->physical_register), 0); + if (!m_direct_register_load) + emitMove(static_cast(location->physical_register), 0); m_materialized_values.insert(value.value); } + const bool direct_register_load = m_direct_register_load; + m_direct_register_load = false; if (m_spill_offsets.count(value.value)) { emitSpill(value, false); retainSpillCopy(value, false); } m_active_values.erase(value.value); - if (scalar_snapshot) m_accumulator_value = value; + if (scalar_snapshot && !direct_register_load) m_accumulator_value = value; } void IRCodeGenerator::emitPhiCopies(IRBlockId target, const Token& source) { @@ -977,7 +1294,11 @@ void IRCodeGenerator::emitPhiCopies(IRBlockId target, const Token& source) { const auto move = moves[ready]; const auto* destination = m_register_allocator.find(move.destination); const auto* input = m_register_allocator.find(move.source); - if (destination && destination->has_register && ((move.saved && register_only) || + std::int64_t literal = 0; + if (!move.saved && destination && destination->has_register && + !isFarPointer(producer(move.source, source).type) && constantValue(move.source, literal)) { + emitRegisterLiteral(destination->physical_register, static_cast(literal)); + } else if (destination && destination->has_register && ((move.saved && register_only) || (!move.saved && input && input->has_register))) { emitMove(static_cast(destination->physical_register), move.saved ? 6 : input->physical_register); } else if (optimized() && !move.saved && input && input->has_register && @@ -997,10 +1318,10 @@ void IRCodeGenerator::emitPhiCopies(IRBlockId target, const Token& source) { void IRCodeGenerator::emitMemoryInitialize(const IRInstruction& instruction) { const auto& root = producer(instruction.operands.at(0), instruction.source); - const auto symbols = m_all_local_symbols.find(m_current_function->name); - if (symbols == m_all_local_symbols.end()) fail("IR codegen: initializer has no local table.", instruction.source); - const auto found = symbols->second.find(root.symbol_id); - if (found == symbols->second.end() || found->second.stackOffset >= 0 || found->second.type.is_volatile || + if (m_current_function->immutable_local_arrays.count(root.symbol_id)) return; + const auto& symbols = localSymbols(*m_current_function); + const auto found = symbols.find(root.symbol_id); + if (found == symbols.end() || found->second.stackOffset >= 0 || found->second.type.is_volatile || found->second.type.pointer_level || found->second.type.array_size < 4 || static_cast(found->second.type.array_size) * found->second.type.sizeInBytes != instruction.immediate || found->second.type.base != instruction.type.base) @@ -1034,7 +1355,7 @@ void IRCodeGenerator::emitMemoryInitialize(const IRInstruction& instruction) { if (n + 1 < values.size()) { emitByte(0xd3); if (!byte) emitByte(0xd3); } } } - m_accumulator_value = m_last_ram_word_address = IRValueId{}; + m_accumulator_value = IRValueId{}; m_last_ram_word_address = IRValueId{}; } void IRCodeGenerator::planLocalValues() { @@ -1228,7 +1549,11 @@ void IRCodeGenerator::selectAutomaticCaches() { } void IRCodeGenerator::emitBranch(IRBlockId target, const Token& source) { - if (globallyOptimized()) emitPhiCopies(target, source); + if (globallyOptimized()) { + IRInstruction edge; edge.targets = {target}; edge.source = source; + emitLoopBoundary(edge, true); + emitPhiCopies(target, source); + } const bool bypass = m_cached_backedges.count({static_cast(m_current_block_index), target.value}) != 0; const auto alias = bypass ? loopEntrySymbol(target, true) : std::string{}; if (!bypass && target.isValid() && target.value == m_current_block_index + 1) { @@ -1256,7 +1581,8 @@ void IRCodeGenerator::emitBranch(IRBlockId target, const Token& source) { bool IRCodeGenerator::longBranch(std::size_t serial) const { const auto found = m_long_branches.find(m_current_function->name); - return m_force_long_branches || (found != m_long_branches.end() && found->second.count(serial)); + return m_force_long_branches || m_long_branch_functions.count(m_current_function->name) || + (found != m_long_branches.end() && found->second.count(serial)); } void IRCodeGenerator::emitBlockBranch(std::uint8_t opcode, IRBlockId target, @@ -1301,6 +1627,7 @@ void IRCodeGenerator::emitBlockBranch(std::uint8_t opcode, IRBlockId target, } void IRCodeGenerator::emitConditionalBranch(const IRInstruction& instruction) { + emitLoopBoundary(instruction, false); if (instruction.operands.size() != 1 || instruction.targets.size() != 2) { fail("IR codegen: conditional branch shape is invalid.", instruction.source); } @@ -1330,15 +1657,13 @@ void IRCodeGenerator::emitConditionalBranch(const IRInstruction& instruction) { constantValue(update.operands[1], one) && one == 1; } } - if (!reuse_zero) { - emitBinaryOperands(comparison); - emitByte(0x3f); emitByte(static_cast(0x60 | scratchRegister())); - } + const bool reverse = globallyOptimized() && (comparison.operation == "<=" || comparison.operation == ">"); + if (!reuse_zero) emitScalarComparison(comparison, reverse); const bool is_unsigned = producer(comparison.operands[0], instruction.source).type.is_unsigned; const std::uint8_t less = is_unsigned ? 12 : 7; const std::uint8_t ge = is_unsigned ? 13 : 6; const auto yes = instruction.targets[0], no = instruction.targets[1]; - const auto& op = comparison.operation; + const auto op = reverse ? (comparison.operation == "<=" ? ">=" : "<") : comparison.operation; if (op == "<=") { emitBlockBranch(less, yes, instruction.source); emitBlockBranch(9, yes, instruction.source); @@ -1542,8 +1867,13 @@ void IRCodeGenerator::emitInstruction(const IRInstruction& instruction) { emitByte(m_cursor_updates.at(instruction.operands.front().value)); break; } - materialize(instruction.operands.front()); - emitMove(static_cast(instruction.immediate == 0 ? 1 : 2), 0); + if (globallyOptimized() && usesRegisterAllocation(instruction.operands.front())) { + emitMove(static_cast(instruction.immediate == 0 ? 1 : 2), + m_register_allocator.find(instruction.operands.front())->physical_register); + } else { + materialize(instruction.operands.front()); + emitMove(static_cast(instruction.immediate == 0 ? 1 : 2), 0); + } break; case IROpcode::StoreIndirect: emitStoreIndirect(instruction); @@ -1608,11 +1938,11 @@ void IRCodeGenerator::emitInstruction(const IRInstruction& instruction) { emitByte(0x3C); emitByte(static_cast(OpCode::NOP)); if (!instruction.targets.empty()) emitBranch(instruction.targets[1], instruction.source); - else { emitPop(13); emitPop(12); } + else emitHardwareLoopRestore(instruction.loop_id); m_known_color = m_known_plot_options = -1; break; case IROpcode::HardwareLoopLeave: - emitPop(13); emitPop(12); + emitHardwareLoopRestore(instruction.loop_id); break; case IROpcode::Branch: emitBranch(instruction.targets.front(), instruction.source); @@ -1642,6 +1972,8 @@ void IRCodeGenerator::emitBlock(const IRBasicBlock& block) { m_block_remaining_uses.clear(); for (const auto& i : block.instructions) if (i.opcode != IROpcode::Phi) for (const auto v : i.operands) ++m_block_remaining_uses[v.value]; + // Emission order does not prove that a predecessor (or an earlier loop + // trip) ran. Only straight-line stack credit is valid at this entry. m_stack_check_credit.reset(); m_accumulator_value = IRValueId{}; m_last_ram_word_address = IRValueId{}; @@ -1699,7 +2031,7 @@ void IRCodeGenerator::emitBlock(const IRBasicBlock& block) { } } -void IRCodeGenerator::patchBranches() { +bool IRCodeGenerator::patchBranches() { std::map labels; if (optimized()) for (const auto& symbol : m_object_file.symbol_table) if (symbol.section == SymbolSection::CODE) labels.emplace(symbol.name, symbol.offset); @@ -1728,7 +2060,8 @@ void IRCodeGenerator::patchBranches() { m_object_file.code_section.at(fixup.patch_offset) = static_cast(static_cast(distance)); } - if (widen) throw NeedsLongBranch{}; + if (widen) m_relaxation_pending.insert(m_current_function->name); + return widen; } int IRCodeGenerator::planFrameStorage() { @@ -1740,7 +2073,7 @@ int IRCodeGenerator::planFrameStorage() { const auto* location = m_register_allocator.find(IRValueId{value.first}); if (m_branch_comparisons.count(value.first) || m_cursor_elided_values.count(value.first) || m_ephemeral_values.count(value.first) || !m_use_counts[value.first] || - (location && (location->has_register || location->rematerializable))) continue; + (location && (location->rematerializable || (location->has_register && !location->boundary_spill)))) continue; } else { const auto opcode = value.second->opcode; const bool observable = opcode == IROpcode::PlotCoordinateRead || opcode == IROpcode::LoadIndirect || opcode == IROpcode::Load || opcode == IROpcode::Call || value.second->hardwareEffects().observable(); @@ -1773,14 +2106,68 @@ int IRCodeGenerator::planFrameStorage() { return frame_bytes; } -ObjectFile IRCodeGenerator::generateInternal(const IRModule& module) { +void IRCodeGenerator::recordFinalSpillPlan() { + if (!m_config.require_no_spills) return; + const auto record = [&](const std::map& homes, SpillCategory category) { + if (homes.empty()) return; + std::set words; + for (const auto& home : homes) { + words.insert(home.second); + if (category == SpillCategory::IRValue && isFarPointer(m_values.at(home.first)->type)) + words.insert(home.second + 2); + } + SpillIssue issue; + issue.function = m_current_function->name; + issue.value = IRValueId{homes.begin()->first}; + issue.source = m_values.at(issue.value.value)->source; + issue.category = category; + issue.mapped_values = homes.size(); issue.word_slots = words.size(); + m_spill_issues.push_back(std::move(issue)); + }; + record(m_spill_offsets, SpillCategory::IRValue); + record(m_divmod_offsets, SpillCategory::DivModAuxiliary); +} + +bool IRCodeGenerator::preferCandidate(const GeneratedCandidate& candidate, const GeneratedCandidate& current) const { + if (m_config.require_no_spills && candidate.issues.empty() != current.issues.empty()) + return candidate.issues.empty(); + const auto bytes = [](const ObjectFile& object) { + return object.code_section.size() + object.data_section.size() + + (object.data_section.empty() ? 0u : static_cast(object.code_section.size() & 1u)); + }; + const auto proposed = bytes(candidate.object), existing = bytes(current.object); + return proposed < existing || (proposed == existing && candidate.speed_score < current.speed_score); +} + +ObjectFile IRCodeGenerator::finishCandidate(GeneratedCandidate candidate) const { + if (m_config.require_no_spills && !candidate.issues.empty()) { + const auto& issue = candidate.issues.front(); + const auto category = issue.category == SpillCategory::IRValue ? "IR-value spill homes" : "auxiliary divmod spill homes"; + fail("--require-no-spills: function '" + issue.function + "' requires " + category + + "; first value %" + std::to_string(issue.value.value) + " (" + std::to_string(issue.mapped_values) + + " mapped IR value(s), " + std::to_string(issue.word_slots) + " unique 16-bit slot(s)).", issue.source); + } + return std::move(candidate.object); +} + +IRCodeGenerator::GeneratedCandidate IRCodeGenerator::generateInternal(const IRModule& module) { m_object_file = ObjectFile(); + m_spill_issues.clear(); + m_relaxation_pending.clear(); m_selected_auto_cache = m_had_cache_probe = false; m_division_helpers.clear(); m_object_file.config = m_config; m_object_file.config.bitmap = module.bitmap; + if (globallyOptimized()) planCallClobbers(module); + else m_call_clobbers.clear(); for (const auto& function : module.functions) { + const auto policy = m_function_policies.find(function.name); + if (policy != m_function_policies.end()) m_allocation_policy = policy->second; + const auto division = m_function_division_policies.find(function.name); + if (division != m_function_division_policies.end()) m_share_division = division->second; + const auto pool = m_function_pool_policies.find(function.name); + if (pool != m_function_pool_policies.end()) m_pool_enabled = pool->second; m_current_function = &function; m_values.clear(); m_use_counts.clear(); @@ -1805,9 +2192,12 @@ ObjectFile IRCodeGenerator::generateInternal(const IRModule& module) { m_last_ram_word_address = IRValueId{}; m_local_label_serial = 0; m_stack_check_credit.reset(); + m_boundary_stores_emitted.clear(); m_address_proof = GSUAddressProof{}; m_checked_pointer_mode = function.return_type.pointer_level > 0; m_has_hardware_loop = false; + m_register_hardware_loops.clear(); + m_regional_loop_values.clear(); m_auto_caches.clear(); m_manual_cache = function.is_cached; m_cache_trips.clear(); m_hardware_headers.clear(); m_cached_headers.clear(); m_cached_backedges.clear(); @@ -1848,9 +2238,7 @@ ObjectFile IRCodeGenerator::generateInternal(const IRModule& module) { } } if (globallyOptimized()) { - const auto locals = m_all_local_symbols.find(function.name); - const Analyzer::LocalSymbolTable empty; - m_address_proof.run(function, locals == m_all_local_symbols.end() ? empty : locals->second, + m_address_proof.run(function, localSymbols(function), static_cast(function.total_local_alloc_size), m_checked_pointer_mode); planLocalValues(); planLoopCaches(); @@ -1858,9 +2246,7 @@ ObjectFile IRCodeGenerator::generateInternal(const IRModule& module) { int frame_bytes = planFrameStorage(); if (!optimized()) buildRegisterAllocation(function); if (globallyOptimized()) { - const auto locals = m_all_local_symbols.find(function.name); - const Analyzer::LocalSymbolTable empty; - m_address_proof.run(function, locals == m_all_local_symbols.end() ? empty : locals->second, + m_address_proof.run(function, localSymbols(function), static_cast(frame_bytes), m_checked_pointer_mode); const bool lost_proof = std::any_of(m_ephemeral_address_widths.begin(), m_ephemeral_address_widths.end(), [&](const std::pair& address) { @@ -1873,11 +2259,14 @@ ObjectFile IRCodeGenerator::generateInternal(const IRModule& module) { for (const auto& address : m_ephemeral_address_widths) m_ephemeral_values.erase(address.first); m_ephemeral_address_widths.clear(); frame_bytes = planFrameStorage(); - m_address_proof.run(function, locals == m_all_local_symbols.end() ? empty : locals->second, + m_address_proof.run(function, localSymbols(function), static_cast(frame_bytes), m_checked_pointer_mode); } + planHardwareLoopPreservation(); + planRegionalLoopValues(); } + recordFinalSpillPlan(); const auto function_offset = static_cast(m_object_file.code_section.size()); m_object_file.symbol_table.push_back({function.link_name.empty() ? function.name : function.link_name, SymbolSection::CODE, function_offset}); if (function.is_cached) emitByte(static_cast(OpCode::CACHE)); @@ -1915,6 +2304,11 @@ ObjectFile IRCodeGenerator::generateInternal(const IRModule& module) { selectAutomaticCaches(); } + // Collect all functions' widening requests before retrying. Stopping at + // the first function made the pass bound depend on function count and + // needlessly forced unrelated, already-short branches into long forms. + if (!m_relaxation_pending.empty()) throw NeedsLongBranch{}; + emitDivisionHelpers(); for (const auto& pair : m_data_manager.getEntries()) { @@ -1940,32 +2334,69 @@ ObjectFile IRCodeGenerator::generateInternal(const IRModule& module) { m_object_file.data_section.insert(m_object_file.data_section.end(), entry.bytes.begin(), entry.bytes.end()); } + struct PoolEntry { std::vector bytes; unsigned alignment; std::uint32_t offset; }; + std::vector pool; + for (const auto& function : module.functions) for (const auto& array : function.immutable_local_arrays) { + const auto alignment = static_cast(std::max(2, array.second.type.alignment)); + auto existing = std::find_if(pool.begin(), pool.end(), [&](const PoolEntry& entry) { + return entry.alignment >= alignment && entry.offset % alignment == 0 && entry.bytes == array.second.bytes; + }); + std::uint32_t offset = 0; + if (existing != pool.end()) offset = existing->offset; + else { + while (m_object_file.data_section.size() % alignment) m_object_file.data_section.push_back(0); + if (array.second.bytes.size() > 65536u - std::min(65536, m_object_file.data_section.size())) + fail("Immutable local-array payload exceeds one bank.", Token(TokenType::UNKNOWN, function.name, 1, 1)); + offset = static_cast(m_object_file.data_section.size()); + m_object_file.data_section.insert(m_object_file.data_section.end(), array.second.bytes.begin(), array.second.bytes.end()); + pool.push_back({array.second.bytes, alignment, offset}); + } + m_object_file.data_alignment = std::max(m_object_file.data_alignment, static_cast(alignment)); + m_object_file.symbol_table.push_back({std::string(GSUAbi::ImmutablePoolPrefix) + function.name + "." + + std::to_string(array.first.value), SymbolSection::DATA, offset}); + } if (globallyOptimized()) GSUMachineScheduler::run(m_object_file); - return m_object_file; + GeneratedCandidate result; + result.object = std::move(m_object_file); result.issues = std::move(m_spill_issues); + if (globallyOptimized()) { + result.speed_score = candidateSpeed(module, result.object); + if (sizeOptimized()) recordCandidateMetrics(module, result); + } + return result; } ObjectFile IRCodeGenerator::generate(const IRModule& module) { + struct Cleanup { + IRCodeGenerator& generator; + ~Cleanup() { generator.resetCandidateEmissionState(); } + } cleanup{*this}; + m_function_candidates.clear(); m_function_policies.clear(); m_function_division_policies.clear(); + m_function_pool_policies.clear(); m_pool_enabled = true; + m_collect_function_candidates = false; + std::size_t instructions = 0; + for (const auto& f : module.functions) for (const auto& b : f.blocks) if (instructions <= 16000) + instructions += std::min(b.instructions.size(), 16001 - instructions); + const bool bounded = module.functions.size() <= 64 && instructions <= 16000; + const auto bank = m_config.code_start_address >> 16; + const bool pool_capable = m_config.target == TargetKind::GSU && (bank == 0x70 || bank == 0x71); if (sizeOptimized()) { - // Eight IR/helper policies with scheduled cache/no-cache alternatives. + // At most fourteen IR/helper policies, each with scheduled cache/no-cache alternatives. // Compare emitted CODE plus // DATA alignment, not IR node counts or frequency-weighted timing. // The O2-policy candidate retains speed transformations only when they // actually shrink bytes; it uses O2 allocation but no CACHE padding. - const auto bytes = [](const ObjectFile& object) { - return object.code_section.size() + object.data_section.size() + - (object.data_section.empty() ? 0u : static_cast(object.code_section.size() & 1u)); - }; m_share_division = false; - auto best = generateOptimized(module, OptimizationLevel::O2); - std::size_t instructions = 0; - for (const auto& f : module.functions) for (const auto& b : f.blocks) instructions += b.instructions.size(); - if (module.functions.size() > 64 || instructions > 16000) return best; + m_collect_function_candidates = bounded; + // Outside the comparison budget keep per-call initialization: + // eligibility does not prove that DATA plus checks is cheaper. + auto best = generateOptimized(module, OptimizationLevel::O2, IRCompactionPolicy::Enabled, bounded); + if (!bounded) return finishCandidate(std::move(best)); for (const auto policy : {OptimizationLevel::O2, OptimizationLevel::Size}) { for (const bool shared : {false, true}) { if (policy == OptimizationLevel::O2 && !shared) continue; m_share_division = shared; auto candidate = generateOptimized(module, policy); - if (bytes(candidate) < bytes(best)) best = std::move(candidate); + if (preferCandidate(candidate, best)) best = std::move(candidate); } } // LOOP's ABI saves and pointer SSA can be faster yet larger. Retain @@ -1975,24 +2406,65 @@ ObjectFile IRCodeGenerator::generate(const IRModule& module) { for (const bool shared : {false, true}) { m_share_division = shared; auto candidate = generateOptimized(module, policy, IRCompactionPolicy::Disabled); - if (bytes(candidate) < bytes(best)) best = std::move(candidate); + if (preferCandidate(candidate, best)) best = std::move(candidate); } } - return best; + if (pool_capable) for (const auto policy : {OptimizationLevel::O2, OptimizationLevel::Size}) { + // Two extra primary alternatives, not another Cartesian + // helper/compaction search. Start from original unplanned input. + m_share_division = false; + auto candidate = generateOptimized(module, policy, IRCompactionPolicy::Enabled, false); + if (preferCandidate(candidate, best)) best = std::move(candidate); + } + m_collect_function_candidates = false; + auto mixed = generateMixedCandidate(module); + if (preferCandidate(mixed, best)) best = std::move(mixed); + // Anchor every extension to this complete emitted winner, not the + // independently greedy function snapshots or a later mutable best. + const CandidateReference legacy(best); + // Preserve the complete legacy search, including its re-emitted mixed + // CODE/DATA sharing, as an owned incumbent before enriching snapshots. + // Pointer SSA need not also pay for counted-loop compaction. These two + // primaries reuse its checks/proofs and start from the original input. + m_function_policies.clear(); m_function_division_policies.clear(); m_function_pool_policies.clear(); + m_share_division = false; + m_collect_function_candidates = true; + for (const auto policy : {OptimizationLevel::O2, OptimizationLevel::Size}) { + auto candidate = generateOptimized(module, policy, IRCompactionPolicy::PointerPromotionOnly, true, &legacy); + if (admissibleExtension(candidate, legacy) && preferCandidate(candidate, best)) best = std::move(candidate); + } + m_collect_function_candidates = false; + auto enriched = generateMixedCandidate(module); + if (admissibleExtension(enriched, legacy) && preferCandidate(enriched, best)) best = std::move(enriched); + return finishCandidate(std::move(best)); } - return generateOptimized(module, m_config.optimization); + auto best = generateOptimized(module, m_config.optimization, IRCompactionPolicy::Enabled, bounded); + if (globallyOptimized() && bounded && pool_capable) { + auto unpooled = generateOptimized(module, m_config.optimization, IRCompactionPolicy::Enabled, false); + if (preferCandidate(unpooled, best)) best = std::move(unpooled); + } + return finishCandidate(std::move(best)); } -ObjectFile IRCodeGenerator::generateOptimized(const IRModule& module, OptimizationLevel policy, - IRCompactionPolicy compaction) { +IRCodeGenerator::GeneratedCandidate IRCodeGenerator::generateOptimized(const IRModule& module, OptimizationLevel policy, + IRCompactionPolicy compaction, bool pool_enabled, const CandidateReference* legacy) { + m_pool_enabled = pool_enabled; m_allocation_policy = policy; m_division_candidates.clear(); m_force_long_branches = false; m_long_branches.clear(); + m_long_branch_functions.clear(); if (optimized()) { auto optimized_module = module; if (globallyOptimized()) IRGlobalOptimizer::run(optimized_module, m_all_local_symbols, policy, compaction); else IRLocalOptimizer::run(optimized_module, m_all_local_symbols); + if (globallyOptimized()) { + IRStorageOptions options; + const auto bank = m_config.code_start_address >> 16; + if (m_pool_enabled && m_config.target == TargetKind::GSU && (bank == 0x70 || bank == 0x71)) + options.immutable_arrays = IRImmutableArrayPolicy::CopiedNearRamPayload; + IRStorageOptimizer::run(optimized_module, m_all_local_symbols, options); + } if (sizeOptimized() && m_share_division) { std::map counts; for (const auto& f : optimized_module.functions) for (const auto& b : f.blocks) for (const auto& i : b.instructions) @@ -2011,7 +2483,9 @@ ObjectFile IRCodeGenerator::generateOptimized(const IRModule& module, Optimizati continue; } } - m_force_long_branches = true; + // A pathological intra-function cascade loses only that + // function's short forms, not every branch in the module. + m_long_branch_functions.insert(m_relaxation_pending.begin(), m_relaxation_pending.end()); return generateInternal(optimized_module); }; m_allow_auto_cache = true; @@ -2022,13 +2496,14 @@ ObjectFile IRCodeGenerator::generateOptimized(const IRModule& module, Optimizati // also block useful-slot scheduling: compare actual scheduled bytes, // not the pre-scheduler NOP replacement's apparent neutral cost. m_allow_auto_cache = false; - m_force_long_branches = false; m_long_branches.clear(); + m_force_long_branches = false; m_long_branches.clear(); m_long_branch_functions.clear(); auto uncached = emit(); - const auto bytes = [](const ObjectFile& o) { return o.code_section.size() + o.data_section.size() + - (o.data_section.empty() ? 0u : static_cast(o.code_section.size() & 1u)); }; - if (!selected || bytes(uncached) < bytes(result)) result = std::move(uncached); + const bool discard_probe = !selected && (!m_config.require_no_spills || + uncached.issues.empty() == result.issues.empty()); + if (discard_probe || preferCandidate(uncached, result)) result = std::move(uncached); } m_allow_auto_cache = true; + considerFunctionCandidates(optimized_module, result, legacy); return result; } try { diff --git a/src/IRCodeGenerator.hpp b/src/IRCodeGenerator.hpp index f49d872..b09f0cb 100644 --- a/src/IRCodeGenerator.hpp +++ b/src/IRCodeGenerator.hpp @@ -31,6 +31,8 @@ class IRCodeGenerator { ObjectFile generate(const IRModule& module); private: + friend struct IRCodeGeneratorTestAccess; + struct NeedsLongBranch {}; struct BranchFixup { std::size_t patch_offset = 0; IRBlockId target; @@ -46,6 +48,36 @@ class IRCodeGenerator { Token source = {TokenType::UNKNOWN, "", 0, 0}; }; + enum class SpillCategory { IRValue, DivModAuxiliary }; + struct SpillIssue { + std::string function; + Token source = {TokenType::UNKNOWN, "", 0, 0}; + IRValueId value; + SpillCategory category = SpillCategory::IRValue; + std::size_t mapped_values = 0, word_slots = 0; + }; + // Allocation evidence belongs to the exact emitted candidate, not the + // mutable emitter left behind by a later Os/CACHE/relaxation attempt. + // These owned diagnostics are invocation-only, never object metadata. + struct FunctionMetric { + std::uint64_t speed_score = 0; + bool no_spills = false; + }; + struct GeneratedCandidate { + ObjectFile object; + std::vector issues; + std::uint64_t speed_score = 0; + std::map function_metrics; + }; + struct CandidateReference { + std::uint64_t speed_score; + bool no_spills; + std::map function_metrics; + CandidateReference(const GeneratedCandidate& candidate) + : speed_score(candidate.speed_score), no_spills(candidate.issues.empty()), + function_metrics(candidate.function_metrics) {} + }; + void emitByte(std::uint8_t byte); void emitWord(std::uint16_t word); void emitLiteral(std::int64_t value); @@ -67,7 +99,7 @@ class IRCodeGenerator { bool longBranch(std::size_t serial) const; void emitConditionalBranch(const IRInstruction& instruction); void emitSwitch(const IRInstruction& instruction); - void patchBranches(); + bool patchBranches(); void patchLocalBranches(const std::vector& fixups); std::uint8_t scratchRegister() const; @@ -76,12 +108,15 @@ class IRCodeGenerator { void materialize(IRValueId value); void emitAddress(const IRInstruction& instruction); void emitBinary(const IRInstruction& instruction); + void emitMultiply(const IRInstruction& instruction); + bool emitAllocatedMultiply(const IRInstruction& instruction, std::uint8_t destination); bool emitAllocatedOperation(const IRInstruction& instruction, std::uint8_t destination); bool emitConstantArithmetic(const IRInstruction& instruction); void emitConstantShift(bool left, unsigned count, bool unsigned_right); void emitWordMask(std::uint16_t mask); void emitBitExtract(const IRInstruction& instruction); void emitBinaryOperands(const IRInstruction& instruction); + void emitScalarComparison(const IRInstruction& instruction, bool reverse); void emitIntegerOperation(const IRInstruction& instruction); void emitDivMod(const IRInstruction& instruction); void emitDivModResult(const IRInstruction& instruction); @@ -91,6 +126,19 @@ class IRCodeGenerator { void emitLoadIndirect(const IRInstruction& instruction); void emitStoreIndirect(const IRInstruction& instruction); void emitHardwareLoop(const IRInstruction& instruction); + void planHardwareLoopPreservation(); + void planRegionalLoopValues(); + void emitRegionalLoopValues(std::uint32_t loop_id); + int regionalRegister(IRValueId value) const; + bool phiCopiesEmpty(std::uint32_t from, IRBlockId target) const; + bool phiCopiesUseR6(std::uint32_t from, IRBlockId target) const; + IRBlockId threadedLoopEntry(IRBlockId target) const; + void planCallClobbers(const IRModule& module); + std::uint16_t callClobbers(const IRInstruction& instruction) const; + void emitLoopBoundary(const IRInstruction& terminator, bool reload); + void emitBoundaryTransfer(std::uint32_t value, bool load); + void emitHardwareLoopCapacityGuard(const Token& source); + void emitHardwareLoopRestore(std::uint32_t loop_id); void emitMemoryInitialize(const IRInstruction& instruction); void planLocalValues(); int planFrameStorage(); @@ -108,7 +156,9 @@ class IRCodeGenerator { void emitPhiCopies(IRBlockId target, const Token& source); void emitNearBank(std::uint8_t reg, AddressSpace space); void emitSelectBank(std::uint8_t reg, AddressSpace space); - void emitAddressCheck(const Type& pointer, int width); + enum class AddressComponents { Unknown, NonNull, Aligned, NonNullAligned }; + void emitAddressCheck(const Type& pointer, int width, + AddressComponents known = AddressComponents::Unknown); void ensureAddressChecked(IRValueId value, int width); void emitGuard(OpCode success_condition, std::uint16_t fault); void emitAddressFault(std::uint16_t fault); @@ -119,6 +169,7 @@ class IRCodeGenerator { void emitPointerValueCheck(const Type& type, int width); void emitPointerCompare(const IRInstruction& instruction); void emitPointerOffset(const IRInstruction& instruction); + bool emitConstantPointerOffset(const IRInstruction& instruction); void emitSpill(IRValueId value, bool load); void emitWordSpillStore(IRValueId value, std::uint8_t source); void retainSpillCopy(IRValueId value, bool after_load); @@ -127,13 +178,39 @@ class IRCodeGenerator { RelocationType type); void fail(const std::string& message, const Token& source) const; void buildRegisterAllocation(const IRFunction& function); + // Caller-saved extras (R2, R4, R14) join R5/R7/R8 only when this function + // does not use them as plot, multiply, division, far-bank, or ROM hardware. + std::vector allocatableRegisters(const IRFunction& function) const; bool usesRegisterAllocation(IRValueId value) const; void saveLiveRegistersForCall(const IRInstruction& instruction, std::vector& saved_registers); void restoreRegistersAfterCall(const std::vector& saved_registers); - ObjectFile generateInternal(const IRModule& module); - ObjectFile generateOptimized(const IRModule& module, OptimizationLevel policy, - IRCompactionPolicy compaction = IRCompactionPolicy::Enabled); + void recordFinalSpillPlan(); + bool preferCandidate(const GeneratedCandidate& candidate, const GeneratedCandidate& current) const; + ObjectFile finishCandidate(GeneratedCandidate candidate) const; + GeneratedCandidate generateInternal(const IRModule& module); + GeneratedCandidate generateOptimized(const IRModule& module, OptimizationLevel policy, + IRCompactionPolicy compaction = IRCompactionPolicy::Enabled, bool pool_enabled = true, + const CandidateReference* legacy = nullptr); + struct FunctionCandidate { + IRFunction function; + OptimizationLevel policy = OptimizationLevel::O2; + bool shared_division = false; + bool pool_enabled = true; + bool no_spills = false; + std::size_t bytes = 0; + std::uint64_t speed_score = 0; + }; + void considerFunctionCandidates(const IRModule& module, const GeneratedCandidate& candidate, + const CandidateReference* legacy = nullptr); + void recordCandidateMetrics(const IRModule& module, GeneratedCandidate& candidate) const; + bool admissibleMetric(const FunctionMetric& proposed, const FunctionMetric& legacy) const; + bool admissibleExtension(const GeneratedCandidate& proposed, const CandidateReference& legacy) const; + void resetCandidateEmissionState() noexcept; + std::uint64_t candidateSpeed(const IRModule& module, const ObjectFile& object, + const IRFunction* only = nullptr) const; + GeneratedCandidate generateMixedCandidate(const IRModule& module); + const Analyzer::LocalSymbolTable& localSymbols(const IRFunction& function) const; void emitSharedDivision(const IRInstruction& instruction); void emitDivisionHelpers(); @@ -143,6 +220,7 @@ class IRCodeGenerator { const CompilerConfig& m_config; ObjectFile m_object_file; + std::vector m_spill_issues; // Own the synthetic descriptor: the current-function observer may borrow // it while emitting a kernel, including branch-relaxation retries. IRFunction m_division_helper_context; @@ -155,6 +233,7 @@ class IRCodeGenerator { LinearScanAllocator m_register_allocator; GSUAddressProof m_address_proof; GSUStackCheckCredit m_stack_check_credit; + std::set m_boundary_stores_emitted; std::map m_block_addresses; std::vector m_branch_fixups; std::size_t m_current_block_index = 0; @@ -167,9 +246,19 @@ class IRCodeGenerator { // Stable emission ordinals, not byte offsets: widening an earlier branch // must not change the identity of a later one on the next bounded pass. std::map> m_long_branches; + std::set m_relaxation_pending, m_long_branch_functions; std::size_t m_branch_serial = 0; bool m_checked_pointer_mode = false; bool m_has_hardware_loop = false; + // Only a closed PLOT/CACHE-only scope may borrow volatile R4/R6. General + // scopes, calls and nested bodies retain the checked stack-save contract. + std::set m_register_hardware_loops; + struct RegionalLoopValues { + std::set blocks; + std::vector> values; + }; + std::map m_regional_loop_values; + std::map m_call_clobbers; struct AutomaticCache { std::size_t offset; IRBlockId header; std::uint32_t trips; bool entry_slot = false; }; std::vector m_auto_caches; std::map m_cache_trips; @@ -185,12 +274,26 @@ class IRCodeGenerator { // emitted block only. Never evidence about a subsequently reloaded pointer. std::map m_checked_addresses; IRValueId m_emitting_value; - // A strictly adjacent scalar/near-address snapshot in R0, never a lexical - // variable or pointer-pair alias. Emitted bytes and CFG entries fence it. + // A strictly adjacent immutable scalar/near-RAM-pointer snapshot in R0, + // never a lexical variable or far pair. Its frame home stays authoritative; + // emitted bytes, memory/volatile work, calls and CFG entries fence it. IRValueId m_accumulator_value; + // Set when a scalar load wrote its allocated register directly. R0 still + // holds the address, so the definition must not be copied or snapshotted. + bool m_direct_register_load = false; // SBK uses the last physical RAM address, not a register. Only remember // checked near-word accesses; stack traffic and control-flow joins fence it. - IRValueId m_last_ram_word_address; + struct RamWordAddress { + IRValueId source; + bool has_frame = false; + int frame_offset = 0; + RamWordAddress& operator=(IRValueId value) { + source = value; has_frame = false; return *this; + } + void rememberFrame(int offset) { + source = IRValueId{}; has_frame = true; frame_offset = offset; + } + } m_last_ram_word_address; std::map m_spill_offsets; GSUSpillCache m_spill_cache; std::map m_block_remaining_uses; @@ -203,5 +306,11 @@ class IRCodeGenerator { OptimizationLevel m_allocation_policy = OptimizationLevel::O2; std::set m_division_candidates; std::set m_division_helpers; + std::map m_function_candidates; + std::map m_function_policies; + std::map m_function_division_policies; + std::map m_function_pool_policies; + bool m_collect_function_candidates = false; + bool m_pool_enabled = true; std::size_t m_local_label_serial = 0; }; diff --git a/src/IRGlobalOptimizer.cpp b/src/IRGlobalOptimizer.cpp index f93acf9..316fd85 100644 --- a/src/IRGlobalOptimizer.cpp +++ b/src/IRGlobalOptimizer.cpp @@ -7,6 +7,7 @@ #include "IRSizeOptimizer.hpp" #include "IRCompactOptimizer.hpp" #include "ConstantEvaluator.hpp" +#include "GSUCostModel.hpp" #include #include @@ -484,7 +485,12 @@ void inlineLeaves(IRModule& module, const std::map(std::min(12, std::max(2, call_bytes / 4))); + const auto limit = size_policy ? size_limit : cached ? 4u : 12u; if (!legal || cost > limit || caller_growth + cost > 64 || module_growth + cost > 512 || caller_nodes + nodes > 256 || module_nodes + nodes > 2048) { output.push_back(std::move(call)); continue; @@ -511,6 +517,52 @@ void inlineLeaves(IRModule& module, const std::map 16000 || f.blocks.size() > 256) return; + const auto defs = definitions(f); + std::vector uses(defs.size(), 0); + std::vector branches_only(defs.size(), true); + for (const auto& b : f.blocks) for (const auto& i : b.instructions) + for (const auto v : i.operands) { + ++uses.at(v.value); + branches_only.at(v.value) = branches_only.at(v.value) && i.opcode == IROpcode::CondBranch; + } + std::set selected; + for (std::size_t v = 1; v < defs.size() && selected.size() < 64; ++v) { + const auto& i = defs[v]; + if (uses[v] != 2 || !branches_only[v] || i.opcode != IROpcode::Binary || i.operands.size() != 2 || + (i.operation != "<" && i.operation != ">" && i.operation != "<=" && i.operation != ">=" && + i.operation != "==" && i.operation != "!=")) continue; + // A single scalar snapshot against a literal costs at most one home + // reload and CMP per use. Two flag-only uses avoid Boolean creation, + // its home and both Boolean tests. No producer is cloned: a volatile + // load remains one evaluation, with its SSA lifetime extended to both + // consumers before register allocation reconstructs interference. + if (!scalar(defs.at(i.operands[0].value).type) || + defs.at(i.operands[1].value).opcode != IROpcode::Constant || + !scalar(defs.at(i.operands[1].value).type)) continue; + selected.insert(static_cast(v)); + } + if (selected.empty() || selected.size() * 2 > 100000u - f.value_count) return; + for (auto& b : f.blocks) { + std::vector output; + for (auto i : b.instructions) { + if (selected.count(i.result.value)) continue; + if (i.opcode == IROpcode::CondBranch && selected.count(i.operands[0].value)) { + auto compare = defs.at(i.operands[0].value); + compare.result = newValue(f); + compare.in_plot_context = i.in_plot_context; + i.operands[0] = compare.result; + output.push_back(std::move(compare)); + } + output.push_back(std::move(i)); + } + b.instructions = std::move(output); + } + std::vector aliases(static_cast(f.value_count) + 1); + compact(f, aliases, {}); +} + bool movable(const IRInstruction& i, const Definitions& defs) { if (i.is_live_range_split) return false; if (i.memory_volatile) return false; @@ -630,12 +682,13 @@ void strengthReduce(IRFunction& f) { void IRGlobalOptimizer::run(IRModule& module, const std::map& locals, OptimizationLevel policy, IRCompactionPolicy compaction) { const bool size_policy = policy == OptimizationLevel::Size; + const bool promote_near_pointers = compaction != IRCompactionPolicy::Disabled; const bool compact_ir = compaction == IRCompactionPolicy::Enabled; IRVerifier::verify(module); for (auto& f : module.functions) { IRControlFlow::exposeHardwareLoops(f); const auto symbols = locals.find(f.name); - if (symbols != locals.end()) promote(f, symbols->second, compact_ir); + if (symbols != locals.end()) promote(f, symbols->second, promote_near_pointers); } IRVerifier::verify(module); IRConditionalOptimizer::run(module); @@ -646,7 +699,7 @@ void IRGlobalOptimizer::run(IRModule& module, const std::maphas_register && + address->physical_register <= 11 && provenAddress(id, pointer, instruction.type.sizeInBytes)) { m_spill_cache.clobber(destination); emitByte(static_cast(0x10 | destination)); @@ -58,6 +59,32 @@ bool IRCodeGenerator::emitAllocatedOperation(const IRInstruction& instruction, s return true; } } + if (globallyOptimized() && instruction.opcode == IROpcode::PointerOffset && instruction.operands.size() == 2 && + (instruction.operation == "+" || instruction.operation == "-") && !isFarPointer(instruction.type) && + m_address_proof.provesOffset(instruction)) { + const auto* base = m_register_allocator.find(instruction.operands[0]); + std::int64_t index = 0; + if (base && base->has_register && base->physical_register <= 11 && constantValue(instruction.operands[1], index)) { + auto displacement = index * static_cast(instruction.immediate); + if (instruction.operation == "-") displacement = -displacement; + if (displacement != 0 && displacement >= -15 && displacement <= 15) { + m_spill_cache.clobber(destination); + if (base->physical_register == destination && (displacement == 1 || displacement == -1)) { + emitByte(static_cast((displacement == 1 ? 0xd0 : 0xe0) | destination)); + return true; + } + if (base->physical_register == destination) emitByte(static_cast(0x20 | destination)); + else { + emitByte(static_cast(0x10 | destination)); + emitByte(static_cast(0xb0 | base->physical_register)); + } + const auto magnitude = static_cast(displacement < 0 ? -displacement : displacement); + emitByte(0x3e); + emitByte(static_cast((displacement < 0 ? 0x60 : 0x50) | magnitude)); + return true; + } + } + } const auto& op = instruction.operation; const bool binary = instruction.opcode == IROpcode::Binary && (op == "+" || op == "-" || op == "&" || op == "|" || op == "^"); @@ -69,7 +96,13 @@ bool IRCodeGenerator::emitAllocatedOperation(const IRInstruction& instruction, s const bool constant = binary ? constantValue(instruction.operands[1], literal) : op == "!"; if (unary && op == "!") literal = 1; const auto* right = binary && !constant ? m_register_allocator.find(instruction.operands[1]) : nullptr; - if (binary && !constant && (!right || !right->has_register || (!globallyOptimized() && !m_materialized_values.count(instruction.operands[1].value)))) return false; + const bool frame_right = globallyOptimized() && binary && !constant && (op == "+" || op == "-") && + instruction.type.base == BaseType::WORD && instruction.type.sizeInBytes == 2 && instruction.type.pointer_level == 0 && + usesRegisterAllocation(instruction.operands[0]) && m_spill_offsets.count(instruction.operands[1].value) && + producer(instruction.operands[1], instruction.source).type.base == BaseType::WORD && + producer(instruction.operands[1], instruction.source).type.pointer_level == 0; + if (binary && !constant && !frame_right && (!right || !right->has_register || + (!globallyOptimized() && !m_materialized_values.count(instruction.operands[1].value)))) return false; const bool immediate = constant && literal >= 0 && literal <= 15 && ((op != "&" && op != "|" && op != "^") || literal != 0); if (constant && literal == 1 && source->physical_register == destination && @@ -86,6 +119,13 @@ bool IRCodeGenerator::emitAllocatedOperation(const IRInstruction& instruction, s // destination saves no moves and can force an extra Rn -> R0 reload. if ((constant || unary) && m_accumulator_value.value == instruction.operands[0].value) return false; if (constant && !immediate) emitRegisterLiteral(scratchRegister(), static_cast(literal)); + // The RHS has already been evaluated into its authoritative frame home. + // Load it before selecting the destination, as on the fallback path; + // the live LHS and result registers cannot be borrowed by the spill cache. + // ADD/SUB accept R0, unlike the bitwise register forms. A word result + // needs no narrowing, and the caller records only the allocated result, + // never an R0 snapshot of it (R0 still holds the RHS). + if (frame_right) materialize(instruction.operands[1]); m_spill_cache.clobber(destination); if (source->physical_register == destination) emitByte(static_cast(0x20 | destination)); else { emitByte(static_cast(0x10 | destination)); emitByte(static_cast(0xb0 | source->physical_register)); } @@ -97,7 +137,9 @@ bool IRCodeGenerator::emitAllocatedOperation(const IRInstruction& instruction, s if (!instruction.type.is_unsigned) { emitByte(static_cast(0x20 | destination)); emitByte(0x95); } } } else { - const auto operand = static_cast(constant ? immediate ? literal : scratchRegister() : right->physical_register); + std::uint8_t operand = 0; + if (constant) operand = static_cast(immediate ? literal : scratchRegister()); + else if (!frame_right) operand = right->physical_register; if (op == "^" || (unary && op == "!")) emitByte(immediate ? 0x3f : 0x3d); else if (immediate) emitByte(0x3e); const auto base = static_cast(op == "+" ? 0x50 : op == "-" ? 0x60 : op == "&" ? 0x70 : 0xc0); @@ -223,9 +265,15 @@ void IRCodeGenerator::emitIntegerOperation(const IRInstruction& instruction) { // Fixed sixteen-step restoring division. R1 carries dividend/quotient, // R2 the remainder, R3 the divisor, R4 the count. R12/R13 are untouched // so this is safe inside hardware loops. Preserve plotting coordinates. - emitMove(Register::R4_LMULT_LO, scratch); // Stack guards use R3; retain the divisor across pushes. - if (m_isInPlottingContext) { emitPush(1); emitPush(2); } - emitMove(Register::R3, 4); + if (sizeOptimized() && !m_isInPlottingContext) { + // No coordinate pushes can clobber R3 here. Copy the divisor before + // R1 becomes the dividend; signed sign pushes save R3 into R4 below. + emitMove(Register::R3, scratch); + } else { + emitMove(Register::R4_LMULT_LO, scratch); // Stack guards use R3; retain the divisor across pushes. + if (m_isInPlottingContext) { emitPush(1); emitPush(2); } + emitMove(Register::R3, 4); + } emitMove(Register::R1_PIXEL_X, 0); emitRegisterLiteral(6, 0); emitByte(0xb3); emitByte(0x3f); emitByte(0x66); @@ -256,15 +304,13 @@ void IRCodeGenerator::emitIntegerOperation(const IRInstruction& instruction) { emitRegisterLiteral(4, 16); const auto loop = localLabel(), subtract = localLabel(), keep = localLabel(); // Sixteen iterations amortize the cold fetch of this bounded kernel. - // Os can replace the guard's selector-reset NOP with CACHE without adding + // Global policies can replace the guard's selector-reset NOP with CACHE without adding // bytes. Its prefetched PC is the taken success target, and this bounded // kernel follows it within one cache window. Explicit cache ownership // still wins; calls have no cache-base preservation ABI. The backedge // skips CACHE itself. - if (sizeOptimized() && !m_manual_cache) + if (globallyOptimized() && !m_manual_cache) m_object_file.code_section.at(division_guard_slot) = static_cast(OpCode::CACHE); - if (globallyOptimized() && !sizeOptimized() && !m_manual_cache) - emitByte(static_cast(OpCode::CACHE)); bindLabel(loop); emitByte(0xb1); emitByte(0x11); emitByte(0x51); // Shift quotient/input into carry. emitByte(0xb2); emitByte(0x12); emitByte(0x3d); emitByte(0x52); // ADC R2. diff --git a/src/IRPointerCodeGenerator.cpp b/src/IRPointerCodeGenerator.cpp index 287801a..f6055c6 100644 --- a/src/IRPointerCodeGenerator.cpp +++ b/src/IRPointerCodeGenerator.cpp @@ -3,6 +3,7 @@ #include "ABI.hpp" #include "Opcodes.hpp" #include "GSUCostModel.hpp" +#include "ConstantEvaluator.hpp" void IRCodeGenerator::emitPointerValueCheck(const Type& type, int width) { const auto validate = localLabel(), done = localLabel(); @@ -13,7 +14,10 @@ void IRCodeGenerator::emitPointerValueCheck(const Type& type, int width) { emitLocalJump(validate, 8); } emitLocalJump(done); // Null is a storable/comparable value, not an address. - bindLabel(validate); emitAddressCheck(type, width); bindLabel(done); + bindLabel(validate); + emitAddressCheck(type, width, globallyOptimized() && !isFarPointer(type) + ? AddressComponents::NonNull : AddressComponents::Unknown); + bindLabel(done); } bool IRCodeGenerator::needsPointerSpill(const IRInstruction& instruction) const { @@ -113,6 +117,8 @@ std::string IRCodeGenerator::localLabel() { void IRCodeGenerator::bindLabel(const std::string& name) { m_spill_cache.clear(); m_accumulator_value = IRValueId{}; + m_last_ram_word_address = IRValueId{}; + // Alternative local paths must not accumulate each other's stack pops. m_stack_check_credit.reset(); m_object_file.symbol_table.push_back({name, SymbolSection::CODE, static_cast(m_object_file.code_section.size())}); @@ -138,14 +144,16 @@ void IRCodeGenerator::emitLocalJump(const std::string& name, std::uint8_t condit emitByte(1); } -void IRCodeGenerator::emitAddressCheck(const Type& pointer, int width) { +void IRCodeGenerator::emitAddressCheck(const Type& pointer, int width, AddressComponents known) { // R0 is the offset, R4 the bank for a far value. Checks leave both intact. // No bank is switched until every check succeeds, so failures cannot send // a subsequent stack access into the foreign data bank. - if (!isFarPointer(pointer)) { emitCompare(Register::R0, 0); emitGuard(OpCode::BNE, 2); } + const bool nonnull = known == AddressComponents::NonNull || known == AddressComponents::NonNullAligned; + const bool aligned = known == AddressComponents::Aligned || known == AddressComponents::NonNullAligned; + if (!isFarPointer(pointer) && !nonnull) { emitCompare(Register::R0, 0); emitGuard(OpCode::BNE, 2); } if (width > 1) { const auto alignment = storageAlignment(pointeeType(pointer)); - if (alignment > 1) { + if (alignment > 1 && !aligned) { // Verified type layouts have a power-of-two alignment <= 128. emitRegisterLiteral(3, static_cast(alignment - 1)); emitByte(0xb0); emitByte(0x13); emitByte(0x73); // R3 = R0 & (alignment - 1) emitGuard(OpCode::BEQ, 1); @@ -170,12 +178,23 @@ void IRCodeGenerator::emitAddressCheck(const Type& pointer, int width) { void IRCodeGenerator::emitWordSpillStore(IRValueId value, std::uint8_t source) { const auto cached = m_accumulator_value; + const auto offset = m_spill_offsets.at(value.value); + if (globallyOptimized() && m_last_ram_word_address.has_frame && + m_last_ram_word_address.frame_offset == offset) { + // Slot identity is physical, not SSA identity: noninterfering values + // may reuse one frame word. All bank/stack/CFG fences clear this latch. + if (source) emitByte(static_cast(0x20 | source)); + emitByte(0x90); + m_accumulator_value = cached; + return; + } m_last_ram_word_address = IRValueId{}; // Explicitly target R3: neither R0 nor the allocated source is changed. // In particular R6 remains available for a parallel-copy cycle. emitRegisterLiteral(3, static_cast(m_spill_offsets.at(value.value))); emitByte(0xb9); emitByte(0x13); emitByte(0x53); emitStore(Register::R3, source, false); + if (globallyOptimized()) m_last_ram_word_address.rememberFrame(offset); if (globallyOptimized()) m_accumulator_value = cached; } @@ -191,11 +210,11 @@ void IRCodeGenerator::ensureAddressChecked(IRValueId value, int width) { void IRCodeGenerator::emitSpill(IRValueId value, bool load) { const auto cached = m_accumulator_value; - m_last_ram_word_address = IRValueId{}; const auto& type = producer(value, Token(TokenType::UNKNOWN, "", 0, 0)).type; // Preserve the baseline and far-pair encodings, whose bank word also // participates in address selection. Optimized word stores keep R0. if (!load && optimized() && !isFarPointer(type)) { emitWordSpillStore(value, 0); return; } + m_last_ram_word_address = IRValueId{}; if (!load) emitMove(Register::R6_LMULT_HI, 0); emitRegisterLiteral(3, static_cast(m_spill_offsets.at(value.value))); emitByte(0xb9); emitByte(0x13); emitByte(0x53); // R3 = FP + signed frame displacement. @@ -204,7 +223,11 @@ void IRCodeGenerator::emitSpill(IRValueId value, bool load) { else emitStore(Register::R3, 4, false); emitByte(0xd3); emitByte(0xd3); } - if (load) emitByte(0x43); // offset/scalar -> R0 + if (load) { + emitByte(0x43); // offset/scalar -> R0 + if (globallyOptimized() && !isFarPointer(type)) + m_last_ram_word_address.rememberFrame(m_spill_offsets.at(value.value)); + } else { emitStore(Register::R3, 6, false); emitMove(Register::R0, 6); @@ -229,17 +252,79 @@ void IRCodeGenerator::retainSpillCopy(IRValueId value, bool after_load) { m_spill_cache.record(static_cast(reg), value); } +bool IRCodeGenerator::emitConstantPointerOffset(const IRInstruction& instruction) { + if (!optimized() || instruction.operands.size() != 2 || + (instruction.operation != "+" && instruction.operation != "-")) return false; + const auto& pointer = producer(instruction.operands[0], instruction.source).type; + if (isFarPointer(pointer)) return false; // Far byte stepping has canonical bank-window transitions. + std::int64_t index = 0; + if (!constantValue(instruction.operands[1], index)) return false; + index = ConstantEvaluator::convert(index, producer(instruction.operands[1], instruction.source).type); + const auto stride = instruction.immediate; + auto displacement = index * stride; // Verified scalar/stride bounds fit int64_t. + if (instruction.operation == "-") displacement = -displacement; + materialize(instruction.operands[0]); + ensureAddressChecked(instruction.operands[0], static_cast(stride)); + // Base representation errors must still precede an offset overflow. + if (displacement < -65535 || displacement > 65535) { emitAddressFault(3); return true; } + if (!displacement) return true; + const bool proved = globallyOptimized() && m_address_proof.provesOffset(instruction); + const bool subtract = displacement < 0; + const auto magnitude = static_cast(subtract ? -displacement : displacement); + if (magnitude <= 15) { + emitByte(0x3e); emitByte(static_cast((subtract ? 0x60 : 0x50) | magnitude)); + } else { + emitRegisterLiteral(3, magnitude); emitByte(subtract ? 0x63 : 0x53); + } + if (proved) return true; + emitGuard(subtract ? OpCode::BCS : OpCode::BCC, 3); + if (pointer.space == AddressSpace::ROM && subtract) { + const auto full = localLabel(); + emitNearBank(4, AddressSpace::ROM); emitCompare(Register::R4_LMULT_LO, 0x40); emitLocalJump(full, 13); + emitCompare(Register::R0, 0x8000); emitGuard(OpCode::BCS, 3); bindLabel(full); + } + auto known = AddressComponents::Unknown; + if (globallyOptimized() && pointer.space == AddressSpace::RAM && + magnitude % storageAlignment(pointeeType(pointer)) == 0) { + // The base has just been checked. An aligned displacement preserves + // its alignment; positive addition without carry cannot produce null. + // Subtraction may land exactly at zero, so its null guard remains. + known = subtract ? AddressComponents::Aligned : AddressComponents::NonNullAligned; + } + emitAddressCheck(pointer, static_cast(stride), known); + if (globallyOptimized() && pointer.space == AddressSpace::RAM && m_checked_addresses.size() < 1024) + m_checked_addresses[instruction.result.value] = static_cast(stride); + return true; +} + void IRCodeGenerator::emitPointerOffset(const IRInstruction& instruction) { + if (emitConstantPointerOffset(instruction)) return; const auto& pointer = producer(instruction.operands[0], instruction.source).type; const auto& index_type = producer(instruction.operands[1], instruction.source).type; const auto stride = static_cast(instruction.immediate); - materialize(instruction.operands[1]); - if (index_type.sizeInBytes == 1 && !index_type.is_unsigned) emitByte(0x95); - emitMove(Register::R6_LMULT_HI, 0); // Signed displacement/count, retained across pointer materialization. + const auto index = instruction.operands[1]; + const auto* index_home = m_register_allocator.find(index); + const bool direct_index = sizeOptimized() && instruction.operands.size() == 2 && + pointer.space == AddressSpace::RAM && !isFarPointer(pointer) && + index_type.base == BaseType::WORD && index_type.sizeInBytes == 2 && + index_type.pointer_level == 0 && index_type.array_size == 0 && + index.value != m_emitting_value.value && !m_ephemeral_values.count(index.value) && + regionalRegister(index) < 0 && usesRegisterAllocation(index) && index_home && + !index_home->rematerializable && !index_home->boundary_spill && m_address_proof.provesOffset(instruction); + if (direct_index) { + // The allocated word is authoritative, with no byte normalization, + // regional/frame home or deferred definition to materialize first. + // R6 survives the unchanged base materialization and address checks. + emitMove(Register::R6_LMULT_HI, index_home->physical_register); + } else { + materialize(index); + if (index_type.sizeInBytes == 1 && !index_type.is_unsigned) emitByte(0x95); + emitMove(Register::R6_LMULT_HI, 0); // Signed displacement/count, retained across pointer materialization. + } materialize(instruction.operands[0]); // Reloading a spill uses R3, not R6. All checked-address values are eager // frame values, so there can be no intervening call/rematerialized load. - if (!provenAddress(instruction.operands[0], pointer, stride)) emitAddressCheck(pointer, stride); + ensureAddressChecked(instruction.operands[0], stride); if (globallyOptimized() && m_address_proof.provesOffset(instruction)) { // The signed displacement is used as a raw word. Two's-complement // ADD/SUB is correct here because the proof excludes address wrap, @@ -263,7 +348,9 @@ void IRCodeGenerator::emitPointerOffset(const IRInstruction& instruction) { emitNearBank(4, AddressSpace::ROM); emitCompare(Register::R4_LMULT_LO, 0x40); emitLocalJump(full, 13); emitCompare(Register::R0, 0x8000); emitGuard(OpCode::BCS, 3); bindLabel(full); } - emitAddressCheck(pointer, stride); + emitAddressCheck(pointer, stride, + globallyOptimized() && pointer.space == AddressSpace::RAM && !subtract + ? AddressComponents::NonNull : AddressComponents::Unknown); if (globallyOptimized() && pointer.space == AddressSpace::RAM && !isFarPointer(pointer) && m_checked_addresses.size() < 1024) m_checked_addresses[instruction.result.value] = stride; return; @@ -298,7 +385,12 @@ void IRCodeGenerator::emitPointerOffset(const IRInstruction& instruction) { step(instruction.operation == "+"); } bindLabel(done); - emitAddressCheck(pointer, stride); + auto known = AddressComponents::Unknown; + if (globallyOptimized() && pointer.space == AddressSpace::RAM && + stride % storageAlignment(pointeeType(pointer)) == 0) + known = index_type.is_unsigned && instruction.operation == "+" + ? AddressComponents::NonNullAligned : AddressComponents::Aligned; + emitAddressCheck(pointer, stride, known); if (globallyOptimized() && pointer.space == AddressSpace::RAM && m_checked_addresses.size() < 1024) m_checked_addresses[instruction.result.value] = stride; return; diff --git a/src/IRStorageOptimizer.cpp b/src/IRStorageOptimizer.cpp new file mode 100644 index 0000000..c9a92fc --- /dev/null +++ b/src/IRStorageOptimizer.cpp @@ -0,0 +1,320 @@ +#include "IRStorageOptimizer.hpp" + +#include "GSUAddressProof.hpp" +#include "IRControlFlow.hpp" +#include +#include + +namespace { +constexpr std::size_t MaxSlots = 256, MaxBlocks = 256, MaxValues = 8192; +constexpr std::size_t MaxInstructions = 16000, MaxWork = 2000000; + +bool ramPointer(const Type& type) { + return type.pointer_level > 0 && type.space == AddressSpace::RAM; +} +bool integer(const Type& type) { + return type.pointer_level == 0 && + (type.base == BaseType::BYTE || type.base == BaseType::WORD || type.base == BaseType::BOOL); +} +int extent(const Type& type) { + if (type.sizeInBytes <= 0 || type.sizeInBytes > 65528 || type.array_size < 0 || + type.array_size > 65528 / type.sizeInBytes) return 0; + return type.sizeInBytes * std::max(1, type.array_size); +} +int reservation(const Type& type) { + const auto bytes = extent(type); + const auto alignment = std::max(std::max(2, type.alignment), storageAlignment(type)); + if (!bytes || alignment > 128 || (alignment & (alignment - 1))) return 0; + // Match emitAddress's upward rounding from an even FP-relative base. + return ((bytes + 1) / 2) * 2 + alignment - 2; +} +struct Provenance { + SymbolId symbol; + std::int64_t low = 0, high = 0; +}; +struct Access { + SymbolId symbol; + bool read = false, write = false, full_write = false; +}; +using Location = std::pair; +struct Analysis { + std::vector pointers; + std::vector> accesses; + std::map initializers; + std::set mutated, referenced; +}; + +bool analyze(const IRFunction& function, const Analyzer::LocalSymbolTable& locals, + const GSUAddressProof& ranges, Analysis& result) { + result.pointers.resize(static_cast(function.value_count) + 1); + result.accesses.resize(function.blocks.size()); + std::vector spaces(result.pointers.size(), AddressSpace::NONE); + // In a flat address model these may observe any stack byte. Do not infer + // noalias from an absent source-level &local expression. + for (const auto& parameter : function.parameters) + if (ramPointer(parameter.type)) return false; + for (const auto& block : function.blocks) for (const auto& instruction : block.instructions) { + if (instruction.result.isValid()) spaces.at(instruction.result.value) = instruction.type.space; + if (instruction.opcode == IROpcode::Call || instruction.opcode == IROpcode::Load || + instruction.opcode == IROpcode::Store || + (instruction.opcode == IROpcode::Address && instruction.operation == "temporary")) return false; + if (instruction.opcode == IROpcode::Address && instruction.operation.empty() && ramPointer(instruction.type)) { + const auto found = locals.find(instruction.symbol_id); + if (found == locals.end() || isFarPointer(instruction.type) || !extent(found->second.type)) return false; + result.pointers.at(instruction.result.value) = {instruction.symbol_id, 0, 0}; + result.referenced.insert(instruction.symbol_id); + } + } + // Only owned root/member/typed-offset paths retain provenance. There is + // no cast/PHI/pointer-load guess, and an unproved path freezes the plan. + for (unsigned round = 0; round < 64; ++round) { + bool changed = false; + for (const auto& block : function.blocks) for (const auto& instruction : block.instructions) { + if (!instruction.result.isValid() || !ramPointer(instruction.type) || + result.pointers.at(instruction.result.value).symbol.isValid() || instruction.operands.empty()) continue; + auto base = result.pointers.at(instruction.operands[0].value); + if (!base.symbol.isValid() || isFarPointer(instruction.type)) continue; + if (instruction.opcode == IROpcode::Address && instruction.operation == "member") { + base.low += instruction.immediate; base.high += instruction.immediate; + } else if (instruction.opcode == IROpcode::PointerOffset && instruction.operands.size() == 2 && + (instruction.operation == "+" || instruction.operation == "-")) { + std::int64_t low = 0, high = 0; + if (!ranges.scalarBounds(instruction.operands[1], block.id, low, high)) continue; + // Scalar facts are at most one word. Keep arithmetic bounded + // before multiplying even if this routine receives hostile IR. + if (low < -65535 || high > 65535 || instruction.immediate < 1 || instruction.immediate > 65528) return false; + low *= instruction.immediate; high *= instruction.immediate; + if (instruction.operation == "-") { const auto old = low; low = -high; high = -old; } + base.low += low; base.high += high; + } else continue; + const auto bytes = extent(locals.at(base.symbol).type); + // One-past arithmetic is harmless only if never dereferenced. + // Keeping it here still requires every terminal access below to + // fit completely, not just be aligned/non-null/in the RAM bank. + if (base.low < 0 || base.high > bytes || base.low > base.high) return false; + result.pointers.at(instruction.result.value) = base; changed = true; + } + if (!changed) break; + } + for (const auto& block : function.blocks) { + auto& accesses = result.accesses.at(block.id.value); + accesses.resize(block.instructions.size()); + for (std::size_t position = 0; position < block.instructions.size(); ++position) { + const auto& instruction = block.instructions[position]; + for (std::size_t operand = 0; operand < instruction.operands.size(); ++operand) { + const auto pointer = result.pointers.at(instruction.operands[operand].value); + if (!pointer.symbol.isValid()) continue; + const bool derived = operand == 0 && instruction.result.isValid() && + result.pointers.at(instruction.result.value).symbol.isValid() && + (instruction.opcode == IROpcode::Address || instruction.opcode == IROpcode::PointerOffset); + const bool memory = operand == 0 && (instruction.opcode == IROpcode::LoadIndirect || + instruction.opcode == IROpcode::StoreIndirect || instruction.opcode == IROpcode::MemoryInitialize); + if (!derived && !memory) return false; // identity, escape, or an unknown alias + } + if (instruction.opcode != IROpcode::LoadIndirect && instruction.opcode != IROpcode::StoreIndirect && + instruction.opcode != IROpcode::MemoryInitialize) continue; + const auto pointer_id = instruction.operands.at(0); + const auto pointer = result.pointers.at(pointer_id.value); + if (!pointer.symbol.isValid()) { + // ROM reads cannot alias local RAM; every untracked RAM read + // or write can, including numeric and incoming pointers. + const bool rom_read = instruction.opcode == IROpcode::LoadIndirect && spaces.at(pointer_id.value) == AddressSpace::ROM; + if (!rom_read) return false; + continue; + } + const auto& symbol = locals.at(pointer.symbol); + const auto bytes = extent(symbol.type); + std::int64_t width = instruction.type.sizeInBytes; + if (instruction.opcode == IROpcode::MemoryInitialize) + width *= static_cast(instruction.initialization_values.size()); + if (width <= 0 || width > bytes || pointer.low < 0 || pointer.high > bytes - width || + instruction.memory_volatile || symbol.type.is_volatile) return false; + auto& access = accesses[position]; access.symbol = pointer.symbol; + access.read = instruction.opcode == IROpcode::LoadIndirect; + access.write = !access.read; + access.full_write = access.write && pointer.low == 0 && pointer.high == 0 && width == bytes; + if (instruction.opcode == IROpcode::MemoryInitialize) { + if (!access.full_write || !result.initializers.emplace(pointer.symbol, Location{block.id.value, position}).second) + result.mutated.insert(pointer.symbol); + } else if (access.write) result.mutated.insert(pointer.symbol); + } + } + return true; +} + +bool initializedBeforeReads(const IRFunction& function, const Analysis& analysis, + const IRControlFlow& cfg, SymbolId symbol, Location initialization) { + for (const auto& block : function.blocks) { + const auto& accesses = analysis.accesses.at(block.id.value); + for (std::size_t position = 0; position < accesses.size(); ++position) { + const auto& access = accesses[position]; + if (access.symbol != symbol || !access.read) continue; + if (!cfg.dominates(initialization.first, block.id.value) || + (initialization.first == block.id.value && initialization.second >= position)) return false; + } + } + return true; +} + +bool optimize(IRFunction& function, const Analyzer::LocalSymbolTable& locals, IRStorageOptions options) { + if (function.storage_layout_valid) return false; // Already a final owned snapshot. + if (function.total_local_alloc_size < 0 || function.total_local_alloc_size > 65530 || function.total_local_alloc_size % 2) return false; + if (locals.size() > MaxSlots || function.blocks.size() > MaxBlocks || function.value_count > MaxValues) return false; + std::size_t instructions = 0; + for (const auto& block : function.blocks) { + if (block.instructions.size() > MaxInstructions - instructions) return false; + instructions += block.instructions.size(); + for (const auto& instruction : block.instructions) + if ((instruction.opcode == IROpcode::HardwareLoop || instruction.opcode == IROpcode::HardwareLoopEnd) && + instruction.targets.empty()) return false; + } + int original_depth = 0; + for (const auto& local : locals) { + if (!local.first.isValid() || local.second.id != local.first || !reservation(local.second.type)) return false; + if (local.second.stackOffset < 0) { + if (local.second.stackOffset < -65528 || local.second.stackOffset % 2) return false; + original_depth = std::max(original_depth, -local.second.stackOffset); + } + } + GSUAddressProof ranges; + ranges.run(function, locals, static_cast(function.total_local_alloc_size), true); + Analysis analysis; + if (!analyze(function, locals, ranges, analysis)) return false; + const IRControlFlow cfg(function); + + std::map pools; + std::set erased; + std::size_t pool_bytes = 0; + if (options.immutable_arrays == IRImmutableArrayPolicy::CopiedNearRamPayload) { + for (const auto& initializer : analysis.initializers) { + const auto& symbol = locals.at(initializer.first); + const auto& type = symbol.type; + if (symbol.stackOffset >= 0 || !integer(type) || type.array_size < 4 || type.array_size > 256 || + (type.sizeInBytes != 1 && type.sizeInBytes != 2) || + type.sizeInBytes != (type.base == BaseType::WORD ? 2 : 1) || + type.is_volatile || type.alignment > 2 || analysis.mutated.count(initializer.first) || + !initializedBeforeReads(function, analysis, cfg, initializer.first, initializer.second)) continue; + const auto& instruction = function.blocks.at(initializer.second.first).instructions.at(initializer.second.second); + if (instruction.initialization_values.size() != static_cast(type.array_size)) continue; + if (static_cast(extent(type)) > 65528 - pool_bytes) continue; + IRImmutableLocalArray pool; pool.type = type; + for (const auto value : instruction.initialization_values) { + pool.bytes.push_back(static_cast(value & 255)); + if (type.sizeInBytes == 2) pool.bytes.push_back(static_cast(value >> 8)); + } + pool_bytes += pool.bytes.size(); + pools.emplace(initializer.first, std::move(pool)); erased.insert(initializer.second); + } + } + + std::vector slots; + std::map indices; + Analyzer::LocalSymbolTable layout; + for (const auto& local : locals) { + if (local.second.stackOffset > 0) layout.emplace(local); + else if (analysis.referenced.count(local.first) && !pools.count(local.first)) { + indices.emplace(local.first, slots.size()); slots.push_back(local.first); layout.emplace(local); + } + } + // Whole-object content liveness. A partial store does not kill the object; + // even a dead store interferes with every live object it could overwrite. + using Live = std::set; + std::vector uses(function.blocks.size()), defs(function.blocks.size()); + std::vector live_in(function.blocks.size()), live_out(function.blocks.size()); + std::vector> conflicts(slots.size()); + for (const auto& block : function.blocks) for (const auto& access : analysis.accesses.at(block.id.value)) { + const auto slot = indices.find(access.symbol); + if (slot == indices.end()) continue; + if (access.read && !defs[block.id.value].count(slot->second)) uses[block.id.value].insert(slot->second); + if (access.full_write) defs[block.id.value].insert(slot->second); + } + std::size_t work = 0; bool changed = true; + while (changed) { + changed = false; + for (std::size_t reverse = function.blocks.size(); reverse > 0; --reverse) { + const auto block = reverse - 1; + Live out; + for (const auto successor : cfg.successors[block]) + out.insert(live_in[successor].begin(), live_in[successor].end()); + auto in = uses[block]; + for (const auto slot : out) if (!defs[block].count(slot)) in.insert(slot); + work += out.size() + in.size() + 1; + if (work > MaxWork) return false; + if (in != live_in[block] || out != live_out[block]) { + live_in[block] = std::move(in); live_out[block] = std::move(out); changed = true; + } + } + } + const auto interfere = [&](std::size_t a, std::size_t b) { + if (a != b) { conflicts[a].insert(b); conflicts[b].insert(a); } + }; + for (const auto& block : function.blocks) { + auto live = live_out[block.id.value]; + for (const auto a : live) for (const auto b : live) interfere(a, b); + const auto& accesses = analysis.accesses.at(block.id.value); + for (std::size_t reverse = accesses.size(); reverse > 0; --reverse) { + const auto& access = accesses[reverse - 1]; const auto slot = indices.find(access.symbol); + if (slot == indices.end()) continue; + if (access.write) for (const auto other : live) interfere(slot->second, other); + if (access.full_write) live.erase(slot->second); + if (access.read) { + for (const auto other : live) interfere(slot->second, other); + live.insert(slot->second); + } + } + } + struct Home { int depth, bytes; std::vector occupants; }; + std::vector homes; int depth = 0; + for (std::size_t slot = 0; slot < slots.size(); ++slot) { + auto& symbol = layout.at(slots[slot]); const auto bytes = reservation(symbol.type); + Home* selected = nullptr; + for (auto& home : homes) { + if (home.bytes < bytes) continue; + bool available = true; + for (const auto occupant : home.occupants) available = available && !conflicts[slot].count(occupant); + if (available) { selected = &home; break; } + } + if (!selected) { + if (bytes > 65528 - depth) return false; + depth += bytes; homes.push_back({depth, bytes, {}}); selected = &homes.back(); + } + selected->occupants.push_back(slot); symbol.stackOffset = -selected->depth; + } + // Preserve explicit/unexplained frame reservations (used by checked-frame + // clients), while reclaiming only the analyzer's known local area. + const auto known_frame = original_depth ? original_depth + 2 : 0; + const auto extra = std::max(0, function.total_local_alloc_size - known_frame); + if (depth > 65528 - extra) return false; + const auto frame = depth + extra > 0 ? depth + extra + 2 : 0; + if (frame > function.total_local_alloc_size) return false; + + function.storage_layout = std::move(layout); + function.immutable_local_arrays = std::move(pools); + function.total_local_alloc_size = frame; + function.storage_layout_valid = true; + for (auto& block : function.blocks) { + auto& code = block.instructions; + for (std::size_t reverse = code.size(); reverse > 0; --reverse) + if (erased.count(Location{block.id.value, reverse - 1})) + code.erase(code.begin() + static_cast::difference_type>(reverse - 1)); + } + return true; +} +} + +void IRStorageOptimizer::run(IRModule& module, + const std::map& original_locals, IRStorageOptions options) { + IRVerifier::verify(module); + if (module.target != TargetKind::GSU) return; + for (auto& function : module.functions) { + const auto locals = original_locals.find(function.name); + const Analyzer::LocalSymbolTable empty; + try { optimize(function, locals == original_locals.end() ? empty : locals->second, options); } + catch (const CompilerError&) { + // Optional bounded CFG/range analysis failure retains the original + // function. All planning mutations are local until commit above. + if (function.storage_layout_valid) throw; + } + } + IRVerifier::verify(module); +} diff --git a/src/IRStorageOptimizer.hpp b/src/IRStorageOptimizer.hpp new file mode 100644 index 0000000..e27ab02 --- /dev/null +++ b/src/IRStorageOptimizer.hpp @@ -0,0 +1,23 @@ +#pragma once + +#include "IR.hpp" + +enum class IRImmutableArrayPolicy { Disabled, CopiedNearRamPayload }; + +struct IRStorageOptions { + // Enabled only when the backend/linker can represent private DATA in the + // copied payload's near RAM bank. This never requests ROM-buffer reads. + IRImmutableArrayPolicy immutable_arrays = IRImmutableArrayPolicy::Disabled; +}; + +// Final, candidate-owned storage plan. No analyzer table is mutated. Unknown +// flat-address aliases retain the entire original layout; there is no implied +// restrict/provenance rule. Repeated calls on an unchanged final plan are +// idempotent. After any IR/CFG mutation, discard the plan AND restore the +// original unplanned IR before rebuilding: pooled initializers were removed. +class IRStorageOptimizer { +public: + static void run(IRModule& module, + const std::map& original_locals, + IRStorageOptions options = {}); +}; diff --git a/src/LinearScanAllocator.cpp b/src/LinearScanAllocator.cpp index a7d1bef..cfa7ae2 100644 --- a/src/LinearScanAllocator.cpp +++ b/src/LinearScanAllocator.cpp @@ -4,6 +4,7 @@ #include #include +#include #include #include @@ -466,9 +467,22 @@ std::vector LinearScanAllocator::spareRegisters(std::size_t positi return found == m_spare_registers.end() ? std::vector{} : found->second; } +const std::vector& LinearScanAllocator::boundaryStores(std::uint32_t block) const { + static const std::vector empty; + const auto found = m_boundary_store.find(block); + return found == m_boundary_store.end() ? empty : found->second; +} + +const std::vector& LinearScanAllocator::boundaryReloads(std::uint32_t block) const { + static const std::vector empty; + const auto found = m_boundary_reload.find(block); + return found == m_boundary_reload.end() ? empty : found->second; +} + void LinearScanAllocator::runGlobalImpl(const IRFunction& function, const std::vector& allocatable_registers, bool cost_priority, bool size_policy) { m_locations.clear(); m_call_live.clear(); m_spare_registers.clear(); + m_boundary_store.clear(); m_boundary_reload.clear(); IRControlFlow cfg(function); std::vector begin(function.blocks.size()), end(function.blocks.size()); std::map weight; @@ -532,9 +546,34 @@ void LinearScanAllocator::runGlobalImpl(const IRFunction& function, if (position) --position; } } + // Same-depth uses tie. Prefer the carried value over an older invariant + // with the same weight; do not multiply the weight, or a rarely used + // induction evicts a value reloaded on every trip. + std::set inductions; + if (!size_policy) { + for (const auto& loop : cfg.loops) { + for (const auto& phi : function.blocks[loop.header].instructions) { + if (phi.opcode != IROpcode::Phi) break; + bool carried = false; + IRValueId update; + for (std::size_t n = 0; n < phi.operands.size() && n < phi.targets.size(); ++n) { + if (!loop.latches.count(phi.targets[n].value)) continue; + carried = true; + update = phi.operands[n]; + } + if (!carried) continue; + inductions.insert(phi.result.value); + if (update.isValid()) inductions.insert(update.value); + } + } + } std::vector registers; for (const auto reg : allocatable_registers) { - if (reg != 5 && reg != 7 && reg != 8) throw std::runtime_error("Global allocator: register has a fixed GSU/ABI role."); + // R0/R1/R3 stay backend scratch, R6 stays the cross-value temporary, + // and R9-R13/R15 stay ABI or hardware registers. The caller decides + // which of R2/R4/R14 are actually free in this function. + if (reg != 2 && reg != 4 && reg != 5 && reg != 7 && reg != 8 && reg != 14) + throw std::runtime_error("Global allocator: register has a fixed GSU/ABI role."); if (std::find(registers.begin(), registers.end(), reg) == registers.end()) registers.push_back(reg); } std::sort(registers.begin(), registers.end()); @@ -542,6 +581,8 @@ void LinearScanAllocator::runGlobalImpl(const IRFunction& function, for (const auto& entry : m_locations) if (!entry.second.rematerializable && !far.count(entry.first) && weight.count(entry.first)) ordered.push_back(entry.first); std::sort(ordered.begin(), ordered.end(), [&](std::uint32_t a, std::uint32_t b) { if (weight.at(a) != weight.at(b)) return weight.at(a) > weight.at(b); + const bool a_induction = inductions.count(a) != 0, b_induction = inductions.count(b) != 0; + if (a_induction != b_induction) return a_induction; if (m_locations.at(a).start != m_locations.at(b).start) return m_locations.at(a).start < m_locations.at(b).start; return a < b; }); @@ -578,7 +619,25 @@ void LinearScanAllocator::runGlobalImpl(const IRFunction& function, affinity[edgeKey(i->result.value, v.value)] += size_policy ? 1 : GSUCostModel::blockWeight(cfg.loop_depth.at(i->targets.at(n).value)); } - } else for (const auto v : i->operands) if (eligible(v.value)) live.insert(v.value); + } else { + // Two-address GSU forms need only WITH when the dying input + // and result share a home. Preferences never remove an edge: + // a still-live input continues to interfere with its result. + const bool tied = (i->opcode == IROpcode::Unary && + (i->operation == "~" || i->operation == "!" || i->operation == "-")) || + (i->opcode == IROpcode::Cast && i->type.pointer_level == 0) || + (i->opcode == IROpcode::Binary && (i->operation == "+" || i->operation == "-" || + i->operation == "&" || i->operation == "|" || i->operation == "^")); + if (tied && i->result.isValid() && !i->operands.empty()) { + const auto input = i->operands.front().value, output = i->result.value; + if (input != output && eligible(input) && eligible(output)) { + preferences[output].insert(input); preferences[input].insert(output); + affinity[edgeKey(output, input)] += size_policy ? 1 : + GSUCostModel::blockWeight(cfg.loop_depth.at(block.id.value)); + } + } + for (const auto v : i->operands) if (eligible(v.value)) live.insert(v.value); + } } } for (const auto value : ordered) { @@ -593,9 +652,17 @@ void LinearScanAllocator::runGlobalImpl(const IRFunction& function, } // Incoming backedges are hot; ascending SSA IDs otherwise favor a cold // preheader copy. Ranking changes only choices, never interference. + // Equal affinity keeps the historical R5/R7/R8 homes. The widened + // registers are chosen only when one of those three is occupied. + const auto preference = [](std::uint8_t reg) { + if (reg == 5) return 0; + if (reg == 7) return 1; + if (reg == 8) return 2; + return 100 + static_cast(reg); + }; std::sort(choices.begin(), choices.end(), [&](std::uint8_t a, std::uint8_t b) { if (scores.at(a) != scores.at(b)) return scores.at(a) > scores.at(b); - return a < b; + return preference(a) < preference(b); }); for (const auto reg : choices) { bool occupied = false; diff --git a/src/LinearScanAllocator.hpp b/src/LinearScanAllocator.hpp index f1913ca..5bb945a 100644 --- a/src/LinearScanAllocator.hpp +++ b/src/LinearScanAllocator.hpp @@ -11,6 +11,9 @@ struct LinearScanLocation { bool has_register = false; bool rematerializable = false; + // Live in a register outside a loop whose body reuses that register. + // The frame slot is only for the entry store and the exit reload. + bool boundary_spill = false; std::uint8_t physical_register = 0; std::size_t start = 0; std::size_t end = 0; @@ -39,6 +42,12 @@ class LinearScanAllocator { const LinearScanLocation* find(IRValueId value) const; const std::map& locations() const; + // Values to store at the end of a loop preheader, before the body reuses + // their registers. Empty when this block is not such a preheader. + const std::vector& boundaryStores(std::uint32_t block) const; + // Values to reload at the start of a block reached only by leaving a loop + // that reused their registers. + const std::vector& boundaryReloads(std::uint32_t block) const; // Registers with no live assigned value at this IR point. Includes result // interference and PHI edge uses; never lends special hardware registers. std::vector spareRegisters(std::size_t position) const; @@ -49,4 +58,6 @@ class LinearScanAllocator { std::map m_locations; std::map> m_call_live; std::map> m_spare_registers; + std::map> m_boundary_store; + std::map> m_boundary_reload; }; diff --git a/src/LinkerDriver.cpp b/src/LinkerDriver.cpp index 88b24ca..7ec41a6 100644 --- a/src/LinkerDriver.cpp +++ b/src/LinkerDriver.cpp @@ -432,6 +432,10 @@ int runLinker(std::vector arguments) { throw std::runtime_error("Linker Error: Undefined local symbol '" + local_name + "'."); } const auto* local_symbol = local->second; + if (reloc.target_symbol_name.compare(0, std::string(GSUAbi::ImmutablePoolPrefix).size(), + GSUAbi::ImmutablePoolPrefix) == 0 && (local_symbol->section != SymbolSection::DATA || + local_symbol->offset >= obj.data_section.size())) + throw std::runtime_error("Immutable local-array pool requires a nonempty private DATA target."); target_section = local_symbol->section; target_is_instruction = target_section == SymbolSection::CODE && local_symbol->offset < obj.code_section.size(); const auto base = local_symbol->section == SymbolSection::CODE @@ -472,7 +476,14 @@ int runLinker(std::vector arguments) { } if (config.target == TargetKind::GSU) { if (reloc.type == RelocationType::ADDR16_RAM) { - if (target_section != SymbolSection::RAM) + const bool pool = reloc.target_symbol_name.compare(0, std::string(GSUAbi::ImmutablePoolPrefix).size(), + GSUAbi::ImmutablePoolPrefix) == 0; + if (pool && (target_section != SymbolSection::DATA || + GsuMemoryMap::region(config.code_start_address) != GsuMemoryMap::Region::Ram || + GsuMemoryMap::region(target_addr) != GsuMemoryMap::Region::Ram || + (config.code_start_address >> 16) != 0x70u + ram_bank)) + throw std::runtime_error("Immutable local-array pool requires copied RAM payload placement in the selected near RAM bank."); + if (target_section != SymbolSection::RAM && !pool) throw std::runtime_error("RAM relocation must target static RAM or a stack limit."); if ((target_addr >> 16) != 0x70u + ram_bank) throw std::runtime_error("RAM relocation target is outside the near RAM bank."); diff --git a/src/ProjectBuild.cpp b/src/ProjectBuild.cpp index 3400313..42d91e1 100644 --- a/src/ProjectBuild.cpp +++ b/src/ProjectBuild.cpp @@ -31,6 +31,7 @@ void projectUsage() { " --host-initialized-globals | --no-host-initialized-globals\n" " --output-dir -o --emit-asm --no-emit-asm\n" " --check (frontend/IR only, no outputs or linking)\n" + " --require-no-spills (GSU source allocation check, also in --check; not prebuilt objects)\n" " -O | -O1 | -O0 | -O2 | -Os (compiler optimization; CLI overrides manifest defaults)\n" " -Wall | -Werror | -W | -Wno-\n"; } @@ -41,7 +42,7 @@ int buildProject(const std::vector& arguments) { try { if (arguments.size() < 2 || arguments.size() > 1024) throw std::runtime_error("Invalid project command."); std::string path, output_directory; - bool check = false; + bool check = false, require_no_spills = false; std::vector compile_flags, link_flags; const std::set shared = {"--target", "--origin", "--memory-mapping", "--execution-memory"}; const std::set link_values = {"--ram-bank", "--ram-origin", "--rom-bank", "--stack-pointer", "--entry", "--emit-asm", "-o"}; @@ -62,7 +63,10 @@ int buildProject(const std::vector& arguments) { } else if (flag.compare(0, 2, "-I") == 0) compile_flags.insert(compile_flags.end(), {"--import-path", absolutePath(flag.substr(2))}); else if (flag == "--check") check = true; - else if (shared.count(flag)) { + else if (flag == "--require-no-spills") { + require_no_spills = true; + compile_flags.push_back(flag); + } else if (shared.count(flag)) { const auto value = next(); compile_flags.insert(compile_flags.end(), {flag, value}); link_flags.insert(link_flags.end(), {flag, value}); @@ -98,6 +102,8 @@ int buildProject(const std::vector& arguments) { else if (compile[index] == "--import-path" || compile[index] == "-I") import_paths.push_back(absolutePath(compile.at(++index))); else if (optionTakesValue(compile[index], Consumer::Compiler)) ++index; } + if (require_no_spills && target != TargetKind::GSU) + throw std::runtime_error("Target 'spc700' does not support --require-no-spills: no code-generation backend is available."); if (!check && target != TargetKind::GSU) throw std::runtime_error("Target 'spc700' has a target model but no code-generation backend yet; use build --check."); CompilerConfig module_config; module_config.target = target; diff --git a/src/TargetConfig.hpp b/src/TargetConfig.hpp index c16e0e9..53833f4 100644 --- a/src/TargetConfig.hpp +++ b/src/TargetConfig.hpp @@ -56,6 +56,8 @@ struct CompilerConfig { std::uint32_t code_start_address = 0x8000; bool optimize_loop_setup = false; OptimizationLevel optimization = OptimizationLevel::Baseline; + // Invocation-only allocation constraint, not serialized placement/ABI metadata. + bool require_no_spills = false; bool warn_on_cache_overflow = true; BitmapConfig bitmap; }; diff --git a/tests/GSUCheckedProofTests.cpp b/tests/GSUCheckedProofTests.cpp index 044f21d..4dce6c2 100644 --- a/tests/GSUCheckedProofTests.cpp +++ b/tests/GSUCheckedProofTests.cpp @@ -3,6 +3,10 @@ #include "Lexer.hpp" #include "Parser.hpp" #include "IRGlobalOptimizer.hpp" +#include "IRCodeGenerator.hpp" +#include "GSUInstructionModel.hpp" +#include "ABI.hpp" +#include "AssemblyGenerator.hpp" #include #include @@ -251,11 +255,373 @@ void verifiedClearLoop() { } require(offsets >= 2, "Clear-loop fixture no longer exercises both absolute RAM and frame-backed arrays"); } + +std::vector executable(const ObjectFile& object, std::uint16_t stack_floor = 0) { + auto bytes = object.code_section; + for (const auto& r : object.relocation_table) { + std::uint32_t address = 0; bool found = false; + const std::string prefix = GSUAbi::StackLimitPrefix; + if (r.target_symbol_name.compare(0,prefix.size(),prefix) == 0) { + address = static_cast(std::stoul(r.target_symbol_name.substr(prefix.size()))); + require(address <= 65535u - stack_floor, "Checked fixture stack limit exceeds one bank"); + address += stack_floor; found = true; + } else if (r.target_symbol_name == GSUAbi::NearRamBankSymbol) { address = 0x70; found = true; } + else if (r.target_symbol_name == GSUAbi::NearRomBankSymbol) { address = 0; found = true; } + for (const auto& s : object.symbol_table) if (s.name == r.target_symbol_name && s.section == SymbolSection::CODE) { + address = 0x8000 + s.offset; found = true; + } + require(found, "Missing checked-offset fixture relocation"); + bytes.at(r.patch_offset + 1) = static_cast(address); + if (r.type != RelocationType::ADDR24_BANK) bytes.at(r.patch_offset + 2) = static_cast(address >> 8); + } + return bytes; +} + +unsigned entryStackBytes(const ObjectFile& object) { + const std::string prefix = GSUAbi::StackLimitPrefix; + for (const auto& r : object.relocation_table) { + if (r.target_symbol_name.compare(0, prefix.size(), prefix) == 0) { + const auto bytes = std::stoul(r.target_symbol_name.substr(prefix.size())); + require(bytes >= 4 && bytes < 128, "Stack-boundary fixture lost its bounded entry frame"); + return static_cast(bytes); + } + } + throw std::runtime_error("Stack-boundary fixture has no entry capacity guard"); +} + +void seedWord(DiscoGSU::Machine& machine, std::uint32_t address, std::uint16_t value) { + machine.seed(address, static_cast(value), false); + machine.seed(address + 1, static_cast(value >> 8), false); +} + +void stackCreditControlFlowExecution() { + const std::string conditional = + "word main(){word n=*(volatile word*)0x100;word c=*(volatile word*)0x102;word s=0;" + "while(n>0){*(volatile word*)0x110=11;" + "if(c){s+=n/(*(volatile word*)0x104);}else{s+=n/(*(volatile word*)0x106);}" + "*(volatile word*)0x112=22;n--;}return s;}"; + const std::string far_join = + "word main(){word n=*(volatile word*)0x100;" + "far word* p=*(word* far volatile*)0x108;far word* q=*(word* far volatile*)0x10c;word s=0;" + "while(n>0){plot{s+=(word)(p==q);*(volatile word*)0x110=11;" + "s+=n/(*(volatile word*)0x104);*(volatile word*)0x112=22;}n--;}return s;}"; + unsigned tested = 0; + for (const bool far : {false, true}) { + Lexer lexer(far ? far_join : conditional); const auto tokens = lexer.scanTokens(); + Parser parser(tokens); auto ast = parser.parseProgram(); + DataSegmentManager data; Analyzer analyzer(data); analyzer.analyze(ast); + IRLowerer lowerer; const auto module = lowerer.lower(ast); + // O1 is the unaffected local backend; all three policies must satisfy + // the same ABI contract relative to their own final emitted frame. + for (const auto level : {OptimizationLevel::O1, OptimizationLevel::O2, OptimizationLevel::Size}) { + CompilerConfig config; config.optimization = level; + IRCodeGenerator generator(analyzer.getAllLocalSymbols(), analyzer.getFunctionSymbols(), data, config); + const auto object = generator.generate(module); + const auto entry_bytes = entryStackBytes(object); + for (const std::uint16_t floor : {std::uint16_t{0}, std::uint16_t{64}}) + for (const unsigned trips : {0u, 1u, 6u}) + for (unsigned arm = 0; arm < (far ? 3u : 2u); ++arm) + for (const bool negative_divisor : {false, true}) + for (const unsigned boundary : {0u, 2u, 4u}) { + // Far comparison needs four bytes itself. In + // plot, the following signed division needs + // eight (coordinates plus two sign words). + const auto available = boundary + (far ? 4u : 0u); + const auto required = far ? 8u : 4u; + const auto initial = floor + entry_bytes + available; + require(initial + 2 < 0x100, "Stack-boundary fixture overlaps its input ports"); + DiscoGSU::Machine machine(executable(object, floor), 0x8000, + static_cast(initial)); + const int divisor = negative_divisor ? -3 : 3; + const int other_divisor = negative_divisor ? -5 : 5; + seedWord(machine, 0x700100, static_cast(trips)); + seedWord(machine, 0x700102, static_cast(arm)); + seedWord(machine, 0x700104, static_cast(divisor)); + seedWord(machine, 0x700106, static_cast(other_divisor)); + // Far pointer slots store bank first, then offset. + seedWord(machine, 0x700108, 0x70); seedWord(machine, 0x70010a, 0x200); + seedWord(machine, 0x70010c, arm == 2 ? 0x71 : 0x70); + seedWord(machine, 0x70010e, arm == 1 ? 0x202 : 0x200); + seedWord(machine, 0x700110, 0x33cc); seedWord(machine, 0x700112, 0x9669); + const auto below = floor ? floor - 2u : 0xfffeu; + seedWord(machine, 0x700000u + floor, 0x5aa5); + seedWord(machine, 0x700000u + below, 0xc33c); + seedWord(machine, 0x700000u + initial + 2u, 0x6996); + seedWord(machine, 0x710000u + floor, 0x3cc3); + machine.run(); + const bool fault = trips != 0 && available < required; + const auto context = std::string(far ? "far compare join" : "conditional arm") + + " level=" + std::to_string(static_cast(level)) + + " floor=" + std::to_string(floor) + " trips=" + std::to_string(trips) + + " arm=" + std::to_string(arm) + " negative=" + std::to_string(negative_divisor) + + " available=" + std::to_string(available); + const auto check = [&](bool ok, const char* message) { + if (!ok) throw std::runtime_error(context + ": " + message); + }; + check(machine.reg(6) == (fault ? 2 : 0), "stack fault category changed"); + check(machine.reg(10) == (fault ? floor : initial - 4), "stack failure/return SP changed"); + check(machine.ramBank() == 0 && machine.romBank() == 0, "stack guard changed a bank"); + check(machine.word(0x700110) == (trips ? 11 : 0x33cc), "guard moved before its preceding witness"); + check(machine.word(0x700112) == (trips && !fault ? 22 : 0x9669), "guard moved after its following witness"); + check(machine.accesses(0x700110, true) == (fault ? 1u : trips) && + machine.accesses(0x700112, true) == (fault ? 0u : trips), "volatile witness count changed"); + const auto divisions = fault ? 1u : trips; + check(machine.accesses(0x700104, false) == (far || arm ? divisions : 0u) && + machine.accesses(0x700106, false) == (!far && !arm ? divisions : 0u), + "guard crossed or duplicated a selected volatile divisor read"); + check(machine.word(0x700000u + floor) == 0x5aa5 && + machine.word(0x700000u + below) == 0xc33c && + machine.word(0x700000u + initial + 2u) == 0x6996 && + machine.word(0x710000u + floor) == 0x3cc3, "stack boundary/foreign-bank canary changed"); + for (const auto address : {0x700000u + floor, 0x700000u + below, + 0x700000u + initial + 2u, 0x710000u + floor}) + check(machine.accesses(address, true) == 0, "stack boundary/foreign-bank canary was written"); + if (!fault) { + int expected = 0; + for (unsigned n = trips; n > 0; --n) + expected += static_cast(n) / (far || arm ? divisor : other_divisor) + + (far && arm == 0 ? 1 : 0); + check(machine.reg(0) == static_cast(expected), "original loop result changed"); + } + ++tested; + } + } + } + std::cout << "Stack-credit CFG/local joins: " << tested << " execution boundary cases\n"; +} + +void constantOffsetExecution() { + for (const auto& offset : {std::string("0"), std::string("1"), std::string("-1"), std::string("32767"), + std::string("-32768"), std::string("(unsigned word)65535")}) { + for (const bool subtract : {false,true}) { + const std::string source = "word main(){word* p=(word*)*(volatile unsigned word*)0x100;" + "*(volatile word*)0x102=11;word* q=p" + std::string(subtract ? "-" : "+") + "(" + offset + + ");*(volatile word*)0x104=22;return (word)(unsigned word)q;}"; + Lexer lexer(source); const auto tokens=lexer.scanTokens(); Parser parser(tokens); auto ast=parser.parseProgram(); + DataSegmentManager data; Analyzer analyzer(data); analyzer.analyze(ast); IRLowerer lowerer; const auto module=lowerer.lower(ast); + CompilerConfig config; config.optimization=OptimizationLevel::Baseline; + IRCodeGenerator baseline(analyzer.getAllLocalSymbols(),analyzer.getFunctionSymbols(),data,config); + const auto original=executable(baseline.generate(module)); + for (const auto level : {OptimizationLevel::O1,OptimizationLevel::O2,OptimizationLevel::Size}) { + config.optimization=level; IRCodeGenerator generator(analyzer.getAllLocalSymbols(),analyzer.getFunctionSymbols(),data,config); + const auto optimized=executable(generator.generate(module)); + for (const unsigned address : {0u,1u,2u,4u,0x7ffeu,0x8000u,0xfffcu,0xfffeu,0xffffu}) { + DiscoGSU::Machine a(original,0x8000),b(optimized,0x8000); + for (auto* m : {&a,&b}) { m->seed(0x700100,static_cast(address),false); + m->seed(0x700101,static_cast(address >> 8),false); m->run(); } + require(a.reg(6)==b.reg(6),"Literal pointer offset changed null/alignment/wrap fault priority"); + require(a.reg(6)!=0 || a.reg(0)==b.reg(0),"Literal pointer offset changed its successful result"); + require(a.word(0x700102)==b.word(0x700102) && a.word(0x700104)==b.word(0x700104), + "Literal pointer offset moved a trap across an observable store"); + } + } + } + } } + +void pointerComponentExecution() { + for (const bool unsigned_index : {false,true}) for (const bool subtract : {false,true}) { + const std::string index_type = unsigned_index ? "unsigned word" : "word"; + const std::string source = "word main(){word* p=(word*)*(volatile unsigned word*)0x100;" + index_type + + " i=*(volatile " + index_type + "*)0x102;*(volatile word*)0x104=11;word* q=p" + + (subtract ? "-" : "+") + "i;*(volatile word*)0x106=22;return (word)(unsigned word)q;}"; + Lexer lexer(source); const auto tokens=lexer.scanTokens(); Parser parser(tokens); auto ast=parser.parseProgram(); + DataSegmentManager data; Analyzer analyzer(data); analyzer.analyze(ast); IRLowerer lowerer; const auto module=lowerer.lower(ast); + CompilerConfig config; config.optimization=OptimizationLevel::Baseline; + IRCodeGenerator baseline(analyzer.getAllLocalSymbols(),analyzer.getFunctionSymbols(),data,config); + const auto original=executable(baseline.generate(module)); + for (const auto level : {OptimizationLevel::O2,OptimizationLevel::Size}) { + config.optimization=level; IRCodeGenerator generator(analyzer.getAllLocalSymbols(),analyzer.getFunctionSymbols(),data,config); + const auto optimized=executable(generator.generate(module)); + for (const unsigned address : {0u,1u,2u,4u,0x7ffeu,0x8000u,0xfffcu,0xfffeu,0xffffu}) + for (const unsigned index : {0u,1u,2u,32767u,32768u,65535u}) { + DiscoGSU::Machine a(original,0x8000), b(optimized,0x8000); + for (auto* m : {&a,&b}) { + m->seed(0x700100,static_cast(address),false); + m->seed(0x700101,static_cast(address >> 8),false); + m->seed(0x700102,static_cast(index),false); + m->seed(0x700103,static_cast(index >> 8),false); m->run(); + } + require(a.reg(6)==b.reg(6) && (a.reg(6) || a.reg(0)==b.reg(0)), + "Component check changed signed/unsigned pointer alignment, null or overflow priority"); + require(a.word(0x700104)==b.word(0x700104) && a.word(0x700106)==b.word(0x700106), + "Component check moved a pointer fault across an observable witness"); + } + } + } + for (const auto& address : {std::string("0x700000"),std::string("0x710000")}) { + const auto source = "word main(){*(volatile word*)0x104=11;far word* p=(far word*)" + address + + ";word* q=(word*)p;*(volatile word*)0x106=22;return (word)(unsigned word)q;}"; + Lexer lexer(source); const auto tokens=lexer.scanTokens(); Parser parser(tokens); auto ast=parser.parseProgram(); + DataSegmentManager data; Analyzer analyzer(data); analyzer.analyze(ast); IRLowerer lowerer; const auto module=lowerer.lower(ast); + for (const auto level : {OptimizationLevel::Baseline,OptimizationLevel::O2,OptimizationLevel::Size}) { + CompilerConfig config; config.optimization=level; + IRCodeGenerator generator(analyzer.getAllLocalSymbols(),analyzer.getFunctionSymbols(),data,config); + DiscoGSU::Machine machine(executable(generator.generate(module)),0x8000); machine.run(); + require(machine.reg(6)==(address=="0x700000" ? 2 : 4) && machine.word(0x700104)==11 && machine.word(0x700106)==0, + "Non-null far pair incorrectly proved its zero offset non-null during narrowing"); + } + } +} + +void pointerSnapshotExecution() { + for (const std::string source : { + "word main(){word a=7;word b=11;word* p=&a;word** alias=&p;word* old=*alias;*alias=&b;return *old+*p;}", + "word step();word main(){word* p=(word*)*(volatile unsigned word*)0x100;word before=*p;word effect=step();return before+effect+*p;}" + "word step(){*(volatile word*)0x200=19;return 3;}", + "word main(){volatile word* p=(volatile word*)*(volatile unsigned word*)0x100;word before=*p;*p=19;return before+*p;}"}) { + Lexer lexer(source); const auto tokens=lexer.scanTokens(); Parser parser(tokens); auto ast=parser.parseProgram(); + DataSegmentManager data; Analyzer analyzer(data); analyzer.analyze(ast); IRLowerer lowerer; const auto module=lowerer.lower(ast); + CompilerConfig config; config.optimization=OptimizationLevel::Baseline; + IRCodeGenerator baseline(analyzer.getAllLocalSymbols(),analyzer.getFunctionSymbols(),data,config); + const auto original=executable(baseline.generate(module)); + for (const auto level : {OptimizationLevel::O2,OptimizationLevel::Size}) { + config.optimization=level; IRCodeGenerator generator(analyzer.getAllLocalSymbols(),analyzer.getFunctionSymbols(),data,config); + const auto optimized=executable(generator.generate(module)); + for (const unsigned address : {0u,1u,0x200u,0xfffeu}) { + DiscoGSU::Machine a(original,0x8000),b(optimized,0x8000); + for (auto* m : {&a,&b}) { + m->seed(0x700100,static_cast(address),false); + m->seed(0x700101,static_cast(address >> 8),false); + m->seed(0x700200,7,false); m->run(); + } + require(a.reg(6)==b.reg(6) && (a.reg(6) || a.reg(0)==b.reg(0)) && a.word(0x700200)==b.word(0x700200), + "Near pointer snapshot escaped an alias, call or volatile fence"); + if (std::string(source).find("volatile word* p=") != std::string::npos && address==0x200) + require(b.accesses(0x700200,false)==2 && b.accesses(0x700200,true)==1 && b.reg(0)==26, + "Near pointer forwarding eliminated a volatile access"); + } + } + } +} + +void multiplyProofsAndExecution() { + Builder b; + const auto input=b.add(IROpcode::Call,word()), full=b.literal(32767), zero=b.literal(0); + const auto low=b.add(IROpcode::Binary,word(),{input,b.literal(127)},"&"); + const auto signed_byte=b.add(IROpcode::Binary,word(),{low,b.literal(64)},"-"); + const auto unsigned_byte=b.add(IROpcode::Binary,word(),{input,b.literal(255)},"&"); + const auto signed_product=b.add(IROpcode::Binary,word(),{signed_byte,low},"*"); + const auto unsigned_product=b.add(IROpcode::Binary,word(),{unsigned_byte,unsigned_byte},"*"); + const auto mixed=b.add(IROpcode::Binary,word(),{signed_byte,unsigned_byte},"*"); + const auto small_product=b.add(IROpcode::Binary,word(),{full,zero},"*"); b.run(); + const auto kind=[&](IRValueId v){return b.proof.multiplyKind(b.function.blocks[0].instructions.at(v.value-1));}; + require(kind(signed_product)==GSUAddressProof::MultiplyKind::SignedByte && + kind(unsigned_product)==GSUAddressProof::MultiplyKind::UnsignedByte,"Byte multiplication lost its signed/unsigned operand proof"); + require(kind(mixed)==GSUAddressProof::MultiplyKind::FullWord && kind(small_product)==GSUAddressProof::MultiplyKind::FullWord, + "A mixed-range input or small product justified truncating a wide operand"); + struct Fixture { const char* expression; const char* instruction; }; + for (const auto& fixture : {Fixture{"(a&127)*((b&127)-64)","mult "}, + Fixture{"(a&255)*(b&255)","umult "},Fixture{"a*(b&1)",".byte $9F ; explicit encoding"}, + Fixture{"((a&127)-64)*(b&255)",".byte $9F ; explicit encoding"},Fixture{"a*b",".byte $9F ; explicit encoding"}}) { + const std::string source=std::string("word main(){word a=*(volatile word*)0x100;word b=*(volatile word*)0x106;return ")+fixture.expression+";}"; + Lexer lexer(source); const auto tokens=lexer.scanTokens(); Parser parser(tokens); auto ast=parser.parseProgram(); + DataSegmentManager data; Analyzer analyzer(data); analyzer.analyze(ast); IRLowerer lowerer; const auto module=lowerer.lower(ast); + CompilerConfig config; config.optimization=OptimizationLevel::Baseline; + IRCodeGenerator baseline(analyzer.getAllLocalSymbols(),analyzer.getFunctionSymbols(),data,config); + const auto original=executable(baseline.generate(module)); + for (const auto level : {OptimizationLevel::O1,OptimizationLevel::O2,OptimizationLevel::Size}) { + config.optimization=level; IRCodeGenerator generator(analyzer.getAllLocalSymbols(),analyzer.getFunctionSymbols(),data,config); + const auto object=generator.generate(module); + const auto assembly=AssemblyGenerator(object).generate(); + if (isGlobalOptimization(level) && assembly.find(fixture.instruction)==std::string::npos) + throw std::runtime_error(std::string("Selected multiply instruction disagrees with the actual operand ranges: ")+ + fixture.expression+"\n"+assembly); + for (const unsigned a : {0u,1u,127u,128u,255u,256u,32767u,32768u,65535u}) + for (const unsigned bvalue : {0u,1u,127u,128u,255u,32768u,65535u}) { + DiscoGSU::Machine x(original,0x8000),y(executable(object),0x8000); + for (auto* m : {&x,&y}) { + m->seed(0x700100,static_cast(a),false);m->seed(0x700101,static_cast(a>>8),false); + m->seed(0x700106,static_cast(bvalue),false);m->seed(0x700107,static_cast(bvalue>>8),false);m->run(); + } + require(x.reg(0)==y.reg(0) && y.reg(6)==0,"Byte/full multiply selection changed modular word semantics"); + } + } + } +} +} + +struct IRCodeGeneratorTestAccess { + static void stackCreditFences() { + DataSegmentManager data; std::map locals; + std::map globals; CompilerConfig config; + config.optimization = OptimizationLevel::O2; + IRCodeGenerator backend(locals, globals, data, config); + const Token source(TokenType::UNKNOWN, "", 0, 0); + backend.emitStackGuard(8, source); + backend.emitPush(0); backend.emitPop(0); backend.emitStackGuard(8, source); + require(backend.m_object_file.relocation_table.size() == 1, + "Straight-line balanced stack traffic lost its existing capacity proof"); + backend.bindLabel("local.join"); + require(!backend.m_stack_check_credit.covers(1), "Local-label entry retained path-specific stack credit"); + backend.emitPush(0); backend.emitPop(0); + require(backend.m_object_file.relocation_table.size() == 2, "Local-label entry suppressed its first stack guard"); + const IRBasicBlock block{IRBlockId{0}, "cfg.join", {}}; + backend.emitBlock(block); + require(!backend.m_stack_check_credit.covers(1), "IR-block entry retained predecessor-specific stack credit"); + backend.emitPush(0); + require(backend.m_object_file.relocation_table.size() == 3, "IR-block entry suppressed its first stack guard"); + } + + static void pointerIndexCopies() { + unsigned tested = 0; + for (const auto level : {OptimizationLevel::O1,OptimizationLevel::O2,OptimizationLevel::Size}) + for (unsigned kind = 0; kind < 13; ++kind) { + Builder b; + const auto input_address = b.literal(0x100,pointer()); + const auto input = b.add(IROpcode::LoadIndirect,word(),{input_address}); + const auto mask = b.literal(31); + auto index_type = word(kind == 1); + if (kind == 2 || kind == 3) { index_type = byte(); index_type.is_unsigned = kind == 3; } + if (kind == 4) index_type = Type{BaseType::BOOL,"",1,false}; + if (kind == 12) index_type.array_size = 2; + auto index = kind == 5 ? mask : b.add(IROpcode::Binary,index_type,{input,mask},"&"); + const bool far = kind == 7; + const auto base_type = pointer(word(),kind == 8 ? AddressSpace::ROM : AddressSpace::RAM,far); + const auto base = b.literal(far ? 0x710400 : kind == 8 ? 0x8000 : kind == 6 ? 0xfffe : 0x400,base_type); + const auto result = b.add(IROpcode::PointerOffset,base_type,{base,index},"+",2); + const auto read = b.add(IROpcode::LoadIndirect,word(),{result}); + IRInstruction ret; ret.opcode = IROpcode::Return; ret.type = word(); ret.operands = {read}; + b.function.blocks[0].instructions.push_back(ret); + const auto offset = b.function.blocks[0].instructions.at(result.value - 1); + DataSegmentManager data; std::map locals; + std::map globals; CompilerConfig config; config.optimization = level; + IRCodeGenerator backend(locals,globals,data,config); backend.m_current_function = &b.function; + backend.m_checked_pointer_mode = true; backend.m_emitting_value = result; + backend.m_register_allocator.runGlobal(b.function,{5},OptimizationLevel::Size); + for (const auto& i : b.function.blocks[0].instructions) { + if (i.result.isValid()) backend.m_values.emplace(i.result.value,&i); + for (const auto value : i.operands) ++backend.m_use_counts[value.value]; + } + backend.m_address_proof.run(b.function,b.locals,44,true); + const auto* home = backend.m_register_allocator.find(index); + if (kind <= 4 || kind >= 9) require(home && home->has_register, + "Pointer-index copy fixture lost the allocated index home"); + if (kind == 9) backend.m_spill_offsets[index.value] = -8; + if (kind == 10) { + IRCodeGenerator::RegionalLoopValues regional; regional.blocks.insert(0); regional.values.push_back({index,4}); + backend.m_regional_loop_values.emplace(1,std::move(regional)); + } + if (kind == 11) backend.m_ephemeral_values.insert(index.value); + backend.m_direct_register_load = true; + backend.emitPointerOffset(offset); + const auto& bytes = backend.m_object_file.code_section; + const bool direct = home && bytes.size() >= 2 && bytes[0] == static_cast(0x20 | home->physical_register) && bytes[1] == 0x16; + require(direct == (level == OptimizationLevel::Size && kind <= 1), + "Direct pointer index copy crossed type/proof/register-home/policy boundaries"); + require(!backend.m_direct_register_load, "Base materialization did not reset direct-load emitter state"); + ++tested; + } + std::cout << "Pointer index copy: " << tested << " policy/type/proof/home structural cases\n"; + } +}; + int main() { try { + IRCodeGeneratorTestAccess::stackCreditFences(); + IRCodeGeneratorTestAccess::pointerIndexCopies(); frameRanges(); pointerBoundaries(); wrappedAndCyclicValues(); inductionIntervals(); boundedCastsAndConditions(); - stackCredit(); verifiedSource(); verifiedClearLoop(); + stackCredit(); verifiedSource(); verifiedClearLoop(); stackCreditControlFlowExecution(); constantOffsetExecution(); pointerComponentExecution(); pointerSnapshotExecution(); multiplyProofsAndExecution(); std::cout << "GSU checked-address and stack-credit proofs passed\n"; return 0; } catch (const std::exception& error) { std::cerr << error.what() << '\n'; return 1; } } diff --git a/tests/GSUCompactionTests.cpp b/tests/GSUCompactionTests.cpp index 12ac370..1a45cd9 100644 --- a/tests/GSUCompactionTests.cpp +++ b/tests/GSUCompactionTests.cpp @@ -4,6 +4,7 @@ #include "AssemblyGenerator.hpp" #include "Assembler.hpp" #include "GSUInstructionModel.hpp" +#include "ABI.hpp" #include "Lexer.hpp" #include "Parser.hpp" #include "Optimizer.hpp" @@ -15,7 +16,8 @@ namespace { void require(bool b, const char* text) { if (!b) throw std::runtime_error(text); } struct Compilation { ObjectFile object; IRModule ir; }; -Compilation compile(const std::string& source, OptimizationLevel level, std::uint32_t origin, int minimum_frame_bytes = 0) { +Compilation compile(const std::string& source, OptimizationLevel level, std::uint32_t origin, int minimum_frame_bytes = 0, + bool retain_stack_loop_saves = false, bool check_backend_reuse = false) { Lexer lexer(source); auto tokens = lexer.scanTokens(); Parser parser(tokens); auto ast = parser.parseProgram(); DataSegmentManager data; Analyzer analyzer(data); analyzer.analyze(ast); IRLowerer lowerer; auto ir = lowerer.lower(ast); @@ -30,6 +32,22 @@ Compilation compile(const std::string& source, OptimizationLevel level, std::uin if (first != dumpIR(optimized)) throw std::runtime_error("Compaction/global pipeline is not idempotent\n" + source + "\nFirst:\n" + first + "Second:\n" + dumpIR(optimized)); } + if (retain_stack_loop_saves) { + // A codegen-no-op scope marker deliberately excludes the narrow + // PLOT/CACHE-only register-save proof, without changing behavior or + // adding an SSA lifetime. This retains the previous checked path as + // an execution/fault-order reference, not another production option. + ir = optimized; + for (auto& function : ir.functions) for (auto& block : function.blocks) { + const auto end = std::find_if(block.instructions.begin(), block.instructions.end(), + [](const IRInstruction& i) { return i.opcode == IROpcode::HardwareLoopEnd; }); + if (end == block.instructions.end()) continue; + IRInstruction fence; fence.opcode = IROpcode::PlotBegin; + fence.in_plot_context = end->in_plot_context; fence.source = end->source; + block.instructions.insert(end, std::move(fence)); + } + IRVerifier::verify(ir); + } CompilerConfig config; config.optimization = level; config.code_start_address = origin; IRCodeGenerator backend(analyzer.getAllLocalSymbols(), analyzer.getFunctionSymbols(), data, config); auto object = backend.generate(ir); @@ -38,12 +56,25 @@ Compilation compile(const std::string& source, OptimizationLevel level, std::uin require(round_trip.code_section == object.code_section, "Compact assembly round trip changed bytes"); IRCodeGenerator again(analyzer.getAllLocalSymbols(), analyzer.getFunctionSymbols(), data, config); require(assembly == AssemblyGenerator(again.generate(ir)).generate(), "Compaction is nondeterministic"); + if (check_backend_reuse) require(assembly == AssemblyGenerator(backend.generate(ir)).generate(), + "Division lowering retained candidate/selector state across backend reuse"); return {std::move(object), std::move(optimized)}; } std::vector linked(const ObjectFile& object) { auto bytes = object.code_section; + if (!object.data_section.empty()) + while ((object.config.code_start_address + bytes.size()) % object.data_alignment) bytes.push_back(0); + const auto data_offset = bytes.size(); + bytes.insert(bytes.end(), object.data_section.begin(), object.data_section.end()); for (const auto& relocation : object.relocation_table) { bool found = false; + if (relocation.target_symbol_name == GSUAbi::NearRamBankSymbol) { + require(relocation.type == RelocationType::ADDR24_BANK, "Invalid near-RAM bank fixture relocation"); + // This fixture runs with near-RAM selector 0, whose physical bank + // byte is $70, matching the real linker's bank-context symbol. + bytes.at(relocation.patch_offset + 1) = 0x70; + continue; + } const std::string prefix = "__disco_stack_limit_"; if (relocation.target_symbol_name.compare(0, prefix.size(), prefix) == 0) { const auto required = static_cast(std::stoul(relocation.target_symbol_name.substr(prefix.size()))); @@ -52,10 +83,13 @@ std::vector linked(const ObjectFile& object) { bytes.at(relocation.patch_offset + 2) = static_cast(required >> 8); found = true; } for (const auto& symbol : object.symbol_table) if (symbol.name == relocation.target_symbol_name) { - require(symbol.section == SymbolSection::CODE && - (relocation.type == RelocationType::ADDR16_IWT || relocation.type == RelocationType::ADDR16_JAL), + const bool pool = symbol.section == SymbolSection::DATA && relocation.type == RelocationType::ADDR16_RAM && + symbol.name.compare(0, std::string(GSUAbi::ImmutablePoolPrefix).size(), GSUAbi::ImmutablePoolPrefix) == 0 && + (object.config.code_start_address >> 16) == 0x70 && symbol.offset < object.data_section.size(); + require(pool || (symbol.section == SymbolSection::CODE && + (relocation.type == RelocationType::ADDR16_IWT || relocation.type == RelocationType::ADDR16_JAL)), "Unexpected compaction fixture relocation"); - const auto value = object.config.code_start_address + symbol.offset; + const auto value = object.config.code_start_address + symbol.offset + (pool ? data_offset : 0); bytes.at(relocation.patch_offset + 1) = static_cast(value); bytes.at(relocation.patch_offset + 2) = static_cast(value >> 8); found = true; } @@ -92,6 +126,175 @@ void reject(IRModule m, const char* expected) { } throw std::runtime_error("Malformed compact IR was accepted"); } +void pointerPromotionPolicies() { + const std::string source = "word main(){word* p=(word*)0x400;for(word i=0;i<32;i++){p[i]=0;}" + "word seed=*((volatile word*)0x100);word sum=seed;plot{at(4,3);color 2;" + "for(word i=0;i<64;i++){sum+=i^seed;pixel;}flush;return sum+cursor.x;}}"; + Lexer lexer(source); auto tokens = lexer.scanTokens(); Parser parser(tokens); auto ast = parser.parseProgram(); + DataSegmentManager data; Analyzer analyzer(data); analyzer.analyze(ast); IRLowerer lowerer; + const auto original = lowerer.lower(ast); + for (const auto level : {OptimizationLevel::O2, OptimizationLevel::Size}) { + auto conservative = original, promoted = original, compacted = original; + IRGlobalOptimizer::run(conservative, analyzer.getAllLocalSymbols(), level, IRCompactionPolicy::Disabled); + IRGlobalOptimizer::run(promoted, analyzer.getAllLocalSymbols(), level, IRCompactionPolicy::PointerPromotionOnly); + const auto first = dumpIR(promoted); + IRGlobalOptimizer::run(promoted, analyzer.getAllLocalSymbols(), level, IRCompactionPolicy::PointerPromotionOnly); + require(first == dumpIR(promoted), "Pointer-only global pipeline is not idempotent"); + IRVerifier::verify(promoted); + IRGlobalOptimizer::run(compacted, analyzer.getAllLocalSymbols(), level, IRCompactionPolicy::Enabled); + const auto pointerLoads = [](const IRModule& module) { + unsigned loads = 0; + for (const auto& f : module.functions) for (const auto& b : f.blocks) for (const auto& i : b.instructions) + loads += i.opcode == IROpcode::LoadIndirect && i.type.pointer_level != 0; + return loads; + }; + require(pointerLoads(conservative) != 0 && pointerLoads(promoted) == 0, + "Pointer-only policy did not independently promote the bounded clear base"); + require(count(promoted, IROpcode::PointerOffset) != 0 && count(promoted, IROpcode::HardwareLoop) == 0 && + count(promoted, IROpcode::HardwareLoopEnd) == 0 && count(promoted, IROpcode::MemoryInitialize) == 0, + "Pointer-only policy ran counted-loop/initializer compaction or lost the checked index"); + if (level == OptimizationLevel::O2) require(count(compacted, IROpcode::HardwareLoop) >= 2, + "Pointer-only fixture did not distinguish enabled counted-loop compaction"); + } + // Reuse the existing rejection boundaries: escaped/aliased + // cells, indefinite assignment, and conditional incoming pointer checks. + for (const std::string unsafe : { + "void escape(word** p);word f(word* p){escape(&p);return *p;}", + "void alias(word** p,word** q);word f(word* p){alias(&p,&p);return *p;}", + "word f(word take){word* p;if(take)p=(word*)0x400;return *p;}", + "word f(word* p,word take){if(take)return *p;return 7;}"}) { + try { + Lexer local_lexer(unsafe); const auto local_tokens = local_lexer.scanTokens(); Parser local_parser(local_tokens); + auto local_ast = local_parser.parseProgram(); DataSegmentManager local_data; Analyzer local_analyzer(local_data); + local_analyzer.analyze(local_ast); IRLowerer local_lowerer; const auto module = local_lowerer.lower(local_ast); + for (const auto level : {OptimizationLevel::O2, OptimizationLevel::Size}) { + auto result = module; + IRGlobalOptimizer::run(result, local_analyzer.getAllLocalSymbols(), level, IRCompactionPolicy::PointerPromotionOnly); + unsigned retained = 0; + for (const auto& f : result.functions) for (const auto& b : f.blocks) for (const auto& i : b.instructions) + retained += i.opcode == IROpcode::LoadIndirect && i.type.pointer_level != 0; + require(retained != 0, "Pointer-only promotion crossed an alias/escape/assignment/incoming-check boundary"); + const auto first = dumpIR(result); + IRGlobalOptimizer::run(result, local_analyzer.getAllLocalSymbols(), level, IRCompactionPolicy::PointerPromotionOnly); + require(first == dumpIR(result), "Rejected pointer-only promotion is not idempotent"); + } + } catch (const CompilerError& error) { + throw std::runtime_error("Pointer-only rejection fixture: " + unsafe + "\n" + error.getMessage()); + } + } + for (const auto origin : {0x008000u, 0x70600fu}) { + const auto baseline = compile(source, OptimizationLevel::O1, origin); + const auto selected = compile(source, OptimizationLevel::Size, origin); + for (const auto input : {0u, 1u, 37u, 65535u}) { + DiscoGSU::Machine a(linked(selected.object), origin), b(linked(baseline.object), origin); + for (auto* machine : {&a, &b}) { + machine->seed(0x700100, static_cast(input), false); + machine->seed(0x700101, static_cast(input >> 8), false); + for (unsigned p = 0x400; p < 0x440; ++p) machine->seed(0x700000 + p, 0xa5, false); + machine->run(); + } + std::uint16_t sum = static_cast(input); + for (unsigned i = 0; i < 64; ++i) sum = static_cast(sum + (i ^ input)); + require(a.reg(0) == static_cast(sum + 68) && a.reg(0) == b.reg(0) && a.reg(6) == 0 && + a.reg(10) == b.reg(10) && a.reg(12) == b.reg(12) && a.reg(13) == b.reg(13), + "Pointer-only candidate search changed carried arithmetic or ABI state"); + require(a.accesses(0x700100, false) == 1 && a.accesses(0x700100, true) == 0, + "Pointer-only candidate search changed volatile input multiplicity"); + for (unsigned p = 0; p < 0x440; ++p) require(a.byte(0x700000 + p) == b.byte(0x700000 + p), + "Pointer-only candidate search changed clear/pixel RAM output"); + for (unsigned p = 0x400; p < 0x440; ++p) require(a.byte(0x700000 + p) == 0, + "Pointer-only candidate search left a bounded clear incomplete"); + } + } +} +void conditionalIncomingPointers() { + const std::string source = "word f(word* p,word take){*((volatile byte*)0x104)=(byte)11;" + "if(take)return *p;return 7;}"; + const auto callable = [](const ObjectFile& object) { + auto bytes = linked(object); + const auto stop = object.config.code_start_address + static_cast(bytes.size()); + bytes.insert(bytes.begin(), {0xfb, static_cast(stop), static_cast(stop >> 8)}); + // R6 is caller-scratch on a successful non-entry return. Normalize it + // only in the return continuation; a checked fault STOP never arrives. + bytes.insert(bytes.end(), {0xa6, 0, 0, 1}); + return bytes; + }; + const auto baseline = compile(source, OptimizationLevel::O1, 0x008003); + for (const auto level : {OptimizationLevel::O2, OptimizationLevel::Size}) { + const auto optimized = compile(source, level, 0x008003); + for (const unsigned take : {0u,1u}) for (const unsigned pointer : {0u,1u,0x400u,0xffffu}) { + DiscoGSU::Machine a(callable(optimized.object), 0x008000), b(callable(baseline.object), 0x008000); + for (auto* machine : {&a,&b}) { + machine->seed(0x702002, static_cast(pointer), false); + machine->seed(0x702003, static_cast(pointer >> 8), false); + machine->seed(0x702004, static_cast(take), false); + machine->seed(0x700400, 123, false); machine->run(); + } + const bool fault = take && pointer != 0x400; + const unsigned expected_fault = fault ? (pointer == 0 ? 2 : 1) : 0; + if (!(a.reg(6) == expected_fault && a.reg(6) == b.reg(6) && + a.byte(0x700104) == 11 && a.accesses(0x700104, true) == 1 && + b.byte(0x700104) == 11 && b.accesses(0x700104, true) == 1)) + throw std::runtime_error("Conditional pointer fixture: level=" + std::to_string(static_cast(level)) + + " take=" + std::to_string(take) + " pointer=" + std::to_string(pointer) + + " faults=" + std::to_string(a.reg(6)) + "/" + std::to_string(b.reg(6)) + + " witnesses=" + std::to_string(a.byte(0x700104)) + "/" + std::to_string(b.byte(0x700104)) + + " stores=" + std::to_string(a.accesses(0x700104,true)) + "/" + std::to_string(b.accesses(0x700104,true))); + if (!fault) require(a.reg(0) == (take ? 123 : 7) && a.reg(0) == b.reg(0) && a.reg(10) == b.reg(10), + "Conditional incoming pointer changed its successful result or stack"); + require(a.accesses(0x700400, false) == (take && !fault ? 1u : 0u), + "Conditional incoming pointer unexpectedly accessed its pointee"); + } + } +} +void pointerIndexCopyExecution() { + unsigned executed = 0, rejected = 0; + const std::string raw_bool_index = "word main(){bool i=(bool)*((volatile word*)0x100);" + "word* p=(word*)0x400;return p[i];}"; + for (const auto level : {OptimizationLevel::O1,OptimizationLevel::Size}) { + bool diagnosed = false; + try { compile(raw_bool_index,level,0x008000u); } + catch (const CompilerError& error) { + require(error.getMessage() == "Array index must be an integer type.", + "Raw BOOL index produced the wrong diagnostic"); + diagnosed = true; ++rejected; + } + require(diagnosed,"Raw BOOL index escaped the integer-index language boundary"); + } + for (unsigned kind = 0; kind < 5; ++kind) { + const std::string type = kind == 0 ? "word" : kind == 1 ? "byte" : kind == 2 ? "u8" : kind == 3 ? "bool" : "word"; + const std::string expression = kind == 0 || kind == 4 ? "(word)*((volatile word*)0x100)&31" : + kind == 3 ? "(bool)*((volatile word*)0x100)" : "*((volatile " + type + "*)0x100)"; + const std::string source = "word main(){" + type + " i=" + expression + ";*((volatile word*)0x104)=11;" + + (kind == 4 ? "far word* p=(far word*)0x710400;" : "word* p=(word*)0x400;") + + "word r=p[" + (kind == 3 ? "(word)i" : "i") + "];*((volatile word*)0x106)=r;return r;}"; + for (const auto origin : {0x008000u,0x70600fu}) { + const auto selected = compile(source,OptimizationLevel::Size,origin,0,false,true); + const auto baseline = compile(source,OptimizationLevel::O1,origin); + for (const unsigned input : {0u,1u,127u,128u,255u,32768u,65535u}) { + const int index = kind == 0 || kind == 4 ? static_cast(input & 31) : + kind == 3 ? (input != 0) : kind == 1 && (input & 255) >= 128 ? static_cast(input & 255) - 256 : static_cast(input & 255); + const auto address = static_cast((kind == 4 ? 0x710400 : 0x700400) + index * 2); + DiscoGSU::Machine a(linked(selected.object),origin), b(linked(baseline.object),origin); + for (auto* machine : {&a,&b}) { + machine->seed(0x700100,static_cast(input),false); + machine->seed(0x700101,static_cast(input >> 8),false); + machine->seed(address,0x34,false); machine->seed(address+1,0x12,false); machine->run(); + } + require(a.reg(0) == 0x1234 && a.reg(0) == b.reg(0) && a.reg(6) == 0 && a.reg(10) == b.reg(10) && + a.ramBank() == b.ramBank() && a.romBank() == b.romBank() && a.reg(12) == b.reg(12) && a.reg(13) == b.reg(13), + "Pointer index copy changed word/byte/bool/far normalization, banks or ABI"); + require(a.word(0x700104) == 11 && a.word(0x700106) == 0x1234 && + a.accesses(0x700100,false) == 1 && a.accesses(address,false) == 1, + "Pointer index copy changed volatile input, witness or checked memory access"); + ++executed; + } + } + } + require(executed == 70 && rejected == 2,"Pointer index copy coverage did not complete"); + std::cout << "Pointer index copy: " << executed << " ROM/RAM semantic comparisons and " + << rejected << " raw BOOL-index diagnostics\n"; +} void dynamicLoops() { const std::string span = "word main(){word n=*((volatile word*)0x100);word r=10;plot{at(4,3);color 2;" "@cache for(word i=0;i= 2 ? 1u : 0u) && + count(selected.ir, IROpcode::DivModResult) == (projection >= 2 ? 1u : 0u), + "Division-copy fixture lost its single/fused projection distinction"); + const std::vector direct{0x21,0x13,0x20,0x11,0xa6,0,0xb3,0x3f,0x66}; + require(std::search(selected.object.code_section.begin(), selected.object.code_section.end(), + direct.begin(), direct.end()) != selected.object.code_section.end(), + "Non-plot Os division did not copy R1 directly to R3 before replacing R1"); + const auto seeded = [](const ObjectFile& object) { + auto bytes = linked(object); bytes.insert(bytes.begin(), {0xfc,0xef,0xbe,0xfd,0xfe,0xca}); return bytes; + }; + for (const auto& values : {std::make_pair(0u,13u),std::make_pair(1u,1u),std::make_pair(65535u,3u), + std::make_pair(32768u,65535u),std::make_pair(32768u,1u),std::make_pair(32767u,32768u), + std::make_pair(1u,32768u),std::make_pair(65535u,65535u),std::make_pair(149u,0u)}) { + DiscoGSU::Machine a(seeded(selected.object), origin - 6), b(seeded(baseline.object), origin - 6); + for (auto* machine : {&a,&b}) { + machine->seed(0x700100, static_cast(values.first), false); + machine->seed(0x700101, static_cast(values.first >> 8), false); + machine->seed(0x700102, static_cast(values.second), false); + machine->seed(0x700103, static_cast(values.second >> 8), false); + for (unsigned p = 0x106; p < 0x10a; ++p) machine->seed(0x700000 + p, 0xa5, false); + machine->run(); + } + ++executions; + require(a.reg(6) == b.reg(6) && a.reg(6) == (values.second ? 0u : 6u) && + a.word(0x700104) == 11 && a.accesses(0x700104,true) == 1 && + a.accesses(0x700100,false) == 1 && a.accesses(0x700102,false) == 1 && + a.ramBank() == b.ramBank() && a.romBank() == b.romBank() && + a.reg(12) == 0xbeef && a.reg(13) == 0xcafe, + "Direct divisor copy changed checked fault/witness/input/bank/LOOP state"); + const auto convert = [&](unsigned value) { + return !is_unsigned && value >= 0x8000 ? static_cast(value) - 65536 : static_cast(value); + }; + if (values.second) { + // Compute in a wider host type: -32768/-1 is representable + // here before the target's modular word conversion. + const auto q = static_cast(convert(values.first) / convert(values.second)); + const auto r = static_cast(convert(values.first) % convert(values.second)); + const auto result = projection == 0 ? q : projection == 1 ? r : static_cast(q ^ r); + require(a.reg(0) == result && a.reg(0) == b.reg(0) && a.reg(10) == b.reg(10) && + a.word(0x700106) == (projection != 1 ? q : 0xa5a5) && + a.word(0x700108) == (projection != 0 ? r : 0xa5a5), + "Direct divisor copy changed signed/unsigned quotient/remainder projections or stack"); + } else require(a.word(0x700106) == 0xa5a5 && a.word(0x700108) == 0xa5a5, + "Divide-by-zero reached a result/projection store"); + for (unsigned p = 0x104; p < 0x10a; ++p) require(a.byte(0x700000 + p) == b.byte(0x700000 + p), + "Direct divisor copy changed witnessed result RAM"); + } + } + } + // Controls stay on the legacy plot staging and private shared-helper ABI. + for (const bool plot : {false,true}) for (const bool is_unsigned : {false,true}) { + const std::string type = is_unsigned ? "u16" : "word"; + const std::string source = "word main(){" + type + " a=*((volatile " + type + "*)0x100);" + + type + " b=*((volatile " + type + "*)0x102);*((volatile word*)0x104)=11;" + + (plot ? "plot{at(7,3);color 2;" : "") + type + " q=a/b;" + type + + (plot ? " r=a%b;" : " r=(a^(" + type + ")23130)/b;") + + "*((volatile word*)0x106)=(word)q;*((volatile word*)0x108)=(word)r;" + + (plot ? "pixel;flush;return (word)(q^r)+cursor.x*16+cursor.y;}}" : "return (word)(q^r);}"); + for (const auto origin : {0x00800fu,0x706007u}) { + const auto selected = compile(source, OptimizationLevel::Size, origin, 0, false, true); + const auto baseline = compile(source, OptimizationLevel::O1, origin); + if (plot) { + const std::vector legacy{0x24,0x13,0x20,0x11}; + require(std::search(selected.object.code_section.begin(), selected.object.code_section.end(), + legacy.begin(),legacy.end()) != selected.object.code_section.end(), + "Plot division lost the R4-staged divisor after coordinate pushes"); + } else { + const auto helper = std::string(1,'\x01') + "__disco_os_divmod_" + (is_unsigned ? "u" : "s"); + require(std::count_if(selected.object.symbol_table.begin(),selected.object.symbol_table.end(), + [&](const SymbolEntry& symbol) { return symbol.name == helper; }) == 1, + "Repeated-division control lost its private shared helper"); + } + for (const auto& values : {std::make_pair(32768u,65535u),std::make_pair(65535u,3u),std::make_pair(149u,0u)}) { + DiscoGSU::Machine a(linked(selected.object),origin), b(linked(baseline.object),origin); + for (auto* machine : {&a,&b}) { + machine->seed(0x700100,static_cast(values.first),false); + machine->seed(0x700101,static_cast(values.first >> 8),false); + machine->seed(0x700102,static_cast(values.second),false); + machine->seed(0x700103,static_cast(values.second >> 8),false); machine->run(); + } + ++executions; + require(a.reg(6) == b.reg(6) && a.reg(6) == (values.second ? 0u : 6u) && + a.word(0x700104) == 11 && a.accesses(0x700104,true) == 1 && + a.accesses(0x700100,false) == 1 && a.accesses(0x700102,false) == 1, + "Plot/shared division control changed fault/witness/input state"); + if (values.second) require(a.reg(0) == b.reg(0) && a.reg(10) == b.reg(10) && + a.word(0x700106) == b.word(0x700106) && a.word(0x700108) == b.word(0x700108) && + (!plot || (a.reg(1) == 8 && a.reg(2) == 3 && a.graphicsState("--plots") == 1 && a.graphicsState("--rpix") == 1)), + "Plot/shared division control changed results, coordinates, flush or stack"); + for (unsigned p = 0; p < 0x10a; ++p) require(a.byte(0x700000+p) == b.byte(0x700000+p), + "Plot/shared division control changed external RAM or pixels"); + } + } + } + std::cout << "Direct divisor copy: " << executions << " signed/unsigned projection/fault executions across ROM/RAM all16 alignments\n"; +} + void inlineHardwareScopes() { // These are verified IR contracts, not source patterns: the AST optimizer // intentionally declines nested/calling countdown loops. Both implicit // counters and the caller's pre-existing R12/R13 must survive lowering. - for (const bool calls : {false, true}) { - const std::string source = calls + for (const unsigned call_kind : {0u, 1u, 2u, 3u}) { + const bool calls = call_kind != 0; + std::string source = calls ? "word step(); word main(){volatile word total=0;total+=step();return total;}" : "word main(){volatile word total=0;total++;return total;}"; + if (call_kind == 2) source += "word step(){return *(volatile word*)0x100+2;}"; + if (call_kind == 3) source += "word helper(){word s=0;for(word j=0;j<8;j++)s+=j;return s;}" + "word step(){return helper()-26;}"; Lexer lexer(source); const auto tokens = lexer.scanTokens(); Parser parser(tokens); auto ast = parser.parseProgram(); DataSegmentManager data; Analyzer analyzer(data); analyzer.analyze(ast); Optimizer optimizer; optimizer.optimize(ast); IRLowerer lowerer; auto ir = lowerer.lower(ast); @@ -335,7 +654,7 @@ void inlineHardwareScopes() { CompilerConfig config; config.optimization = level; config.code_start_address = origin; IRCodeGenerator backend(analyzer.getAllLocalSymbols(), analyzer.getFunctionSymbols(), data, config); auto object = backend.generate(ir); - if (calls) { + if (call_kind == 1) { // An external callee deliberately clobbers both loop // registers; a compiler-generated callee would preserve // them itself and could mask missing caller protection. @@ -344,6 +663,24 @@ void inlineHardwareScopes() { object.code_section.insert(object.code_section.end(), {0xfc, 0x11, 0x11, 0xfd, 0x22, 0x22, 0xa0, 2, 0x9b, 1}); } + if (level == OptimizationLevel::O2 && call_kind >= 2) { + std::size_t end = object.code_section.size(); + for (const auto& symbol : object.symbol_table) if (symbol.section == SymbolSection::CODE && + (symbol.name == "step" || symbol.name == "helper")) end = std::min(end, symbol.offset); + unsigned preservation_prefixes = 0; + for (std::size_t n = 0; n < end;) { + const auto op = object.code_section[n]; + preservation_prefixes += op == 0x2c; + n += op >= 0xf0 ? 3 : ((op >= 0xa0 && op <= 0xaf) || (op >= 5 && op <= 15)) ? 2 : 1; + } + // Scheduling may place WITH R12 in a guard's delay slot + // while its matching STW is at the success label. Count + // both save/load prefixes as instructions, not adjacent + // byte strings that would miss that valid pipeline plan. + if (preservation_prefixes != (call_kind == 2 ? 4u : 6u)) + throw std::runtime_error("Internal/transitive callee summary did not select precise caller LOOP preservation: kind=" + + std::to_string(call_kind) + " prefixes=" + std::to_string(preservation_prefixes)); + } require(Assembler().assemble(AssemblyGenerator(object).generate()).code_section == object.code_section, "Nested inline scope assembly changed bytes"); auto bytes = linked(object); @@ -356,6 +693,149 @@ void inlineHardwareScopes() { } } +void registerBackedPixelScopes() { + const std::string source = "word main(){word n=*((volatile word*)0x100);" + "*((volatile word*)0x300)=17;plot{at(4,3);color 2;" + "for(word y=0;y<3;y++){for(word x=0;xconfigureStackWindow(0x701000, 0x702002); + machine->seed(0x700100, static_cast(n), false); + machine->seed(0x700101, static_cast(n >> 8), false); + machine->run(); + } + require(a.reg(0) == b.reg(0) && a.reg(1) == b.reg(1) && a.reg(2) == b.reg(2) && + a.reg(6) == 0 && a.reg(10) == b.reg(10) && a.reg(12) == 0xbeef && a.reg(13) == 0xcafe, + "Register-backed pixel scope changed cursor, ABI or fault state"); + for (unsigned p = 0x100; p < 0x400; ++p) + require(a.byte(0x700000 + p) == b.byte(0x700000 + p), "Register-backed pixel scope changed pixels/witnesses"); + if (n && n < 0x8000) { + if (!(a.metrics().stack_stores + 6 == b.metrics().stack_stores && + a.metrics().stack_loads + 6 == b.metrics().stack_loads)) + throw std::runtime_error("Pixel scope traffic mismatch at n=" + std::to_string(n) + + " stores=" + std::to_string(a.metrics().stack_stores) + "/" + std::to_string(b.metrics().stack_stores) + + " loads=" + std::to_string(a.metrics().stack_loads) + "/" + std::to_string(b.metrics().stack_loads) + + "\n" + dumpIR(fast.ir)); + require(a.metrics().instructions < b.metrics().instructions, + "Register-backed pixel scope increased executed instructions"); + } else { + require(a.metrics().stack_stores == b.metrics().stack_stores && a.metrics().stack_loads == b.metrics().stack_loads, + "Untaken pixel scope added stack work"); + } + if (origin == 0x008006u && n == 3) + std::cout << "Pixel-save three-row fixture: bytes=" << fast.object.code_section.size() << "/" << stack.object.code_section.size() + << " instructions=" << a.metrics().instructions << "/" << b.metrics().instructions + << " stack-stores=" << a.metrics().stack_stores << "/" << b.metrics().stack_stores + << " stack-loads=" << a.metrics().stack_loads << "/" << b.metrics().stack_loads << '\n'; + } + // Both push boundaries, including odd SP alignment faults in the + // prologue. Failed paths must preserve the first push's exact word, + // SP, original loop registers and the preceding volatile witness. + unsigned setup_faults = 0, successful = 0; + for (std::uint16_t sp = 0; sp <= 96; ++sp) { + DiscoGSU::Machine a(run_bytes(fast.object), origin - 6, sp), b(run_bytes(stack.object), origin - 6, sp); + for (auto* machine : {&a, &b}) { machine->seed(0x700100, 3, false); machine->run(); } + require(a.reg(6) == b.reg(6) && a.reg(10) == b.reg(10) && + a.reg(12) == b.reg(12) && a.reg(13) == b.reg(13) && a.word(0x700300) == b.word(0x700300), + "Pixel-save capacity path reordered a checked fault or damaged scope ownership"); + if (a.reg(6)) { + for (unsigned p = 0; p <= 96; ++p) + require(a.byte(0x700000 + p) == b.byte(0x700000 + p), + "Low-stack fallback changed the first saved word before fault"); + if (a.word(0x700300) == 17) ++setup_faults; + } else ++successful; + } + require(setup_faults >= 2 && successful != 0, "Pixel-save fixture missed setup fault boundaries or fast success"); + } +} + +void physicalComparisons() { + for (const bool is_unsigned : {false,true}) { + const std::string type = is_unsigned ? "unsigned word" : "word"; + const std::string source = "word main(){" + type + " a=*(volatile " + type + "*)0x100;" + type + + " b=*(volatile " + type + "*)0x102;word r=(word)(ab)*4;r+=(word)(a>=b)*8;r+=(word)(a==b)*16;r+=(word)(a!=b)*32;" + "if(a<=b)r+=64;if(a>b)r+=128;return r;}"; + for (const auto level : {OptimizationLevel::O1, OptimizationLevel::O2, OptimizationLevel::Size}) { + const auto compiled = compile(source, level, 0x008000); + for (const unsigned a : {0u,1u,2u,127u,128u,255u,32767u,32768u,65535u}) + for (const unsigned b : {0u,1u,2u,127u,128u,255u,32767u,32768u,65535u}) { + DiscoGSU::Machine machine(linked(compiled.object), 0x008000); + machine.seed(0x700100,static_cast(a),false); + machine.seed(0x700101,static_cast(a >> 8),false); + machine.seed(0x700102,static_cast(b),false); + machine.seed(0x700103,static_cast(b >> 8),false); machine.run(); + const auto convert = [&](unsigned value) { + return is_unsigned || value < 32768 ? static_cast(value) : static_cast(value) - 65536; + }; + const int x = convert(a), y = convert(b); + const auto expected = (x < y) + 2*(x <= y) + 4*(x > y) + 8*(x >= y) + + 16*(x == y) + 32*(x != y) + 64*(x <= y) + 128*(x > y); + require(machine.reg(0) == expected && machine.reg(6) == 0, + "Physical/reversed CMP changed signed/unsigned boolean or branch semantics"); + } + } + } +} + +void allocatedFrameArithmetic() { + for (unsigned variant = 0; variant < 3; ++variant) { + const bool calls = variant == 2; + const std::string update = variant == 1 + ? "word next=r-a;r=next+r;r+=b;r-=c;r+=d;" + : std::string("r+=a;") + (calls ? "r=step(r);" : "") + "r-=b;r+=c;r-=d;"; + const std::string source = std::string(calls ? "word step(word n);" : "") + + "word main(){word a=*(volatile word*)0x100;word b=*(volatile word*)0x102;" + "word c=*(volatile word*)0x104;word d=*(volatile word*)0x106;word r=*(volatile word*)0x108;" + "word flags=0;for(word i=0;i<7;i++){" + update + + "if(r<0)flags++;if(r==0)flags+=2;}*(volatile word*)0x10a=flags;return r;}" + + (calls ? "@cache word step(word n){return n+1;}" : ""); + for (const auto level : {OptimizationLevel::O1,OptimizationLevel::O2,OptimizationLevel::Size}) { + const auto compiled = compile(source, level, 0x008000); + if (calls) require(count(compiled.ir,IROpcode::Call) != 0, "Frame arithmetic call-boundary fixture was inlined"); + if (level == OptimizationLevel::O2) { + // R0 is the frame-home form when the RHS spilled. A wider pool + // can keep that RHS in a register and subtract it directly. + const auto assembly = AssemblyGenerator(compiled.object).generate(); + require(assembly.find("sub r") != std::string::npos, + "Frame arithmetic fixture did not select an allocated subtract"); + } + for (const unsigned initial : {0u,1u,2u,127u,128u,255u,32767u,32768u,65535u}) + for (const unsigned a : {0u,1u,2u,127u,128u,255u,32767u,32768u,65535u}) { + DiscoGSU::Machine machine(linked(compiled.object),0x008000); + const unsigned inputs[] = {a,65535u-a,32768u,1u,initial}; + for (unsigned n=0;n<5;++n) { + machine.seed(0x700100+n*2,static_cast(inputs[n]),false); + machine.seed(0x700101+n*2,static_cast(inputs[n] >> 8),false); + } + machine.run(); + auto result = static_cast(initial); + unsigned flags = 0; + for (unsigned n=0;n<7;++n) { + const auto next = variant == 1 + ? 2*static_cast(result)-static_cast(a)+static_cast(inputs[1])-32768+1 + : static_cast(result)+static_cast(a)+(calls ? 1 : 0)-static_cast(inputs[1])+32768-1; + result = static_cast(next); + flags += (result >= 32768) + 2*(result == 0); + } + require(machine.reg(0)==result && machine.reg(6)==0 && machine.reg(10)==0x1ffc && machine.word(0x70010a)==flags, + "Frame RHS selection changed wrap, aliased destination, predicates, loop or call-boundary semantics"); + } + } + } +} + void cursorAndTransientValues() { for (const auto& source : { "word main(){plot{at(-1,0);cursor.x++;cursor.y--;return cursor.x+cursor.y;}}", @@ -440,6 +920,7 @@ void cacheWindowsAndCheckedAddresses() { const std::string checked = "word main(){word* p=(word*)*((volatile u16*)0x100);word i=*((volatile word*)0x102);" "*((volatile byte*)0x104)=(byte)11;word* q=p+i;word v=*q;*q=v+1;return *q;}"; for (const auto& input : {std::make_pair(0x200u, 1u), std::make_pair(0x200u, 0xffffu), + std::make_pair(0x200u, 0x8000u), std::make_pair(0x200u, 0x7fffu), std::make_pair(0xfffeu, 1u), std::make_pair(0u, 1u), std::make_pair(0x201u, 1u)}) { const auto baseline = compile(checked, OptimizationLevel::O1, 0x008000); for (const auto level : {OptimizationLevel::O2, OptimizationLevel::Size}) { @@ -505,7 +986,8 @@ void frameProofBoundary() { // without exceeding the frontend's valid local-declaration size limit. const auto reserved = compile("u16 main(){volatile byte padding[2];padding[0]=1;" "u16 a=*((volatile u16*)0x100);u16 b=*((volatile u16*)0x102);u16 c=*((volatile u16*)0x104);" - "u16 d=*((volatile u16*)0x106);u16* p=(u16*)0x400+(word)(d&3);*p=123;return a+b+c+d;}", + "u16 d=*((volatile u16*)0x106);u16 e=*((volatile u16*)0x108);u16 f=*((volatile u16*)0x10a);" + "u16* p=(u16*)0x400+(word)(d&3);*p=123;return a+b+c+d+e+f;}", OptimizationLevel::O2, 0x008000, 65520); const auto& function = reserved.ir.functions.at(0); require(function.total_local_alloc_size == 65520, "Reserved frame disappeared during SSA promotion"); @@ -537,8 +1019,14 @@ void frameProofBoundary() { } int main() { try { + pointerPromotionPolicies(); + conditionalIncomingPointers(); + pointerIndexCopyExecution(); standaloneCountdowns(); inlineHardwareScopes(); + registerBackedPixelScopes(); + physicalComparisons(); + allocatedFrameArithmetic(); cursorAndTransientValues(); cacheWindowsAndCheckedAddresses(); frameProofBoundary(); const std::string table = "word main(){word a[17]={0,6,12,19,24,30,36,41,45,49,53,56,59,61,63,64,64};" "word i=(word)*((volatile u8*)0x100)&3; word total=0; for(word n=0;n<17;n++) total+=a[n]; return total+(i-i);}"; @@ -584,7 +1072,7 @@ int main() { const auto live_out = compile("word main(){word r=0;for(word i=0;i<16;i++){r+=i;}return r;}", OptimizationLevel::O2, 0x008000); require(count(live_out.ir, IROpcode::HardwareLoop) == 1, "Loop with repaired live-out SSA value was not converted"); execution("word main(){word r=0;for(word i=0;i<16;i++){r+=i;}return r;}", 120); - dynamicLoops(); sizeCountdownsAndFlags(); sizeDivisionCache(); + dynamicLoops(); sizeCountdownsAndFlags(); sizeDivisionCache(); sizeDivisionDivisorCopy(); execution("word main(){word a=9;word b=9;word* p=&a;if(*((volatile u8*)0x100))p=&b;return *p;}", 9); const auto volatile_table = compile("word main(){volatile word a[4]={1,2,3,4};return a[0];}", OptimizationLevel::O2, 0x008000); require(count(volatile_table.ir, IROpcode::MemoryInitialize) == 0, "Volatile stores were batched"); diff --git a/tests/GSUExecutionTests.cpp b/tests/GSUExecutionTests.cpp index 7dc0db7..0eca6a7 100644 --- a/tests/GSUExecutionTests.cpp +++ b/tests/GSUExecutionTests.cpp @@ -10,6 +10,7 @@ #include #include "GSUGraphicsModel.hpp" #include "GSUInstructionModel.hpp" +#include "GemChessRasterOracle.hpp" namespace { @@ -84,6 +85,13 @@ void checkIncrementalTriangleEdges() { } void selfTest() { + GemChessRasterOracle::selfTest(); + for (const auto limit : {std::size_t{0},std::size_t{100000001}}) { + Machine invalidLimit({0,1},0x8000); + bool rejected=false; + try { invalidLimit.run(limit); } catch (const std::invalid_argument&) { rejected=true; } + require(rejected && invalidLimit.metrics().instructions==0,"Invalid model instruction limit executed or was accepted"); + } checkIncrementalTriangleEdges(); checkGraphicsAddressBoundaries(); Machine highByte({0xf0, 0x00, 0x80, 0xc0, 0x0b, 0x03, 0x01, 0xd0, 0x01, 0, 1}, 0x8000); @@ -300,12 +308,100 @@ std::vector readPayload(const char* path) { return bytes; } +void checkCompiledGemRaster(const std::vector& payload, std::uint32_t origin, bool private_kernel=false) { + std::size_t cases = 0; + std::uint64_t max_instructions=0; + const auto edges=private_kernel?GemChessRasterOracle::runtimePrivateEdges():GemChessRasterOracle::runtimeEdges(); + for (auto edge : edges) for (unsigned reverse = 0; reverse < 2; ++reverse) { + if (reverse) { std::swap(edge[0], edge[2]); std::swap(edge[1], edge[3]); } + const auto expected = GemChessRasterOracle::reference(edge); + Machine machine(payload, origin); + const auto seed = [&](std::uint32_t address, std::uint16_t value) { + machine.seed(address, static_cast(value), false); + machine.seed(address + 1, static_cast(value >> 8), false); + }; + for (std::size_t i = 0; i < edge.size(); ++i) + seed(0x700100 + static_cast(i) * 2, static_cast(edge[i])); + seed(0x700ffe, 0x1357); seed(0x701300, 0x2468); + try { machine.run(100000000); } + catch (const std::runtime_error& error) { + throw std::runtime_error("Gem edge case="+std::to_string(cases)+" "+GemChessRasterOracle::describe(edge)+ + " pc="+std::to_string(machine.reg(15))+" instructions="+std::to_string(machine.metrics().instructions)+": "+error.what()); + } + max_instructions=std::max(max_instructions,machine.metrics().instructions); + require(machine.reg(0) == 42 && machine.reg(6) == 0 && machine.reg(10) == 0x1ffc, + "Compiled gem raster failed normal return, fault or stack check"); + require(machine.word(0x700ffe) == 0x1357 && machine.word(0x701300) == 0x2468, + "Compiled gem raster wrote outside its 384-word span storage"); + for (std::size_t y = 0; y < 192; ++y) { + const auto address = static_cast(y) * 2; + if (machine.word(0x701000 + address) != static_cast(expected.left[y]) || + machine.word(0x701180 + address) != static_cast(expected.right[y])) + throw std::runtime_error("Compiled gem raster differs at y=" + std::to_string(y) + ": " + + GemChessRasterOracle::describe(edge)); + } + ++cases; + } + std::cout << "Compiled gem raster: " << cases << " edges/reversals, all 384 span words and boundary sentinels passed; maximum instructions=" << max_instructions << '\n'; +} + +void checkCompiledGemPaint(const std::vector& payload, std::uint32_t origin) { + std::size_t cases=0; + std::uint64_t max_instructions=0; + for (auto polygon : GemChessRasterOracle::runtimePolygons()) for (unsigned rotation=0;rotation(1+cases%15); + const auto expected=GemChessRasterOracle::framebuffer(polygon,wire,ink); + Machine machine(payload,origin); machine.configureScreen(0x21,24); + const auto seed=[&](std::uint32_t address,std::uint16_t value) { + machine.seed(address,static_cast(value),false); + machine.seed(address+1,static_cast(value>>8),false); + }; + seed(0x700100,static_cast(polygon.size())); seed(0x700102,ink); seed(0x700104,wire?1:0); + for (std::size_t i=0;i(i)*2,static_cast(polygon[i][0])); + seed(0x700110+static_cast(i)*2,static_cast(polygon[i][1])); + } + // Dirty all rows so vertex-only initialization cannot accidentally + // pass by reading zero-initialized or previously matching spans. + for (unsigned i=0;i<384;++i) seed(0x701000+i*2,0x5a5a); + seed(0x700ffe,0x1357); seed(0x701300,0x2468); + seed(0x705ffe,0x3579); seed(0x70c000,0x468a); + try { machine.run(100000000); } + catch (const std::runtime_error& error) { + throw std::runtime_error("Gem paint case="+std::to_string(cases)+" wire="+std::to_string(wire)+ + " pc="+std::to_string(machine.reg(15))+" instructions="+std::to_string(machine.metrics().instructions)+": "+error.what()); + } + max_instructions=std::max(max_instructions,machine.metrics().instructions); + require(machine.reg(0)==42 && machine.reg(6)==0 && machine.reg(10)==0x1ffc, + "Compiled gem paint failed normal return, fault or stack check"); + require(machine.word(0x700ffe)==0x1357 && machine.word(0x701300)==0x2468, + "Compiled gem paint wrote outside its span storage"); + require(machine.word(0x705ffe)==0x3579 && machine.word(0x70c000)==0x468a, + "Compiled gem paint wrote outside its 24-KiB framebuffer"); + for (std::size_t i=0;i(i))!=expected[i]) + throw std::runtime_error("Compiled gem paint differs: case="+std::to_string(cases)+ + " wire="+std::to_string(wire)+" framebuffer-byte="+std::to_string(i)); + ++cases; + } + } + std::cout << "Compiled gem paint: " << cases << " clockwise/degenerate polygon modes/rotations; every 24-KiB framebuffer byte passed; maximum instructions=" << max_instructions << '\n'; +} + } // namespace int main(int argc, char** argv) { try { if (argc == 2 && std::string(argv[1]) == "--self-test") { selfTest(); + } else if (argc == 4 && std::string(argv[1]) == "--gem-raster") { + checkCompiledGemRaster(readPayload(argv[2]), number(argv[3], 0xffffff)); + } else if (argc == 4 && std::string(argv[1]) == "--gem-raster-private") { + checkCompiledGemRaster(readPayload(argv[2]), number(argv[3], 0xffffff),true); + } else if (argc == 4 && std::string(argv[1]) == "--gem-paint") { + checkCompiledGemPaint(readPayload(argv[2]), number(argv[3], 0xffffff)); } else { if (argc < 6) { throw std::runtime_error("usage: runner payload origin [--initial-sp value] [--initial-ram-bank 0|1] [--word address expected | --register index expected]..."); diff --git a/tests/GSUInstructionModel.hpp b/tests/GSUInstructionModel.hpp index c15bc0b..e9ea18d 100644 --- a/tests/GSUInstructionModel.hpp +++ b/tests/GSUInstructionModel.hpp @@ -90,8 +90,10 @@ class Machine { (static_cast(ram_.at(index ^ 1u)) << 8)); } - void run() { - for (std::size_t step = 0; step < 10000000; ++step) { + void run(std::size_t instruction_limit = 10000000) { + if (!instruction_limit || instruction_limit > 100000000) + throw std::invalid_argument("GSU instruction limit must be in 1..100000000"); + for (std::size_t step = 0; step < instruction_limit; ++step) { const auto opcode = pipeline_; pipeline_ = fetch(); pc_written_ = false; diff --git a/tests/GSUMachineSchedulerTests.cpp b/tests/GSUMachineSchedulerTests.cpp index ed0c8f9..d30d986 100644 --- a/tests/GSUMachineSchedulerTests.cpp +++ b/tests/GSUMachineSchedulerTests.cpp @@ -3,6 +3,7 @@ #include "GSUInstructionModel.hpp" #include "AssemblyGenerator.hpp" #include "Assembler.hpp" +#include #include #include @@ -36,7 +37,8 @@ void equivalent(const ObjectFile& before, const ObjectFile& after, bool compare_ a.graphicsState("--por") == b.graphicsState("--por") && a.graphicsState("--plots") == b.graphicsState("--plots") && a.graphicsState("--rpix") == b.graphicsState("--rpix"), "Scheduler changed graphics state/effects"); - require(a.metrics().taken_branches == b.metrics().taken_branches, "Scheduler changed a branch decision"); + require(a.metrics().taken_branches + a.metrics().jumps == b.metrics().taken_branches + b.metrics().jumps, + "Scheduler changed an executed control transfer"); if (compare_memory) for (std::uint32_t p = 0x700000; p <= 0x71ffff; ++p) require(a.byte(p) == b.byte(p), "Scheduler changed RAM/framebuffer bytes"); else require(a.metrics().stores == 0 && b.metrics().stores == 0 && @@ -117,6 +119,43 @@ void compactBranches() { } } +void shortJumps() { + for (const unsigned gap : {0u, 1u, 126u, 127u, 128u}) { + auto o = object({1,0xff,0,0,1}); + o.code_section.resize(5 + gap, 1); + o.symbol_table.push_back({"target", SymbolSection::CODE, static_cast(o.code_section.size())}); + o.code_section.insert(o.code_section.end(), {0xa0,31,0,1}); + o.relocation_table.push_back({"target",SymbolSection::CODE,1,RelocationType::ADDR16_IWT}); + const auto before = o; + const bool fits = gap <= 126; + const auto stats = GSUMachineScheduler::run(o); + require(stats.short_jumps == (fits ? 1u : 0u) && o.code_section.size() == before.code_section.size() - (fits ? 1u : 0u), + "Symbol-aware PC jump shortening ignored the exact signed-byte boundary"); + require(o.relocation_table.size() == (fits ? 0u : 1u), "Shortened jump retained an absolute relocation"); + equivalent(before,o,false); + require(Assembler().assemble(AssemblyGenerator(o).generate()).code_section == o.code_section, + "Shortened jump lost byte-exact assembly export"); + } + for (const unsigned at : {127u,128u,129u}) { + auto o = object({0,1}); o.code_section.resize(at,1); + o.code_section.insert(o.code_section.end(), {0xff,0,0,1}); + o.symbol_table.push_back({"target",SymbolSection::CODE,2}); + o.relocation_table.push_back({"target",SymbolSection::CODE,at,RelocationType::ADDR16_IWT}); + require(GSUMachineScheduler::run(o).short_jumps == (at <= 128 ? 1u : 0u), + "Backward PC jump shortening ignored the -128 boundary"); + } + // A call's LINK #4 distance and ALT/source selectors are hard fences. + for (const auto& prefix : {std::vector{0x94}, {0x3d}, {0x15}, {0xb5}}) { + auto o = object({1}); o.code_section.insert(o.code_section.end(),prefix.begin(),prefix.end()); + const auto at = static_cast(o.code_section.size()); + o.code_section.insert(o.code_section.end(), {0xff,0,0,1,0,1}); + o.symbol_table.push_back({"target",SymbolSection::CODE,at+4}); + o.relocation_table.push_back({"target",SymbolSection::CODE,at,RelocationType::ADDR16_IWT}); + require(GSUMachineScheduler::run(o).short_jumps == 0 && o.relocation_table.size() == 1, + "PC shortening crossed LINK/ALT/source/destination state"); + } +} + void targetPrefixSlots() { for (const auto selector : {0x17,0xba}) for (const auto stack : {0x20feu,0x1ffeu}) { auto o = object({0xa0,8,0xfa,static_cast(stack),static_cast(stack >> 8), @@ -153,10 +192,214 @@ void targetPrefixSlots() { } } } + +void privatePixelSlots() { + const std::string body = "\x01pixel#body"; + auto original = object({0xfc,3,0,0xfd,0,0,1,0x4c,0x3c,1,0x3d,0x4c,0,1}); + original.symbol_table.push_back({body,SymbolSection::CODE,7}); + original.relocation_table.push_back({body,SymbolSection::CODE,3,RelocationType::ADDR16_IWT}); + for (const unsigned count : {0u,1u,2u,65535u}) { + auto before = original; + before.code_section[1] = static_cast(count); + before.code_section[2] = static_cast(count >> 8); + auto after = before; + const auto statistics = GSUMachineScheduler::run(after); + require(statistics.private_pixel_loops == 1 && statistics.delay_slots == 1 && + after.code_section.size() + 1 == before.code_section.size() && + after.code_section[7] == 0x3c && after.code_section[8] == 0x4c && after.symbol_table[0].offset == 7, + "Private pixel entry did not retain its anchor on LOOP / useful PLOT slot"); + equivalent(before, after); + DiscoGSU::Machine executed(linked(after), 0x008000); executed.run(); + require(executed.graphicsState("--plots") == (count ? count : 65536u) && executed.reg(12) == 0 && executed.reg(1) == count, + "Private pixel slot changed first/final iteration or raw zero wrap"); + require(Assembler().assemble(AssemblyGenerator(after).generate()).code_section == after.code_section, + "Private LOOP/PLOT assembly round trip changed bytes"); + const auto saved = after; + require(GSUMachineScheduler::run(after).private_pixel_loops == 0 && after.code_section == saved.code_section, + "Private pixel scheduling was not idempotent"); + } + for (unsigned fence = 0; fence < 8; ++fence) { + auto unsafe = original; + if (fence == 0) unsafe.symbol_table.push_back({"public_pixel",SymbolSection::CODE,7}); + if (fence == 1) unsafe.symbol_table.push_back({"\x01loop_entry",SymbolSection::CODE,8}); + if (fence == 2) unsafe.symbol_table.push_back({"\x01slot_entry",SymbolSection::CODE,9}); + if (fence == 3) unsafe.relocation_table[0].type = RelocationType::ADDR16_JAL; + if (fence == 4) unsafe.code_section[6] = 0x3d; + if (fence == 5) unsafe.code_section.insert(unsafe.code_section.end(), {5,0xf7,1}); + if (fence == 6) { + unsafe.code_section.insert(unsafe.code_section.begin() + 3, 0x3d); + unsafe.symbol_table[0].offset = 8; unsafe.relocation_table[0].patch_offset = 4; + } + if (fence == 7) { + unsafe.code_section.insert(unsafe.code_section.begin() + 6, 0x9b); + unsafe.symbol_table[0].offset = 8; + } + const auto saved = unsafe; + require(GSUMachineScheduler::run(unsafe).private_pixel_loops == 0 && unsafe.code_section == saved.code_section, + "Private pixel slot crossed an external/secondary entry, LINK/ALT state or non-R13 relocation"); + } +} } +void pipelinePlan() { + auto split = object({0x01, 0x05, 0x02, 0x01, 0x00, 0xa5, 0x09, 0x00, 0x01}); + const auto split_before = split; + require(GSUMachineScheduler::run(split).immediate_slots == 1 && split.code_section.size() == 8 && + split.code_section[3] == 0xa5 && split.code_section[5] == 0x09, + "Unconditional BRA did not leave only the IBT opcode in its delay slot"); + equivalent(split_before, split, false); + DiscoGSU::Machine split_machine(linked(split), 0x008000); split_machine.run(); + require(split_machine.reg(5) == 9, "Split IBT did not write its immediate"); + + auto adjacent = object({0x01, 0x05, 0x01, 0x01, 0xa5, 0x09, 0x00, 0x01}); + const auto adjacent_before = adjacent; + require(GSUMachineScheduler::run(adjacent).immediate_slots == 1 && adjacent.code_section[3] == 0xa5 && + adjacent.code_section[4] == 0x09, "Adjacent IBT was not split across BRA's delay slot"); + equivalent(adjacent_before, adjacent, false); + + auto fallthrough = object({0x01, 0x05, 0x02, 0x01, 0x01, 0xa5, 0x09, 0x00, 0x01}); + const auto fallthrough_before = fallthrough; + require(GSUMachineScheduler::run(fallthrough).immediate_slots == 0 && + fallthrough.code_section == fallthrough_before.code_section, + "A fall-through immediate was split into a delay slot"); + auto conditional = object({0x01, 0x09, 0x02, 0x01, 0x00, 0xa5, 0x09, 0x00, 0x01}); + const auto conditional_before = conditional; + require(GSUMachineScheduler::run(conditional).immediate_slots == 0 && + conditional.code_section == conditional_before.code_section, + "A conditional branch split an immediate that both paths execute"); + + auto entry = object({0x01, 0x05, 0x02, 0x01, 0x00, 0xd5, 0x05, 0x01, 0x01, 0x00, 0x01}); + entry.symbol_table.push_back({"\x01head", SymbolSection::CODE, 5}); + const auto entry_before = entry; + const auto entry_stats = GSUMachineScheduler::run(entry); + require(entry_stats.entry_slots == 1 && entry.code_section.size() == 10 && entry.code_section[7] == 0xd5, + "An INC entry immediately before BRA did not become that BRA's delay slot"); + equivalent(entry_before, entry, false); + DiscoGSU::Machine entry_machine(linked(entry), 0x008000); entry_machine.run(); + require(entry_machine.reg(5) == 1, "Retargeted INC entry did not run once"); + + auto nested = object({0x02}); + nested.code_section.insert(nested.code_section.end(), 2, 0x01); + nested.code_section.insert(nested.code_section.end(), {0x02, 0x00, 0x01}); + nested.symbol_table.push_back({GSUCodeLayout::CacheAlignmentSymbol, SymbolSection::CODE, 0}); + const auto nested_before = nested; + require(GSUMachineScheduler::run(nested).nested_caches == 1, "Proven same-CBR/clear-selector CACHE was kept"); + require(std::count(nested.code_section.begin(), nested.code_section.end(), 0x02) == 1, + "Nested CACHE opcode remained in the window"); + equivalent(nested_before, nested, false); + + auto spaced = object({0x02}); + spaced.code_section.insert(spaced.code_section.end(), 520, 0x01); + spaced.code_section.insert(spaced.code_section.end(), {0x02, 0x00, 0x01}); + require(GSUMachineScheduler::run(spaced).nested_caches == 0 && + std::count(spaced.code_section.begin(), spaced.code_section.end(), 0x02) == 2, + "A CACHE outside the previous 512-byte window was removed"); + + auto function_entry = nested_before; + function_entry.symbol_table.push_back({"inner", SymbolSection::CODE, 3}); + require(GSUMachineScheduler::run(function_entry).nested_caches == 0 && + std::count(function_entry.code_section.begin(), function_entry.code_section.end(), 0x02) == 2, + "A public entry CACHE inside another window was removed"); +} +void nestedCacheDelaySlots() { + for (const auto branch : {std::uint8_t{5}, std::uint8_t{9}}) for (const auto input : {0u, 1u}) { + auto o = object({2,0xf0,static_cast(input),0,0xa3,0,0xb0,0x3f,0x63, + branch,5,2,0xa5,0x44,0,1,0xa0,42,0,1}); + const auto before = o; + const auto stats = GSUMachineScheduler::run(o); + require(stats.nested_caches == 0 && o.code_section.size() == before.code_section.size(), + "Unproven effective-PC CACHE delay slot was changed"); + require(o.code_section[11] == 2, "Branch CACHE delay slot lost its CBR effect or one-byte pipeline boundary"); + equivalent(before, o, false); + DiscoGSU::Machine machine(linked(o),0x008000); machine.run(); + const bool taken = branch == 5 || input == 0; + require(machine.reg(0) == (taken ? 42 : input) && machine.reg(5) == (taken ? 0 : 0x44), + "CACHE removal split a following immediate on a taken/untaken branch"); + } + for (const auto trips : {1u, 2u, 7u}) { + auto o = object({2,0xac,static_cast(trips),0xfd,0,0,0xd5,0x3c,2,0xa0,42,0,1}); + o.symbol_table.push_back({"\x01loop-cache-slot",SymbolSection::CODE,6}); + o.relocation_table.push_back({"\x01loop-cache-slot",SymbolSection::CODE,3,RelocationType::ADDR16_IWT}); + const auto before = o; + require(GSUMachineScheduler::run(o).nested_caches == 0 && o.code_section[8] == 2, + "LOOP's effective-PC CACHE lost its CBR effect or required delay-slot byte"); + equivalent(before, o, false); + DiscoGSU::Machine machine(linked(o),0x008000); machine.run(); + require(machine.reg(5) == trips && machine.reg(0) == 42, + "CACHE removal changed LOOP's taken/final iteration pipeline"); + } + auto transfers = object({2,5,3,2,5,2,1,1,0,1}); + const auto before = transfers; + GSUMachineScheduler::run(transfers); equivalent(before, transfers, false); +} + +void cacheProvenance() { + // IBT's opcode moves into BRA's slot, but its #02 operand remains at the + // target. Numeric equality with CACHE must not turn that operand into code. + auto immediate = object({2,5,2,1,0,0xa5,2,0,1}); + immediate.symbol_table.push_back({GSUCodeLayout::CacheAlignmentSymbol, SymbolSection::CODE, 0}); + const auto immediate_before = immediate; + const auto split = GSUMachineScheduler::run(immediate); + require(split.immediate_slots == 1 && split.nested_caches == 0 && immediate.code_section[5] == 2, + "CACHE elimination interpreted a split immediate #02 operand as executable code"); + equivalent(immediate_before, immediate, false); + DiscoGSU::Machine immediate_machine(linked(immediate), 0x008000); immediate_machine.run(); + require(immediate_machine.reg(5) == 2, "Split immediate #02 lost its literal value"); + + for (const bool aligned : {false, true}) { + auto window = object({2}); window.code_section.insert(window.code_section.end(), 20, 1); + window.code_section.insert(window.code_section.end(), {2,0,1}); + if (aligned) window.symbol_table.push_back({GSUCodeLayout::CacheAlignmentSymbol, SymbolSection::CODE, 0}); + const auto before = window; + require(GSUMachineScheduler::run(window).nested_caches == 0 && window.code_section == before.code_section, + "Different-CBR CACHE was removed merely because its PC was inside the old 512-byte window"); + DiscoGSU::Machine a(linked(before), 0x008000), b(linked(window), 0x008000); a.run(); b.run(); + require(a.cacheBase() == b.cacheBase() && a.cacheRequests() == b.cacheRequests(), + "CACHE scheduling changed the final effective CBR/window"); + } + auto selectors = object({0xa5,9,2,0x25,2,0x17,0,1}); + selectors.symbol_table.push_back({GSUCodeLayout::CacheAlignmentSymbol, SymbolSection::CODE, 0}); + const auto selectors_before = selectors; + require(GSUMachineScheduler::run(selectors).nested_caches == 0 && selectors.code_section == selectors_before.code_section, + "CACHE selector reset was removed between WITH and TO"); + equivalent(selectors_before, selectors, false); + for (const auto& bytes : {std::vector{2,0xa0,1,0xa5,7,0xb5,2,0x50,0,1}, + std::vector{2,0xa0,1,0x3e,2,0x50,0,1}}) { + auto active = object(bytes); + active.symbol_table.push_back({GSUCodeLayout::CacheAlignmentSymbol, SymbolSection::CODE, 0}); + const auto before = active; + require(GSUMachineScheduler::run(active).nested_caches == 0 && active.code_section == before.code_section, + "Same-CBR CACHE lost its active FROM/ALT selector reset"); + equivalent(before, active, false); + DiscoGSU::Machine machine(linked(active), 0x008000); machine.run(); + require(machine.reg(0) == 2, "CACHE did not clear an active FROM/ALT before ADD R0"); + } + + for (const bool public_entry : {false, true}) { + auto entry = object({2,1,1,1,2,0,1}); + entry.symbol_table.push_back({GSUCodeLayout::CacheAlignmentSymbol, SymbolSection::CODE, 0}); + entry.symbol_table.push_back({public_entry ? "main" : "\x01private-entry", SymbolSection::CODE, 3}); + const auto before = entry; + require(GSUMachineScheduler::run(entry).nested_caches == 0 && entry.code_section == before.code_section, + "CACHE proof crossed a public/private entry that can bypass its predecessor CACHE"); + DiscoGSU::Machine from_entry(std::vector(entry.code_section.begin() + 3, entry.code_section.end()), 0x008003); + from_entry.run(); require(from_entry.cacheRequests() == 1, "Independent entry lost its only executed CACHE"); + } + auto call = object({2,0x94,0xff,0,0,1,2,0,1}); + call.code_section.resize(520, 1); + call.symbol_table.push_back({GSUCodeLayout::CacheAlignmentSymbol, SymbolSection::CODE, 0}); + call.symbol_table.push_back({"\x01" "callee", SymbolSection::CODE, 520}); + call.relocation_table.push_back({"\x01" "callee", SymbolSection::CODE, 2, RelocationType::ADDR16_IWT}); + call.code_section.insert(call.code_section.end(), {2,0x9b,1}); + const auto call_before = call; + require(GSUMachineScheduler::run(call).nested_caches == 0, "CACHE proof crossed a call that can change CBR"); + equivalent(call_before, call, false); + DiscoGSU::Machine caller(linked(call), 0x008000); caller.run(); + require(caller.cacheRequests() == 3 && caller.cacheBase() == 0x8000, "Call continuation did not restore its CACHE window"); +} + int main() { try { - conditionSlots(); targetPrefixSlots(); compactBranches(); + conditionSlots(); targetPrefixSlots(); compactBranches(); shortJumps(); privatePixelSlots(); pipelinePlan(); nestedCacheDelaySlots(); cacheProvenance(); auto bra = object({0xf0,0,0,0xd0,5,2,1,1,0,1}); auto before = bra; require(GSUMachineScheduler::run(bra).delay_slots == 1 && bra.code_section.size() == 9, "Safe one-byte BRA delay slot was not filled"); equivalent(before, bra); @@ -169,7 +412,8 @@ int main() { auto guard = object({0xfa,0,0x20,0xea,0x0c,5,1,0xff,0,0,1,0,1}); guard.symbol_table.push_back({"exit",SymbolSection::CODE,11}); guard.relocation_table.push_back({"exit",SymbolSection::CODE,7,RelocationType::ADDR16_IWT}); before = guard; - require(GSUMachineScheduler::run(guard).delay_slots == 1 && guard.code_section[6] == 0xff, + const auto guard_stats = GSUMachineScheduler::run(guard); + require(guard_stats.delay_slots == 1 && guard_stats.short_jumps == 1 && guard.code_section[6] == 5, "A filled conditional slot was moved again into the following jump's slot"); equivalent(before, guard); for (auto opcode : {8,9,10,11,6,7}) { auto unsafe = object({0xf0,0,0,0xd0,static_cast(opcode),2,1,1,0,1}); before = unsafe; diff --git a/tests/GSUSevenBackendTests.cpp b/tests/GSUSevenBackendTests.cpp new file mode 100644 index 0000000..b43ce86 --- /dev/null +++ b/tests/GSUSevenBackendTests.cpp @@ -0,0 +1,484 @@ +#include "IRCodeGenerator.hpp" +#include "IRGlobalOptimizer.hpp" +#include "IRStorageOptimizer.hpp" +#include "IRControlFlow.hpp" +#include "AssemblyGenerator.hpp" +#include "Assembler.hpp" +#include "GSUInstructionModel.hpp" +#include "ABI.hpp" +#include "Lexer.hpp" +#include "Parser.hpp" +#include "LinkerDriver.hpp" +#include "TestTempDirectory.hpp" + +#include +#include +#include +#include +#include +#include + +namespace { +void require(bool condition, const char* message) { if (!condition) throw std::runtime_error(message); } +struct Compilation { ObjectFile object; IRModule ir; }; +Compilation compile(const std::string& source, OptimizationLevel level, std::uint32_t origin = 0x008000, + bool require_no_spills = false) { + Lexer lexer(source); const auto tokens = lexer.scanTokens(); Parser parser(tokens); auto ast = parser.parseProgram(); + DataSegmentManager data; Analyzer analyzer(data); analyzer.analyze(ast); IRLowerer lowerer; auto original = lowerer.lower(ast); + auto ir = original; + if (isGlobalOptimization(level)) { + IRGlobalOptimizer::run(ir, analyzer.getAllLocalSymbols(), level); + IRStorageOptions options; + if ((origin >> 16) == 0x70 || (origin >> 16) == 0x71) + options.immutable_arrays = IRImmutableArrayPolicy::CopiedNearRamPayload; + IRStorageOptimizer::run(ir, analyzer.getAllLocalSymbols(), options); + } + CompilerConfig config; config.optimization = level; config.code_start_address = origin; + config.require_no_spills = require_no_spills; + IRCodeGenerator backend(analyzer.getAllLocalSymbols(), analyzer.getFunctionSymbols(), data, config); + auto object = backend.generate(original); + Assembler assembler; const auto roundtrip = assembler.assemble(AssemblyGenerator(object).generate()); + require(roundtrip.code_section == object.code_section && roundtrip.data_section == object.data_section, + "Seven-point backend assembly roundtrip changed CODE/DATA"); + for (const auto& r : object.relocation_table) if (r.target_symbol_name.compare(0, + std::string(GSUAbi::ImmutablePoolPrefix).size(), GSUAbi::ImmutablePoolPrefix) == 0) { + require(std::any_of(roundtrip.relocation_table.begin(), roundtrip.relocation_table.end(), [&](const RelocationEntry& copy) { + return copy.patch_offset == r.patch_offset && copy.type == RelocationType::ADDR16_RAM && + copy.target_symbol_name.compare(0, std::string(GSUAbi::ImmutablePoolPrefix).size(), GSUAbi::ImmutablePoolPrefix) == 0; + }), "Assembly roundtrip lost the guarded private RAM-pool namespace"); + } + IRCodeGenerator repeated(analyzer.getAllLocalSymbols(), analyzer.getFunctionSymbols(), data, config); + require(AssemblyGenerator(repeated.generate(original)).generate() == AssemblyGenerator(object).generate(), + "Seven-point candidate selection differs between fresh backend instances"); + require(AssemblyGenerator(backend.generate(original)).generate() == AssemblyGenerator(object).generate(), + "Selected-candidate state or spill evidence leaked across reuse of one backend instance"); + return {std::move(object), std::move(ir)}; +} + +std::vector linked(const ObjectFile& object) { + constexpr unsigned startup = 10; + const auto base = object.config.code_start_address; + auto bytes = object.code_section; + if (!object.data_section.empty() && (base + startup + bytes.size()) % object.data_alignment) + bytes.resize(bytes.size() + object.data_alignment - (base + startup + bytes.size()) % object.data_alignment, 0); + const auto data = bytes.size(); + bytes.insert(bytes.end(), object.data_section.begin(), object.data_section.end()); + std::uint32_t entry = 0; + for (const auto& s : object.symbol_table) if (s.name == "main") entry = base + startup + s.offset; + require(entry != 0, "Backend execution fixture has no main"); + for (const auto& r : object.relocation_table) { + std::uint32_t value = 0; bool found = false; + const auto limit = std::string(GSUAbi::StackLimitPrefix); + if (r.target_symbol_name.compare(0, limit.size(), limit) == 0) { + value = static_cast(std::stoul(r.target_symbol_name.substr(limit.size()))); found = true; + } + if (r.target_symbol_name == GSUAbi::NearRamBankSymbol) { value = 0x700000; found = true; } + if (r.target_symbol_name == GSUAbi::NearRomBankSymbol) { value = base <= 0x5fffff ? base : 0x008000; found = true; } + for (const auto& s : object.symbol_table) if (s.name == r.target_symbol_name) { + require(s.section != SymbolSection::RAM, "Unexpected static RAM fixture"); + value = base + startup + (s.section == SymbolSection::DATA ? data : 0) + s.offset; found = true; + } + require(found, "Unresolved seven-point execution fixture relocation"); + bytes.at(r.patch_offset + 1) = static_cast(r.type == RelocationType::ADDR24_BANK ? value >> 16 : value); + if (r.type != RelocationType::ADDR24_BANK) bytes.at(r.patch_offset + 2) = static_cast(value >> 8); + } + bytes.insert(bytes.begin(), {0xfc, 0xef, 0xbe, 0xfd, 0xfe, 0xca, 0xff, + static_cast(entry), static_cast(entry >> 8), 1}); + return bytes; +} + +void execution(const std::string& source, const std::vector>& cases, + std::uint32_t origin = 0x008000, unsigned expected_reads = 1) { + for (const auto level : {OptimizationLevel::O1, OptimizationLevel::O2, OptimizationLevel::Size}) { + const auto compiled = compile(source, level, origin); + for (const auto& input : cases) { + DiscoGSU::Machine machine(linked(compiled.object), origin); + machine.seed(0x700100, static_cast(input.first), false); + machine.seed(0x700101, static_cast(input.first >> 8), false); + machine.run(); + require(machine.reg(0) == input.second, "Seven-point backend changed the scalar result"); + require(machine.reg(6) == 0 && machine.reg(12) == 0xbeef && machine.reg(13) == 0xcafe, + "Seven-point backend changed fault/LOOP register restoration"); + require(machine.reg(10) == 0x1ffc && machine.reg(9) == 0x1ffc, + "Seven-point backend changed main's frame/stack restoration"); + require(machine.accesses(0x700100, false) == expected_reads && machine.accesses(0x700100, true) == 0, + "Seven-point backend changed actual volatile input access multiplicity"); + } + } +} + +void predicates() { + const std::string source = "word main(){unsigned word x=*(volatile unsigned word*)0x100;word s=0;" + "if(x>32)s=1;if(x>32)s+=2;return s;}"; + execution(source, {{0,0},{32,0},{33,3},{65535,3}}); + const auto ir = compile(source, OptimizationLevel::O2).ir; + unsigned reads = 0; + for (const auto& f : ir.functions) for (const auto& b : f.blocks) for (const auto& i : b.instructions) + reads += i.opcode == IROpcode::LoadIndirect && i.memory_volatile; + require(reads == 1, "Conditional-only comparison rematerialization repeated a volatile load"); +} + +void callsAndLoops() { + const std::string leaf = "word leaf(word x){if(x==12345)return 4;return x+1;}" + "word main(){word s=0;for(word i=0;i<32;i++)s+=leaf(i);return s;}"; + execution(leaf, {{0,528}}, 0x008000, 0); + const std::string transitive = "word inner(word x){word s=x;for(word j=0;j<5;j++)s+=j;return s;}" + "word middle(word x){return inner(x);}" + "word main(){word s=0;for(word i=0;i<8;i++)s+=middle(i);return s;}"; + execution(transitive, {{0,108}}, 0x008000, 0); + const std::string recursive = "word inner(word x){word s=x;for(word j=0;j<5;j++)s+=j;return s;}" + "word recursive(word n){if(n==0)return inner(0);return recursive(n-1)+1;}" + "word main(){word s=0;for(word i=0;i<8;i++)s+=recursive(i);return s;}"; + execution(recursive, {{0,108}}, 0x008000, 0); + execution("word main(){word a=*(volatile word*)0x100;word s=0;for(word i=0;i<64;i++){s+=a;s^=i;}return s;}", + {{0,0},{1,64},{7,384}}); +} + +void physicalSpills() { + std::string source = "word main(){"; + for (unsigned n = 0; n < 8; ++n) + source += "word a" + std::to_string(n) + "=*(volatile word*)" + std::to_string(256 + n * 2) + ";"; + for (unsigned n = 0; n < 8; ++n) source += "a" + std::to_string(n) + "++;"; + source += "return a0+a1+a2+a3+a4+a5+a6+a7;}"; + execution(source, {{0,8},{32767,32775},{65535,7}}); + const auto object = compile(source, OptimizationLevel::O2).object; + require(AssemblyGenerator(object).generate().find("sbk") != std::string::npos, + "Physical reusable frame-word updates did not reuse their SBK address latch"); +} + +void numericStackAliases() { + for (const bool write : {false, true}) { + const auto path = std::filesystem::path(__FILE__).parent_path() / "storage" / + (write ? "raw_numeric_alias_write.dc" : "raw_numeric_alias_read.dc"); + std::ifstream in(path, std::ios::binary); + require(static_cast(in), "Cannot read the registered numeric stack-alias fixture"); + const std::string source(std::istreambuf_iterator(in), {}); + // Check the fixture's raw address against the independently analyzed + // ORIGINAL frame, never against the storage plan under test. + Lexer lexer(source); const auto tokens = lexer.scanTokens(); + Parser parser(tokens); auto ast = parser.parseProgram(); DataSegmentManager data; Analyzer analyzer(data); + analyzer.analyze(ast); IRLowerer lowerer; const auto original = lowerer.lower(ast); + SymbolId table; + for (const auto& b : original.functions[0].blocks) for (const auto& i : b.instructions) + if (i.opcode == IROpcode::Address && i.symbol == "table") table = i.symbol_id; + require(table.isValid(), "Raw stack-alias fixture lost its original array identity"); + const auto offset = analyzer.getAllLocalSymbols().at("main").at(table).stackOffset; + const auto address = static_cast(0x1ffc + offset); + require(address == 0x1ff4, "Numeric alias fixture no longer names its original local-array bytes"); + for (const auto level : {OptimizationLevel::O1, OptimizationLevel::O2, OptimizationLevel::Size}) { + const auto compiled = compile(source, level, 0x706000); + if (isGlobalOptimization(level)) require(!compiled.ir.functions[0].storage_layout_valid && + compiled.ir.functions[0].immutable_local_arrays.empty(), "Raw numeric stack alias did not freeze the original storage plan"); + for (const auto& r : compiled.object.relocation_table) + require(r.target_symbol_name.compare(0, std::string(GSUAbi::ImmutablePoolPrefix).size(), + GSUAbi::ImmutablePoolPrefix) != 0, "Raw numeric stack alias acquired pooled storage"); + for (const auto index : {0u, 1u, 2u, 3u}) { + DiscoGSU::Machine machine(linked(compiled.object), 0x706000); + machine.seed(0x700100, static_cast(index), false); + machine.seed(0x700180, 0x5a, false); machine.seed(0x710180, 0xa5, false); + machine.run(); + const auto expected = write ? (index == 0 ? 99u : (index + 1) * 11u) : (index + 2) * 11u; + require(machine.reg(0) == expected, "Raw numeric pointer did not observe the original local-array bytes"); + const auto reads = (write ? 0u : 1u) + (index == 0 ? 1u : 0u); + require(machine.accesses(0x700000 + address, false) == reads && + machine.accesses(0x700000 + address, true) == (write ? 2u : 1u) && machine.accesses(0x700100, false) == 1, + "Raw stack-alias read/write multiplicity changed"); + require(machine.reg(10) == 0x1ffc && machine.reg(9) == 0x1ffc && machine.reg(6) == 0 && + machine.reg(12) == 0xbeef && machine.reg(13) == 0xcafe && machine.ramBank() == 0 && + machine.byte(0x700180) == 0x5a && machine.byte(0x710180) == 0xa5, + "Raw stack-alias fallback damaged stack, fault, bank or sentinel state"); + } + } + } +} + +std::string immutablePoolSource() { + // Nonuniform initialization is deliberately large enough to amortize + // retained pointer checks and DATA. A small eligible pool need not win. + std::string source = "word f(unsigned word i){word a[64]={"; + for (unsigned n = 0; n < 64; ++n) { + if (n) source += ','; + source += std::to_string(n % 4 == 0 ? 3 : n % 4 == 1 ? 5 : n % 4 == 2 ? 7 : 11); + } + return source + "};return a[i&63];}word main(){return f(*(volatile unsigned word*)0x100);}"; +} + +void actualLinkerPools() { + auto object = compile(immutablePoolSource(), OptimizationLevel::Size, 0x706000).object; + TestTempDirectory temp("seven-backend-link"); + const auto direct = temp.path() / "direct.o", assembly = temp.path() / "assembly.o"; + object.write(direct.string()); + auto from_assembly = Assembler().assemble(AssemblyGenerator(object).generate()); from_assembly.write(assembly.string()); + const auto read = [](const std::filesystem::path& path) { + std::ifstream in(path, std::ios::binary); require(static_cast(in), "Cannot read linker roundtrip artifact"); + return std::vector(std::istreambuf_iterator(in), {}); + }; + const auto link = [&](const std::filesystem::path& input, const std::string& stem, bool runtime) { + const auto binary = temp.path() / (stem + ".bin"), text = temp.path() / (stem + ".s"); + std::vector arguments{"discld", input.string(), "--origin", "0x706000", "--ram-bank", "0", + "--emit-asm", text.string(), "-o", binary.string()}; + if (runtime) arguments.insert(arguments.end(), {"--init-runtime", "--stack-pointer", "0x4000"}); + struct Capture { + std::ostringstream text; + std::streambuf* out = std::cout.rdbuf(text.rdbuf()); + std::streambuf* error = std::cerr.rdbuf(text.rdbuf()); + ~Capture() { std::cout.rdbuf(out); std::cerr.rdbuf(error); } + } capture; + const auto result = runLinker(arguments); + if (result != 0) throw std::runtime_error("Actual pool linker path failed: " + capture.text.str()); + return read(binary); + }; + const auto direct_bytes = link(direct, "direct", true), assembly_bytes = link(assembly, "assembly", true); + require(direct_bytes == assembly_bytes, "Actual linker changed payload between compiler object and compiler .s"); + std::ifstream final_text(temp.path() / "direct.s"); + const std::string final_source(std::istreambuf_iterator(final_text), {}); + final_text.close(); + auto final_object = Assembler().assemble(final_source); const auto final_path = temp.path() / "final.o"; + final_object.write(final_path.string()); + require(link(final_path, "final", false) == direct_bytes, "Actual linked .s roundtrip changed payload bytes"); + DiscoGSU::Machine machine(direct_bytes, 0x706000); machine.seed(0x700100, 2, false); machine.run(); + require(machine.reg(0) == 7 && machine.reg(6) == 0 && machine.reg(10) == 0x3ffc && machine.accesses(0x700100, false) == 1, + "Actual linked immutable-pool payload changed execution/stack/volatile reads"); + temp.cleanup(); +} + +void immutablePools() { + const auto source = immutablePoolSource(); + execution(source, {{0,3},{1,5},{2,7},{3,11},{4,3},{65535,11}}, 0x706000); + for (const auto level : {OptimizationLevel::O2, OptimizationLevel::Size}) { + const auto object = compile(source, level, 0x706000).object; + require(std::any_of(object.relocation_table.begin(), object.relocation_table.end(), [](const RelocationEntry& r) { + return r.type == RelocationType::ADDR16_RAM && r.target_symbol_name.compare(0, + std::string(GSUAbi::ImmutablePoolPrefix).size(), GSUAbi::ImmutablePoolPrefix) == 0; + }), "Profitable proven immutable local array was not emitted in copied RAM payload DATA"); + } +} + +void legacyMixedIncumbent() { + // Different functions share one immutable DATA range. Division ownership, + // CODE/DATA padding and the pointer-clear choice must be costed together, + // not replaced by independently attractive function-byte estimates. + const auto original = immutablePoolSource(); + const auto main = original.find("word main()"); + require(main != std::string::npos, "Mixed incumbent fixture lost main"); + const auto table = original.substr(0, main); + auto second = table; second.replace(second.find("word f("), 7, "word g("); + const auto source = table + second + "word divide(word a,word b,word c){return a/b+c/b;}" + "word main(){unsigned word i=*((volatile unsigned word*)0x100);word* p=(word*)0x400;" + "for(word n=0;n<32;n++){p[n]=0;}return f(i)+g(i+1)+divide((word)i,3,99);}"; + const auto selected = compile(source, OptimizationLevel::Size, 0x706000).object; + // Frozen legacy complete-search payload: CODE669 + startup10 + pad1 + + // shared DATA128 =808. This ceiling is self-contained, not tool-dependent. + require(linked(selected).size() <= 808 && selected.data_section.size() == 128, + "Pointer-only extension lost the complete legacy mixed CODE/padding/shared-DATA incumbent"); + std::vector offsets; + for (const auto& symbol : selected.symbol_table) if (symbol.section == SymbolSection::DATA && + symbol.name.compare(0, std::string(GSUAbi::ImmutablePoolPrefix).size(), GSUAbi::ImmutablePoolPrefix) == 0) + offsets.push_back(symbol.offset); + require(offsets.size() == 2 && offsets[0] == offsets[1], "Mixed candidate lost cross-function DATA deduplication"); + for (const auto input : {0u, 1u, 2u, 37u}) { + DiscoGSU::Machine machine(linked(selected), 0x706000); + machine.seed(0x700100, static_cast(input), false); + for (unsigned p = 0x400; p < 0x440; ++p) machine.seed(0x700000 + p, 0xa5, false); + machine.run(); + const unsigned values[] = {3,5,7,11}; + require(machine.reg(0) == values[input % 4] + values[(input + 1) % 4] + input / 3 + 33 && + machine.reg(6) == 0 && machine.reg(10) == 0x1ffc && machine.reg(12) == 0xbeef && machine.reg(13) == 0xcafe, + "Mixed incumbent changed pooled loads/division or ABI restoration"); + require(machine.accesses(0x700100, false) == 1 && machine.accesses(0x700100, true) == 0, + "Mixed incumbent changed volatile input multiplicity"); + for (unsigned p = 0x400; p < 0x440; ++p) require(machine.byte(0x700000 + p) == 0, + "Mixed incumbent changed bounded clear stores"); + } +} + +void strictCandidateReuse() { + const std::string source = "word main(){word a=*(volatile word*)0x100;word b=*(volatile word*)0x102;" + "word c=*(volatile word*)0x104;for(word i=0;i<32;i++){a+=b*3;b+=c*3;c+=a*3;}return a^b^c;}" + "word last_spill_free(){return 42;}"; + const auto ordinary = compile(source, OptimizationLevel::Size).object; + // compile() also repeats generate on this SAME strict backend instance. + const auto strict = compile(source, OptimizationLevel::Size, 0x008000, true).object; + require(strict.code_section.size() > ordinary.code_section.size() && + AssemblyGenerator(strict).generate().find("loop ;") != std::string::npos && + AssemblyGenerator(ordinary).generate().find("loop ;") == std::string::npos, + "Strict mixed-function Os lost its exact larger spill-free selected candidate"); + for (const auto* object : {&ordinary, &strict}) for (const auto initial : {0u, 1u, 32768u, 65535u}) { + DiscoGSU::Machine machine(linked(*object), 0x008000); + std::uint16_t values[] = {static_cast(initial), 5, 7}; + for (unsigned n = 0; n < 3; ++n) { + machine.seed(0x700100 + n * 2, static_cast(values[n]), false); + machine.seed(0x700101 + n * 2, static_cast(values[n] >> 8), false); + } + for (unsigned n = 0; n < 32; ++n) { + values[0] = static_cast(values[0] + values[1] * 3); + values[1] = static_cast(values[1] + values[2] * 3); + values[2] = static_cast(values[2] + values[0] * 3); + } + machine.run(); + require(machine.reg(0) == (values[0] ^ values[1] ^ values[2]) && machine.reg(6) == 0 && + machine.reg(9) == 0x1ffc && machine.reg(10) == 0x1ffc && machine.reg(12) == 0xbeef && machine.reg(13) == 0xcafe, + "Strict selected-candidate reuse changed execution or ABI restoration"); + for (unsigned n = 0; n < 3; ++n) require(machine.accesses(0x700100 + n * 2, false) == 1 && + machine.accesses(0x700100 + n * 2, true) == 0, "Strict selected candidate changed volatile reads"); + } +} + +void regionalFarOperandFallback() { + // Deliberately use the validated IR boundary: the frontend disallows a + // far-to-word cast, but the verifier/backend support this operand shape. + // Eight live scalar snapshots force regional invariant pressure. The + // otherwise identical near-pointer case must really borrow both registers. + for (const bool far : {false, true}) { + const Type integer{BaseType::WORD, "", 2, true}; + IRFunction function; function.name = "main"; function.return_type = integer; + function.entry = IRBlockId{0}; + function.blocks = {{IRBlockId{0}, "entry", {}}, {IRBlockId{1}, "pixels", {}}, {IRBlockId{2}, "exit", {}}}; + const auto value = [&](unsigned block, IROpcode opcode, Type type, std::vector operands, + std::int64_t immediate = 0, const std::string& operation = std::string()) { + IRInstruction instruction; instruction.opcode = opcode; instruction.type = std::move(type); + instruction.result = IRValueId{++function.value_count}; instruction.operands = std::move(operands); + instruction.immediate = immediate; instruction.operation = operation; + instruction.in_plot_context = block != 0; + function.blocks.at(block).instructions.push_back(std::move(instruction)); + return IRValueId{function.value_count}; + }; + const auto effect = [&](unsigned block, IROpcode opcode, std::vector operands = {}) { + IRInstruction instruction; instruction.opcode = opcode; instruction.operands = std::move(operands); + if (opcode == IROpcode::Rpix) instruction.type = Type{BaseType::VOID, "", 0, false}; + instruction.in_plot_context = block != 0; + function.blocks.at(block).instructions.push_back(std::move(instruction)); + }; + std::vector inputs; + auto volatile_integer = integer; volatile_integer.is_volatile = true; + for (unsigned n = 0; n < 8; ++n) { + const auto address = value(0, IROpcode::Constant, pointerTo(volatile_integer, AddressSpace::RAM), {}, 0x100 + n * 2); + inputs.push_back(value(0, IROpcode::LoadIndirect, integer, {address})); + function.blocks[0].instructions.back().memory_volatile = true; + } + const auto pointer = value(0, IROpcode::Constant, + pointerTo(Type{BaseType::BYTE, "", 1, true}, AddressSpace::RAM, far), {}, far ? 0x700201 : 0x0201); + const auto trips = value(0, IROpcode::Constant, integer, {}, 4); + IRInstruction setup; setup.opcode = IROpcode::HardwareLoop; setup.operands = {trips}; + setup.targets = {IRBlockId{1}}; setup.loop_target = IRBlockId{1}; setup.loop_id = 1; + function.blocks[0].instructions.push_back(std::move(setup)); + const auto cast = value(1, IROpcode::Cast, integer, {pointer}); + effect(1, IROpcode::SetColor, {cast}); effect(1, IROpcode::Plot); + for (const auto input : inputs) { effect(1, IROpcode::SetColor, {input}); effect(1, IROpcode::Plot); } + IRInstruction latch; latch.opcode = IROpcode::HardwareLoopEnd; latch.loop_id = 1; + latch.targets = {IRBlockId{1}, IRBlockId{2}}; latch.in_plot_context = true; + function.blocks[1].instructions.push_back(std::move(latch)); + effect(2, IROpcode::HardwareLoopLeave); function.blocks[2].instructions.back().loop_id = 1; + effect(2, IROpcode::Rpix); // Flush both pixel caches before checking the independent planar image. + auto sum = inputs.front(); + for (std::size_t n = 1; n < inputs.size(); ++n) sum = value(2, IROpcode::Binary, integer, {sum, inputs[n]}, 0, "+"); + effect(2, IROpcode::Return, {sum}); + IRModule module; module.functions.push_back(std::move(function)); IRVerifier::verify(module); + const std::map locals{{"main", {}}}; + const std::map functions; + DataSegmentManager data; + auto optimized = module; IRGlobalOptimizer::run(optimized, locals); IRVerifier::verify(optimized); + std::map definitions; + for (const auto& b : optimized.functions[0].blocks) for (const auto& i : b.instructions) + if (i.result.isValid()) definitions.emplace(i.result.value, i.type); + const IRControlFlow flow(optimized.functions[0]); + unsigned loops = 0, far_casts = 0; + for (const auto& b : optimized.functions[0].blocks) for (const auto& i : b.instructions) { + loops += i.opcode == IROpcode::HardwareLoop; + if (i.opcode == IROpcode::Cast && isFarPointer(definitions.at(i.operands.at(0).value))) + far_casts += std::any_of(flow.loops.begin(), flow.loops.end(), + [&](const IRControlFlow::Loop& loop) { return loop.blocks.count(b.id.value) != 0; }); + } + require(loops == 1 && far_casts == (far ? 1u : 0u), "Regional far-operand fixture lost its verified LOOP/Cast boundary"); + for (const auto level : {OptimizationLevel::O1, OptimizationLevel::O2, OptimizationLevel::Size}) { + CompilerConfig config; config.optimization = level; config.code_start_address = 0x008000; + IRCodeGenerator generator(locals, functions, data, config); const auto object = generator.generate(module); + const auto assembly = AssemblyGenerator(object).generate(); + require(Assembler().assemble(assembly).code_section == object.code_section, + "Regional far-operand fixture changed bytes in assembly roundtrip"); + unsigned borrowed = 0, used = 0, setups = 0; + if (isGlobalOptimization(level)) { + // Canonical assembly already separates opcodes from operand + // bytes. Never treat a literal $14/$43 as TO R4 / LDW (R3). + struct Emitted { std::size_t offset; std::string text; }; + std::vector instructions; + std::istringstream lines(assembly); std::string line; + const std::string marker = " ; CODE+$"; + while (std::getline(lines, line)) { + const auto comment = line.find(marker), start = line.find_first_not_of(' '); + if (comment != std::string::npos && start < comment) + instructions.push_back({static_cast(std::stoul(line.substr(comment + marker.size()), nullptr, 16)), + line.substr(start, comment - start)}); + } + for (const auto& relocation : object.relocation_table) { + const auto end = static_cast(relocation.patch_offset); + if (relocation.section_to_patch != SymbolSection::CODE || relocation.type != RelocationType::ADDR16_IWT || + object.code_section.at(end) != 0xfd) continue; + ++setups; + const auto target = std::find_if(object.symbol_table.begin(), object.symbol_table.end(), + [&](const SymbolEntry& symbol) { return symbol.name == relocation.target_symbol_name; }); + require(target != object.symbol_table.end() && target->section == SymbolSection::CODE, + "Regional fixture has no resolved LOOP body label"); + const auto latch = std::find_if(instructions.begin(), instructions.end(), [&](const Emitted& i) { + return i.offset >= target->offset && i.text == "loop"; + }); + require(latch != instructions.end(), "Regional fixture has no decoded LOOP latch"); + for (std::size_t n = 0; n + 1 < instructions.size(); ++n) { + const auto& i = instructions[n]; + if (i.offset < end && end - i.offset <= 24 && instructions[n + 1].text == "ldw (r3)") { + if (i.text == "to r4") borrowed |= 1u << 4; + if (i.text == "to r6") borrowed |= 1u << 6; + } + if (i.offset >= target->offset && i.offset < latch->offset) { + if (i.text == "from r4" || i.text == "with r4") used |= 1u << 4; + if (i.text == "from r6" || i.text == "with r6") used |= 1u << 6; + } + } + } + } + for (const auto first : {1u, 0x7ff8u, 0xfff8u}) { + DiscoGSU::Machine machine(linked(object), 0x008000); machine.configureScreen(0x21, 0x18); + unsigned expected_sum = 0; + for (unsigned n = 0; n < 8; ++n) { + const auto input = static_cast(first + n); expected_sum += input; + machine.seed(0x700100 + n * 2, static_cast(input), false); + machine.seed(0x700101 + n * 2, static_cast(input >> 8), false); + } + machine.seed(0x700180, 0x5a, false); machine.seed(0x710180, 0xa5, false); machine.run(); + require(machine.reg(0) == static_cast(expected_sum) && machine.reg(6) == 0 && + machine.reg(9) == 0x1ffc && machine.reg(10) == 0x1ffc && machine.reg(11) == 0x5555 && + machine.reg(12) == 0xbeef && machine.reg(13) == 0xcafe && machine.ramBank() == 0 && + machine.reg(1) == 36 && machine.reg(2) == 0 && machine.graphicsState("--plots") == 36 && + machine.byte(0x700180) == 0x5a && machine.byte(0x710180) == 0xa5, + "Regional far fallback changed scalar/cursor/stack/LOOP/fault/bank/sentinel state"); + for (unsigned n = 0; n < 8; ++n) require(machine.accesses(0x700100 + n * 2, false) == 1 && + machine.accesses(0x700100 + n * 2, true) == 0, "Regional far fallback changed volatile snapshot multiplicity"); + std::vector pixels(0x6000, 0); + for (unsigned x = 0; x < 36; ++x) { + const auto color = x % 9 == 0 ? 1u : (first + x % 9 - 1) & 15u; + // Independent 256x192 4bpp row-zero planar expectation. + for (unsigned plane = 0; plane < 4; ++plane) if (color & (1u << plane)) + pixels.at((x / 8) * 768 + (plane / 2) * 16 + plane % 2) |= static_cast(1u << (7 - x % 8)); + } + for (std::size_t n = 0; n < pixels.size(); ++n) { + const auto actual = machine.byte(0x706000 + static_cast(n)); + if (actual != pixels[n]) throw std::runtime_error("Regional plotted-byte mismatch: far=" + std::to_string(far) + + " level=" + std::to_string(static_cast(level)) + " input=" + std::to_string(first) + + " offset=" + std::to_string(n) + " expected=" + std::to_string(pixels[n]) + + " actual=" + std::to_string(actual)); + } + } + if (isGlobalOptimization(level) && !(setups == 1 && borrowed == (far ? 0u : (1u << 4) | (1u << 6)) && + (far || used == borrowed))) + throw std::runtime_error("Regional path proof failed: far=" + std::to_string(far) + + " level=" + std::to_string(static_cast(level)) + " setups=" + std::to_string(setups) + + " borrowed=" + std::to_string(borrowed) + " used=" + std::to_string(used)); + } + } +} +} + +int main() { + try { predicates(); callsAndLoops(); physicalSpills(); numericStackAliases(); immutablePools(); actualLinkerPools(); legacyMixedIncumbent(); strictCandidateReuse(); regionalFarOperandFallback(); return 0; } + catch (const std::exception& error) { std::cerr << error.what() << '\n'; return 1; } +} diff --git a/tests/GSUSizeOptimizationTests.cpp b/tests/GSUSizeOptimizationTests.cpp index 681e6c5..b557135 100644 --- a/tests/GSUSizeOptimizationTests.cpp +++ b/tests/GSUSizeOptimizationTests.cpp @@ -5,6 +5,7 @@ #include "IRCodeGenerator.hpp" #include "LinearScanAllocator.hpp" #include "AssemblyGenerator.hpp" +#include "GSUInstructionModel.hpp" #include "Lexer.hpp" #include "Optimizer.hpp" #include "Parser.hpp" @@ -13,6 +14,142 @@ #include #include +// Private, invocation-only evidence stays out of ObjectFile and public APIs. +struct IRCodeGeneratorTestAccess { + static void verify() { + const auto require = [](bool ok, const char* message) { if (!ok) throw std::runtime_error(message); }; + const std::string source = "word main(){return 7;}"; + Lexer lexer(source); const auto tokens = lexer.scanTokens(); Parser parser(tokens); + auto ast = parser.parseProgram(); DataSegmentManager data; Analyzer analyzer(data); analyzer.analyze(ast); + IRLowerer lowerer; auto module = lowerer.lower(ast); module.functions[0].link_name = "physical_main"; + CompilerConfig config; config.optimization = OptimizationLevel::Size; + IRCodeGenerator backend(analyzer.getAllLocalSymbols(),analyzer.getFunctionSymbols(),data,config); + auto emitted = backend.generateOptimized(module,OptimizationLevel::O2,IRCompactionPolicy::Disabled); + require(emitted.function_metrics.size() == 1 && emitted.function_metrics.count("main") == 1 && + !emitted.function_metrics.count("physical_main") && emitted.function_metrics.at("main").speed_score != 0, + "Scheduled function metrics lost source identity or became a missing zero score"); + const auto owned = emitted.function_metrics; + auto reference = emitted; reference.speed_score = 100; reference.function_metrics.at("main").speed_score = 100; + for (const unsigned score : {99u,100u,101u}) { + auto proposed = reference; proposed.speed_score = score; proposed.function_metrics.at("main").speed_score = score; + require(backend.admissibleExtension(proposed,reference) == (score <= 100), + "Extension speed admissibility did not distinguish lower/equal/higher scores"); + } + auto missing = reference; missing.function_metrics.clear(); + require(!backend.admissibleExtension(missing,reference), "Missing function evidence admitted an extension"); + auto divergent = reference; divergent.speed_score = 50; divergent.function_metrics.at("main").speed_score = 101; + require(!backend.admissibleExtension(divergent,reference), "A faster total hid a slower function"); + auto balanced_reference = reference; balanced_reference.speed_score = 200; + balanced_reference.function_metrics.emplace("other",IRCodeGenerator::FunctionMetric{100,true}); + auto balanced = balanced_reference; balanced.speed_score = 151; + balanced.function_metrics.at("main").speed_score = 101; balanced.function_metrics.at("other").speed_score = 50; + require(!backend.admissibleExtension(balanced,balanced_reference), "An improvement in another function hid a local loss"); + IRCodeGenerator::SpillIssue issue; issue.function = "main"; + for (const bool strict : {false,true}) { + config.require_no_spills = strict; + auto spilling = reference; spilling.issues.push_back(issue); spilling.function_metrics.at("main").no_spills = false; + auto eligible = reference; eligible.issues.clear(); eligible.function_metrics.at("main").no_spills = true; + eligible.speed_score = 200; eligible.function_metrics.at("main").speed_score = 200; + require(backend.admissibleExtension(eligible,spilling) == strict, + "Strict spill-free improvement did not preserve eligibility precedence"); + spilling.speed_score = 1; spilling.function_metrics.at("main").speed_score = 1; + require(backend.admissibleExtension(spilling,eligible) == !strict, + "Strict extension replaced an eligible function with a faster spilling function"); + } + auto global_spilling = balanced_reference; global_spilling.issues.push_back(issue); + global_spilling.function_metrics.at("main").no_spills = false; + auto globally_eligible = balanced_reference; globally_eligible.issues.clear(); + globally_eligible.speed_score = 700; globally_eligible.function_metrics.at("main").speed_score = 200; + globally_eligible.function_metrics.at("other").speed_score = 500; + require(backend.admissibleExtension(globally_eligible,global_spilling), + "Global strict spill-free improvement was blocked by a slower already-eligible function"); + require(!backend.admissibleExtension(global_spilling,globally_eligible), + "Global strict eligibility regressed before local proxy comparison"); + auto missing_eligible = globally_eligible; missing_eligible.function_metrics.erase("other"); + require(!backend.admissibleExtension(missing_eligible,global_spilling), + "Strict eligibility improvement bypassed complete function evidence"); + config.require_no_spills = false; + auto optimized = module; IRGlobalOptimizer::run(optimized,analyzer.getAllLocalSymbols(),OptimizationLevel::O2,IRCompactionPolicy::Disabled); + backend.m_function_candidates.clear(); backend.m_collect_function_candidates = true; + IRCodeGenerator::FunctionCandidate greedy; greedy.bytes = 100000; greedy.speed_score = 1000; + greedy.function.name = "unchanged"; backend.m_function_candidates["main"] = greedy; + // The greedy map disagrees with the complete incumbent. Reject BEFORE + // collection, even though the function is smaller/faster than that map. + const IRCodeGenerator::CandidateReference collection_reference(reference); + backend.considerFunctionCandidates(optimized,divergent,&collection_reference); + require(backend.m_function_candidates.at("main").function.name == "unchanged", + "Rejected extension overwrote a function snapshot or used the greedy reference"); + backend.considerFunctionCandidates(optimized,reference,&collection_reference); + require(backend.m_function_candidates.at("main").function.name == "main", + "Equal-score extension was not admitted before byte-first collection"); + for (unsigned mutation = 0; mutation < 3; ++mutation) { + auto invalid = emitted; + for (auto i = invalid.object.symbol_table.begin(); i != invalid.object.symbol_table.end();) { + if ((mutation == 0 && i->name == "physical_main") || + (mutation == 1 && i->name == std::string(1,'\x01') + "main#0")) i = invalid.object.symbol_table.erase(i); + else { if (mutation == 2 && i->name == "physical_main") i->offset = static_cast(invalid.object.code_section.size() + 1); ++i; } + } + backend.recordCandidateMetrics(optimized,invalid); + require(invalid.function_metrics.empty() && !backend.admissibleExtension(invalid,reference), + "Invalid symbol/bounds evidence was silently scored as zero"); + } + auto moved = std::move(emitted); + backend.m_collect_function_candidates = false; + backend.generateOptimized(module,OptimizationLevel::Size,IRCompactionPolicy::PointerPromotionOnly); + require(moved.function_metrics.at("main").speed_score == owned.at("main").speed_score && + moved.function_metrics.at("main").no_spills == owned.at("main").no_spills, + "A later emission changed moved candidate-owned metrics"); + // Exercise cleanup after an exception inside mixed override setup. + backend.m_function_candidates.clear(); greedy.function = module.functions[0]; + backend.m_function_candidates["main"] = greedy; + auto incomplete = module; auto absent = module.functions[0]; absent.name = "absent"; absent.link_name.clear(); + incomplete.functions.push_back(absent); bool failed = false; + try { backend.generateMixedCandidate(incomplete); } catch (const CompilerError&) { failed = true; } + require(failed && backend.m_function_policies.empty() && backend.m_function_division_policies.empty() && + backend.m_function_pool_policies.empty() && !backend.m_collect_function_candidates && backend.m_allow_auto_cache && + backend.m_current_function == nullptr && backend.m_values.empty(), + "Mixed exception leaked phase overrides or collection/cache flags"); + auto invalid_module = module; invalid_module.functions[0].blocks.clear(); failed = false; + try { backend.generate(invalid_module); } catch (const CompilerError&) { failed = true; } + require(failed && !backend.m_collect_function_candidates && backend.m_function_policies.empty(), + "Generation exception leaked candidate phase state"); + IRCodeGenerator fresh(analyzer.getAllLocalSymbols(),analyzer.getFunctionSymbols(),data,config); + require(AssemblyGenerator(backend.generate(module)).generate() == AssemblyGenerator(fresh.generate(module)).generate(), + "Fresh and reused backends diverged after failed candidate emission"); + const std::string loop_source = "word main(){volatile word sum=0;for(word i=0;i<97;i++){if(i&1)sum+=2;else sum++;}return sum;}"; + Lexer loop_lexer(loop_source); + const auto loop_tokens = loop_lexer.scanTokens(); Parser loop_parser(loop_tokens); auto loop_ast = loop_parser.parseProgram(); + DataSegmentManager loop_data; Analyzer loop_analyzer(loop_data); loop_analyzer.analyze(loop_ast); + IRLowerer loop_lowerer; const auto loop = loop_lowerer.lower(loop_ast); + IRCodeGenerator trials(loop_analyzer.getAllLocalSymbols(),loop_analyzer.getFunctionSymbols(),loop_data,config); + trials.m_collect_function_candidates = true; + auto trial = trials.generateOptimized(loop,OptimizationLevel::O2); + IRModule scheduled; + for (const auto& f : loop.functions) scheduled.functions.push_back(trials.m_function_candidates.at(f.name).function); + require(trial.speed_score == trials.candidateSpeed(scheduled,trial.object) && + trial.function_metrics.at("main").speed_score == trials.candidateSpeed(scheduled,trial.object,&scheduled.functions[0]), + "CACHE/uncached selection left metrics from a different scheduled object"); + const auto trial_metric = trial.function_metrics.at("main"); + trials.m_force_long_branches = true; trials.m_allow_auto_cache = true; + auto cached = trials.generateInternal(scheduled); + require(trials.m_had_cache_probe && cached.speed_score == trials.candidateSpeed(scheduled,cached.object), + "Candidate metrics fixture lost automatic CACHE probing or its exact emitted score"); + const auto cached_metric = cached.function_metrics.at("main"); + trials.m_allow_auto_cache = false; + const auto uncached = trials.generateInternal(scheduled); + require(!trials.m_had_cache_probe && uncached.function_metrics.at("main").speed_score == + trials.candidateSpeed(scheduled,uncached.object,&scheduled.functions[0]) && + cached.function_metrics.at("main").speed_score == cached_metric.speed_score, + "Uncached emission changed an earlier CACHE candidate's owned metrics"); + trials.m_collect_function_candidates = false; + auto mixed = trials.generateMixedCandidate(loop); + require(mixed.speed_score == trials.candidateSpeed(scheduled,mixed.object) && + mixed.function_metrics.at("main").speed_score == trials.candidateSpeed(scheduled,mixed.object,&scheduled.functions[0]) && + trial.function_metrics.at("main").speed_score == trial_metric.speed_score, + "Mixed re-emission reused greedy scores or changed an earlier candidate's evidence"); + } +}; + namespace { void require(bool value, const char* message) { if (!value) throw std::runtime_error(message); } std::size_t count(const IRModule& m, IROpcode op) { @@ -155,10 +292,10 @@ void phiStress() { [](const IRModule&, const IRModule& m) { require(count(m, IROpcode::HardwareLoopEnd) == 1, "PHI stress lost its hardware backedge"); }, true); } -ObjectFile compile(const std::string& source, OptimizationLevel level) { +ObjectFile compile(const std::string& source, OptimizationLevel level, bool require_no_spills = false) { Lexer lexer(source); const auto tokens = lexer.scanTokens(); Parser parser(tokens); auto ast = parser.parseProgram(); DataSegmentManager data; Analyzer analyzer(data); analyzer.analyze(ast); IRLowerer lowerer; const auto module = lowerer.lower(ast); - CompilerConfig config; config.optimization = level; + CompilerConfig config; config.optimization = level; config.require_no_spills = require_no_spills; IRCodeGenerator backend(analyzer.getAllLocalSymbols(), analyzer.getFunctionSymbols(), data, config); return backend.generate(module); } @@ -183,6 +320,24 @@ void sharedTails() { "Alignment islands were not shared locally or recreated beyond byte-branch range"); } +void moduleBranchRelaxation() { + const auto function = [](unsigned n) { + std::string source = "word f" + std::to_string(n) + "(word x){if(x){"; + for (unsigned k = 0; k < 24; ++k) + source += "*(volatile word*)0x100=x+" + std::to_string(k) + ";"; + return source + "}if(x==1)return 17;return 19;}"; + }; + std::string source; + for (unsigned n = 0; n < 12; ++n) source += function(n); + for (const auto level : {OptimizationLevel::O1, OptimizationLevel::O2}) { + const auto single = compile(function(0), level), many = compile(source, level); + require(many.code_section.size() == 12 * single.code_section.size(), + "Module-wide branch widening depends on function count or expanded unrelated short branches"); + require(occurrences(AssemblyGenerator(many).generate(), "iwt r15") >= 12, + "Relaxation stress no longer needs a long branch in each function"); + } +} + void boundedFusion() { IRModule module; IRFunction f; f.name = "main"; f.entry = IRBlockId{0}; f.return_type = Type{BaseType::WORD, "", 2}; f.blocks.push_back({IRBlockId{0}, "entry", {}}); @@ -268,11 +423,168 @@ void sizePolicy() { require(AssemblyGenerator(size).generate() == AssemblyGenerator(compile(repeated, OptimizationLevel::Size)).generate(), "Os machine candidate selection is nondeterministic"); } + +void noSpillCandidates() { + for (const auto level : {OptimizationLevel::Baseline,OptimizationLevel::O1,OptimizationLevel::O2,OptimizationLevel::Size}) { + for (const auto source : {"word main(){return 42;}", + "void opaque(word* p,word n);void allowed(word n){word local=42;opaque(&local,7);}"}) { + const auto ordinary = compile(source,level), strict = compile(source,level,true); + require(AssemblyGenerator(ordinary).generate()==AssemblyGenerator(strict).generate(), + "No-spill validation changed a spill-free object or rejected explicit local/ABI storage"); + } + } + const std::string pressure = + "word sink(word a,word b,word c,word d,word e,word f,word g,word h);" + "word pressure(){word sum=0;for(word i=0;i<32;i++)sum+=*(volatile word*)0x200;" + "return sum+sink(*(volatile word*)0x100,*(volatile word*)0x102,*(volatile word*)0x104,*(volatile word*)0x106," + "*(volatile word*)0x108,*(volatile word*)0x10a,*(volatile word*)0x10c,*(volatile word*)0x10e);}" + "word last_spill_free(){return 42;}"; + require(AssemblyGenerator(compile(pressure,OptimizationLevel::O2)).generate().find("cache ;")!=std::string::npos, + "Cached/uncached spill-witness fixture lost its automatic CACHE trial"); + for (const auto level : {OptimizationLevel::Baseline,OptimizationLevel::O1,OptimizationLevel::O2,OptimizationLevel::Size}) { + bool rejected = false; + try { compile(pressure,level,true); } + catch (const CompilerError& error) { + const auto& message = error.getMessage(); + require(message.find("--require-no-spills")!=std::string::npos && message.find("function 'pressure'")!=std::string::npos && + message.find("first value %")!=std::string::npos && message.find("mapped IR value(s)")!=std::string::npos && + message.find("unique 16-bit slot(s)")!=std::string::npos, + "No-spill failure lost its selected candidate's function/value/category/physical-slot evidence"); + rejected = true; + } + require(rejected,"A later spill-free function/CACHE alternative erased a real spill witness"); + } + for (const auto level : {OptimizationLevel::O2,OptimizationLevel::Size}) { + bool rejected = false; + try { compile("word paired(word a,word b){return a/b+a%b;}",level,true); } + catch (const CompilerError& error) { + require(error.getMessage().find("auxiliary divmod spill homes")!=std::string::npos, + "No-spill diagnostic did not identify the compiler-generated secondary divmod word"); + rejected = true; + } + require(rejected,"An auxiliary divmod frame word escaped no-spill validation"); + } + // O2's hardware counter leaves registers for the carried values and the + // multiply temporary. The smaller size-policy countdown spills. Strict + // Os must keep the earlier larger eligible object, not the last + // candidate's mutable emitter state or the smallest bytes. + // Three carried words plus the software counter exceed the widened pool. + // The hardware counter still leaves a register for every carried word. + const std::string loop = "word main(){word a=*(volatile word*)0x100;word b=*(volatile word*)0x102;" + "word c=*(volatile word*)0x104;" + "for(word i=0;i<32;i++){a+=b*3;b+=c*3;c+=a*3;}return a^b^c;}"; + const auto ordinary = compile(loop,OptimizationLevel::Size), strict = compile(loop,OptimizationLevel::Size,true); + require(strict.code_section.size()>ordinary.code_section.size(), + "Eligible-candidate fixture no longer skips a smaller ineligible Os object"); + require(AssemblyGenerator(strict).generate().find("loop ;")!=std::string::npos && + AssemblyGenerator(ordinary).generate().find("loop ;")==std::string::npos, + "Strict Os did not preserve its spill-free hardware-counter candidate"); + require(AssemblyGenerator(strict).generate()==AssemblyGenerator(compile(loop,OptimizationLevel::Size,true)).generate(), + "Eligible-candidate selection or spill evidence is nondeterministic"); + const auto linked = [](const ObjectFile& object) { + auto bytes = object.code_section; + for (const auto& relocation : object.relocation_table) { + std::uint32_t address = 0; bool found = false; + const std::string prefix = "__disco_stack_limit_"; + if (relocation.target_symbol_name.compare(0,prefix.size(),prefix)==0) { + address=static_cast(std::stoul(relocation.target_symbol_name.substr(prefix.size()))); found=true; + } + for (const auto& symbol : object.symbol_table) if (symbol.name==relocation.target_symbol_name && symbol.section==SymbolSection::CODE) { + address=0x8006+symbol.offset; found=true; + } + require(found,"Missing strict-candidate execution relocation"); + bytes.at(relocation.patch_offset+1)=static_cast(address); + bytes.at(relocation.patch_offset+2)=static_cast(address >> 8); + } + bytes.insert(bytes.begin(), {0xfc,0xef,0xbe,0xfd,0xfe,0xca}); + return bytes; + }; + for (const auto* object : {&ordinary,&strict}) for (const unsigned initial : {0u,1u,32768u,65535u}) { + DiscoGSU::Machine machine(linked(*object),0x8000); + std::uint16_t values[] = {static_cast(initial),5,7}; + for (unsigned n=0;n<3;++n) { + machine.seed(0x700100+n*2,static_cast(values[n]),false); + machine.seed(0x700101+n*2,static_cast(values[n] >> 8),false); + } + for (unsigned n=0;n<32;++n) { + values[0]=static_cast(values[0]+values[1]*3); + values[1]=static_cast(values[1]+values[2]*3); + values[2]=static_cast(values[2]+values[0]*3); + } + machine.run(); + // main deliberately STOPs with its own frame installed; unlike an + // ordinary callee it does not pop the saved frame/link words. + if (machine.reg(0)!=(values[0]^values[1]^values[2]) || machine.reg(6)!=0 || machine.reg(10)!=0x1ffc || machine.reg(9)!=0x1ffc || + machine.reg(11)!=0x5555 || machine.reg(12)!=0xbeef || machine.reg(13)!=0xcafe) { + throw std::runtime_error("Spill-candidate execution/ABI mismatch: bytes=" + std::to_string(object->code_section.size()) + + " input=" + std::to_string(initial) + " R0=" + std::to_string(machine.reg(0)) + + " expected=" + std::to_string(values[0]^values[1]^values[2]) + " R6=" + std::to_string(machine.reg(6)) + + " R9=" + std::to_string(machine.reg(9)) + + " R10=" + std::to_string(machine.reg(10)) + " R11=" + std::to_string(machine.reg(11)) + + " R12=" + std::to_string(machine.reg(12)) + " R13=" + std::to_string(machine.reg(13))); + } + } +} + +void nearRomRegisterScratch() { + for (const bool rom : {false, true}) { + const unsigned width = rom ? 4 : 5; + std::string source = "word main(){"; + for (unsigned n = 0; n < width; ++n) + source += "word a" + std::to_string(n) + "=*(volatile word*)" + std::to_string(256 + n * 2) + ";"; + if (rom) source += "plot{color *(rom byte*)0x9000;pixel;flush;}"; + source += "return a0"; + for (unsigned n = 1; n < width; ++n) source += "+a" + std::to_string(n); + source += ";}"; + for (const auto level : {OptimizationLevel::O1, OptimizationLevel::O2, OptimizationLevel::Size}) { + const auto object = compile(source, level); + if (!rom && isGlobalOptimization(level)) require(AssemblyGenerator(object).generate().find("to r4 ") != std::string::npos, + "Near-ROM scratch reservation unnecessarily removed safe near-RAM R4 allocation"); + auto bytes = object.code_section; + require(object.data_section.empty(), "Near-ROM scratch fixture unexpectedly acquired DATA"); + for (const auto& relocation : object.relocation_table) { + std::uint32_t address = 0; bool found = false; + const std::string prefix = "__disco_stack_limit_"; + if (relocation.target_symbol_name.compare(0, prefix.size(), prefix) == 0) { + address = static_cast(std::stoul(relocation.target_symbol_name.substr(prefix.size()))); found = true; + } + if (relocation.target_symbol_name == "__disco_near_rom_bank") { address = 0; found = true; } + for (const auto& symbol : object.symbol_table) if (symbol.name == relocation.target_symbol_name && symbol.section == SymbolSection::CODE) { + address = 0x8006 + symbol.offset; found = true; + } + require(found, "Missing near-ROM scratch fixture relocation"); + bytes.at(relocation.patch_offset + 1) = static_cast(address); + if (relocation.type != RelocationType::ADDR24_BANK) + bytes.at(relocation.patch_offset + 2) = static_cast(address >> 8); + } + bytes.insert(bytes.begin(), {0xfc,0xef,0xbe,0xfd,0xfe,0xca}); + for (const auto initial : {0u, 32767u, 65535u}) { + DiscoGSU::Machine machine(bytes, 0x008000); + unsigned expected = initial; + for (unsigned n = 0; n < width; ++n) { + const auto value = n ? n + 1 : initial; + if (n) expected += value; + machine.seed(0x700100 + n * 2, static_cast(value), false); + machine.seed(0x700101 + n * 2, static_cast(value >> 8), false); + } + machine.seed(0x009000, 149, true); machine.run(); + require(machine.reg(0) == static_cast(expected) && machine.reg(6) == 0 && + machine.reg(9) == 0x1ffc && machine.reg(10) == 0x1ffc && machine.reg(12) == 0xbeef && machine.reg(13) == 0xcafe, + "Near-ROM guard scratch corrupted a live scalar home or ABI restoration"); + for (unsigned n = 0; n < width; ++n) require(machine.accesses(0x700100 + n * 2, false) == 1 && + machine.accesses(0x700100 + n * 2, true) == 0, "Near-ROM scratch reservation changed volatile reads"); + if (rom) require(machine.graphicsState("--plots") == 1 && machine.metrics().rom_reads == 1, + "Near-ROM reservation regression did not execute its guarded GETC/PLOT path"); + } + } + } +} } int main() { try { - divmod(); phiStress(); sharedTails(); boundedFusion(); sizePolicy(); reusableDivisionContexts(); + IRCodeGeneratorTestAccess::verify(); + divmod(); phiStress(); sharedTails(); moduleBranchRelaxation(); boundedFusion(); sizePolicy(); reusableDivisionContexts(); noSpillCandidates(); nearRomRegisterScratch(); std::cout << "O2/Os divmod, exact-tail sharing, PHI interference and deterministic codegen checks passed\n"; } catch (const CompilerError& e) { std::cerr << e.getMessage() << '\n'; return 1; } catch (const std::exception& e) { std::cerr << e.what() << '\n'; return 1; } diff --git a/tests/GemChessRasterOracle.hpp b/tests/GemChessRasterOracle.hpp new file mode 100644 index 0000000..2168c31 --- /dev/null +++ b/tests/GemChessRasterOracle.hpp @@ -0,0 +1,205 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +// Widened midpoint oracle for the gem-chess collector. No compiler-produced +// instruction or candidate collector formula participates in this reference. +namespace GemChessRasterOracle { +using Edge = std::array; +struct Spans { + std::array left, right; + Spans() { left.fill(256); right.fill(-1); } +}; +inline void mark(Spans& spans, int first, int last, int y) { + if (y >= 0 && y < 192) { + spans.left.at(static_cast(y)) = std::min(spans.left.at(static_cast(y)), first); + spans.right.at(static_cast(y)) = std::max(spans.right.at(static_cast(y)), last); + } +} +inline Edge canonical(Edge edge) { + const auto dx = std::abs(edge[2] - edge[0]), dy = std::abs(edge[3] - edge[1]); + if ((dx >= dy && edge[0] > edge[2]) || (dx < dy && edge[1] > edge[3])) { + std::swap(edge[0], edge[2]); std::swap(edge[1], edge[3]); + } + return edge; +} +inline Spans reference(Edge edge) { + edge = canonical(edge); + const int dx = std::abs(edge[2] - edge[0]), dy = std::abs(edge[3] - edge[1]); + const bool horizontal = dx >= dy; + const int major = horizontal ? dx : dy, minor = horizontal ? dy : dx; + const int direction = horizontal ? (edge[3] < edge[1] ? -1 : 1) : (edge[2] < edge[0] ? -1 : 1); + Spans result; + for (int k = 0; k <= major; ++k) { + const auto numerator = static_cast(major / 2) + static_cast(k) * minor; + const int step = major ? static_cast(numerator / major) : 0; + const int x = horizontal ? edge[0] + k : edge[0] + direction * step; + const int y = horizontal ? edge[1] + direction * step : edge[1] + k; + mark(result, x, x, y); + } + return result; +} +// Literal widened baseline port, used only to validate the independent oracle. +inline Spans frozenCollector(Edge edge) { + edge = canonical(edge); + int x = edge[0], y = edge[1]; + const int dx = std::abs(edge[2] - x), dy = std::abs(edge[3] - y); + Spans result; + if (dx >= dy) { + const int direction = edge[3] < y ? -1 : 1; + int first = x, error = dx / 2; + for (int k = 0; k <= dx; ++k) { + int next = error + dy; + if (k == dx || next >= dx) { + mark(result, first, x, y); + if (k == dx) break; + first = x + 1; y += direction; next -= dx; + } + error = next; ++x; + } + } else { + const int direction = edge[2] < x ? -1 : 1; + int error = dy / 2; + for (int k = 0; k <= dy; ++k) { + mark(result, x, x, y); + error += dx; + if (error >= dy) { error -= dy; x += direction; } + ++y; + } + } + return result; +} +inline std::string describe(const Edge& edge) { + return std::to_string(edge[0]) + "," + std::to_string(edge[1]) + " -> " + + std::to_string(edge[2]) + "," + std::to_string(edge[3]); +} +inline std::vector runtimeEdges() { + std::vector result = {Edge{{0,0,0,0}}, Edge{{255,191,255,191}}, Edge{{0,0,2,1}}, + Edge{{-3,0,258,0}}, Edge{{0,-1,0,192}}, Edge{{-512,-256,767,447}}, Edge{{0,191,255,192}}}; + const int xs[] = {-512,-1,0,1,127,254,255,256,767}; + const int ys[] = {-256,-1,0,1,95,190,191,192,447}; + for (const auto x : xs) for (const auto y : ys) result.push_back(Edge{{x,y,255-x,191-y}}); + std::uint32_t seed = 0x43f1d2a5; + const auto random = [&](unsigned limit) -> int { + seed = seed * 1664525u + 1013904223u; return static_cast(seed % limit); + }; + for (unsigned i = 0; i < 128; ++i) + result.push_back(Edge{{random(1280)-512,random(704)-256,random(1280)-512,random(704)-256}}); + return result; +} +inline std::vector runtimePrivateEdges() { + std::vector result; + for (const auto& edge : runtimeEdges()) + if (edge[0]>=0 && edge[0]<=255 && edge[2]>=0 && edge[2]<=255 && + edge[1]>=0 && edge[1]<=191 && edge[3]>=0 && edge[3]<=191) result.push_back(edge); + std::uint32_t seed=0xa5d54edd; + const auto random=[&](unsigned limit)->int { seed=seed*1664525u+1013904223u;return static_cast(seed%limit); }; + for (unsigned i=0;i<256;++i) result.push_back(Edge{{random(256),random(192),random(256),random(192)}}); + return result; +} +using Point = std::array; +using Polygon = std::vector; +inline std::vector runtimePolygons() { + std::vector result = { + {Point{{0,0}},Point{{0,191}},Point{{255,191}},Point{{255,0}}}, + {Point{{-1,-1}},Point{{-1,192}},Point{{256,192}},Point{{256,-1}}}, + {Point{{2,7}},Point{{19,7}},Point{{11,7}}}, + {Point{{9,9}},Point{{9,9}},Point{{9,9}}}, + {Point{{0,-1}},Point{{0,192}},Point{{255,191}}}, + {Point{{255,0}},Point{{-1,191}},Point{{256,191}}}, + {Point{{-30,50}},Point{{-10,100}},Point{{-5,80}}}, + {Point{{300,50}},Point{{310,100}},Point{{320,80}}}, + {Point{{0,-30}},Point{{2,-10}},Point{{30,-20}}}, + {Point{{0,220}},Point{{2,250}},Point{{30,230}}}, + {Point{{0,0}},Point{{0,191}},Point{{0,80}}}, + {Point{{255,191}},Point{{255,0}},Point{{255,80}}}, + {Point{{-256,0}},Point{{128,191}},Point{{511,0}}}, + {Point{{0,0}},Point{{1,1}},Point{{2,0}}} + }; + std::uint32_t seed = 0x174437fd; + const auto random = [&](unsigned limit) -> int { + seed = seed * 1664525u + 1013904223u; return static_cast(seed % limit); + }; + for (unsigned i=0; i<64; ++i) { + const int x=random(384)-64, y=random(256)-32; + const int u=random(192)+1, v=random(96)-48, w=random(96)-48, t=-random(144)-1; + Polygon polygon{Point{{x,y}},Point{{x+u,y+v}},Point{{x+u+w,y+v+t}},Point{{x+w,y+t}}}; + if (u*t-v*w > 0) std::reverse(polygon.begin(), polygon.end()); + if (i%2) polygon.pop_back(); + result.push_back(std::move(polygon)); + } + return result; +} +inline void rasterEdge(const Edge& original, std::array& pixels, std::uint8_t ink) { + const auto edge=canonical(original); + const int dx=std::abs(edge[2]-edge[0]), dy=std::abs(edge[3]-edge[1]); + const bool horizontal=dx>=dy; + const int major=horizontal?dx:dy, minor=horizontal?dy:dx; + const int direction=horizontal?(edge[3]((static_cast(major/2)+static_cast(k)*minor)/major):0; + const int x=horizontal?edge[0]+k:edge[0]+direction*step; + const int y=horizontal?edge[1]+direction*step:edge[1]+k; + if (x>=0 && x<256 && y>=0 && y<192) pixels.at(static_cast(y)*256+x)=ink; + } +} +inline std::array framebuffer(const Polygon& polygon, bool wire, std::uint8_t ink) { + Spans combined; + std::array pixels{}; + for (std::size_t i=0;i(x))=ink; + std::array result{}; + for (unsigned y=0;y<192;++y) for (unsigned x=0;x<256;++x) { + const auto value=pixels[y*256+x]; + const auto address=((x/8)*24+y/8)*32+(y%8)*2; + for (unsigned plane=0;plane<4;++plane) + result.at(address+(plane/2)*16+plane%2)|=static_cast(((value>>plane)&1u)<<(7-x%8)); + } + return result; +} +inline void selfTest() { + const auto compare = [](const Edge& edge) { + const auto expected = reference(edge), actual = frozenCollector(edge); + if (expected.left != actual.left || expected.right != actual.right) + throw std::runtime_error("Midpoint reference disagrees with frozen collector: " + describe(edge)); + const Edge reverse{{edge[2],edge[3],edge[0],edge[1]}}; + const auto reversed = reference(reverse); + if (expected.left != reversed.left || expected.right != reversed.right) + throw std::runtime_error("Midpoint edge orientation changed coverage: " + describe(edge)); + }; + for (int a=-4; a<=4; ++a) for (int b=-4; b<=4; ++b) + for (int c=-4; c<=4; ++c) for (int d=-4; d<=4; ++d) compare(Edge{{a,b,c,d}}); + for (const auto& edge : runtimeEdges()) compare(edge); + const auto tie = reference(Edge{{0,0,2,1}}), point = reference(Edge{{4,7,4,7}}); + if (tie.left[0] != 0 || tie.right[0] != 0 || tie.left[1] != 1 || tie.right[1] != 2 || + point.left[7] != 4 || point.right[7] != 4 || point.left[6] != 256 || point.right[8] != -1) + throw std::runtime_error("Midpoint tie, endpoint inclusion or degenerate-point convention changed"); + const auto full=framebuffer(runtimePolygons().front(),false,15); + if (!std::all_of(full.begin(),full.end(),[](std::uint8_t value){return value==255;})) + throw std::runtime_error("Planar oracle missed a full-viewport pixel or final bitplane byte"); + const Polygon lastPixel{Point{{255,191}},Point{{255,191}},Point{{255,191}}}; + const auto last=framebuffer(lastPixel,false,8); + if (last.back()!=1 || std::count(last.begin(),last.end(),static_cast(0))!=24575) + throw std::runtime_error("Planar oracle's last-pixel golden byte is incorrect"); +} +} // namespace GemChessRasterOracle diff --git a/tests/IRConditionalOptimizerTests.cpp b/tests/IRConditionalOptimizerTests.cpp index 6e20e14..5aa01bc 100644 --- a/tests/IRConditionalOptimizerTests.cpp +++ b/tests/IRConditionalOptimizerTests.cpp @@ -1,6 +1,8 @@ #include "IRConditionalOptimizer.hpp" #include "IRGlobalOptimizer.hpp" #include "IRControlFlow.hpp" +#include "IRCodeGenerator.hpp" +#include "GSUInstructionModel.hpp" #include "Lexer.hpp" #include "Parser.hpp" #include "Optimizer.hpp" @@ -69,6 +71,166 @@ template void source(const std::string& text, Test test, bool hardwa const auto first = dumpIR(m); IRGlobalOptimizer::run(m, analyzer.getAllLocalSymbols()); if (first != dumpIR(m)) throw std::runtime_error("Global O2 pipeline is not idempotent after SCCP: " + text + "\nFirst:\n" + first + "\nSecond:\n" + dumpIR(m)); } +template void conditionalSource(const std::string& text, Test test, TargetKind target = TargetKind::GSU) { + Lexer lexer(text); const auto tokens = lexer.scanTokens(); Parser parser(tokens); auto ast = parser.parseProgram(); + DataSegmentManager data; Analyzer analyzer(data, target); analyzer.analyze(ast); + std::vector frames; + for (const auto& statement : ast) + if (const auto* function = dynamic_cast(statement.get())) + if (!function->is_prototype) frames.push_back(function->total_local_alloc_size); + IRLowerer lowerer(target); auto module = lowerer.lower(ast); IRVerifier::verify(module); + test(module, frames); +} +std::size_t logicalSlots(const IRModule& module) { + std::size_t result = 0; + for (const auto& function : module.functions) for (const auto& block : function.blocks) + for (const auto& instruction : block.instructions) + result += instruction.opcode == IROpcode::Address && instruction.operation == "temporary"; + return result; +} +void directConditions() { + for (const auto target : {TargetKind::GSU, TargetKind::SPC700}) { + conditionalSource("word f(word a,word b,word c){if(!((a& frames) { + require(logicalSlots(module) == 0 && count(module, IROpcode::Phi) == 0, + "Condition-only logical expression allocated a boolean temporary"); + require(count(module, IROpcode::CondBranch) == 4 && count(module, IROpcode::Unary) == 0, + "Mixed logical conditions did not branch directly or invert negation targets"); + require(module.functions[0].total_local_alloc_size == frames[0], "Conditional lowering grew the source frame"); + }, target); + conditionalSource("word f(word a){if(!a)return 1;else return 2;}", + [](const IRModule& module, const std::vector&) { + const auto& function = module.functions[0]; const auto& branch = function.blocks[0].instructions.back(); + require(function.blocks.at(branch.targets[0].value).label == "if.else" && + function.blocks.at(branch.targets[1].value).label == "if.then", "Negation did not swap branch targets"); + }, target); + conditionalSource("word f(word a){if((byte)a)return 1;return 2;}", + [](const IRModule& module, const std::vector&) { + require(count(module, IROpcode::Cast) == 1, "Condition lowering discarded a non-truth-preserving narrowing cast"); + }, target); + conditionalSource("bool f(word a,word b){bool value=a||b;return value && !a;}", + [](const IRModule& module, const std::vector& frames) { + require(logicalSlots(module) == 2 && module.functions[0].total_local_alloc_size > frames[0], + "Logical values lost their canonical boolean materialization"); + require(count(module, IROpcode::Unary) == 1, "Value-producing negation was treated as control flow"); + }, target); + } + conditionalSource("word f(word a,word b){plot{at(3,4);if(a && !b)pixel;" + "while(a && b){if(a || b)break;}for(word i=0;i<2 && a;i++){if(b || !a)continue;pixel;}flush;}return 0;}", + [](const IRModule& module, const std::vector& frames) { + require(logicalSlots(module) == 0 && module.functions[0].total_local_alloc_size == frames[0], + "Plot/loop conditions allocated boolean slots"); + for (const auto& block : module.functions[0].blocks) for (const auto& instruction : block.instructions) + if (instruction.opcode == IROpcode::CondBranch) + require(instruction.in_plot_context, "Short-circuit branch lost its plotting context"); + }); + bool rejected = false; + try { conditionalSource("word f(){if(false && 10/0)return 1;return 2;}", [](const IRModule&, const std::vector&) {}); } + catch (const CompilerError& error) { rejected = error.getMessage().find("Division by zero") != std::string::npos; } + require(rejected, "Short-circuit lowering suppressed an existing source diagnostic"); +} +std::vector conditionalCode(const std::string& text, OptimizationLevel level) { + Lexer lexer(text); const auto tokens = lexer.scanTokens(); Parser parser(tokens); auto ast = parser.parseProgram(); + DataSegmentManager data; Analyzer analyzer(data); analyzer.analyze(ast); IRLowerer lowerer; + const auto module = lowerer.lower(ast); IRVerifier::verify(module); + CompilerConfig config; config.optimization = level; config.code_start_address = 0x008000; + IRCodeGenerator backend(analyzer.getAllLocalSymbols(), analyzer.getFunctionSymbols(), data, config); + const auto object = backend.generate(module); auto bytes = object.code_section; + // These fixtures have only same-object code and stack-limit relocations. + // Reject any new relocation category instead of leaving unlinked operands. + for (const auto& relocation : object.relocation_table) { + bool found = false; std::uint32_t value = 0; const std::string prefix = "__disco_stack_limit_"; + if (relocation.target_symbol_name.compare(0, prefix.size(), prefix) == 0) { + require(relocation.type == RelocationType::ADDR16_RAM, "Unexpected stack-limit relocation"); + value = static_cast(std::stoul(relocation.target_symbol_name.substr(prefix.size()))); found = true; + } else for (const auto& symbol : object.symbol_table) if (symbol.name == relocation.target_symbol_name) { + require(symbol.section == SymbolSection::CODE && + (relocation.type == RelocationType::ADDR16_IWT || relocation.type == RelocationType::ADDR16_JAL), + "Unexpected conditional fixture relocation"); + value = object.config.code_start_address + symbol.offset; found = true; + } + require(found && value <= 65535, "Missing/out-of-range conditional fixture relocation"); + bytes.at(relocation.patch_offset + 1) = static_cast(value); + bytes.at(relocation.patch_offset + 2) = static_cast(value >> 8); + } + return bytes; +} +void seedWord(DiscoGSU::Machine& machine, std::uint32_t address, std::uint16_t value) { + machine.seed(address, static_cast(value), false); + machine.seed(address + 1, static_cast(value >> 8), false); +} +void conditionalExecution() { + // Entry STOP retains the saved R9/R11 header, unlike a returning callee. + constexpr std::uint16_t stopped_entry_stack = 0x1ffc; + const std::string effects = "bool probe(word tag,word value);word main(){word a=*(volatile word*)0x100;" + "word b=*(volatile word*)0x102;word c=*(volatile word*)0x104;*(volatile word*)0x110=0;" + "if(!((a && probe(1,b)) || (!b && probe(2,c))) && probe(3,a || c))return 31;return 47;}" + "bool probe(word tag,word value){*(volatile word*)0x110=*(volatile word*)0x110*4+tag;return (bool)value;}"; + const std::string loops = "word main(){word n=*(volatile word*)0x100;word total=0;" + "for(word i=0;i=n)){j++;if(!(j!=2) && n)continue;total+=10*j;}return total;}"; + const std::string faults = "word main(){word gate=*(volatile word*)0x100;word divisor=*(volatile word*)0x102;" + "word* p=(word*)*(volatile u16*)0x104;*(volatile byte*)0x110=(byte)11;" + "if(gate && (10/divisor>0 || *p!=0))*(volatile byte*)0x111=(byte)22;" + "*(volatile byte*)0x112=(byte)33;return 42;}"; + const std::string values = "bool both(word a,word b);bool identity(bool a);word main(){word a=*(volatile word*)0x100;" + "word b=*(volatile word*)0x102;bool value=a || b;value=identity(a && !b);" + "return (word)value+2*(word)both(a,b)+4*(word)identity(a || b);}" + "bool both(word a,word b){return a && b;}bool identity(bool a){return a;}"; + for (const auto level : {OptimizationLevel::Baseline, OptimizationLevel::O1, OptimizationLevel::O2, OptimizationLevel::Size}) { + const auto effect_code = conditionalCode(effects, level), loop_code = conditionalCode(loops, level); + const auto fault_code = conditionalCode(faults, level), value_code = conditionalCode(values, level); + const auto narrow_code = conditionalCode("word main(){word a=*(volatile word*)0x100;if((byte)a)return 1;return 2;}", level); + for (const auto a : {0u, 1u, 256u, 65535u}) for (const auto b : {0u, 1u, 256u, 65535u}) { + DiscoGSU::Machine value_machine(value_code, 0x008000); seedWord(value_machine, 0x700100, static_cast(a)); + seedWord(value_machine, 0x700102, static_cast(b)); value_machine.run(); + require(value_machine.reg(0) == static_cast((a && !b) + 2*(a && b) + 4*(a || b)) && value_machine.reg(6) == 0, + "Assignment/call/return logical values were not canonical booleans"); + for (const auto c : {0u, 1u, 256u, 65535u}) { + unsigned trace = 0, calls = 0; + const auto probe = [&](unsigned tag, bool value) { trace = trace*4+tag; ++calls; return value; }; + const bool selected = !((a && probe(1,b != 0)) || (!b && probe(2,c != 0))) && probe(3,a || c); + DiscoGSU::Machine machine(effect_code, 0x008000); seedWord(machine, 0x700100, static_cast(a)); + seedWord(machine, 0x700102, static_cast(b)); seedWord(machine, 0x700104, static_cast(c)); machine.run(); + require(machine.reg(0) == (selected ? 31 : 47) && machine.word(0x700110) == trace, + "Nested logical condition changed evaluation order or its selected branch"); + require(machine.accesses(0x700110, false) == calls && machine.accesses(0x700110, true) == calls+1, + "Short-circuit condition duplicated or speculated volatile call effects"); + if (machine.accesses(0x700100, false) != 1 || machine.accesses(0x700102, false) != 1 || + machine.accesses(0x700104, false) != 1 || machine.reg(6) != 0 || machine.reg(10) != stopped_entry_stack) + throw std::runtime_error("Logical condition damaged input observations, checked state or stack ownership: level=" + + std::to_string(static_cast(level)) + " inputs=" + std::to_string(a) + "," + std::to_string(b) + "," + + std::to_string(c) + " reads=" + std::to_string(machine.accesses(0x700100, false)) + "," + + std::to_string(machine.accesses(0x700102, false)) + "," + std::to_string(machine.accesses(0x700104, false)) + + " r6=" + std::to_string(machine.reg(6)) + " sp=" + std::to_string(machine.reg(10))); + } + } + for (const auto n : {0u, 1u, 2u, 4u, 7u}) { + unsigned expected = 0; + for (unsigned i=0; i(n)); machine.run(); + require(machine.reg(0) == expected && machine.reg(6) == 0 && machine.reg(10) == stopped_entry_stack, + "Direct for/while conditions changed nested continue targets or loop results"); + } + for (unsigned scenario=0; scenario<4; ++scenario) { + DiscoGSU::Machine machine(fault_code, 0x008000); seedWord(machine, 0x700100, scenario == 0 ? 0 : 1); + seedWord(machine, 0x700102, scenario < 2 ? 0 : scenario == 2 ? 1 : 20); seedWord(machine, 0x700104, 0); machine.run(); + const bool fault = scenario == 1 || scenario == 3; + require(machine.byte(0x700110) == 11 && machine.accesses(0x700110, true) == 1, + "Condition fault moved before its volatile witness"); + require(machine.byte(0x700112) == (fault ? 0 : 33) && (machine.reg(6) != 0) == fault, + "Short-circuit condition speculated a fault or removed a required fail-stop"); + require(machine.byte(0x700111) == (scenario == 2 ? 22 : 0), "Faulting/skipped condition executed its body"); + if (!fault) require(machine.reg(0) == 42 && machine.reg(10) == stopped_entry_stack, "Skipped fault damaged normal return state"); + } + for (const auto input : {0u, 1u, 256u, 65535u}) { + DiscoGSU::Machine machine(narrow_code, 0x008000); seedWord(machine, 0x700100, static_cast(input)); machine.run(); + require(machine.reg(0) == ((input & 255u) ? 1 : 2), "Condition bypassed byte-cast truncation"); + } + } +} void executableEdges() { Builder b(4); const auto condition = b.literal(0, 0); b.terminal(0, IROpcode::CondBranch, {condition}, {{1}, {2}}); @@ -124,6 +286,7 @@ void dispatch(std::int64_t selector, bool default_target, std::size_t expected_b } int main() { try { + directConditions(); conditionalExecution(); executableEdges(); mixedPhis(); lateBackedge(); dispatch(5, true, 1, 20); dispatch(99, true, 1, 30); dispatch(99, false, 3, 0); source("word f(word x) { word a; if(x) a=255; else a=255; return (word)(byte)a; }", [](const IRModule& m) { returnedConstant(m, -1); }); source("word f(word x) { word a; if(x) a=32767; else a=32767; return a+1; }", [](const IRModule& m) { returnedConstant(m, -32768); }); diff --git a/tests/IRStorageOptimizerTests.cpp b/tests/IRStorageOptimizerTests.cpp new file mode 100644 index 0000000..d73b01a --- /dev/null +++ b/tests/IRStorageOptimizerTests.cpp @@ -0,0 +1,359 @@ +#include "IRStorageOptimizer.hpp" +#include "IRCompactOptimizer.hpp" +#include "IRGlobalOptimizer.hpp" +#include "GSUAddressProof.hpp" +#include "IRScalarFold.hpp" +#include "Lexer.hpp" +#include "Parser.hpp" + +#include +#include +#include + +namespace { +void require(bool value, const char* message) { if (!value) throw std::runtime_error(message); } +struct Fixture { + IRModule module; + std::map locals; +}; +Fixture prepare(const std::string& source, bool global = true) { + Lexer lexer(source); const auto tokens = lexer.scanTokens(); Parser parser(tokens); auto program = parser.parseProgram(); + DataSegmentManager data; Analyzer analyzer(data); analyzer.analyze(program); IRLowerer lowerer; + Fixture result{lowerer.lower(program), analyzer.getAllLocalSymbols()}; + if (global) IRGlobalOptimizer::run(result.module, result.locals); + else IRCompactOptimizer::run(result.module, result.locals, OptimizationLevel::Size); + return result; +} +IRStorageOptions pooled() { IRStorageOptions options; options.immutable_arrays = IRImmutableArrayPolicy::CopiedNearRamPayload; return options; } +void reject(const IRModule& module, const std::string& message) { + try { IRVerifier::verify(module); } + catch (const CompilerError& error) { + require(error.getMessage().find(message) != std::string::npos, "Wrong storage verifier diagnostic"); return; + } + throw std::runtime_error("Invalid storage metadata was accepted"); +} +SymbolId symbol(const IRFunction& function, const std::string& name) { + for (const auto& block : function.blocks) for (const auto& instruction : block.instructions) + if (instruction.opcode == IROpcode::Address && instruction.operation.empty() && instruction.symbol == name) + return instruction.symbol_id; + throw std::runtime_error("Fixture lost its expected storage address"); +} + +// Small independent typed-IR evaluator. Storage addresses really overlap when +// the plan says so, and pooled bytes come from their owned little-endian image. +std::int64_t evaluate(const IRFunction& function, const Analyzer::LocalSymbolTable& original, std::int64_t argument) { + const auto& locals = function.storage_layout_valid ? function.storage_layout : original; + std::map memory; + std::map pool_addresses; + std::int64_t pool_cursor = 0x2000; + for (const auto& pool : function.immutable_local_arrays) { + pool_addresses.emplace(pool.first, pool_cursor); + for (const auto byte : pool.second.bytes) memory[pool_cursor++] = byte; + } + const auto store = [&](std::int64_t address, std::int64_t value, int width) { + require(width == 1 || width == 2, "Unsupported evaluator storage width"); + memory[address] = static_cast(static_cast(value) & 255); + if (width == 2) memory[address + 1] = static_cast((static_cast(value) >> 8) & 255); + }; + if (!function.parameters.empty()) { + const auto& parameter = function.parameters[0]; + store(0x8000 + locals.at(parameter.symbol_id).stackOffset, argument, parameter.type.sizeInBytes); + } + std::vector values(static_cast(function.value_count) + 1); + std::vector definitions(values.size(), nullptr); + for (const auto& block : function.blocks) for (const auto& instruction : block.instructions) + if (instruction.result.isValid()) definitions.at(instruction.result.value) = &instruction; + IRBlockId current = function.entry, previous; + for (unsigned steps = 0; steps < 20000; ++steps) { + const auto& block = function.blocks.at(current.value); + std::map phis; + for (const auto& instruction : block.instructions) { + if (instruction.opcode != IROpcode::Phi) break; + bool found = false; + for (std::size_t edge = 0; edge < instruction.targets.size(); ++edge) + if (instruction.targets[edge].value == previous.value) { + phis[instruction.result.value] = values.at(instruction.operands[edge].value); found = true; + } + require(found, "Missing evaluator PHI edge"); + } + for (const auto& phi : phis) values.at(phi.first) = phi.second; + bool branch = false; + for (const auto& instruction : block.instructions) { + const auto operand = [&](std::size_t index) { return values.at(instruction.operands.at(index).value); }; + auto& result = values.at(instruction.result.value); + switch (instruction.opcode) { + case IROpcode::Phi: break; + case IROpcode::Constant: result = ConstantEvaluator::convert(instruction.immediate, instruction.type); break; + case IROpcode::Address: + if (instruction.operation == "member") result = operand(0) + instruction.immediate; + else if (pool_addresses.count(instruction.symbol_id)) result = pool_addresses.at(instruction.symbol_id); + else { + const auto& local = locals.at(instruction.symbol_id); + result = 0x8000 + local.stackOffset; + if (local.type.alignment > 2) result = (result + local.type.alignment - 1) & -local.type.alignment; + } + break; + case IROpcode::PointerOffset: + require(instruction.operands.size() == 2, "Unsupported evaluator scaled offset"); + result = operand(0) + (instruction.operation == "-" ? -1 : 1) * operand(1) * instruction.immediate; break; + case IROpcode::MemoryInitialize: + for (std::size_t n = 0; n < instruction.initialization_values.size(); ++n) + store(operand(0) + static_cast(n) * instruction.type.sizeInBytes, + instruction.initialization_values[n], instruction.type.sizeInBytes); + break; + case IROpcode::StoreIndirect: store(operand(0), operand(1), instruction.type.sizeInBytes); break; + case IROpcode::LoadIndirect: { + auto raw = static_cast(memory[operand(0)]); + if (instruction.type.sizeInBytes == 2) raw |= static_cast(memory[operand(0) + 1]) << 8; + result = ConstantEvaluator::convert(raw, instruction.type); break; + } + case IROpcode::Binary: + case IROpcode::Unary: + case IROpcode::Cast: { + if (instruction.opcode == IROpcode::Cast && instruction.type.pointer_level > 0) { + // These fixtures use only verifier-checked literal + // near addresses, not a new arithmetic oracle. + result = operand(0); break; + } + std::array inputs{}; + for (std::size_t n = 0; n < instruction.operands.size(); ++n) + inputs[n] = {&definitions.at(instruction.operands[n].value)->type, operand(n)}; + require(IRScalarFold::evaluate(instruction, inputs, result), "Unsupported evaluator arithmetic"); break; + } + case IROpcode::BitExtract: + result = (static_cast(operand(0)) >> static_cast(instruction.immediate)) & 1; break; + case IROpcode::Branch: + case IROpcode::CondBranch: + previous = current; + current = instruction.targets.at(instruction.opcode == IROpcode::CondBranch && !operand(0) ? 1 : 0); + branch = true; break; + case IROpcode::Return: return operand(0); + default: throw std::runtime_error("Unsupported evaluator opcode"); + } + if (branch) break; + } + require(branch, "Evaluator failed to branch/return"); + } + throw std::runtime_error("Evaluator exhausted its instruction budget"); +} +void equivalent(const IRModule& before, const Fixture& after) { + const auto& locals = after.locals.at(before.functions[0].name); + for (int argument = -64; argument < 128; ++argument) + require(evaluate(before.functions[0], locals, argument) == evaluate(after.module.functions[0], locals, argument), + "Storage planning changed typed IR semantics"); +} + +void deadAndSharedStorage() { + auto dead = prepare("word f(word p) { word dead[31]; word live[4]={1,3,5,7}; return live[p&3]; }"); + const auto before = dead.module; const auto original = dead.locals; + IRStorageOptimizer::run(dead.module, dead.locals); + const auto& f = dead.module.functions[0]; + require(f.storage_layout_valid && f.total_local_alloc_size == 10, "Dead array slots were not reclaimed"); + require(f.storage_layout.at(f.parameters[0].symbol_id).stackOffset == 6, "Parameter ABI offset changed"); + require(dead.locals.at("f").size() == original.at("f").size(), "Analyzer table was modified"); + for (const auto& entry : original.at("f")) + require(dead.locals.at("f").at(entry.first).stackOffset == entry.second.stackOffset, "Analyzer offsets were mutated"); + equivalent(before, dead); + const auto first = dumpIR(dead.module); IRStorageOptimizer::run(dead.module, dead.locals); + require(first == dumpIR(dead.module), "Repeated storage planning is not idempotent"); + + auto shared = prepare("word f(word p) { word a[4]={1,3,5,7}; if(p>0) return a[p&3]; word b[4]={2,4,6,8}; return b[p&3]; }", false); + const auto unshared = shared.module; + IRStorageOptimizer::run(shared.module, shared.locals); + const auto& s = shared.module.functions[0]; + require(s.storage_layout_valid && s.total_local_alloc_size == 10, "Disjoint CFG lifetimes did not share a home"); + require(s.storage_layout.at(symbol(s, "a")).stackOffset == s.storage_layout.at(symbol(s, "b")).stackOffset, + "Disjoint arrays did not reuse storage"); + equivalent(unshared, shared); + + auto live = prepare("word f(word p) { word a[4]={1,3,5,7}; word b[4]={2,4,6,8}; return a[p&3]+b[p&3]; }", false); + const auto simultaneous = live.module; IRStorageOptimizer::run(live.module, live.locals); + auto& l = live.module.functions[0]; + require(l.storage_layout.at(symbol(l, "a")).stackOffset != l.storage_layout.at(symbol(l, "b")).stackOffset, + "Interfering arrays shared storage"); + equivalent(simultaneous, live); + l.storage_layout.at(symbol(l, "b")).stackOffset = l.storage_layout.at(symbol(l, "a")).stackOffset; + reject(live.module, "lifetime conflict"); + + auto aligned = prepare("@align(16) struct Packet { word value; }; word f(word p) { word dead[8]; struct Packet packet; packet.value=p; return packet.value; }", false); + const auto padded = aligned.module; IRStorageOptimizer::run(aligned.module, aligned.locals); + const auto& a = aligned.module.functions[0]; + require(a.storage_layout_valid && a.total_local_alloc_size == 32 && a.storage_layout.at(symbol(a, "packet")).stackOffset == -30, + "Over-aligned object lost its upward-rounding padding"); + equivalent(padded, aligned); + auto empty = prepare("word f(){return 3;}"); IRStorageOptimizer::run(empty.module, empty.locals, pooled()); + require(empty.module.functions[0].storage_layout_valid && empty.module.functions[0].total_local_alloc_size == 0, + "Empty storage acquired an unnecessary bottom word"); +} + +void malformedHomes() { + auto fixture = prepare("word f(word p){word t[4]={11,22,33,44};return t[p&3];}"); + IRStorageOptimizer::run(fixture.module, fixture.locals); + const auto root = symbol(fixture.module.functions[0], "t"); + const auto parameter = fixture.module.functions[0].parameters[0].symbol_id; + auto broken = fixture.module; + broken.functions[0].storage_layout.at(parameter).stackOffset = 8; + reject(broken, "parameter ABI offsets"); + broken = fixture.module; broken.functions[0].storage_layout.at(root).stackOffset = -7; + reject(broken, "identity or alignment"); + broken = fixture.module; + broken.functions[0].storage_layout.at(root).stackOffset = -broken.functions[0].total_local_alloc_size; + reject(broken, "exceeds the checked frame"); + broken = fixture.module; + broken.functions[0].storage_layout.at(root).stackOffset = -broken.functions[0].total_local_alloc_size - 2; + reject(broken, "exceeds the checked frame"); + broken = fixture.module; broken.functions[0].storage_layout.erase(root); + reject(broken, "absent from its owned storage layout"); +} + +void inlineLoopFallbacks() { + for (const int count : {0, 1, 2}) { + auto fixture = prepare("word f(word p){word t[4]={11,22,33,44};return t[p&3]+7;}", false); + auto& f = fixture.module.functions[0]; require(f.blocks.size() == 1, "Inline scope requires one fixture block"); + auto original_code = f.blocks[0].instructions; + IRInstruction saved; saved.opcode = IROpcode::Constant; saved.type = Type{BaseType::WORD, "", 2, false}; + saved.result = IRValueId{++f.value_count}; saved.immediate = 19; + IRInstruction trips = saved; trips.result = IRValueId{++f.value_count}; trips.immediate = count; + IRInstruction setup; setup.opcode = IROpcode::HardwareLoop; setup.loop_id = 1; setup.operands = {trips.result}; + IRInstruction end; end.opcode = IROpcode::HardwareLoopEnd; end.loop_id = 1; + IRInstruction joined; joined.opcode = IROpcode::Binary; joined.type = saved.type; joined.operation = "+"; + joined.result = IRValueId{++f.value_count}; joined.operands = {saved.result, original_code.back().operands[0]}; + auto ret = original_code.back(); ret.operands = {joined.result}; original_code.pop_back(); + f.blocks[0].instructions = {saved, trips, setup}; + f.blocks[0].instructions.insert(f.blocks[0].instructions.end(), original_code.begin(), original_code.end()); + f.blocks[0].instructions.push_back(end); f.blocks[0].instructions.push_back(joined); f.blocks[0].instructions.push_back(ret); + IRVerifier::verify(fixture.module); + const auto before = dumpIR(fixture.module); const auto frame = f.total_local_alloc_size; + IRStorageOptimizer::run(fixture.module, fixture.locals, pooled()); + require(dumpIR(fixture.module) == before && !f.storage_layout_valid && f.total_local_alloc_size == frame, + "Empty-target hardware scope did not retain the original storage contract"); + } + // Source-level zero trips are not the raw LOOP count zero (65536 trips). + for (const int trips : {0, 2}) { + auto fixture = prepare("word f(word p){word t[4]={11,22,33,44};for(word i=0;i<" + + std::to_string(trips) + ";i++){}return t[p&3];}", false); + const auto before = fixture.module; IRStorageOptimizer::run(fixture.module, fixture.locals, pooled()); + equivalent(before, fixture); + } +} + +void flatAliasSemantics() { + for (const bool write : {false, true}) { + // This evaluator's independently fixed FP is $8000. The first local + // four-word array occupies $7FF8..$7FFF in the original layout. + auto fixture = prepare(write + ? "word f(word p){word t[4]={11,22,33,44};*(volatile word*)0x7ff8=99;return t[p&3];}" + : "word f(word p){word t[4]={11,22,33,44};return t[p&3]+*(volatile word*)0x7ff8;}"); + const auto root = symbol(fixture.module.functions[0], "t"); + require(fixture.locals.at("f").at(root).stackOffset == -8, "Raw alias fixture lost its independent stack address"); + const auto before = fixture.module; const auto snapshot = dumpIR(before); + IRStorageOptimizer::run(fixture.module, fixture.locals, pooled()); + require(dumpIR(fixture.module) == snapshot && !fixture.module.functions[0].storage_layout_valid && + fixture.module.functions[0].immutable_local_arrays.empty(), "Flat RAM alias acquired a storage/pool proof"); + equivalent(before, fixture); + const int table[] = {11, 22, 33, 44}; + for (int index = 0; index < 4; ++index) + require(evaluate(fixture.module.functions[0], fixture.locals.at("f"), index) == + (write ? (index ? table[index] : 99) : table[index] + 11), "Raw alias storage semantics were not observed"); + } +} + +void poolAndFallbacks() { + const std::string sine = "word sine(word phase) { word quarter[17]={0,6,12,19,24,30,36,41,45,49,53,56,59,61,63,64,64};" + "phase &= 63; word index=phase&15; if((phase&16)!=0) index=16-index; word value=quarter[index];" + "if((phase&32)!=0) value=-value; return value; }"; + auto fixture = prepare(sine); const auto before = fixture.module; + auto disabled = fixture; IRStorageOptimizer::run(disabled.module, disabled.locals); + require(disabled.module.functions[0].immutable_local_arrays.empty(), "Pool capability was enabled implicitly"); + IRStorageOptimizer::run(fixture.module, fixture.locals, pooled()); + auto& f = fixture.module.functions[0]; + require(f.storage_layout_valid && f.immutable_local_arrays.size() == 1 && f.total_local_alloc_size == 0, + "Closed sine table was not pooled"); + const auto& bytes = f.immutable_local_arrays.begin()->second.bytes; + require(bytes.size() == 34 && bytes[2] == 6 && bytes[3] == 0 && bytes[32] == 64, "Incorrect little-endian pool image"); + equivalent(before, fixture); + for (const auto& block : f.blocks) for (const auto& instruction : block.instructions) + require(instruction.opcode != IROpcode::MemoryInitialize, "Pooled initializer still executes"); + auto copy = fixture.module; copy.functions[0].immutable_local_arrays.begin()->second.bytes.pop_back(); + reject(copy, "array extent"); + copy = fixture.module; copy.functions[0].immutable_local_arrays.begin()->second.type.sizeInBytes = 1; + reject(copy, "array extent"); + copy = fixture.module; copy.functions[0].storage_layout_valid = false; reject(copy, "valid owned layout"); + copy = fixture.module; copy.functions[0].storage_layout.clear(); reject(copy, "parameter ABI"); + copy = fixture.module; + for (const auto& block : before.functions[0].blocks) for (const auto& initializer : block.instructions) { + if (initializer.opcode != IROpcode::MemoryInitialize) continue; + auto& entry = copy.functions[0].blocks[0].instructions; + for (auto root = entry.begin(); root != entry.end(); ++root) if (root->result.value == initializer.operands[0].value) { + entry.insert(std::next(root), initializer); break; + } + } + copy.functions[0].total_local_alloc_size = before.functions[0].total_local_alloc_size; + reject(copy, "lifetime conflict"); + copy = fixture.module; + auto& code = copy.functions[0].blocks[0].instructions; + const IRInstruction* address = nullptr; const IRInstruction* constant = nullptr; + for (const auto& instruction : code) { + if (instruction.opcode == IROpcode::Address && instruction.symbol == "quarter") address = &instruction; + if (instruction.opcode == IROpcode::Constant && instruction.type.base == BaseType::WORD) constant = &instruction; + } + require(address && constant, "Mutation fixture has no pool root/constant"); + IRInstruction write; write.opcode = IROpcode::StoreIndirect; write.type = constant->type; + write.operands = {address->result, constant->result}; code.insert(std::prev(code.end()), write); + reject(copy, "lifetime conflict"); + + const std::vector frozen = { + "word f(word* other,word p){word t[4]={1,2,3,4};return t[p&3]+*other;}", + "word f(word p){word t[4]={1,2,3,4};return t[p&3]+*(word*)0x8000;}", + "unsigned word f(word p){word t[4]={1,2,3,4};return (unsigned word)&t[p&3];}", + "word f(word p){word t[4]={1,2,3,4};return t[(p&7)-1];}", + "word f(word p){word t[4]={1,2,3,4};return t[4]+p;}", + "word f(far word* other,word p){word t[4]={1,2,3,4};return t[p&3]+*other;}", + "word f(word p){word t[4]={1,2,3,4};word* other=*(word**)0x0100;return t[p&3]+*other;}", + "word f(word p){volatile word t[4]={1,2,3,4};return t[p&3];}", + "word external(word* p);word f(word p){word t[4]={1,2,3,4};return external(&t[p&3]);}" + }; + for (const auto& source : frozen) { + auto negative = prepare(source); const auto frame = negative.module.functions[0].total_local_alloc_size; + IRStorageOptimizer::run(negative.module, negative.locals, pooled()); + require(!negative.module.functions[0].storage_layout_valid && negative.module.functions[0].immutable_local_arrays.empty() && + negative.module.functions[0].total_local_alloc_size == frame, "Unknown alias/identity/out-of-object path changed layout"); + } + auto mutable_array = prepare("word f(word p){word t[4]={1,2,3,4};t[p&3]=p;return t[p&3];}"); + const auto mutable_before = mutable_array.module; + IRStorageOptimizer::run(mutable_array.module, mutable_array.locals, pooled()); + require(mutable_array.module.functions[0].immutable_local_arrays.empty(), "Mutable local array was pooled"); + equivalent(mutable_before, mutable_array); + + auto partial = prepare("word f(word p){word t[8]={1,2,3,4,5,6,7,8};return t[p&7];}"); + for (auto& block : partial.module.functions[0].blocks) for (auto& instruction : block.instructions) + if (instruction.opcode == IROpcode::MemoryInitialize) instruction.initialization_values.pop_back(); + IRStorageOptimizer::run(partial.module, partial.locals, pooled()); + require(partial.module.functions[0].immutable_local_arrays.empty(), "Partial initializer was pooled"); +} + +void queryAndLimits() { + auto fixture = prepare("word f(word p){word t[4]={1,2,3,4};return t[p&3];}"); + const auto& f = fixture.module.functions[0]; GSUAddressProof ranges; + ranges.run(f, fixture.locals.at("f"), static_cast(f.total_local_alloc_size), true); + std::int64_t low = 123, high = 456; + require(!ranges.scalarBounds({}, f.entry, low, high) && low == 123 && high == 456, "Invalid scalar ID changed query outputs"); + require(!ranges.scalarBounds(IRValueId{f.value_count + 1}, f.entry, low, high), "Out-of-range scalar ID was accepted"); + require(!ranges.scalarBounds(IRValueId{1}, {}, low, high), "Invalid block ID was accepted"); + require(!ranges.scalarBounds(IRValueId{1}, IRBlockId{static_cast(f.blocks.size())}, low, high), "Out-of-range block ID was accepted"); + require(low == 123 && high == 456, "Failed scalar queries modified outputs"); + const auto snapshot = dumpIR(fixture.module); + auto local = fixture.locals.at("f").begin()->second; + for (std::uint32_t id = 1000; id < 1257; ++id) { + local.id = SymbolId{id}; fixture.locals.at("f").emplace(local.id, local); + } + IRStorageOptimizer::run(fixture.module, fixture.locals, pooled()); + require(snapshot == dumpIR(fixture.module), "Bounded analysis fallback changed IR"); +} +} + +int main() { + try { deadAndSharedStorage(); malformedHomes(); inlineLoopFallbacks(); flatAliasSemantics(); poolAndFallbacks(); queryAndLimits(); } + catch (const std::exception& error) { std::cerr << "IR storage optimizer tests: " << error.what() << '\n'; return 1; } + std::cout << "IR storage optimizer tests passed\n"; +} diff --git a/tests/LinkerHardeningTests.cpp b/tests/LinkerHardeningTests.cpp index f06de7c..e337380 100644 --- a/tests/LinkerHardeningTests.cpp +++ b/tests/LinkerHardeningTests.cpp @@ -374,6 +374,26 @@ void metadataCases(Cases& cases) { cases.link("alignment-crosses-bank", {aligned}, {"--origin", "0x00fffd"}, "program-bank boundary"); } +void immutablePoolCases(Cases& cases) { + auto pool = entry(); pool.code_section = {0xf0, 0, 0, 0, 1}; pool.data_section = {0x34, 0x12}; + pool.data_alignment = 2; pool.config.code_start_address = 0x706000; + const auto label = std::string(GSUAbi::ImmutablePoolPrefix) + "f.1"; + pool.symbol_table.push_back({label, SymbolSection::DATA, 0}); + pool.relocation_table.push_back({label, SymbolSection::CODE, 0, RelocationType::ADDR16_RAM}); + const auto bytes = cases.link("pool-near-ram", {pool}); + require(bytes.size() == 8 && bytes[1] == 0x06 && bytes[2] == 0x60, + "Private immutable pool did not resolve to its aligned copied RAM payload"); + cases.link("pool-relink-rom", {pool}, {"--origin", "0x008000"}, "Immutable local-array pool requires"); + cases.link("pool-wrong-near-bank", {pool}, {"--ram-bank", "1"}, "Immutable local-array pool requires"); + auto ordinary = pool; + ordinary.symbol_table.back().name = std::string(1, '\x01') + "ordinary_data"; + ordinary.relocation_table[0].target_symbol_name = ordinary.symbol_table.back().name; + cases.link("ordinary-data-not-pool", {ordinary}, {}, "RAM relocation must target static RAM"); + auto wrong_section = pool; wrong_section.symbol_table.back().section = SymbolSection::RAM; + wrong_section.ram_section = {1, 0}; + cases.link("pool-reject-wrong-section", {wrong_section}, {"--host-initialized-globals"}, "Immutable local-array pool requires"); +} + void malformedCases(const std::filesystem::path& root) { const auto object = root / "malformed.o", output = root / "malformed.bin"; const Bytes sentinel{0x95, 0x42}; writeBytes(output, sentinel); @@ -415,7 +435,7 @@ int main() { try { TestTempDirectory temporary("linker-tests"); Cases cases(temporary.path()); relocationCases(cases); runtimeCases(cases); runtimeEncodingCases(); runtimeImageCases(); metadataCases(cases); malformedCases(temporary.path()); - aggregateBudgetCase(temporary.path()); + immutablePoolCases(cases); aggregateBudgetCase(temporary.path()); temporary.cleanup(); return 0; } catch (const std::exception& error) { diff --git a/tests/run_gem_chess_regression.cmake b/tests/run_gem_chess_regression.cmake new file mode 100644 index 0000000..e360a1e --- /dev/null +++ b/tests/run_gem_chess_regression.cmake @@ -0,0 +1,108 @@ +# Included by run_toolchain_regression.cmake, including its checked commands, +# optimization selection and case-specific output directory. +if(NOT DEFINED GSU_RUNNER OR NOT OPTIMIZATION MATCHES "^[2s]$") + message(FATAL_ERROR "Gem-chess regressions require GSU_RUNNER and O2 or Os") +endif() + +# Compile the actual scene functions unchanged. The declaration boundaries are +# checked so a scene reorganization fails visibly instead of testing stale code. +# Omit its framebuffer at zero and scene main: this fixture's stack and inputs +# live below a separate framebuffer at $6000. +set(scene_source "${ROOT_DIR}/examples/snes/gem_chess/main.dc") +file(READ "${scene_source}" scene) +string(REPLACE "\r\n" "\n" scene "${scene}") +foreach(boundary IN ITEMS q6 quad_face) + string(REGEX MATCHALL "\nword ${boundary}\\(" declarations "${scene}") + list(LENGTH declarations declaration_count) + if(NOT declaration_count EQUAL 1) + message(FATAL_ERROR "Expected one ${boundary} declaration in ${scene_source}") + endif() +endforeach() +string(FIND "${scene}" "\nword q6(" kernel_begin) +string(FIND "${scene}" "\nword quad_face(" kernel_end) +if(kernel_begin LESS 0 OR kernel_end LESS_EQUAL kernel_begin) + message(FATAL_ERROR "Gem-chess kernel declaration boundaries are missing or reordered") +endif() +math(EXPR kernel_begin "${kernel_begin} + 1") +math(EXPR kernel_length "${kernel_end} - ${kernel_begin}") +string(SUBSTRING "${scene}" ${kernel_begin} ${kernel_length} kernel) +foreach(declaration IN ITEMS "void collect_clipped(" "void collect_horizontal_left(" + "void collect_horizontal_right(" "void collect_vertical_left(" + "void collect_vertical_right(" "void collect(" "void line(" "word paint(") + string(FIND "${kernel}" "\n${declaration}" declaration_position) + if(declaration_position LESS 0) + message(FATAL_ERROR "Current gem-chess kernel is missing declaration: ${declaration}") + endif() +endforeach() + +set(bitmap "") +if(CASE STREQUAL "gem_chess_paint") + set(runner_mode --gem-paint) + set(bitmap "bitmap fixture { mode bitmap; size 256x192; depth 4bpp; base 0x6000; }\n") + set(entry [=[ +word main() { + use bitmap fixture; + word* input = (word*)0x100; + plot { options; } + paint((word*)0x108, (word*)0x110, input[0], input[1], input[2] != 0, (word*)0x1000); + flush; + return 42; +} +]=]) +else() + set(entry [=[ +word main() { + word* input = (word*)0x100; + word* spans = (word*)0x1000; + @cache for (word y = 0; y < 192; y++) { spans[y] = 256; spans[192+y] = -1; } +]=]) + if(CASE STREQUAL "gem_chess_raster_private") + set(runner_mode --gem-raster-private) + # A private edge writes one side according to its orientation. Walk + # both directions to compare both boundaries to the widened oracle. + string(APPEND entry [=[ + collect(input[0], input[1], input[2], input[3], spans); + collect(input[2], input[3], input[0], input[1], spans); +]=]) + elseif(CASE STREQUAL "gem_chess_raster") + set(runner_mode --gem-raster) + string(APPEND entry " collect_clipped(input[0], input[1], input[2], input[3], spans);\n") + else() + message(FATAL_ERROR "Unknown gem-chess regression case: ${CASE}") + endif() + string(APPEND entry " return 42;\n}\n") +endif() + +set(source "${TEST_DIR}/kernel.dc") +file(WRITE "${source}" "${bitmap}${kernel}\n${entry}") +set(link_options --origin 0x008000 --init-runtime --ram-bank 0 --stack-pointer 0x2000) +check_round_trip(kernel "${source}" ${link_options}) + +# Export the genuinely linked payload, with bootstrap and resolved metadata. +# Reassemble it without injecting another runtime or overriding its placement. +run_command("${DISCLD}" "${TEST_DIR}/kernel.o" ${link_options} + --emit-asm "${TEST_DIR}/kernel-final.s" -o "${TEST_DIR}/kernel.bin") +run_command("${DISCAS}" "${TEST_DIR}/kernel-final.s" -o "${TEST_DIR}/kernel-final.o") +run_command("${DISCLD}" "${TEST_DIR}/kernel-final.o" -o "${TEST_DIR}/kernel-final.bin") +file(SHA256 "${TEST_DIR}/kernel.bin" direct_hash) +foreach(payload IN ITEMS kernel-asm kernel-final) + file(SHA256 "${TEST_DIR}/${payload}.bin" round_trip_hash) + if(NOT round_trip_hash STREQUAL direct_hash) + message(FATAL_ERROR "${CASE} O${OPTIMIZATION}: ${payload} is not byte-exact") + endif() +endforeach() +if(CASE STREQUAL "gem_chess_paint") + foreach(listing IN ITEMS kernel.s kernel-final.s) + file(READ "${TEST_DIR}/${listing}" assembly) + if(NOT assembly MATCHES "__DISCO_BITMAP_SCBR[ \t]+\\$18" OR + NOT assembly MATCHES "__DISCO_BITMAP_SCMR[ \t]+\\$21") + message(FATAL_ERROR "${listing} lost the fixture's 256x192/4bpp framebuffer metadata") + endif() + endforeach() +endif() + +# The runner owns the full deterministic edge/reversal/private/polygon corpus, +# independent widened oracle, instruction cap, ABI checks and memory sentinels. +# Byte equality above makes all three production paths execute this same code. +run_command("${GSU_RUNNER}" ${runner_mode} "${TEST_DIR}/kernel.bin" 0x008000) +message(STATUS "${CASE} O${OPTIMIZATION}: current scene kernel, byte-exact assembly paths and full oracle corpus passed") diff --git a/tests/run_graphics_regression.cmake b/tests/run_graphics_regression.cmake index 170c107..ac9f902 100644 --- a/tests/run_graphics_regression.cmake +++ b/tests/run_graphics_regression.cmake @@ -511,8 +511,18 @@ elseif(CASE STREQUAL "graphics_interactive") run_command("${DISCC}" --target gsu --execution-memory ${execution_memory} --emit-asm "${source}" -o "${TEST_DIR}/shapes-compiler.s") run_command("${DISCAS}" "${TEST_DIR}/shapes-compiler.s" -o "${TEST_DIR}/shapes-asm.o") if(execution_memory STREQUAL "rom") - run_expected_failure_contains("program-bank boundary" "${DISCLD}" "${TEST_DIR}/shapes.o" - --origin 0x708000 --init-runtime --ram-bank 0 --stack-pointer 0xEFFE -o "${TEST_DIR}/too-large.bin") + set(ram_placement_diagnostic "program-bank boundary") + if(OPTIMIZATION STREQUAL "1") + # O1 now fits the half-bank window, but still covers the initial stack. + set(ram_placement_diagnostic "Initial stack word overlaps the linked payload") + endif() + foreach(object IN ITEMS shapes.o shapes-asm.o) + # Sixteen remaining bytes cannot hold this renderer, even as code shrinks. + run_expected_failure_contains("program-bank boundary" "${DISCLD}" "${TEST_DIR}/${object}" + --origin 0x70FFF0 --init-runtime --ram-bank 0 --stack-pointer 0xEFFE -o "${TEST_DIR}/too-large.bin") + run_expected_failure_contains("${ram_placement_diagnostic}" "${DISCLD}" "${TEST_DIR}/${object}" + --origin 0x708000 --init-runtime --ram-bank 0 --stack-pointer 0xEFFE -o "${TEST_DIR}/invalid-ram-placement.bin") + endforeach() endif() if(NOT OPTIMIZATION MATCHES "^[12s]$") foreach(object IN ITEMS shapes.o shapes-asm.o) diff --git a/tests/run_no_spill_regression.cmake b/tests/run_no_spill_regression.cmake new file mode 100644 index 0000000..9658d66 --- /dev/null +++ b/tests/run_no_spill_regression.cmake @@ -0,0 +1,210 @@ +cmake_minimum_required(VERSION 3.15) +if(NOT DEFINED DISCC OR NOT DEFINED TEST_DIR) + message(FATAL_ERROR "DISCC and TEST_DIR are required") +endif() +file(MAKE_DIRECTORY "${TEST_DIR}") + +function(no_spill_command) + execute_process(COMMAND "${DISCC}" ${ARGV} WORKING_DIRECTORY "${TEST_DIR}" + RESULT_VARIABLE result OUTPUT_VARIABLE output ERROR_VARIABLE error TIMEOUT 30) + if(NOT result STREQUAL "0") + message(FATAL_ERROR "No-spill command failed (${result}): ${ARGV}\n${output}\n${error}") + endif() + set(command_output "${output}" PARENT_SCOPE) +endfunction() +function(no_spill_failure pattern) + execute_process(COMMAND "${DISCC}" ${ARGN} WORKING_DIRECTORY "${TEST_DIR}" + RESULT_VARIABLE result OUTPUT_VARIABLE output ERROR_VARIABLE error TIMEOUT 30) + if(result STREQUAL "0" OR NOT "${output}\n${error}" MATCHES "${pattern}" OR + "${output}\n${error}" MATCHES "Internal Compiler Error|IR verifier:") + message(FATAL_ERROR "Expected no-spill diagnostic '${pattern}': ${ARGN}\n${output}\n${error}") + endif() +endfunction() +function(no_spill_equal left right) + file(SHA256 "${left}" left_hash) + file(SHA256 "${right}" right_hash) + if(NOT left_hash STREQUAL right_hash) + message(FATAL_ERROR "No-spill option changed spill-free output: ${left} and ${right}") + endif() +endfunction() +function(no_spill_unchanged path expected) + file(SHA256 "${path}" actual) + if(NOT actual STREQUAL expected) + message(FATAL_ERROR "No-spill validation clobbered ${path}") + endif() +endfunction() + +file(WRITE "${TEST_DIR}/simple.dc" "word main(){return 42;}\n") +# Explicit local storage, unused ABI parameters, and outgoing constant ABI +# arguments must not be mistaken for compiler-generated IR spill homes. +# Address-calculation intermediates can still require real compiler spill homes; +# use a direct scalar address here rather than assuming array indexing is free. +file(WRITE "${TEST_DIR}/allowed.dc" [=[ +void opaque(word* address,word value); +void allowed(word argument){word local=42;opaque(&local,7);} +]=]) +file(WRITE "${TEST_DIR}/array-storage.dc" "void array_storage(){word local[8];}\n") +file(WRITE "${TEST_DIR}/preserves.dc" [=[ +void opaque(word value); +word preserves(word argument){word value=argument+1;opaque(7);return value;} +]=]) +# All volatile arguments are evaluated before the call. Their simultaneous +# liveness exceeds the GSU allocator's register budget at every optimization. +set(pressure [=[ +word sink(word a,word b,word c,word d,word e,word f,word g,word h,word i,word j,word k,word l); +word pressure(){return sink( +*(volatile word*)0x100,*(volatile word*)0x102,*(volatile word*)0x104, +*(volatile word*)0x106,*(volatile word*)0x108,*(volatile word*)0x10a, +*(volatile word*)0x10c,*(volatile word*)0x10e,*(volatile word*)0x110, +*(volatile word*)0x112,*(volatile word*)0x114,*(volatile word*)0x116);} +]=]) +file(WRITE "${TEST_DIR}/pressure.dc" "${pressure}") +file(WRITE "${TEST_DIR}/divmod.dc" "word paired(word a,word b){return a/b+a%b;}\n") +file(WRITE "${TEST_DIR}/empty.dc" "") +file(WRITE "${TEST_DIR}/api.dci" "module API;void touch(word value);\n") + +foreach(help IN ITEMS single project) + if(help STREQUAL "project") + no_spill_command(build --help) + else() + no_spill_command(--help) + endif() + if(NOT command_output MATCHES "--require-no-spills" OR NOT command_output MATCHES "prebuilt objects") + message(FATAL_ERROR "${help} help omits the no-spill switch or source-unit scope") + endif() +endforeach() + +set(project "${TEST_DIR}/source project") +file(MAKE_DIRECTORY "${project}") +file(WRITE "${project}/api.dci" "module API;void touch(word value);\n") +file(WRITE "${project}/touch.dc" "import \"api.dci\";void touch(word value){}\n") +file(WRITE "${project}/main.dc" "import \"touch.dc\";void main(){touch(7);}\n") +file(WRITE "${project}/discoc.toml" "[project]\nname='no-spills'\nsources=['main.dc']\n[runtime]\ninitialize=true\n[output]\nbinary='payload.bin'\nassembly='payload.s'\n") +file(WRITE "${project}/bad.dc" "${pressure}") +# Even an unused imported implementation is analyzed and therefore constrained. +file(WRITE "${project}/bad-main.dc" "import \"bad.dc\";void main(){}\n") +file(WRITE "${project}/bad.toml" "[project]\nsources=['bad-main.dc']\n[output]\nbinary='payload.bin'\n") +# Also cover an explicitly listed implementation that is not imported. +file(WRITE "${project}/listed.toml" "[project]\nsources=['main.dc','bad.dc']\n[output]\nbinary='payload.bin'\n") + +foreach(level IN ITEMS 0 1 2 s) + set(flags "-O${level}") + set(prefix "${TEST_DIR}/O${level}") + file(MAKE_DIRECTORY "${prefix}") + foreach(source IN ITEMS simple allowed array-storage) + foreach(suffix IN ITEMS o s) + set(mode) + if(suffix STREQUAL "s") + set(mode --emit-asm) + endif() + no_spill_command(${flags} ${mode} "${TEST_DIR}/${source}.dc" -o "${prefix}/${source}.${suffix}") + no_spill_command(${flags} --require-no-spills ${mode} "${TEST_DIR}/${source}.dc" -o "${prefix}/${source}-strict.${suffix}") + no_spill_equal("${prefix}/${source}.${suffix}" "${prefix}/${source}-strict.${suffix}") + endforeach() + endforeach() + no_spill_command(${flags} --check "${TEST_DIR}/pressure.dc") + no_spill_command(${flags} --emit-ir "${TEST_DIR}/pressure.dc") + no_spill_command(${flags} "${TEST_DIR}/pressure.dc" -o "${prefix}/pressure-ordinary.o") + foreach(mode IN ITEMS --check --emit-ir) + no_spill_command(${flags} ${mode} "${TEST_DIR}/simple.dc") + set(ordinary_ir "${command_output}") + no_spill_command(${flags} --require-no-spills ${mode} "${TEST_DIR}/simple.dc" + -o "${prefix}/inspection/no-output.o") + if(NOT command_output STREQUAL ordinary_ir) + message(FATAL_ERROR "Strict ${mode} changed successful inspection output at ${flags}") + endif() + if(EXISTS "${prefix}/inspection") + message(FATAL_ERROR "Strict ${mode} created an output directory") + endif() + no_spill_command(${flags} --require-no-spills ${mode} "${TEST_DIR}/empty.dc") + no_spill_command(${flags} --require-no-spills ${mode} "${TEST_DIR}/api.dci") + endforeach() + # Inspection and failed emission must leave existing destinations untouched. + set(sentinel "${prefix}/preserved-output") + file(WRITE "${sentinel}" "existing output\n") + file(SHA256 "${sentinel}" sentinel_hash) + foreach(mode IN ITEMS object --emit-asm --check --emit-ir) + set(options) + if(NOT mode STREQUAL "object") + set(options "${mode}") + endif() + no_spill_failure("--require-no-spills" ${flags} --require-no-spills ${options} + "${TEST_DIR}/pressure.dc" -o "${sentinel}") + no_spill_unchanged("${sentinel}" "${sentinel_hash}") + endforeach() + no_spill_command(${flags} --require-no-spills --check "${TEST_DIR}/simple.dc" -o "${sentinel}") + no_spill_unchanged("${sentinel}" "${sentinel_hash}") + no_spill_failure("--require-no-spills.*--emit-ast" ${flags} --require-no-spills + --emit-ast "${TEST_DIR}/simple.dc" -o "${sentinel}") + no_spill_unchanged("${sentinel}" "${sentinel_hash}") + # An unsupported target remains usable in unconstrained inspection modes. + foreach(mode IN ITEMS --check --emit-ir --emit-ast) + no_spill_command(${flags} --target spc700 ${mode} "${TEST_DIR}/simple.dc") + endforeach() + foreach(mode IN ITEMS --check --emit-ir object) + set(options) + if(NOT mode STREQUAL "object") + set(options "${mode}") + endif() + no_spill_failure("spc700.*--require-no-spills.*backend" ${flags} --target spc700 + --require-no-spills ${options} "${TEST_DIR}/simple.dc") + endforeach() + no_spill_command(${flags} --emit-ast "${TEST_DIR}/simple.dc") + + foreach(mode IN ITEMS --check --emit-ir --emit-asm object) + set(options) + if(NOT mode STREQUAL "object") + set(options "${mode}") + endif() + no_spill_failure("--require-no-spills" ${flags} --require-no-spills ${options} + "${project}/bad-main.dc" -o "${sentinel}") + no_spill_unchanged("${sentinel}" "${sentinel_hash}") + endforeach() + no_spill_command(build --config "${project}/discoc.toml" ${flags} --require-no-spills + --check --output-dir "${prefix}/project-check") + if(EXISTS "${prefix}/project-check") + message(FATAL_ERROR "Strict project --check created output directories") + endif() + no_spill_command(build --config "${project}/discoc.toml" ${flags} + --output-dir "${prefix}/ordinary-project") + # A successful link proves the compiler-only switch was not sent to discld. + no_spill_command(build --config "${project}/discoc.toml" ${flags} --require-no-spills + --output-dir "${prefix}/strict-project") + foreach(output IN ITEMS 0-main.o 1-touch.o payload.bin payload.s) + no_spill_equal("${prefix}/ordinary-project/${output}" "${prefix}/strict-project/${output}") + endforeach() + no_spill_command(--project "${project}/discoc.toml" ${flags} --require-no-spills + --check --output-dir "${prefix}/alias-check") + if(EXISTS "${prefix}/alias-check") + message(FATAL_ERROR "Strict --project --check created output directories") + endif() + foreach(manifest IN ITEMS bad listed) + no_spill_failure("--require-no-spills" build --config "${project}/${manifest}.toml" + ${flags} --require-no-spills --check --output-dir "${prefix}/${manifest}-check") + if(EXISTS "${prefix}/${manifest}-check") + message(FATAL_ERROR "Failed strict project --check created output directories") + endif() + no_spill_failure("--require-no-spills" build --config "${project}/${manifest}.toml" + ${flags} --require-no-spills --output-dir "${prefix}/strict-project") + no_spill_equal("${prefix}/ordinary-project/payload.bin" "${prefix}/strict-project/payload.bin") + endforeach() + no_spill_failure("spc700.*--require-no-spills.*backend" build --config "${project}/discoc.toml" + ${flags} --target spc700 --require-no-spills --check) +endforeach() + +# Register preservation across a call is ABI traffic, not a spill home. +no_spill_command(-O2 --require-no-spills "${TEST_DIR}/preserves.dc" -o "${TEST_DIR}/preserves.o") +# A fused divmod's auxiliary home is a spill even when its quotient has a register. +foreach(mode IN ITEMS --check --emit-ir --emit-asm object) + set(options) + if(NOT mode STREQUAL "object") + set(options "${mode}") + endif() + no_spill_failure("--require-no-spills" -O2 --require-no-spills ${options} + "${TEST_DIR}/divmod.dc" -o "${TEST_DIR}/unexpected-divmod-output") +endforeach() +if(EXISTS "${TEST_DIR}/unexpected-divmod-output" OR EXISTS "${TEST_DIR}/simple.o" OR + EXISTS "${TEST_DIR}/empty.o" OR EXISTS "${TEST_DIR}/api.o") + message(FATAL_ERROR "No-output no-spill validation created an implicit or failed output") +endif() +message(STATUS "No-spill CLI, inspection, output-preservation and project regressions passed") diff --git a/tests/run_prebuilt_tests.cmake b/tests/run_prebuilt_tests.cmake index 797a33c..7553153 100644 --- a/tests/run_prebuilt_tests.cmake +++ b/tests/run_prebuilt_tests.cmake @@ -13,10 +13,12 @@ function(run_checked) endfunction() run_checked("${CMAKE_COMMAND}" "-DROOT_DIR=${ROOT_DIR}" -P "${ROOT_DIR}/tests/BuildTargetParity.cmake") run_checked("${CMAKE_COMMAND}" "-DTEST_DIR=${BIN_DIR}/test-output/build_target_parity" -P "${ROOT_DIR}/tests/BuildTargetParityTests.cmake") +run_checked("${CMAKE_COMMAND}" "-DDISCC=${BIN_DIR}/discc${EXE_SUFFIX}" + "-DTEST_DIR=${BIN_DIR}/test-output/require-no-spills" -P "${ROOT_DIR}/tests/run_no_spill_regression.cmake") foreach(test IN ITEMS disco_object_tests disco_ir_verifier_tests disco_linear_scan_tests disco_target_foundation_tests disco_gsu_mapping_tests disco_assembly_export_tests disco_language_contract_tests disco_project_manifest_tests - disco_module_loader_tests disco_linker_hardening_tests disco_ir_local_optimizer_tests disco_ir_global_optimizer_tests disco_ir_value_optimizer_tests disco_gsu_cost_tests disco_ir_conditional_optimizer_tests disco_gsu_scheduler_tests disco_gsu_checked_proof_tests disco_gsu_size_optimization_tests disco_gsu_compaction_tests) + disco_module_loader_tests disco_linker_hardening_tests disco_ir_local_optimizer_tests disco_ir_global_optimizer_tests disco_ir_value_optimizer_tests disco_gsu_cost_tests disco_ir_conditional_optimizer_tests disco_gsu_scheduler_tests disco_gsu_checked_proof_tests disco_gsu_size_optimization_tests disco_gsu_compaction_tests disco_ir_storage_optimizer_tests disco_gsu_seven_backend_tests) message(STATUS "Running ${test}") run_checked("${BIN_DIR}/${test}${EXE_SUFFIX}") endforeach() diff --git a/tests/run_toolchain_regression.cmake b/tests/run_toolchain_regression.cmake index f47ec58..b5e5e22 100644 --- a/tests/run_toolchain_regression.cmake +++ b/tests/run_toolchain_regression.cmake @@ -74,7 +74,9 @@ function(run_expected_failure_contains pattern) endif() endfunction() -if(CASE STREQUAL "gsu_contributor_correctness") +if(CASE MATCHES "^gem_chess_(raster|raster_private|paint)$") + include("${ROOT_DIR}/tests/run_gem_chess_regression.cmake") +elseif(CASE STREQUAL "gsu_contributor_correctness") include("${ROOT_DIR}/tests/run_contributor_correctness_regression.cmake") elseif(CASE STREQUAL "gsu_size_policy") include("${ROOT_DIR}/tests/run_size_policy_regression.cmake") @@ -332,16 +334,89 @@ elseif(CASE STREQUAL "comparison_semantics") NOT assembly MATCHES "bcc" OR NOT assembly MATCHES "bcs") message(FATAL_ERROR "Signed/unsigned comparison branches are incomplete:\n${assembly}") endif() - if(NOT assembly MATCHES "beq" OR (NOT OPTIMIZATION MATCHES "^[12s]$" AND NOT assembly MATCHES "bne")) - message(FATAL_ERROR "Equality boundaries are not materialized distinctly:\n${assembly}") - endif() - # Fused O1 comparisons need not materialize a boolean then test it with - # BNE. Execution asserts equality/order, rather than requiring that shape. check_round_trip(comparisons "${source}" --init-runtime) foreach(suffix IN ITEMS "" "-asm") run_command("${GSU_RUNNER}" "${TEST_DIR}/comparisons${suffix}.bin" 0x8000 --register 6 0) endforeach() + # The original fixture's only ==/!= expressions have constant operands; + # O2/Os fold them, and reversed <=/> comparisons need no BEQ. Assert the + # equality shape on actual runtime ==/!= instead, then verify truth values + # independently of branch direction or boolean materialization choices. + # Compile this harness once per policy, not once per input pair. + set(matrix_source "${TEST_DIR}/comparison-matrix.dc") + set(matrix "word main(){\n") + foreach(signedness IN ITEMS signed unsigned) + if(signedness STREQUAL "unsigned") + set(type "unsigned word") + set(output_address 0x112) + else() + set(type "word") + set(output_address 0x110) + endif() + string(APPEND matrix "${type} ${signedness}_a=*(volatile ${type}*)0x100;\n") + string(APPEND matrix "${type} ${signedness}_b=*(volatile ${type}*)0x102;\n") + string(APPEND matrix "word ${signedness}_result=0;\n") + set(weight 1) + foreach(predicate IN ITEMS "<" "<=" ">" ">=" "==" "!=") + string(APPEND matrix "${signedness}_result+=(word)(${signedness}_a${predicate}${signedness}_b)*${weight};\n") + math(EXPR weight "${weight} * 2") + endforeach() + foreach(predicate IN ITEMS "<" "<=" ">" ">=" "==" "!=") + string(APPEND matrix "if(${signedness}_a${predicate}${signedness}_b)${signedness}_result+=${weight};\n") + math(EXPR weight "${weight} * 2") + endforeach() + string(APPEND matrix "*(volatile word*)${output_address}=${signedness}_result;\n") + endforeach() + string(APPEND matrix "return 42;}\n") + file(WRITE "${matrix_source}" "${matrix}") + check_round_trip(comparison-matrix "${matrix_source}") + file(READ "${TEST_DIR}/comparison-matrix.s" matrix_assembly) + if(NOT matrix_assembly MATCHES "beq" OR + (NOT OPTIMIZATION MATCHES "^[12s]$" AND NOT matrix_assembly MATCHES "bne")) + message(FATAL_ERROR "Runtime equality boundaries are not materialized distinctly:\n${matrix_assembly}") + endif() + # Nine bounded word encodings cover equal/adjacent values, both zero + # directions, byte boundaries, signed extrema and subtraction overflow. + foreach(a IN ITEMS 0 1 2 127 128 255 32767 32768 65535) + foreach(b IN ITEMS 0 1 2 127 128 255 32767 32768 65535) + foreach(signedness IN ITEMS signed unsigned) + set(x ${a}) + set(y ${b}) + if(signedness STREQUAL "signed") + if(x GREATER 32767) + math(EXPR x "${x} - 65536") + endif() + if(y GREATER 32767) + math(EXPR y "${y} - 65536") + endif() + endif() + # Bits 0..5 encode <,<=,>,>=,==,!=. Bits 6..11 repeat + # those truths via control flow. Trichotomy gives an oracle + # independent of the compiler's comparison implementation. + if(x LESS y) + set(truth_bits 35) + elseif(x GREATER y) + set(truth_bits 44) + else() + set(truth_bits 26) + endif() + math(EXPR expected_${signedness} "${truth_bits} * 65") + endforeach() + math(EXPR a_low "${a} & 255") + math(EXPR a_high "${a} >> 8") + math(EXPR b_low "${b} & 255") + math(EXPR b_high "${b} >> 8") + foreach(suffix IN ITEMS "" "-asm") + run_command("${GSU_RUNNER}" "${TEST_DIR}/comparison-matrix${suffix}.bin" 0x8000 + --ram-byte 0x700100 ${a_low} --ram-byte 0x700101 ${a_high} + --ram-byte 0x700102 ${b_low} --ram-byte 0x700103 ${b_high} + --word 0x700110 ${expected_signed} --word 0x700112 ${expected_unsigned} + --register 0 42 --register 6 0) + endforeach() + endforeach() + endforeach() + elseif(CASE STREQUAL "optimizer_preservation") set(source "${ROOT_DIR}/tests/fixtures/optimizer_preservation.dc") run_command("${DISCC}" --emit-asm "${source}" -o "${TEST_DIR}/optimizer.s") diff --git a/tests/storage/raw_numeric_alias_read.dc b/tests/storage/raw_numeric_alias_read.dc new file mode 100644 index 0000000..73bcd9a --- /dev/null +++ b/tests/storage/raw_numeric_alias_read.dc @@ -0,0 +1,7 @@ +// Model fixture: initial SP=$2000, main FP=$1FFC. The first local array's +// original analyzer offset is -8, so $1FF4 aliases its first element. +word main() { + word table[4] = {11, 22, 33, 44}; + word index = *(volatile word*)0x0100 & 3; + return table[index] + *(volatile word*)0x1FF4; +} diff --git a/tests/storage/raw_numeric_alias_write.dc b/tests/storage/raw_numeric_alias_write.dc new file mode 100644 index 0000000..679045a --- /dev/null +++ b/tests/storage/raw_numeric_alias_write.dc @@ -0,0 +1,8 @@ +// Model fixture: initial SP=$2000, main FP=$1FFC. The numeric store must +// overwrite table[0], not a dead/repacked slot or an immutable pool copy. +word main() { + word table[4] = {11, 22, 33, 44}; + word index = *(volatile word*)0x0100 & 3; + *(volatile word*)0x1FF4 = 99; + return table[index]; +} diff --git a/tests/toolchain-cases.cmake b/tests/toolchain-cases.cmake index 5568618..91bb430 100644 --- a/tests/toolchain-cases.cmake +++ b/tests/toolchain-cases.cmake @@ -95,6 +95,12 @@ add_toolchain_regression(gsu_size_optimization_execution gsu_size_optimization) add_toolchain_regression(gsu_size_policy_execution gsu_size_policy) add_toolchain_regression(gsu_value_optimization_execution gsu_value_optimization) add_toolchain_regression(gsu_sbk gsu_sbk) +# Compile the current scene kernels, not an oracle-only or copied collector. +# Keep this registry shared by CTest and the direct/prebuilt test runner. +foreach(mode IN ITEMS raster raster_private paint) + add_toolchain_regression(gem_chess_${mode}_O2 gem_chess_${mode} 2) + add_toolchain_regression(gem_chess_${mode}_Os gem_chess_${mode} s) +endforeach() # Reuse the same independent execution assertions, diagnostics and byte-exact # assembler round trips under O1/O2/Os. Each case owns a separate output directory. foreach(case IN ITEMS gsu_call_execution switch_abi gsu_contributor_correctness comparison_semantics ir_branch_relaxation ir_switch_relaxation From e026817abfb64661e9e63fd2ee27bb0b5b4e54df Mon Sep 17 00:00:00 2001 From: DiscoMan <143973063+DiscoManOfficial@users.noreply.github.com> Date: Tue, 20 Oct 2026 17:55:28 -0300 Subject: [PATCH 2/2] fix(gsu): resolve static analysis diagnostics --- src/GSUAddressProof.cpp | 5 ++- src/GSUAddressProof.hpp | 8 +++-- src/GSUMachineScheduler.cpp | 19 +++++++----- src/IR.cpp | 2 +- src/IRCodeGenerator.cpp | 2 +- src/IRCodeGenerator.hpp | 2 +- src/IRStorageOptimizer.cpp | 5 +-- tests/GSUMachineSchedulerTests.cpp | 37 ++++++++++++++++------ tests/GSUSizeOptimizationTests.cpp | 8 ++++- tests/IRStorageOptimizerTests.cpp | 49 ++++++++++++++++++++++++++---- 10 files changed, 103 insertions(+), 34 deletions(-) diff --git a/src/GSUAddressProof.cpp b/src/GSUAddressProof.cpp index 5bae15d..42134b8 100644 --- a/src/GSUAddressProof.cpp +++ b/src/GSUAddressProof.cpp @@ -466,13 +466,12 @@ bool GSUAddressProof::provesAccess(IRValueId value, const Type& pointer, int wid return false; } -bool GSUAddressProof::scalarBounds(IRValueId value, IRBlockId block, - std::int64_t& low, std::int64_t& high) const { +bool GSUAddressProof::scalarBounds(IRValueId value, IRBlockId block, ScalarBounds& bounds) const { if (!value.isValid() || value.value >= m_facts.size() || !block.isValid() || block.value >= m_block_count) return false; const auto fact = get(value, block.value); if (fact.kind != Kind::Scalar || fact.low > fact.high) return false; - low = fact.low; high = fact.high; + bounds = {fact.low, fact.high}; return true; } diff --git a/src/GSUAddressProof.hpp b/src/GSUAddressProof.hpp index bf847cd..1fb6c02 100644 --- a/src/GSUAddressProof.hpp +++ b/src/GSUAddressProof.hpp @@ -17,10 +17,12 @@ class GSUAddressProof { std::size_t frame_bytes, bool checked_frame); bool provesAccess(IRValueId value, const Type& pointer, int width) const; bool provesOffset(const IRInstruction& instruction) const; + struct ScalarBounds { + std::int64_t low = 0, high = 0; + }; // Numeric facts only; the caller must independently prove provenance, - // object extent, mutability and lifetime. Failure leaves outputs intact. - bool scalarBounds(IRValueId value, IRBlockId block, - std::int64_t& low, std::int64_t& high) const; + // object extent, mutability and lifetime. Failure leaves output intact. + bool scalarBounds(IRValueId value, IRBlockId block, ScalarBounds& bounds) const; enum class MultiplyKind { FullWord, SignedByte, UnsignedByte }; MultiplyKind multiplyKind(const IRInstruction& instruction) const; diff --git a/src/GSUMachineScheduler.cpp b/src/GSUMachineScheduler.cpp index 962fdaa..4270b72 100644 --- a/src/GSUMachineScheduler.cpp +++ b/src/GSUMachineScheduler.cpp @@ -198,9 +198,13 @@ bool splitImmediate(const Instruction& instruction) { return (op >= 0xa0 && op <= 0xae && instruction.bytes.size() == 2) || (op >= 0xf0 && op <= 0xfe && instruction.bytes.size() == 3); } -void planEntryIncrements(std::vector& code, const std::set& entries, - const std::set& relocation_offsets, - const std::set& protected_offsets, const ObjectFile& object, +struct SchedulingBoundaries { + const std::set& entries; + const std::set& relocation_offsets; + const std::set& protected_offsets; +}; +void planEntryIncrements(std::vector& code, const SchedulingBoundaries& boundaries, + const ObjectFile& object, const std::map& jump_targets, GSUMachineScheduler::Statistics& stats) { std::map incoming; @@ -215,10 +219,10 @@ void planEntryIncrements(std::vector& code, const std::set(symbol.stackOffset) + ((bytes + 1) / 2) * 2 + alignment - 2 > 0)) + static_cast(symbol.stackOffset) + ((static_cast(bytes) + 1) / 2) * 2 + alignment - 2 > 0)) fail("IR verifier: local storage exceeds the checked frame.", storage_source); } int parameter_offset = 6; diff --git a/src/IRCodeGenerator.cpp b/src/IRCodeGenerator.cpp index 08da8ff..2b7958d 100644 --- a/src/IRCodeGenerator.cpp +++ b/src/IRCodeGenerator.cpp @@ -34,7 +34,7 @@ IRCodeGenerator::IRCodeGenerator( m_data_manager(data_manager), m_config(config) {} -void IRCodeGenerator::fail(const std::string& message, const Token& source) const { +[[noreturn]] void IRCodeGenerator::fail(const std::string& message, const Token& source) const { throw CompilerError(message, source); } diff --git a/src/IRCodeGenerator.hpp b/src/IRCodeGenerator.hpp index b09f0cb..01c2401 100644 --- a/src/IRCodeGenerator.hpp +++ b/src/IRCodeGenerator.hpp @@ -176,7 +176,7 @@ class IRCodeGenerator { bool needsPointerSpill(const IRInstruction& instruction) const; void addRelocation(const std::string& symbol, std::size_t patch_offset, RelocationType type); - void fail(const std::string& message, const Token& source) const; + [[noreturn]] void fail(const std::string& message, const Token& source) const; void buildRegisterAllocation(const IRFunction& function); // Caller-saved extras (R2, R4, R14) join R5/R7/R8 only when this function // does not use them as plot, multiply, division, far-bank, or ROM hardware. diff --git a/src/IRStorageOptimizer.cpp b/src/IRStorageOptimizer.cpp index c9a92fc..9d9b3bf 100644 --- a/src/IRStorageOptimizer.cpp +++ b/src/IRStorageOptimizer.cpp @@ -78,8 +78,9 @@ bool analyze(const IRFunction& function, const Analyzer::LocalSymbolTable& local base.low += instruction.immediate; base.high += instruction.immediate; } else if (instruction.opcode == IROpcode::PointerOffset && instruction.operands.size() == 2 && (instruction.operation == "+" || instruction.operation == "-")) { - std::int64_t low = 0, high = 0; - if (!ranges.scalarBounds(instruction.operands[1], block.id, low, high)) continue; + GSUAddressProof::ScalarBounds bounds; + if (!ranges.scalarBounds(instruction.operands[1], block.id, bounds)) continue; + auto low = bounds.low, high = bounds.high; // Scalar facts are at most one word. Keep arithmetic bounded // before multiplying even if this routine receives hostile IR. if (low < -65535 || high > 65535 || instruction.immediate < 1 || instruction.immediate > 65528) return false; diff --git a/tests/GSUMachineSchedulerTests.cpp b/tests/GSUMachineSchedulerTests.cpp index d30d986..901d8b1 100644 --- a/tests/GSUMachineSchedulerTests.cpp +++ b/tests/GSUMachineSchedulerTests.cpp @@ -267,15 +267,34 @@ void pipelinePlan() { conditional.code_section == conditional_before.code_section, "A conditional branch split an immediate that both paths execute"); - auto entry = object({0x01, 0x05, 0x02, 0x01, 0x00, 0xd5, 0x05, 0x01, 0x01, 0x00, 0x01}); - entry.symbol_table.push_back({"\x01head", SymbolSection::CODE, 5}); - const auto entry_before = entry; - const auto entry_stats = GSUMachineScheduler::run(entry); - require(entry_stats.entry_slots == 1 && entry.code_section.size() == 10 && entry.code_section[7] == 0xd5, - "An INC entry immediately before BRA did not become that BRA's delay slot"); - equivalent(entry_before, entry, false); - DiscoGSU::Machine entry_machine(linked(entry), 0x008000); entry_machine.run(); - require(entry_machine.reg(5) == 1, "Retargeted INC entry did not run once"); + for (const auto opcode : {std::uint8_t{0xd5}, std::uint8_t{0xe5}}) { + auto entry = object({0x01, 0x05, 0x02, 0x01, 0x00, opcode, 0x05, 0x01, 0x01, 0x00, 0x01}); + entry.symbol_table.push_back({"\x01head", SymbolSection::CODE, 5}); + const auto entry_before = entry; + const auto entry_stats = GSUMachineScheduler::run(entry); + require(entry_stats.entry_slots == 1 && entry.code_section.size() == 10 && entry.code_section[5] == 5 && + entry.code_section[7] == opcode && entry.symbol_table[0].offset == 5, + "An INC/DEC entry lost its BRA anchor or useful delay slot"); + equivalent(entry_before, entry, false); + DiscoGSU::Machine entry_machine(linked(entry), 0x008000); entry_machine.run(); + require(entry_machine.reg(5) == (opcode == 0xd5 ? 1 : 65535), "Retargeted INC/DEC entry did not run once"); + for (unsigned fence = 0; fence < 5; ++fence) { + auto unsafe = entry_before; + if (fence == 0) unsafe.symbol_table.push_back({"public_head", SymbolSection::CODE, 5}); + if (fence == 1) unsafe.symbol_table.push_back({"\x01" "branch", SymbolSection::CODE, 6}); + if (fence == 2) unsafe.symbol_table.push_back({"\x01slot", SymbolSection::CODE, 8}); + if (fence == 3) unsafe.relocation_table.push_back({"\x01head", SymbolSection::CODE, 5, RelocationType::ADDR24_BANK}); + if (fence == 4) unsafe.relocation_table.push_back({"\x01head", SymbolSection::CODE, 7, RelocationType::ADDR24_BANK}); + const auto saved = unsafe; + require(GSUMachineScheduler::run(unsafe).entry_slots == 0 && unsafe.code_section == saved.code_section && + unsafe.symbol_table[0].offset == saved.symbol_table[0].offset && + unsafe.relocation_table.size() == saved.relocation_table.size(), + "Entry scheduling crossed a public entry, branch/slot label, or relocated byte"); + if (!unsafe.relocation_table.empty()) + require(unsafe.relocation_table[0].patch_offset == saved.relocation_table[0].patch_offset, + "Fenced entry scheduling changed a relocation offset"); + } + } auto nested = object({0x02}); nested.code_section.insert(nested.code_section.end(), 2, 0x01); diff --git a/tests/GSUSizeOptimizationTests.cpp b/tests/GSUSizeOptimizationTests.cpp index b557135..5ea24a2 100644 --- a/tests/GSUSizeOptimizationTests.cpp +++ b/tests/GSUSizeOptimizationTests.cpp @@ -104,7 +104,13 @@ struct IRCodeGeneratorTestAccess { backend.m_function_candidates["main"] = greedy; auto incomplete = module; auto absent = module.functions[0]; absent.name = "absent"; absent.link_name.clear(); incomplete.functions.push_back(absent); bool failed = false; - try { backend.generateMixedCandidate(incomplete); } catch (const CompilerError&) { failed = true; } + try { backend.generateMixedCandidate(incomplete); } + catch (const CompilerError& error) { + require(error.getMessage() == "IR codegen: missing bounded function candidate." && + error.getLine() == 1 && error.getCol() == 1, + "Missing mixed candidate produced the wrong diagnostic or source location"); + failed = true; + } require(failed && backend.m_function_policies.empty() && backend.m_function_division_policies.empty() && backend.m_function_pool_policies.empty() && !backend.m_collect_function_candidates && backend.m_allow_auto_cache && backend.m_current_function == nullptr && backend.m_values.empty(), diff --git a/tests/IRStorageOptimizerTests.cpp b/tests/IRStorageOptimizerTests.cpp index d73b01a..1314245 100644 --- a/tests/IRStorageOptimizerTests.cpp +++ b/tests/IRStorageOptimizerTests.cpp @@ -8,6 +8,7 @@ #include #include +#include #include namespace { @@ -191,6 +192,10 @@ void malformedHomes() { IRStorageOptimizer::run(fixture.module, fixture.locals); const auto root = symbol(fixture.module.functions[0], "t"); const auto parameter = fixture.module.functions[0].parameters[0].symbol_id; + require(fixture.module.functions[0].total_local_alloc_size == 10 && + fixture.module.functions[0].storage_layout.at(root).stackOffset == -8, + "Exact-fit word storage lost its checked-frame boundary"); + IRVerifier::verify(fixture.module); auto broken = fixture.module; broken.functions[0].storage_layout.at(parameter).stackOffset = 8; reject(broken, "parameter ABI offsets"); @@ -202,8 +207,26 @@ void malformedHomes() { broken = fixture.module; broken.functions[0].storage_layout.at(root).stackOffset = -broken.functions[0].total_local_alloc_size - 2; reject(broken, "exceeds the checked frame"); + broken = fixture.module; broken.functions[0].storage_layout.at(root).stackOffset = -6; + reject(broken, "exceeds the checked frame"); + broken = fixture.module; broken.functions[0].storage_layout.at(root).type.alignment = 3; + reject(broken, "identity or alignment"); + broken = fixture.module; broken.functions[0].storage_layout.at(root).type.sizeInBytes = std::numeric_limits::max(); + reject(broken, "local object extent"); + broken = fixture.module; broken.functions[0].storage_layout.at(root).type.array_size = std::numeric_limits::max(); + reject(broken, "local object extent"); broken = fixture.module; broken.functions[0].storage_layout.erase(root); reject(broken, "absent from its owned storage layout"); + + auto odd = prepare("word f(word p){byte t[3]={11,22,33};return t[p&1];}", false); + IRStorageOptimizer::run(odd.module, odd.locals); + const auto odd_root = symbol(odd.module.functions[0], "t"); + require(odd.module.functions[0].total_local_alloc_size == 6 && + odd.module.functions[0].storage_layout.at(odd_root).stackOffset == -4, + "Odd-sized storage did not round up to an exact-fit even reservation"); + IRVerifier::verify(odd.module); + odd.module.functions[0].storage_layout.at(odd_root).stackOffset = -2; + reject(odd.module, "exceeds the checked frame"); } void inlineLoopFallbacks() { @@ -336,12 +359,26 @@ void queryAndLimits() { auto fixture = prepare("word f(word p){word t[4]={1,2,3,4};return t[p&3];}"); const auto& f = fixture.module.functions[0]; GSUAddressProof ranges; ranges.run(f, fixture.locals.at("f"), static_cast(f.total_local_alloc_size), true); - std::int64_t low = 123, high = 456; - require(!ranges.scalarBounds({}, f.entry, low, high) && low == 123 && high == 456, "Invalid scalar ID changed query outputs"); - require(!ranges.scalarBounds(IRValueId{f.value_count + 1}, f.entry, low, high), "Out-of-range scalar ID was accepted"); - require(!ranges.scalarBounds(IRValueId{1}, {}, low, high), "Invalid block ID was accepted"); - require(!ranges.scalarBounds(IRValueId{1}, IRBlockId{static_cast(f.blocks.size())}, low, high), "Out-of-range block ID was accepted"); - require(low == 123 && high == 456, "Failed scalar queries modified outputs"); + GSUAddressProof::ScalarBounds bounds{123, 456}; + require(!ranges.scalarBounds({}, f.entry, bounds) && bounds.low == 123 && bounds.high == 456, "Invalid scalar ID changed query output"); + require(!ranges.scalarBounds(IRValueId{f.value_count + 1}, f.entry, bounds), "Out-of-range scalar ID was accepted"); + require(!ranges.scalarBounds(IRValueId{1}, {}, bounds), "Invalid block ID was accepted"); + require(!ranges.scalarBounds(IRValueId{1}, IRBlockId{static_cast(f.blocks.size())}, bounds), "Out-of-range block ID was accepted"); + IRValueId scalar, pointer; + IRBlockId scalar_block, pointer_block; + for (const auto& block : f.blocks) for (const auto& instruction : block.instructions) { + if (instruction.opcode == IROpcode::PointerOffset) { + scalar = instruction.operands.at(1); scalar_block = block.id; + } + if (instruction.opcode == IROpcode::Address && instruction.symbol == "t") { + pointer = instruction.result; pointer_block = block.id; + } + } + require(scalar.isValid() && pointer.isValid(), "Scalar query fixture lost its masked index or pointer"); + require(!ranges.scalarBounds(pointer, pointer_block, bounds), "Non-scalar address was accepted as numeric bounds"); + require(bounds.low == 123 && bounds.high == 456, "Failed scalar queries modified output"); + require(ranges.scalarBounds(scalar, scalar_block, bounds) && bounds.low == 0 && bounds.high == 3, + "Successful scalar query did not replace the complete output interval"); const auto snapshot = dumpIR(fixture.module); auto local = fixture.locals.at("f").begin()->second; for (std::uint32_t id = 1000; id < 1257; ++id) {