Summary
atome_load()'s binary parser walks the ATOME01 blob with a byte
cursor and casts sections directly to const float* (gamma, beta,
a, b, c_out in read_norm()/read_ssm()) without ensuring
4-byte alignment. On any MCU core that enforces alignment on 32-bit
loads (confirmed: PicoRV32 with its default CATCH_MISALIGN=1), this
causes a hang or trap the first time atome_layer_norm() dereferences
gamma/beta — not a crash, not a wrong answer, just silence.
Root cause
The magic string "ATOME01" is 7 bytes. 7 mod 4 = 3. For any model
config where every packed-ternary section (embed, local_conv.packed,
attn.Wq/Wk/Wv.packed, router.packed, unembed) happens to size out
to an exact multiple of 4 bytes — true whenever the relevant dimensions
are powers of two, which is common (e.g. the nano/tiny config in
c_engine/RAM_TABLE.md: d_model=16, vocab=32, d_head=8) — nothing after
the magic ever corrects that initial 3-byte skew. It propagates through
the entire rest of the file. Every subsequent float* field
(norm.gamma, norm.beta, ssm.a/b/c_out, and the final norm) is
offset by the same constant 3 bytes from a 4-byte boundary.
Verified concretely: for the nano config, block[0].norm.gamma lands
at byte offset 7 + (4 + 128) = 139. 139 mod 4 = 3.
Why this hasn't been caught before
x86 (the test suite, tests/test_parity_with_c.py) and ARMv7-M
(c_engine/targets/cortex-m3-ram, the source of RAM_TABLE.md's
numbers) both perform unaligned 32-bit loads transparently in hardware
— no trap, no visible effect. QEMU (used for the Cortex-M3 target)
likely doesn't model the trap either. Real RV32I silicon with default
settings — PicoRV32 in this case — appears to be the first target in
this repo's history that actually enforces alignment, and it does, on
the very first LayerNorm call of the very first model loaded.
Reproduction
On real PicoRV32/Tang Nano 9K hardware (a new target being submitted
separately as c_engine/targets/picorv32-tangnano9k/): boots, atome_load()
returns 0, the embedding lookup runs correctly, atome_layer_norm()'s
mean/variance/sqrtf all compute real finite values — and then
execution stops permanently the instant params->gamma[0] is read.
Confirmed via temporary UART-instrumented build printing the actual
pointer value: gamma_addr=0x001042DB (0xDB mod 4 = 3).
Root cause confirmed independent of that specific hardware: the same
misalignment is present in the nano_seed42.atome file regardless of
what loads it — it's a property of the exported binary layout, not of
PicoRV32. (PicoRV32 is just the first target to care.)
Suggested fix
Either:
- Pad the magic to a 4-byte-aligned length (e.g.
"ATOME01\0", 8
bytes), and have atome_load()/export_to_atome.py agree that
every read_*() helper rounds its cursor up to the next 4-byte
boundary before treating a field as const float*. This needs
matching changes in both atome_load() (c_engine/upstream/atome.c)
and export_to_atome.py, since the loader and exporter must agree
on the exact byte layout.
- Alternatively, have the loader
memcpy each float field out of the
blob into an aligned scratch location instead of pointer-casting
directly — avoids the format change but costs a bit of extra state
and a few more lines in read_norm()/read_ssm().
Happy to help test against real PicoRV32 hardware once a fix is up —
that's how this was found in the first place.
Summary
atome_load()'s binary parser walks theATOME01blob with a bytecursor and casts sections directly to
const float*(gamma,beta,a,b,c_outinread_norm()/read_ssm()) without ensuring4-byte alignment. On any MCU core that enforces alignment on 32-bit
loads (confirmed: PicoRV32 with its default
CATCH_MISALIGN=1), thiscauses a hang or trap the first time
atome_layer_norm()dereferencesgamma/beta— not a crash, not a wrong answer, just silence.Root cause
The magic string
"ATOME01"is 7 bytes.7 mod 4 = 3. For any modelconfig where every packed-ternary section (
embed,local_conv.packed,attn.Wq/Wk/Wv.packed,router.packed,unembed) happens to size outto an exact multiple of 4 bytes — true whenever the relevant dimensions
are powers of two, which is common (e.g. the
nano/tinyconfig inc_engine/RAM_TABLE.md: d_model=16, vocab=32, d_head=8) — nothing afterthe magic ever corrects that initial 3-byte skew. It propagates through
the entire rest of the file. Every subsequent
float*field(
norm.gamma,norm.beta,ssm.a/b/c_out, and the final norm) isoffset by the same constant 3 bytes from a 4-byte boundary.
Verified concretely: for the
nanoconfig,block[0].norm.gammalandsat byte offset
7 + (4 + 128) = 139.139 mod 4 = 3.Why this hasn't been caught before
x86 (the test suite,
tests/test_parity_with_c.py) and ARMv7-M(
c_engine/targets/cortex-m3-ram, the source ofRAM_TABLE.md'snumbers) both perform unaligned 32-bit loads transparently in hardware
— no trap, no visible effect. QEMU (used for the Cortex-M3 target)
likely doesn't model the trap either. Real RV32I silicon with default
settings — PicoRV32 in this case — appears to be the first target in
this repo's history that actually enforces alignment, and it does, on
the very first
LayerNormcall of the very first model loaded.Reproduction
On real PicoRV32/Tang Nano 9K hardware (a new target being submitted
separately as
c_engine/targets/picorv32-tangnano9k/): boots,atome_load()returns 0, the embedding lookup runs correctly,
atome_layer_norm()'smean/variance/
sqrtfall compute real finite values — and thenexecution stops permanently the instant
params->gamma[0]is read.Confirmed via temporary UART-instrumented build printing the actual
pointer value:
gamma_addr=0x001042DB(0xDB mod 4 = 3).Root cause confirmed independent of that specific hardware: the same
misalignment is present in the
nano_seed42.atomefile regardless ofwhat loads it — it's a property of the exported binary layout, not of
PicoRV32. (PicoRV32 is just the first target to care.)
Suggested fix
Either:
"ATOME01\0", 8bytes), and have
atome_load()/export_to_atome.pyagree thatevery
read_*()helper rounds its cursor up to the next 4-byteboundary before treating a field as
const float*. This needsmatching changes in both
atome_load()(c_engine/upstream/atome.c)and
export_to_atome.py, since the loader and exporter must agreeon the exact byte layout.
memcpyeach float field out of theblob into an aligned scratch location instead of pointer-casting
directly — avoids the format change but costs a bit of extra state
and a few more lines in
read_norm()/read_ssm().Happy to help test against real PicoRV32 hardware once a fix is up —
that's how this was found in the first place.