2026-08-11 update (ticket 1170, whole-transfer engine): the per-packet bulk loop moved from interpreted Spin into the PHY cog (
UH_CMD_INX/OUTX, in-cog CRC16 both directions). Same suite, same SSD, same 200 MHz clock, RESULT PASS all phases (harness/logs/headless_260811-093240.logand the post-clamp rerun): raw 8-block reads 809-819 KB/s (was ~50), raw writes 512-683 KB/s, exFAT 16 KB-chunk file I/O 643 / 603 KB/s write/read (was 46/49), single-block latency ≈ 1 ms (was ≈ 12 ms), mount 54 ms (was 209), mkdir 295 ms (was 3.08 s). At 300 MHz raw sequential reaches 888 / 941 KB/s read/write. The 2088 async worker path (192 KB file copy + concurrent Alicat polling) went from 9.3 s to 501 ms (~392 KB/s) with cadence slip down from 176 ms to 1 ms. The figures below are the pre-1170 baseline, kept for the record.
Ticket 2087 (44b3845e66). All numbers measured on real hardware,
2026-08-10: P2 Eval at 200 MHz, serial-host header (base pin 16), 4-port
bus-powered hub, the SSD attached through the hub alongside a CDC serial
gadget, a keyboard/mouse dongle, and a USB SD-card reader — all enumerated
concurrently. Full log: harness/logs/headless_260810-012803.log; harness:
harness/top_ssd_profile.spin2.
Device
| Bridge | ASMedia SATA-USB, VID $174C PID $A89A |
| Capacity | 2,000,409,264 × 512-byte blocks (≈ 1 TB, 976,762 MB) |
| Filesystem | exFAT, 7,813,842 clusters × 128 KB, MBR partition at LBA 2048 |
| Volume label | P2SSD |
This device is interesting to the stack for three reasons: its top LBA
(2,000,409,263) sits just under the signed-32-bit edge, so it exercises the
full 32-bit LBA range through clus2lba() and READ(10)/WRITE(10); its
cluster count needs all 8 of the driver's mapped bitmap clusters
(BMP_MAX_CLUS); and its bridge chip behaves differently from the SD-card
reader used for all prior validation.
Functional results — all pass
- Late attach handled: the SSD asserts hub-port connect ~6–9 s after hub
power, well after the first
enumerate_bus(). The harness polls empty ports and enumerates it when it appears (see "Late-attaching devices" in the README). disk_open(SCSI bring-up) in 5 ms; full capacity reported.- Raw reads across the whole device, including single-block reads spread over 16 evenly-spaced LBAs spanning ~2.0e9 blocks.
- Raw write-back (rewrite of identical just-read data) at LBA 2,000,409,256 (top of the device), re-read and verified — full-range WRITE(10) proven.
- exFAT mount with boot-region checksum verification; correct geometry and label decoded.
mkdir, 4 small file create/write/close cycles, 64 KB file written and read in 512-byte calls, 512 KB file written and read in 16 KB calls — all content pattern-verified.- 8 pseudo-random sector-aligned seeks with verified 512-byte reads.
- Unmount → remount → file size and head/tail content re-verified (persistence).
- All test artifacts deleted; directory delete verified empty; clean unmount.
Performance
USB full speed is 12 Mbit/s on the wire; the practical ceiling of this stack
is set by the Bulk-Only-Transport protocol overhead (CBW/data/CSW per
command) and the one-cog Spin-orchestrated 64-byte packet loop, not by the
media — the SD-card reader profiles within a few percent of the SSD on the
same rig. Where the SSD clearly wins is command latency (disk_open 5 ms vs
150 ms, no NOT-READY retry dance) and write consistency (no multi-second
flash-GC stalls observed).
Raw block layer (MSC READ(10)/WRITE(10), 4 KB BOT chunks)
| Operation | Total | Time | Per-op | Throughput |
|---|---|---|---|---|
| Single-block reads ×32 | 16 KB | 374 ms | 11.7 ms | 42.8 KB/s |
| 8-block reads ×16 | 64 KB | 1,264 ms | 79 ms | 50.6 KB/s |
| 64-block reads ×4 | 128 KB | 2,528 ms | 632 ms | 50.6 KB/s |
| Single-block reads, device-spanning ×16 | 8 KB | 188 ms | 11.8 ms | 42.6 KB/s |
| 8-block write ×1 | 4 KB | 87 ms | 87 ms | 46.0 KB/s |
| Single-block writes ×8 | 4 KB | 95 ms | 11.9 ms | 42.1 KB/s |
Single-block latency is flat (~11.7 ms) across the whole device — no LBA-position penalty. Multi-block commands amortize BOT overhead and settle at ≈ 50 KB/s regardless of transfer size beyond 4 KB.
Filesystem layer (exFAT fs_* API)
| Operation | Total | Time | Per-op | Throughput |
|---|---|---|---|---|
fs_mount |
— | 209 ms | — | — |
fs_unmount |
— | 23 ms | — | — |
fs_mkdir |
— | 3,080 ms | — | 128 KB cluster zero-fill |
| create + 64 B write + close, ×4 | 256 B | 557 ms | 139 ms/file | metadata-bound |
fs_write in 512 B calls |
64 KB | 1,583 ms | 12.4 ms/call | 40.4 KB/s |
fs_read in 512 B calls |
64 KB | 1,588 ms | 12.4 ms/call | 40.3 KB/s |
fs_write in 16 KB calls |
512 KB | 11,387 ms | 356 ms/call | 45.0 KB/s |
fs_read in 16 KB calls |
512 KB | 10,720 ms | 335 ms/call | 47.8 KB/s |
fs_seek + 512 B read, ×8 |
4 KB | 99 ms | 12.4 ms | — |
Takeaways:
- Chunk size barely matters above one sector. 512-byte calls cost one block command each (12.4 ms); 16 KB calls reach ~95 % of the raw multi-block ceiling. Use whatever buffer size is convenient; prefer sector-multiple sizes.
- Metadata is the expensive part: ~139 ms per file create/close
(directory entry-set write + flush), 3.1 s per
mkdiron 128 KB-cluster media (the new directory's cluster is zero-filled over USB). Batch work into fewer, larger files where throughput matters. - Seeks are effectively free beyond the one sector read that follows them.
Defects found on the way (all fixed, ticket 2087 has the full ledger)
- PHY TX corruption, content-dependent — the silicon-level star of this
ticket. A transmitted byte was corrupted whenever the outgoing bit-stuff
geometry placed a stuffed 0 immediately after the first bit of the byte
following
$FF(lead byte with an MSB-side 1-run of exactly 3, e.g.$E3 $FF). Devices dropped the corrupt packet silently (per spec) and the abandoned transfer wedged the BOT state machine. Root-caused by single-block content bisection (harness/top_txpat.spin2), fixed by raising the AKPIN→WYPIN gap from 3 to 8 clocks at ≥ 168 MHz — a hardware sweep (harness/top_twksweep.spin2) showed 0/3 fail and 6–20 all pass at 200 MHz. The reference OBEX 4198 driver's 3-clock value was never wrong for it — it only ever transmits 8-byte control payloads. - Bulk data-stage patience — storage devices stall data stages (NAK or
silence) for seconds while flash/GC is busy; the old ~64 ms per-packet
retry budget abandoned transfers mid-flight. MSC now runs a ~10–20 s
budget on its client instance (
usb_client.set_nak_budget), matching the existing multi-second CSW patience; control paths keep the fast default. - BOT reset recovery on the steady-state path —
disk_read/disk_writenow run the BOT 6.6.2 recovery sequence (drain, class reset, clear both halts) and retry once on failure; previously onlydisk_openrecovered. - Late-attaching devices — SSDs can take seconds to assert connect;
one-shot enumeration missed the drive entirely.
usb_hub_hostgainedport_status(pn)(raw GET_STATUS(port)) so callers can poll for late connects and enumerate withenum_one_port(pn).
Reproducing
cd harness
pnut-ts -d -I ../src -D USB_MSC -D USB_EXFAT top_ssd_profile.spin2
pnut-term-ts --headless -r top_ssd_profile.bin -p <PORT> -b 2000000 --end-marker "SSD_PROF_END"
The harness self-selects a non-SD-reader MSC device when several are
present, waits up to 30 s for late attaches, and prints PROF lines
(bytes= ms= ops= Bps=) for every measurement plus a final
RESULT PASS/FAIL. top_txpat.spin2 (hostile-content canary) and
top_twksweep.spin2 (TX-gap sweep) remain in harness/ as regression
tools for the TX fix.