Skip to content

Latest commit

 

History

History
163 lines (137 loc) · 11.1 KB

File metadata and controls

163 lines (137 loc) · 11.1 KB

Array/Dictionary are broadly unreliable on mipsel-none-none-elf

Summary

On this project's patched Embedded Swift toolchain (target mipsel-none-none-elf, MIPS I / R3000A, -mno-abicalls -fno-pic -relocation-model=static), ordinary Array operations — growing past an existing capacity, and even removeAll(keepingCapacity: true) on an empty, already-reserved array — intermittently hang forever with no crash, no exception, no log. Dictionary (which shares Array's tail-allocated buffer-class storage shape) is suspected of the same class of bug but not yet independently isolated.

This was discovered while building a game (junkbot-swift's ports/PS1, an Embedded Swift game engine port) on top of this toolchain, and is reproduced here in a minimal, self-contained example: make arraytests / make arraytests-llc (see Sources/ArrayTests/). Both .psexes run the same sequence of increasingly complex Array/Dictionary operations, printing "N RUNNING" then "N PASS" for each; a test that hangs leaves its "RUNNING" line frozen on screen forever, and no later test's line ever appears. That frozen line is the failure signal — there is no other indication anything went wrong (see "Why this is hard to detect, below).

This is not a small, isolable set of bad call sites. Every mitigation attempted so far (described below) has narrowed the reproduction slightly but not converged on a fix, and the failure has turned out to be sensitive to seemingly-irrelevant details of the surrounding code (see "Update: reproducibility is context-sensitive" below) — consistent with a stale/uninitialized register being reused across a live range in the compiled code, rather than one specific wrong formula.

Environment

  • Toolchain: /Volumes/Crucial-2TB/Developer/build/Ninja-ReleaseAssert (built from source per this repo's own README patch set)
  • swiftc --version: Swift version 6.5-dev (LLVM 9597da2255fb6d2, Swift 0cc6e978b39043d), Target: arm64-apple-macosx26.0, Build config: +assertions
  • Target triple: mipsel-none-none-elf
  • Reproduced via this repo's Sources/ArrayTests/ (new), run in DuckStation 0.1-11515.

Reproducing it

make check-sdk       # confirms psn00bsdk/ is set up (see README's "Build" section)
make arraytests       # plain `swiftc -c` (this repo's normal build path for every other example)
make arraytests-llc   # same Main.swift, routed through `swift-frontend -emit-ir` + a separate
                       # `llc` invocation instead (same toolchain, same LLVM MIPS backend either
                       # way — this compares invocation *paths*, not different codegen)

Load either .psexe in an emulator (DuckStation recommended, per this repo's main README) and watch which test's "N RUNNING" line is the last one to ever appear.

The tests (Sources/ArrayTests/Main.swift)

# What it does Result
1 UnsafeMutableRawPointer.allocate(byteCount: 32, alignment: 8) Always passes.
2 var a: [Int32] = []; a.append(1) — first-ever allocation for this array Always passes.
3a var a: [Int32] = []; a.append(10); a.append(20) — array literal starts empty, two separate .append() calls, second one grows past capacity 1 Hangs, both builds (see "Update" below — this passed under arraytests-llc in an earlier, more complex repro context; it does not reproduce that success here).
3b var a: [Int32] = [10]; a.append(20) — same growth, but the initial element comes from an array literal instead of .append() Hangs, both builds.
4 var a: [Int32] = []; a.reserveCapacity(8); for i in 0..<8 { a.append(...) } — growth avoided entirely via reserveCapacity (not reached — 3a/3b already hang first; expected to pass based on junkbot-swift's findings, not yet independently confirmed in this example)
5 var a: [Int32] = []; a.reserveCapacity(8); a.removeAll(keepingCapacity: true) (not reached in this example's current run order — confirmed hanging independently in junkbot-swift's ports/PS1, see below)
6 var d: [Int32: Int32] = [:]; d[1] = 100; d[2] = 200 (not reached)

Why this is hard to detect

There is no exception handler installed on this bare-metal target. When a miscompiled code path produces a bad value that the Swift runtime would normally turn into a trap/precondition failure, the CPU has nowhere to go — it silently spins forever at the reset/exception vector. From the outside, this is indistinguishable from a plain infinite loop: the emulator's own UI keeps running fine, but the "Game" FPS counter (if the emulator reports one) drops to 0 and the framebuffer freezes on whatever was last presented. Nothing in any log indicates a fault occurred. The "print RUNNING, do the thing, print PASS, flip to both video buffers after each line" structure in Sources/ArrayTests/Main.swift exists specifically to make this diagnosable without a debugger or exception handler.

What's been ruled out

  1. Not a naive posix_memalign ignoring alignment. Sources/ArrayTests/shim.c uses a real bump allocator that honors the requested alignment (unlike HelloPS1/Balls/RGB24/Tiles's posix_memalign -> malloc(size) passthrough, which happens to work for those examples only because none of them exercise Array growth). A misaligned pointer causing a hard fault on the R3000A's no-fixup unaligned access was the first hypothesis; fixing the allocator's alignment handling did not change the outcome.
  2. Not heap bounds/out-of-memory. Plenty of headroom confirmed at every failure point in junkbot-swift's more detailed bisection (see that repo's ports/PS1/KNOWN_ISSUES.md).
  3. Not a general C/Swift calling-convention bug. UnsafeMutableRawPointer.allocate (test 1) and a fresh array's first allocation (test 2) both pass reliably — the exact same posix_memalign C entry point, called with different (correct) arguments each time.
  4. Not reserveCapacity alone. It avoids growth-past-capacity in some contexts (confirmed in junkbot-swift) but does not fix removeAll(keepingCapacity: true) on an already-empty array with sufficient reserved capacity — that hangs regardless (see junkbot-swift's ports/PS1/KNOWN_ISSUES.md "Update 2" for the isolated repro: var a: [Int32] = []; a.reserveCapacity(8); a.removeAll(keepingCapacity: true) hangs on its own, with nothing else preceding it).

Update: reproducibility is context-sensitive

An earlier, more complex repro (building a real game's entities array, inside junkbot-swift's ports/PS1) found that routing the SAME kind of two-.append()-calls growth through -emit-ir + llc (rather than plain swiftc -c) avoided the corruption — the byte count arriving at posix_memalign was correct both times, where it had been corrupted (garbage in the upper 16 bits of an otherwise-plausible value, e.g. 0x5104006C instead of 0x0000006C) under plain swiftc -c.

This example's test 3a is the same shape, deliberately isolated down to nothing but two .append() calls with no other code around them — and it hangs under both make arraytests and make arraytests-llc. So the -emit-ir+llc route is not a reliable workaround; it happened to avoid the corruption in one particular surrounding-code context and does not in this simpler one. This is the strongest evidence yet that whatever's wrong is sensitive to register allocation / live-range reuse around the growth call site (which differs between the two repro contexts), not a single deterministically-wrong formula that a given compilation path either has or doesn't have.

Practical implication for anyone debugging this upstream: don't trust "it passed under -emit-ir+llc" as a fix without testing the exact surrounding code shape you care about — as this example demonstrates, the same operation can pass in one context and hang in another under the identical compilation pipeline.

Suggested next steps for someone fixing this upstream

  • Diff the LLVM IR (build/arraytests-llc/main.swift.ll after make arraytests-llc) for test 3a against test 3b — same growth (capacity 1 → 2), only difference is whether the first element came from .append() or an array literal. If the IR is identical or near-identical but codegen differs, the bug is in instruction selection/register allocation, not the frontend's IR generation.
  • Add -mllvm -print-after-all to the llc invocation in the Makefile's build/arraytests-llc/main.swift.o rule and inspect what changes across passes for the growth-copy/removeAll code paths specifically.
  • Compare against a target where the same Sources/ArrayTests/Main.swift (ported to that target's bridging header) runs correctly — junkbot-swift's ports/N64 (target mips-none-none-elf, MIPS III/32r2, official Embedded Swift MIPS slice, no source patches needed) and ports/3DS (target armv6-none-none-eabi) both run Array-heavy code reliably. The -march=mips1 (true MIPS I) instruction subset is far less common/tested in LLVM than either of those — that's the most likely place a narrow codegen bug would hide undetected.
  • Try -Onone instead of -Osize for the affected compilation to check whether this is optimization-dependent (consistent with "stale register reused across an optimized live range" rather than a fundamentally wrong lowering).

A confirmed viable workaround: InlineArray

InlineArray<count, Element> (SE-0453, available on this toolchain's Swift 6.5-dev) is a fixed-size, inline-storage collection — its element count is part of the type, like a C array, with no heap allocation, no COW, no growth, and therefore none of the dynamic buffer-class machinery bugs #1/#2 live in. Test 7 in Sources/ArrayTests/Main.swift confirms this works correctly on this target: literal initialization, subscript writes, subscript reads, and iteration all produce correct results (sum=280 from 0+10+...+70, verified in DuckStation).

The tradeoff is ergonomics: InlineArray has no .append()/.removeAll()/dynamic resizing — using it as an Array replacement in existing code (e.g. JunkbotCore's entities: [Entity]) means wrapping it in a small "fixed capacity + separate count" type that manually implements just the subset of Array's API actually used (append-if-room, iterate 0..<count, swap-remove, etc.) via direct subscript access into a compile-time-sized InlineArray. This is real porting work — every Array/Dictionary use site needs a concrete maximum size chosen up front — but it's a proven-working path that doesn't require an upstream compiler fix.

Impact

Any Embedded Swift program on this target that uses Array/Dictionary beyond the narrowest proven-safe patterns (a single allocation, populate, read — no further growth, no removeAll) risks hitting this. This blocks porting any non-trivial existing Swift codebase (e.g. junkbot-swift's shared JunkbotCore game engine, which is Array-heavy throughout) without either an upstream fix, or rewriting the affected code against InlineArray-backed fixed-capacity containers instead of Array/Dictionary (see above — confirmed viable, not just theoretical).