Foundry/tt/kernels/resident_gather_reader.c

368 lines
17 KiB
C

/* SPDX-License-Identifier: AGPL-3.0-or-later */
/**
* @file resident_gather_reader.c
* @brief Reader half of the RESIDENT MEMORY triad — EMBEDDING and KV_APPEND. It fetches the
* source rows and ASSEMBLES them into face-ordered destination tiles in the output
* circular buffer; resident_place_writer.c puts those tiles on the destination.
*
* THIS IS THE DEGENERATE TRIAD: reader and writer, no compute thread at all. Neither op
* has any arithmetic in it — they are placed copies — and the enables mask is DM0|DM1.
* resident_memory_protocol.h has the reason that is safe (TRISC0 zeroes the CB page
* counters only when BRISC ASKS, and a resident BRISC never returns to the firmware loop
* that asks).
*
* WHY THE SCATTER IS A CPU LOOP AND NOT MORE NoC TRANSACTIONS. A tile row is not
* contiguous: one row of one tile is two 32-byte face rows half a tile apart. So placing
* a row means `group_tiles * 2` short moves. Done with the NoC that is 96 transactions
* for one 1536-wide embedding row at ~50 ns of issue each; done with RISC-V loads and
* stores into L1 — which is ordinary memory here, with its data cache disabled — it is
* ~768 word copies. The read from DRAM stays ONE transaction because the source row is
* contiguous, and a 32-byte DRAM READ would not be legal anyway (64 B alignment,
* ft_tt_noc_alignment()), so there was never a version of this that gathered face rows
* directly out of DRAM.
*
* THE FAST PATH IS THE PREFILL PATH. When the source is already tiled and the source row
* and destination row agree inside their tiles, the destination tiles ARE the source
* tiles: the read goes straight into the output CB and no byte is touched twice. That is
* every 32-token-aligned KV append.
*
* THIS PROCESSOR OWNS `tail`, AND NOTHING ELSE IN THE CONTROL BLOCK
* (resident_queue_protocol.h rule 1). An aborted descriptor advances nothing.
*
*
* C99 on Foundry's own `ft_tensix_dataflow.h` — no tt-metal include (plan Phase 6.6).
* The wait mode, which used to be a `template <uint32_t kMode>` argument, is now an
* ordinary parameter carrying the same compile-time value; `-Os` folds it identically.
* Compile-time args:
* 0: CB_OUT_EMBEDDING index 1: CB_STAGE index 2: CB_INDEX index
* 3: tile size in bytes
* 4: wait mode -- 0 upstream, 1 stoppable (ships), 2 bound-only
* 5: CB_OUT_KV index -- KV_APPEND's own output CB, because A CB CARRIES ONE GROUP SIZE
* (ft_tt_op_memory.h has the arithmetic and the incident). Passing arg 5 EQUAL to
* arg 0 rebuilds the defect deliberately and is how the guard is proved to refuse;
* ft_tensix_compile_time_arg() has no bounds check, so OMITTING it reads past the
* array and produces a CB index nobody chose -- which the same guard refuses, since
* an unconfigured CB has zero pages.
* Runtime args: the ten of resident_queue_protocol.h, identical on all five processors
*
* Build (BRISC, NoC 0):
* CTARGS=16,0,1,2048,1,17 NOC=0 KERNEL_TEXT_BASE=0x15000 bash \
* foundry/tt/build_tensix_dataflow_kernel.sh \
* foundry/tt/kernels/resident_gather_reader.c brisc /tmp/ttk_memory
*
* @status GREEN ON SILICON 2026-08-12 — Tensix 30 and Tensix 32, 17 checks and 0 failures
* each, byte-exact. 2264 B of text on BRISC, no bss, against a 12 KiB slab.
* RE-GREEN on Tensix 40, 2026-08-13, with the output CB split per group size: 28
* descriptors on one image load, 0 checks failed. The aliased build (arg 5 = arg 0)
* on Tensix 41 refuses the embedding descriptor at exactly the point the defect
* predicts — see ft_tt_op_memory.h @status.
* @issues None
* @todo None
*/
#include <stdbool.h>
#include <stdint.h>
#include "resident_memory_protocol.h"
#include "ft_tensix_dataflow.h"
#include "resident_queue_protocol.h"
#define CB_OUT_EMBEDDING ft_tensix_compile_time_arg(0)
#define CB_STAGE ft_tensix_compile_time_arg(1)
#define CB_INDEX ft_tensix_compile_time_arg(2)
#define TILE_BYTES ft_tensix_compile_time_arg(3)
#define WAIT_MODE ft_tensix_compile_time_arg(4)
#define CB_OUT_KV ft_tensix_compile_time_arg(5)
/** The output CB an opcode's group size belongs to. ONE GROUP SIZE PER CB — the whole
* reason there are two (ft_tt_op_memory.h). Both are compile-time constants, so this
* folds to one of them at -Os. */
static uint32_t output_circular_buffer(uint32_t opcode)
{
return (opcode == kOpKvAppend) ? CB_OUT_KV : CB_OUT_EMBEDDING;
}
/** Everything one descriptor says, read once so the loops index registers. */
typedef struct memory_request {
uint32_t flags;
uint32_t row_count;
uint32_t group_count;
uint32_t group_tiles;
uint32_t source_x;
uint32_t source_y;
uint32_t source_base;
uint32_t source_row_stride;
uint32_t source_row0;
uint32_t source_row_limit; /**< rows the ROW-MAJOR table has; bounds every gather index */
uint32_t source_group_stride;
uint32_t destination_row; /**< within its tile: DST_ROW0 % 32 */
} memory_request;
static void load_request(memory_request *request, volatile FTQ_L1_PTR uint32_t *parameters)
{
request->flags = parameters[kMemFlags];
request->row_count = parameters[kMemRowCount];
request->group_count = parameters[kMemGroupCount];
request->group_tiles = parameters[kMemGroupTiles];
request->source_x = parameters[kMemSrcX];
request->source_y = parameters[kMemSrcY];
request->source_base = parameters[kMemSrcBase];
request->source_row_stride = parameters[kMemSrcRowStride];
request->source_row0 = parameters[kMemSrcRow0];
request->source_row_limit = parameters[kMemSrcRowLimit];
request->source_group_stride = parameters[kMemSrcGroupStride];
request->destination_row = parameters[kMemDstRow0] % kMemTileRows;
}
/** A descriptor the kernel refuses rather than half-executes. The host checks all of this
* too; the kernel repeats it because a parameter block is a POINTER in a descriptor and a
* wrong one turns into NoC transactions at addresses nobody chose (spec §7.3). */
static bool request_is_sane(const memory_request *request, uint32_t cb_out)
{
if (request->row_count == 0u || request->row_count > kMemMaxRows)
return false;
if (request->group_count == 0u || request->group_tiles == 0u)
return false;
if (request->destination_row + request->row_count > kMemTileRows)
return false;
if ((request->flags & kMemFlagSrcTiled) != 0u &&
request->source_row0 + request->row_count > kMemTileRows)
return false;
/* THE CB CYCLE, which this file did not have. The output CB takes `group_tiles` pages
* per group and `group_count * group_tiles` in total; `cb_push_back` wraps only on an
* EXACT hit of the limit, so a descriptor that leaves the write pointer part-way round
* sends the NEXT descriptor's blocks over the limit and up into L1
* (ft_tensix_cb_push_grid_is_safe(), and the measured incident it records).
*
* With ONE CB PER GROUP SIZE this can no longer fail for a well-built residency — the
* pointer never leaves its own grid. It is kept, unweakened, because that is exactly
* the statement being relied on, and because a build that aliases the two CB indices
* onto one buffer must still be REFUSED rather than run. */
if (!ft_tensix_cb_push_grid_is_safe(cb_out, request->group_tiles))
return false;
return true;
}
/** Assemble one group from a TILED source.
*
* @return false if a length came out zero — see ftm_length_is_issuable(); the caller then
* aborts the descriptor rather than issuing it. */
static bool assemble_from_tiles(const memory_request *request, uint32_t block_address)
{
const uint32_t group_bytes = request->group_tiles * TILE_BYTES;
const bool rows_align = request->source_row0 == request->destination_row;
const bool whole_tiles = (request->flags & kMemFlagDstWholeTiles) != 0u;
const bool direct = rows_align && !(whole_tiles && request->row_count < kMemTileRows);
const uint32_t landing = direct ? block_address : ft_tensix_cb_write_pointer(CB_STAGE);
if (!ftm_length_is_issuable(group_bytes))
return false;
ft_tensix_noc_read(ft_tensix_noc_address(request->source_x, request->source_y,
request->source_base), landing,
group_bytes);
ft_tensix_noc_read_barrier();
if (direct)
return true;
for (uint32_t row = 0; row < request->row_count; row++) {
ftm_place_row_from_tiles((uint32_t *)(uintptr_t)(block_address),
(const uint32_t *)(uintptr_t)(landing),
request->source_row0 + row, request->destination_row + row,
request->group_tiles, TILE_BYTES);
}
return true;
}
/** Assemble one group from a ROW-MAJOR source, gathering row indices when asked to.
*
* Every row is issued before the ONE barrier: a barrier is per core, not per transaction
* (the dataflow API's barrier), and paying it per row is the mistake that cost 5.9x on the
* matmul reader (docs/tt_bringup_log.md §16.1). */
static bool assemble_from_rows(const memory_request *request, uint32_t block_address)
{
const uint32_t row_bytes = ftm_group_row_bytes(request->group_tiles, TILE_BYTES);
const uint32_t stage = ft_tensix_cb_write_pointer(CB_STAGE);
volatile FTQ_L1_PTR uint32_t *indices =
(volatile FTQ_L1_PTR uint32_t *)(uintptr_t)(ft_tensix_cb_read_pointer(CB_INDEX));
const bool gather = (request->flags & kMemFlagGather) != 0u;
if (!ftm_length_is_issuable(row_bytes))
return false;
/* EVERY row index is checked BEFORE the first transaction is issued, not as it goes.
* A loop that refused half way would leave the earlier reads in flight and the CB page
* counters mid-descriptor; refusing up front means nothing was issued at all. The index
* is a wire value — for a GATHER it is a word out of CB_INDEX — and it becomes a raw
* DRAM address, so this is the bound between a wrong token id and an arbitrary read.
* The same predicate also carries the 64-byte stride alignment the row read needs. */
for (uint32_t row = 0; row < request->row_count; row++) {
const uint32_t index = gather ? indices[row] : request->source_row0 + row;
if (!ftm_row_major_source_is_readable(request->source_base, request->source_row_stride,
request->source_row_limit, index, row_bytes))
return false;
}
for (uint32_t row = 0; row < request->row_count; row++) {
const uint32_t index = gather ? indices[row] : request->source_row0 + row;
const uint32_t source = request->source_base + index * request->source_row_stride;
ft_tensix_noc_read(ft_tensix_noc_address(request->source_x, request->source_y, source),
stage + row * row_bytes, row_bytes);
}
ft_tensix_noc_read_barrier();
for (uint32_t row = 0; row < request->row_count; row++) {
ftm_place_row_from_row_major((uint32_t *)(uintptr_t)(block_address),
(const uint32_t *)(uintptr_t)(stage + row * row_bytes),
request->destination_row + row, request->group_tiles,
TILE_BYTES);
}
return true;
}
/**
* The reader half of one EMBEDDING or KV_APPEND descriptor.
*
* @return true if the whole descriptor was assembled; false if a wait was abandoned, in
* which case the loop still ran to completion (rule 3) and the caller must not
* retire it
*/
static bool read_one_memory_op(ftq_context *ctx, volatile FTQ_L1_PTR uint32_t *parameters,
uint32_t cb_out)
{
memory_request request;
/* A zero tile size would make every transfer below zero-length, which is the shape
* suspected of taking the whole card down (ftm_length_is_issuable). TILE_BYTES is a
* compile-time argument, so this folds away entirely at -Os and costs nothing; it is a
* refusal rather than a build-time assert only because a compile-time argument is not an
* integer constant expression in C. */
if (TILE_BYTES == 0u) {
ftq_abort(ctx, kErrBadParameters);
return false;
}
load_request(&request, parameters);
if (!request_is_sane(&request, cb_out)) {
ftq_abort(ctx, kErrBadParameters);
return false;
}
for (uint32_t group = 0; group < request.group_count; group++) {
memory_request group_request = request;
uint32_t block;
group_request.source_base = request.source_base + group * request.source_group_stride;
if (!ftq_cb_reserve_back_stoppable(ctx, (int32_t)cb_out,
(int32_t)request.group_tiles, WAIT_MODE))
return false;
block = ft_tensix_cb_write_pointer(cb_out);
// Rule 3: while draining, issue NOTHING. The push below still happens so the page
// counters stay balanced, but a transaction issued during a drain would still be in
// flight when the kernel returns.
if (!ctx->aborting) {
// A partial destination tile in WHOLE-TILES mode has to be zero where this
// descriptor places nothing — otherwise it publishes whatever the CB page held
// from an earlier descriptor, which is a plausible wrong answer.
if ((request.flags & kMemFlagDstWholeTiles) != 0u &&
request.row_count < kMemTileRows) {
ftm_zero_words((uint32_t *)(uintptr_t)(block),
request.group_tiles * TILE_BYTES / 4u);
}
const bool assembled = ((request.flags & kMemFlagSrcTiled) != 0u)
? assemble_from_tiles(&group_request, block)
: assemble_from_rows(&group_request, block);
// A zero-length transfer is REFUSED, never skipped: skipping would advance the
// descriptor and let the writer publish completion for work nobody did, and
// issuing it is suspected of taking the card down (ftm_length_is_issuable).
if (!assembled) {
ftq_abort(ctx, kErrBadParameters);
ft_tensix_cb_push_back(cb_out, request.group_tiles);
return false;
}
}
ft_tensix_cb_push_back(cb_out, request.group_tiles);
}
return !ctx->aborting;
}
void kernel_main(void)
{
ftq_context ctx;
bool running = true;
ftq_context_init(&ctx);
ftq_status_set(&ctx, kStateBoot);
ctx.control[kFieldState] = kStateBoot;
if (!ftq_check_control(&ctx)) {
ctx.control[kFieldError] = ctx.error;
ctx.control[kFieldState] = kStateExit;
ftq_status_set(&ctx, kStateExit);
return;
}
// RESUME rather than restart, from completed_count — the ONE number that means "every
// stage finished this descriptor". resident_matmul_reader.c carries the argument.
if (ctx.control[kFieldTail] != ctx.control[kFieldCompletedCount]) {
ctx.error = kErrBadParameters;
ctx.control[kFieldError] = ctx.error;
ctx.control[kFieldState] = kStateExit;
ftq_status_set(&ctx, kStateExit);
return;
}
ctx.cursor = ctx.control[kFieldCompletedCount];
while (running) {
if (!ftq_wait_for_work(&ctx, kFieldHead))
break;
ctx.aborting = false;
ftq_status_set(&ctx, kStateExec);
volatile FTQ_L1_PTR uint32_t *descriptor = ftq_descriptor(&ctx);
const uint32_t opcode = descriptor[kDescOpcode];
bool done;
ctx.control[kFieldLastOpcode] = opcode;
ftq_status_opcode(&ctx, opcode);
if (opcode == kOpEmbedding || opcode == kOpKvAppend) {
ftq_status_set(&ctx, kStateCbReserveBack);
done = read_one_memory_op(&ctx, ftq_parameters(descriptor),
output_circular_buffer(opcode));
ftq_status_set(&ctx, kStateExec);
if (!done)
running = false;
} else if (opcode == kOpNop) {
done = true;
} else if (opcode == kOpStop) {
done = true;
running = false;
} else {
// A binary is bound to one slab, so an opcode another program implements is
// genuinely not available here. Refusing names that; executing nothing would
// retire work that never happened.
ctx.error = kErrBadOpcode;
done = false;
running = false;
}
// Rule 5. An abandoned descriptor was handed to nobody, so it advances NOTHING and
// the next launch re-attempts it.
if (done) {
ctx.cursor++;
ctx.control[kFieldTail] = ctx.cursor;
}
}
// Nothing is outstanding here: the drain path issues no transactions. The barrier is
// the cheap statement of that invariant rather than a fix for a case that can happen.
ft_tensix_noc_read_barrier();
ft_tensix_noc_write_barrier();
ctx.control[kFieldError] = ctx.error;
ctx.control[kFieldState] = kStateExit;
ftq_status_set(&ctx, kStateExit);
}