Skip to content

Ndh/speed up spi nor - #514

Merged
nathanaelhuffman merged 4 commits into
ndh/axi-pipesfrom
ndh/speed-up-spi-nor
Aug 17, 2026
Merged

Ndh/speed up spi nor#514
nathanaelhuffman merged 4 commits into
ndh/axi-pipesfrom
ndh/speed-up-spi-nor

Conversation

@nathanaelhuffman

@nathanaelhuffman nathanaelhuffman commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator

I've stacked this for now on the axi-pipes branch, but will rebase this against main once axi-pipes lands. This allows review of just this change.

Here we increase the SPI nor frequency to 62.5MHz (up from ~20MHz). This required some link layer optimization and the addition of some timing constraints for this. Simulation coverage has also increased.

At least when benchmark over usb, this basically make 0 difference as the usb transfers dominate the performance there.

Given what we know about flash and the system performance, it is unclear whether this will speed anything up in product but it gets the FPGA shift speed out of the way for any future performance investigation. There is probably additional optimization to be had around queueing up more data or other interface changes here should any hubris benchmarking suggest additional optimization opportunity.

Change summary:
Speed up sclk 20.83 MHz → 62.5 MHz (clk/6 → clk/2 off clk_125m) .
The register map untouched, so hubris code requires no changes.
Sclk rate and sample point are new generics on spi_nor_top.

A number of bugs that were not evident at the slower clock frequency were fixed as they were evident at faster speeds:

  • opcode serialized in the transaction's data mode during cs_assert. This would result in an incorrect opcode sent to the device.
  • OE followed the byte-aligned mode, resulting in IO3 re-driven into the part on a quad read turnaround.
  • output teardown at end of write, resulting in corrupting the last sample edge at the flash part.
  • sclk_running conflated with in_tx_phases resulted in trailing clocks during cs_deassert. Erase and write-enable could be silently discarded in this case.
  • io mode taken from the registered state at the reload edge resulted in the first quad data byte shifted by 1 not 4, so we lost the sentinel in the register and the transaction never terminated.

Structural Changes:
TX launches on the sclk falling edge itself; byte and io mode are prefetched together; RX sampling moved to a placed point (rx_sample_taps, half-clk steps) fed by rising/falling capture flops; every pin-facing flop duplicated with no internal fanout and packed into its IOB to close timing. (IOB packing reduces 12–13 ns of routing to ~3.3 ns).

This has been tested in hardware on a cosmo with the following somewhat adhoc tests via humility:

host flash slot: Flash0
test region:     0x400000 .. 0x410000

[id] read JEDEC ID + unique ID
    mfr=0xef type=0x40 capacity=0x21
    uid=ea626c4097221b35ea626c710313313600
    PASS (1.5s)

[status] read status register, check WIP clear
    status = 0x00
    PASS (1.4s)

[erase] sector erase leaves every byte of the sector 0xFF
    PASS (5.7s)

[page_program] program a page and read it back
    PASS (5.5s)

[partial_page] sub-page write of a non-multiple-of-4 length
    PASS (5.7s)

[multi_page] program four consecutive pages, verify the whole sector
    PASS (8.3s)

[odd_lengths] reads of awkward lengths at awkward offsets
    8 length/offset combinations verified (last one crosses a page boundary)
    PASS (15.2s)

[bit_clear] reprogramming without erase only clears bits
    PASS (7.3s)

[ff_write_noop] writing all-0xFF is a documented no-op
    PASS (5.8s)

[erase_after_write] erase clears a programmed sector, and is idempotent
    PASS (7.4s)

[neighbor_isolation] operations do not disturb the adjacent sector
    PASS (5.5s)

[diffwrite] -D programs a full sector; a second -D finds no delta
    second pass reported no delta
    PASS (12.9s)

[bad_address] out-of-slot access is rejected, not silently wrapped
    rejected as expected
    PASS (1.4s)

[sector0_guard] sector 0 is refused without --write-sector0
    refused as expected
    PASS (1.1s)

[throughput] time a full-sector -D write and a full-sector hash pass
    hash (read) pass: 1.46s -> 43.9 KiB/s including invocation overhead
    write pass:       7.40s -> 8.7 KiB/s (erase + program)
    PASS (10.5s)

Takes the flash clock from 20.83MHz (clk/6) to 62.5MHz (clk/2) on cosmo_seq
and grapefruit. Every SP5 boot pulls its host image through this block over
eSPI, so sclk sits directly on the boot path.

clk/2 is the ceiling for this structure. The read sample point is fixed
rather than trained, so the round trip out to the part and back has to land
within half an sclk period of it; above this rate that window closes and it
would take per-lane IDELAY read training to go further. Speed and sample
point are generics on spi_nor_top so a project can dial back to clk/4 with
one line. The register map is untouched, so hubris needs no change.

Simply changing the divisor would not have worked. Three latent bugs were
masked by clk/6 leaving spare clk cycles between sclk edges:

- get_cur_io_mode reported the transaction's data mode during cs_assert, but
  sclk is already running by then. At clk/2 the first sclk edge lands inside
  cs_assert, so a quad read shifted the opcode out four bits at a time and
  the part decoded 0x60 instead of 0x6C.
- io_oe followed the byte-aligned cur_io_mode, which still reads single for
  the first cycles of a dual or quad read -- long enough to re-enable io3 for
  HOLD avoidance exactly as the part takes the lane over. Direction now
  tracks the transaction directly, and release_lanes drops the lanes the part
  is about to drive an sclk cycle early.
- Tearing down io_o and io_oe at the end of a write landed on the sclk edge
  the part samples the last bit on, because both are registered off a phase
  that changes on the falling edge. cs_deassert now counts as driving for
  anything that is not a read.

Separately, the eSPI reader chains page reads and re-asserted cs_n the cycle
after it rose: 8 to 16ns against a tSHSL of 30ns. That was already out of
spec at 20MHz and is now enforced by cs_high_cnts.

The transmit path also had to stop launching a cycle late. It shifted on an
edge detector's view of the sclk falling edge, spending a whole clk period of
the half-period budget, which caps sclk at clk/4 on its own. Both the shifter
and a registered io_o now move on the same clk edge that drives sclk low.

Read data is sampled at rx_sample_taps half-clks after the sclk rising edge,
selected from a rising and a falling edge capture flop so the point can be
placed on a 4ns grid without a faster clock. The sample pulse is qualified by
in_rx_phases where it is generated, not where it is consumed, or the last
dummy clock's pulse arrives after the phase has advanced and steals a sample.

Getting this to close in Vivado needed the flash IO flops in the IOBs. Left
in the fabric the placer put them 12 to 13ns of routing from their pins,
varying by several ns between builds, which both blew the clock-to-data skew
budget and pushed the round trip past every available sample point. sclk and
cs_n cannot pack because they have internal fanout, so each gained a
dedicated duplicate flop driven from the same next-state value: same edge,
same value, no internal load. Flop to pin is now 3.3ns with 0.001ns of
routing, and both projects meet timing with no failing endpoints.

The old testbench pulled the bus to 'H' and checked nothing, so it could not
have caught any of the above. It now drives a W25Q01JV target model that
applies real tCLQV and tCLQX to read data and checks mosi setup and hold plus
the chip select timing, and the harness models the FPGA's own flop-to-pin and
pin-to-flop delays -- without those, simulation validates a regime that does
not exist on hardware. Three testbenches cover the legacy configuration and
the shipped fast one at both IO corners, and a margin test sweeps the round
trip across the range the constraints allow.

Still open: the board trace delays in both XDCs are placeholders pending
measured lengths, and this wants a JEDEC ID read, a full image readback and a
timed SP5 boot on real hardware. If reads come back marginal, sweep
rx_sample_taps before suspecting anything else.

@Aaron-Hartwig Aaron-Hartwig left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some small notes you can take or leave.

Comment thread hdl/projects/cosmo_seq/cosmo_timing.xdc Outdated
Comment thread hdl/ip/vhd/spi_nor_controller/sims/spi_nor_tb_pkg.vhd Outdated
Comment thread hdl/ip/vhd/spi_nor_controller/sims/spi_nor_tb.vhd
Comment thread hdl/ip/vhd/spi_nor_controller/sims/spi_nor_fast_quick_io_tb.vhd
@nathanaelhuffman
nathanaelhuffman merged commit 2fcc3db into main Aug 17, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants