379 lines
18 KiB
C
379 lines
18 KiB
C
/*
|
|
* SPDX-License-Identifier: AGPL-3.0-or-later
|
|
*
|
|
* dram_bandwidth_reader.c
|
|
* ONE BRISC kernel, C99, whose only job is to pull bytes out of one DRAM NoC port as
|
|
* fast as it can and say — on its own wall clock — how long that took.
|
|
*
|
|
* It exists to settle a contradiction between two of this project's own measurements:
|
|
* the cooperative-matmul round put one DRAM bank at ~42 GB/s, while an earlier DRAM
|
|
* round recorded >= 537 GB/s across 14 cores (~67 GB/s a bank) and did not find the
|
|
* ceiling. The suspected difference is ACCESS PATTERN, and the variables are confounded
|
|
* in both. So every one of them is a RUNTIME ARGUMENT here and nothing else changes:
|
|
*
|
|
* sharing the host gives N cores the same base address, or disjoint ones
|
|
* working set `wrap_blocks * block_stride`, from a few KiB to hundreds of MiB
|
|
* port and bank `dram_x`/`dram_y`, so a bank's THREE NoC ports are separable from
|
|
* the bank behind them (ft_tt_coord.h: three doors, one memory)
|
|
* request size `block_bytes`
|
|
* concurrency `blocks_per_pass` / `barrier_every`, i.e. how many reads are in
|
|
* flight before anything is waited on
|
|
*
|
|
* ONE BINARY FOR EVERY ARM IS THE METHOD, not a convenience — the same rule
|
|
* multicast_bench_reader.c follows. Two binaries would put every codegen difference
|
|
* between the arms inside the number.
|
|
*
|
|
* ===========================================================================
|
|
* WHAT IT MEASURES, AND WHY IT IS TIMED THIS WAY
|
|
* ===========================================================================
|
|
* A PASS is `blocks_per_pass` reads of `block_bytes` from a walking address, with a read
|
|
* barrier every `barrier_every` reads. The kernel runs ONE UNTIMED PASS and then
|
|
* `iterations` timed ones, reading its own RISCV_DEBUG_REG_WALL_CLOCK_0 between.
|
|
*
|
|
* Timing is device-side because the host is two 1.8 GHz cores that also drive eight K80s
|
|
* and drift enough over ten seconds to swamp the quantity (DEV_NOTES.md §8), and because a
|
|
* launch is ~14.5 us. The untimed pass absorbs the instruction cache, the first row-buffer
|
|
* miss and the launch skew of the other cores in the group.
|
|
*
|
|
* `barrier_every` IS THE HARNESS'S OWN FALSIFIER. At `barrier_every == 1` every read is
|
|
* waited on before the next is issued, so the result is round-trip LATENCY-bound and must
|
|
* come out far below the pipelined arms. A harness that reports a plausible bandwidth for
|
|
* that arm is mis-instrumented, and the run that proves it is one runtime word away from
|
|
* the run being believed.
|
|
*
|
|
* ===========================================================================
|
|
* THE WALK, AND WHY IT IS A COMPARE RATHER THAN A MODULO
|
|
* ===========================================================================
|
|
* `address += block_stride; if (address >= wrap_end) address = base;` — sequential inside
|
|
* a bounded region, which is what streaming a weight matrix looks like. A modulo would put
|
|
* a RISC-V division in a loop body whose whole budget is a few tens of cycles; this is two
|
|
* arithmetic ops and a predictable branch. It also makes the final state computable on the
|
|
* host in closed form, which is what lets the READ-BACK CHECK below exist.
|
|
*
|
|
* ===========================================================================
|
|
* HOW A GREEN RUN IS PROVEN TO HAVE MOVED THE BYTES
|
|
* ===========================================================================
|
|
* The destination is `l1_slot_count` slots, used round-robin. After the last pass, slot
|
|
* `(blocks_per_pass * (iterations + 1) - k) % l1_slot_count` holds the block the walk was
|
|
* pointing at k reads from the end — an address the host computes with the same closed
|
|
* form and stages with distinct content beforehand. The host poisons every slot first, so
|
|
* "the reads never happened" and "the reads happened" are different bit patterns rather
|
|
* than the same zero (DEV_NOTES.md §3).
|
|
*
|
|
* `final_walk_offset` is published so the host's arithmetic is CHECKED against the
|
|
* kernel's rather than assumed equal to it.
|
|
*
|
|
* ===========================================================================
|
|
* RUNTIME ARGUMENTS
|
|
* ===========================================================================
|
|
* 0 iterations timed passes, >= 1 (ignored when duration_cycles is non-zero)
|
|
* 1 blocks_per_pass reads per pass, >= 1
|
|
* 2 barrier_every reads between read barriers, >= 1
|
|
* 3 block_bytes multiple of 64, > 0
|
|
* 4 dram_x TRANSLATED NoC coordinate of the DRAM PORT
|
|
* 5 dram_y
|
|
* 6 dram_base byte address in that bank, 64-aligned
|
|
* 7 block_stride bytes between consecutive blocks, 64-aligned, >= block_bytes
|
|
* 8 wrap_blocks blocks before the walk returns to dram_base, >= 1
|
|
* 9 l1_destination 64-aligned local L1 address of slot 0
|
|
* 10 l1_slot_count >= 1
|
|
* 11 l1_slot_stride >= block_bytes, 64-aligned
|
|
* 12 status_address local L1, FTDB_STATUS_WORDS words
|
|
* 13 duration_cycles 0: run `iterations` passes. Non-zero: run passes until this many
|
|
* wall-clock cycles have elapsed, and publish how many blocks that
|
|
* took. **THIS IS THE MODE THE SWEEPS USE, and the reason is the
|
|
* TAIL.** With a fixed byte count and N cores at different rates —
|
|
* and they DO differ, by ~20% across the grid — the fast cores
|
|
* finish early and the last stretch of the run has fewer readers in
|
|
* it. The sum of per-core rates then overstates the card (it assumes
|
|
* full overlap) while bytes-over-the-union understates it (it counts
|
|
* the tail). A common wall-clock window collapses the two into one
|
|
* number. Iteration mode stays because the CALIBRATION needs a
|
|
* device interval it did not choose, and the byte-proof needs a
|
|
* deterministic final walk position
|
|
* 14 direction 0: DRAM -> L1 reads. 1: L1 -> DRAM writes. The walk, the wrap and
|
|
* the L1 slot round-robin are identical; only the transfer and its
|
|
* barrier change. **The barrier is NOT interchangeable** — a read
|
|
* barrier deadlocks on Blackhole while writes are outstanding
|
|
* (ft_tensix_noc.h), so the direction picks the barrier at every
|
|
* place one is taken, not only inside the loop
|
|
*
|
|
* Compile-time args: none. Everything about the CALL is a runtime arg.
|
|
*
|
|
* Build (BRISC, NoC 0):
|
|
* CTARGS=0 NOC=0 bash foundry/tt/build_tensix_dataflow_kernel.sh \
|
|
* foundry/tt/kernels/dram_bandwidth_reader.c brisc /tmp/ttk_dramcap
|
|
*
|
|
* @status NEW 2026-08-14. Driven by tests/test_dram_capacity.c. Runtime arg 14 added the
|
|
* same day so the same binary measures WRITES — everything known about this card's DRAM
|
|
* was read bandwidth, and Phase 7 writes KV cache every token.
|
|
* @issues None
|
|
* @todo None
|
|
*/
|
|
|
|
#include "ft_tensix_dataflow.h"
|
|
|
|
/* ---------------------------------------------------------------------------
|
|
* The wire contract. tests/test_dram_capacity.c carries the same values; change
|
|
* both or neither.
|
|
* --------------------------------------------------------------------------- */
|
|
|
|
/** "FTDB". A status block still reading the host's poison means the kernel never ran. */
|
|
#define FTDB_STATUS_MAGIC 0x46544442u
|
|
|
|
#define FTDB_STATUS_MAGIC_WORD 0u
|
|
#define FTDB_STATUS_PASSES_COMPLETED 1u /**< the untimed pass plus every timed one */
|
|
#define FTDB_STATUS_BLOCKS_ISSUED 2u /**< TIMED blocks only — the numerator of the rate */
|
|
#define FTDB_STATUS_ELAPSED_CYCLES 3u
|
|
#define FTDB_STATUS_ERROR 4u
|
|
#define FTDB_STATUS_START_CLOCK 5u
|
|
#define FTDB_STATUS_END_CLOCK 6u
|
|
#define FTDB_STATUS_FINAL_WALK_OFFSET 7u
|
|
#define FTDB_STATUS_WORDS 8u
|
|
|
|
/** Refusals. Every one of them happens BEFORE any transaction is issued, so a refused run
|
|
* leaves nothing in flight and the core is as healthy as it was at entry. */
|
|
#define FTDB_OK 0
|
|
#define FTDB_ERROR_ZERO_LENGTH (-1)
|
|
#define FTDB_ERROR_ALIGNMENT (-2)
|
|
#define FTDB_ERROR_SHAPE (-3)
|
|
#define FTDB_ERROR_L1_BOUNDS (-4)
|
|
|
|
/** DRAM's read alignment — the stricter of the two ends of a DRAM->L1 transfer
|
|
* (ft_tt_noc.h, ft_tt_noc_alignment). A 16- or 32-byte-aligned address reads back
|
|
* byte-exact until its LAST 64 bytes, silently. */
|
|
#define FTDB_DRAM_READ_ALIGNMENT 64u
|
|
|
|
/* --------------------------------------------------------------------------- */
|
|
|
|
/** Everything read once at entry, so the timed loop indexes registers rather than
|
|
* re-reading the runtime-argument block out of L1. */
|
|
typedef struct ftdb_context {
|
|
uint32_t iterations;
|
|
uint32_t blocks_per_pass;
|
|
uint32_t barrier_every;
|
|
uint32_t block_bytes;
|
|
uint32_t dram_x;
|
|
uint32_t dram_y;
|
|
uint32_t dram_base;
|
|
uint32_t block_stride;
|
|
uint32_t wrap_blocks;
|
|
uint32_t l1_destination;
|
|
uint32_t l1_slot_count;
|
|
uint32_t l1_slot_stride;
|
|
volatile uint32_t *status;
|
|
uint32_t duration_cycles;
|
|
uint32_t direction;
|
|
} ftdb_context;
|
|
|
|
#define FTDB_DIRECTION_READ 0u
|
|
#define FTDB_DIRECTION_WRITE 1u
|
|
/** M4: reads, PIPELINED by transaction id — each group of `barrier_every` reads carries trid
|
|
* 1 or 2 alternately, and a group's barrier waits for the PREVIOUS group only, so two groups
|
|
* are in flight (a double-buffered reader). */
|
|
#define FTDB_DIRECTION_READ_PIPELINED 2u
|
|
|
|
static uint32_t ftdb_trid_current = 1u;
|
|
static uint32_t ftdb_trid_previous = 0u;
|
|
|
|
static void ftdb_read_arguments(ftdb_context *context)
|
|
{
|
|
context->iterations = ft_tensix_runtime_arg((int)0u);
|
|
context->blocks_per_pass = ft_tensix_runtime_arg((int)1u);
|
|
context->barrier_every = ft_tensix_runtime_arg((int)2u);
|
|
context->block_bytes = ft_tensix_runtime_arg((int)3u);
|
|
context->dram_x = ft_tensix_runtime_arg((int)4u);
|
|
context->dram_y = ft_tensix_runtime_arg((int)5u);
|
|
context->dram_base = ft_tensix_runtime_arg((int)6u);
|
|
context->block_stride = ft_tensix_runtime_arg((int)7u);
|
|
context->wrap_blocks = ft_tensix_runtime_arg((int)8u);
|
|
context->l1_destination = ft_tensix_runtime_arg((int)9u);
|
|
context->l1_slot_count = ft_tensix_runtime_arg((int)10u);
|
|
context->l1_slot_stride = ft_tensix_runtime_arg((int)11u);
|
|
context->status = (volatile uint32_t *)(unsigned long)ft_tensix_runtime_arg((int)12u);
|
|
context->duration_cycles = ft_tensix_runtime_arg((int)13u);
|
|
context->direction = ft_tensix_runtime_arg((int)14u);
|
|
}
|
|
|
|
/** The barrier that matches the direction. Taking the wrong one is not a slow path, it is a
|
|
* deadlock: a read barrier on Blackhole spins forever while a write is outstanding. */
|
|
static void ftdb_barrier(const ftdb_context *context)
|
|
{
|
|
if (context->direction == FTDB_DIRECTION_WRITE)
|
|
ft_tensix_noc_write_barrier();
|
|
else
|
|
ft_tensix_noc_read_barrier();
|
|
}
|
|
|
|
static void ftdb_publish(const ftdb_context *context, uint32_t passes_completed,
|
|
uint32_t blocks_issued, uint32_t start_clock, uint32_t end_clock,
|
|
uint32_t final_walk_offset, int error)
|
|
{
|
|
/* Magic LAST, so a host reading a torn block sees "never ran" rather than a half-filled
|
|
* result — the token-before-count ordering rule (resident_queue_protocol.h rule 1). */
|
|
context->status[FTDB_STATUS_PASSES_COMPLETED] = passes_completed;
|
|
context->status[FTDB_STATUS_BLOCKS_ISSUED] = blocks_issued;
|
|
context->status[FTDB_STATUS_START_CLOCK] = start_clock;
|
|
context->status[FTDB_STATUS_END_CLOCK] = end_clock;
|
|
context->status[FTDB_STATUS_ELAPSED_CYCLES] = end_clock - start_clock;
|
|
context->status[FTDB_STATUS_FINAL_WALK_OFFSET] = final_walk_offset;
|
|
context->status[FTDB_STATUS_ERROR] = (uint32_t)error;
|
|
context->status[FTDB_STATUS_MAGIC_WORD] = FTDB_STATUS_MAGIC;
|
|
}
|
|
|
|
/**
|
|
* Every refusal this kernel can make, all of them before a single transaction is issued.
|
|
*
|
|
* The host-side checker (ft_tt_noc_check_transfer) runs HOST-side and cannot see a
|
|
* kernel-issued transfer, so the rules have to be enforced here, at the point of issue —
|
|
* that is the generalisable half of the incident that cost cores 50 and 52
|
|
* (DEV_NOTES.md §1). A zero-length NoC transfer is the shape that took this card to
|
|
* `Mem- BusMaster-` on 2026-08-12.
|
|
*/
|
|
static int ftdb_validate(const ftdb_context *context)
|
|
{
|
|
uint64_t l1_span;
|
|
uint64_t dram_span;
|
|
|
|
if (context->block_bytes == 0u || context->blocks_per_pass == 0u ||
|
|
context->barrier_every == 0u || context->iterations == 0u ||
|
|
context->wrap_blocks == 0u || context->l1_slot_count == 0u)
|
|
return FTDB_ERROR_ZERO_LENGTH;
|
|
|
|
if ((context->block_bytes % FTDB_DRAM_READ_ALIGNMENT) != 0u ||
|
|
(context->dram_base % FTDB_DRAM_READ_ALIGNMENT) != 0u ||
|
|
(context->block_stride % FTDB_DRAM_READ_ALIGNMENT) != 0u ||
|
|
(context->l1_destination % FTDB_DRAM_READ_ALIGNMENT) != 0u ||
|
|
(context->l1_slot_stride % FTDB_DRAM_READ_ALIGNMENT) != 0u)
|
|
return FTDB_ERROR_ALIGNMENT;
|
|
|
|
/* Overlapping blocks or slots would make the read-back check meaningless, and an L1
|
|
* slot shorter than a block would have one read scribbling on the next slot. */
|
|
if (context->block_stride < context->block_bytes ||
|
|
context->l1_slot_stride < context->block_bytes ||
|
|
context->direction > FTDB_DIRECTION_READ_PIPELINED)
|
|
return FTDB_ERROR_SHAPE;
|
|
|
|
/* The whole destination window must be inside L1, computed in 64 bits so the bound
|
|
* cannot be defeated by the multiplication wrapping. */
|
|
l1_span = (uint64_t)context->l1_destination +
|
|
(uint64_t)context->l1_slot_count * (uint64_t)context->l1_slot_stride;
|
|
if (l1_span > (uint64_t)FT_TENSIX_L1_SIZE_BYTES)
|
|
return FTDB_ERROR_L1_BOUNDS;
|
|
|
|
/* Same for the walk: it must stay inside the 32-bit address a runtime arg can express. */
|
|
dram_span = (uint64_t)context->dram_base +
|
|
(uint64_t)context->wrap_blocks * (uint64_t)context->block_stride;
|
|
if (dram_span > 0x100000000uLL)
|
|
return FTDB_ERROR_SHAPE;
|
|
|
|
return FTDB_OK;
|
|
}
|
|
|
|
/**
|
|
* One pass: `blocks_per_pass` reads, a read barrier every `barrier_every` of them, and a
|
|
* final barrier so the pass is complete when it returns.
|
|
*
|
|
* `*walk_address` and `*slot_address` are carried across passes, which is what makes the
|
|
* walk continuous rather than restarting — a restart would re-read the same few blocks and
|
|
* turn a streaming test back into a cached one.
|
|
*/
|
|
static void ftdb_run_pass(const ftdb_context *context, uint32_t wrap_end, uint32_t slot_end,
|
|
uint32_t *walk_address, uint32_t *slot_address)
|
|
{
|
|
uint32_t block;
|
|
uint32_t since_barrier = 0u;
|
|
|
|
for (block = 0u; block < context->blocks_per_pass; block++) {
|
|
if (context->direction == FTDB_DIRECTION_READ_PIPELINED && since_barrier == 0u)
|
|
ft_tensix_noc_read_set_trid(ftdb_trid_current);
|
|
if (context->direction == FTDB_DIRECTION_WRITE)
|
|
ft_tensix_noc_write(*slot_address,
|
|
ft_tensix_noc_address(context->dram_x, context->dram_y,
|
|
*walk_address),
|
|
context->block_bytes);
|
|
else
|
|
ft_tensix_noc_read(ft_tensix_noc_address(context->dram_x, context->dram_y,
|
|
*walk_address),
|
|
*slot_address, context->block_bytes);
|
|
|
|
*walk_address += context->block_stride;
|
|
if (*walk_address >= wrap_end)
|
|
*walk_address = context->dram_base;
|
|
*slot_address += context->l1_slot_stride;
|
|
if (*slot_address >= slot_end)
|
|
*slot_address = context->l1_destination;
|
|
|
|
since_barrier++;
|
|
if (since_barrier == context->barrier_every) {
|
|
if (context->direction == FTDB_DIRECTION_READ_PIPELINED) {
|
|
if (ftdb_trid_previous != 0u)
|
|
ft_tensix_noc_read_barrier_trid(ftdb_trid_previous);
|
|
ftdb_trid_previous = ftdb_trid_current;
|
|
ftdb_trid_current = ftdb_trid_current == 1u ? 2u : 1u;
|
|
} else {
|
|
ftdb_barrier(context);
|
|
}
|
|
since_barrier = 0u;
|
|
}
|
|
}
|
|
if (since_barrier != 0u)
|
|
ftdb_barrier(context);
|
|
}
|
|
|
|
void kernel_main(void)
|
|
{
|
|
ftdb_context context;
|
|
uint32_t wrap_end;
|
|
uint32_t slot_end;
|
|
uint32_t walk_address;
|
|
uint32_t slot_address;
|
|
uint32_t pass;
|
|
uint32_t start_clock = 0u;
|
|
uint32_t end_clock = 0u;
|
|
int error;
|
|
|
|
ftdb_read_arguments(&context);
|
|
error = ftdb_validate(&context);
|
|
if (error != FTDB_OK) {
|
|
/* Nothing was issued, so there is nothing to drain. */
|
|
ftdb_publish(&context, 0u, 0u, 0u, 0u, 0u, error);
|
|
return;
|
|
}
|
|
|
|
wrap_end = context.dram_base + context.wrap_blocks * context.block_stride;
|
|
slot_end = context.l1_destination + context.l1_slot_count * context.l1_slot_stride;
|
|
walk_address = context.dram_base;
|
|
slot_address = context.l1_destination;
|
|
|
|
/* Pass 0 is untimed: it absorbs the instruction cache, the first row-buffer miss and
|
|
* the launch skew of the other cores in the group. */
|
|
ftdb_run_pass(&context, wrap_end, slot_end, &walk_address, &slot_address);
|
|
start_clock = ft_bh_wall_clock_low();
|
|
if (context.duration_cycles != 0u) {
|
|
/* The clock is read once per PASS, not once per read, so the deadline costs one
|
|
* MMIO read per `blocks_per_pass` reads and cannot be inside the measured rate at
|
|
* any depth this suite uses. */
|
|
for (pass = 0u; ft_bh_wall_clock_low() - start_clock < context.duration_cycles;
|
|
pass++)
|
|
ftdb_run_pass(&context, wrap_end, slot_end, &walk_address, &slot_address);
|
|
} else {
|
|
for (pass = 0u; pass < context.iterations; pass++)
|
|
ftdb_run_pass(&context, wrap_end, slot_end, &walk_address, &slot_address);
|
|
}
|
|
end_clock = ft_bh_wall_clock_low();
|
|
if (context.direction == FTDB_DIRECTION_READ_PIPELINED) {
|
|
ft_tensix_noc_read_barrier();
|
|
ft_tensix_noc_read_set_trid(0u);
|
|
}
|
|
|
|
/* Outside the timed window and unconditional: brisck.cc asserts both counters are
|
|
* settled after kernel_main() returns, and a transaction left in flight writes L1 the
|
|
* next kernel is about to use. No write or atomic is issued above, so these are cheap. */
|
|
ft_tensix_noc_write_barrier();
|
|
ft_tensix_noc_atomic_barrier();
|
|
|
|
ftdb_publish(&context, pass + 1u, pass * context.blocks_per_pass, start_clock, end_clock,
|
|
walk_address - context.dram_base, FTDB_OK);
|
|
}
|