Foundry/tt/kernels/resident_topk_reader.c

236 lines
11 KiB
C

/* SPDX-License-Identifier: AGPL-3.0-or-later */
/**
* @file resident_topk_reader.c
* @brief Reader third of the RESIDENT TOPK/SAMPLE program: block-prefetch the vocabulary logit
* row out of DRAM into CB_LOGITS, `depth` tiles per NoC barrier, and own `tail`.
*
* IT READS WHOLE TILES FOR ONE ROW, AND THAT IS A MEASURED CHOICE WITH A NAMED
* ALTERNATIVE. The LM head runs at Mt = 1, so it writes 32 token rows and decode wants
* one: of a tile's 2048 bytes exactly 64 are the row this op is selecting over
* (two 32-byte face rows, docs/tt_kernel_model.md §9). A reader could fetch only those,
* cutting DRAM traffic 32x -- at the cost of 2 transactions per tile, i.e. 8160 of them
* for MiniCPM5-1B against ~85 block reads at depth 48. §12's rule is that transaction
* COUNT is what binds on this part and byte count past ~1088 B a tile is not, so the
* strided reader is very unlikely to win and is NOT implemented on that reasoning
* (INFERRED, not measured -- the arithmetic is in the report and it is one runtime-arg
* away if someone wants the measurement).
*
* THE ROW DOES NOT FIT L1 AND THAT IS WHY THIS IS A READER AT ALL. 4080 tiles x 2048 B is
* 8,355,840 B against a 1,351,680 B arena, so there is no "load it and sort it" version
* of this op at any tile size -- see resident_topk_protocol.h §2.
*
* THIS PROCESSOR OWNS `tail`, AND NOTHING ELSE IN THE CONTROL BLOCK.
*
*
* 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_LOGITS index 1: tile size in bytes
* 2: wait mode -- 0 upstream, 1 stoppable (ships), 2 bound-only
* Runtime args: the ten of resident_queue_protocol.h, identical on all five processors
*
* Build (BRISC, NoC 0):
* CTARGS=0,2048,1 NOC=0 KERNEL_TEXT_BASE=0x15000 bash \
* foundry/tt/build_tensix_dataflow_kernel.sh \
* foundry/tt/kernels/resident_topk_reader.c brisc /tmp/ttk_topk
*
* @status GREEN ON SILICON (Tensix 51, 57, 59; 40 checks, 0 failures). text 1616 B on
* BRISC. Five kernel mutations were run through the unmodified test binary and
* all five go red.
* @issues None
* @todo None
*/
#include <stdbool.h>
#include <stdint.h>
#include "ft_tensix_dataflow.h"
#include "resident_queue_protocol.h"
#include "resident_topk_protocol.h"
#define CB_LOGITS ft_tensix_compile_time_arg(0)
#define TILE_BYTES ft_tensix_compile_time_arg(1)
#define WAIT_MODE ft_tensix_compile_time_arg(2)
/**
* The depth a block may actually use: it must DIVIDE the tile count, or the CB write
* pointer leaves the block grid and every later block straddles the wrap. The kernel repeats the host's arithmetic rather than trusting
* it, exactly as resident_matmul_reader.c does; falling back to 1 is always correct.
*/
static uint32_t block_depth(uint32_t run, uint32_t requested)
{
return (requested > 0u && run > 0u && run % requested == 0u) ? requested : 1u;
}
/**
* Refuse a descriptor this program cannot execute, INSTEAD of blocking on it or writing
* outside the circular buffer. Four conditions, and three of the four were paid for.
*
* 1. **A ZERO-LENGTH NoC READ.** A sibling op's card-down on 2026-08-12 traced to a computed
* transfer length that came out zero, and the working hypothesis — INFERRED, and
* deliberately not re-confirmed because confirming it costs the card for everyone — is
* that a zero-length NoC read takes the chip down rather than returning nothing. This
* op's length is `depth * TILE_BYTES` with both factors already non-zero, so the guard is
* belt and braces; it is here anyway because "the arithmetic cannot reach zero" is exactly
* the sentence that was true of the other op until a branch made it false.
*
* 2. **A BLOCK BIGGER THAN THE CIRCULAR BUFFER.** `cb_reserve_back(depth)` on a CB holding
* fewer than `depth` pages is not slow, it is UNSATISFIABLE — and the page count is fixed
* when the RESIDENT program is staged while the depth is a per-descriptor word, so the
* host CAN ask for it. It did, on the first hardware run: six red checks, ten seconds of
* blocked polling and a dead residency, reported as an idle timeout, which it was not.
*
* 3 and 4. **THE BLOCK-PREFETCH INVARIANT IS NOT "DEPTH DIVIDES THE RUN" ON A RESIDENT OP,
* AND THE DIFFERENCE OVERWRITES L1.** docs/tt_kernel_model.md §12 states the rule for a
* ONE-SHOT kernel, where the FIFO write pointer starts at the base. A resident kernel
* runs many descriptors, and `cb_push_back` only wraps when the pointer lands EXACTLY on
* the limit — so a descriptor that leaves the pointer off the
* next descriptor's block grid makes the next block STEP OVER the limit instead of
* wrapping, and every block after it walks further up L1. MEASURED, on this op, on the
* card: 128 tiles at depth 32 through a 96-page buffer leaves the pointer 32 pages in;
* the next descriptor's 48-page blocks then marched past the arena and a
* `noc_async_read` landed on the QUEUE CONTROL BLOCK at 0x170000, which came back
* holding the test's own decoy value. Nothing faulted and nothing said so.
*
* Two conditions make it impossible by induction, given the pointer starts at the base:
* - `pages % depth == 0` — every block wrap lands exactly on the limit;
* - `tile_count % pages == 0` — the descriptor leaves the pointer where it found it.
*
* This is a fact about the FAMILY, not about this op: any resident reader whose
* descriptors vary the depth, or whose tile count is not a whole number of buffer cycles,
* has the same hole.
*/
static bool descriptor_is_executable(ftq_context *ctx, uint32_t tile_count, uint32_t depth)
{
const uint32_t pages = ft_tensix_cb(CB_LOGITS)->fifo_num_pages;
if (tile_count == 0u || depth == 0u || TILE_BYTES == 0u || pages == 0u)
ctx->error = kErrBadParameters;
else if (depth > pages || (pages % depth) != 0u || (tile_count % pages) != 0u)
ctx->error = kErrBadParameters;
else
return true;
return false;
}
/**
* The reader half of one TOPK descriptor.
*
* @return true if the whole row was read; 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_logit_row(ftq_context *ctx, volatile FTQ_L1_PTR uint32_t *parameters)
{
const uint32_t tile_count = parameters[kTopkTileCount];
const uint32_t logits_x = parameters[kTopkLogitsX];
const uint32_t logits_y = parameters[kTopkLogitsY];
const uint32_t logits_base = parameters[kTopkLogitsBase];
const uint32_t depth = block_depth(tile_count, parameters[kTopkDepth]);
const uint32_t block_bytes = depth * TILE_BYTES;
if (!descriptor_is_executable(ctx, tile_count, depth))
return false;
for (uint32_t tile = 0; tile < tile_count; tile += depth) {
uint32_t write_address;
if (!ftq_cb_reserve_back_stoppable(ctx, (int32_t)CB_LOGITS, (int32_t)depth, WAIT_MODE))
return false;
write_address = ft_tensix_cb_write_pointer(CB_LOGITS);
// Rule 3: while draining, issue NOTHING. The push still happens so the page
// counters stay balanced, but a transaction issued during a drain would still be in
// flight when the kernel returns.
// The zero-length guard is repeated AT THE ISSUE SITE and not only at the top,
// because the only version of this check that is worth anything is the one the
// transfer cannot get past.
if (!ctx->aborting && block_bytes != 0u) {
// Consecutive tile indices are consecutive DRAM bytes and land in a contiguous
// CB block by the depth invariant, so the whole block is ONE read and ONE
// barrier -- the pattern that was worth 5.9x (docs/tt_kernel_model.md §12).
ft_tensix_noc_read(ft_tensix_noc_address(logits_x, logits_y,
logits_base + tile * TILE_BYTES),
write_address, block_bytes);
ft_tensix_noc_read_barrier();
}
ft_tensix_cb_push_back(CB_LOGITS, depth);
}
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; resident_matmul_reader.c carries the argument and the
// refusal that enforces it.
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 == kOpTopk) {
ftq_status_set(&ctx, kStateCbReserveBack);
done = read_one_logit_row(&ctx, ftq_parameters(descriptor));
ftq_status_set(&ctx, kStateExec);
if (!done)
running = false;
} else if (opcode == kOpSample || opcode == kOpNop) {
// SAMPLE moves no tiles: it reads a result block the previous TOPK left in L1.
// The reader still walks the descriptor so `tail` keeps naming the same item
// every other processor is on.
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.
ctx.error = kErrBadOpcode;
done = false;
running = false;
}
if (done) {
ctx.cursor++;
ctx.control[kFieldTail] = ctx.cursor;
}
}
ft_tensix_noc_read_barrier();
ft_tensix_noc_write_barrier();
ctx.control[kFieldError] = ctx.error;
ctx.control[kFieldState] = kStateExit;
ftq_status_set(&ctx, kStateExit);
}