/* SPDX-License-Identifier: AGPL-3.0-or-later */ /* Copyright (C) 2026 David (Corey) Herrera */ /** * @file ft_tt_noc.h * @brief Named NoC read/write primitives for the Foundry Blackhole backend: * move an arbitrary run of bytes between host memory and a Tensix's L1 or a * DRAM bank, sliding one TLB window across the span as needed. * * Module prefix: ft_tt_ (foundry tenstorrent). Pure C99 + libc, plus SSE2 * intrinsics on x86 (see the copy-shape note below). No tt-metal, no tt-umd. * * --------------------------------------------------------------------------- * WHAT THIS ADDS OVER THE WINDOW LAYER * --------------------------------------------------------------------------- * ft_tt_tlb.h owns ONE 2 MiB aperture aimed at ONE endpoint. Everything larger * than 2 MiB, or straddling a 2 MiB boundary, has to move the window mid-copy. * That is this file: a transfer of any length at any (aligned) address, with * the re-aiming hidden. With no BAR4 there are no 4 GiB windows (spec §2.3), so * crossing 32 GB of DRAM is ~16,000 window moves at a measured 1.23 µs each — * ~20 ms in total, which is not a bottleneck. * * --------------------------------------------------------------------------- * ONE WINDOW WALK IN THE BACKEND * --------------------------------------------------------------------------- * Phase 2's run control (ft_tt_launch.c) shipped its own 4-byte-granular copy * loop before this file existed. It cannot call ft_tt_write_l1() — it has to * reach a 4-byte register at 0xFFB121B0, outside every memory space, and write * sub-16 B mailbox fields, both of which the L1 helper correctly refuses. So * the seam is ft_tt_noc_transfer_raw(): the window walk and the copy, with a * caller-chosen store width and no space bounds. The named helpers below are * that function plus ft_tt_noc_check_transfer(); the launch path is that * function with FT_TT_NOC_WIDTH_WORD32. There is no second implementation. * * --------------------------------------------------------------------------- * COORDINATES ARE PARAMETERS. THEY ARE NEVER PROBED. * --------------------------------------------------------------------------- * Every entry point takes an ft_tt_noc_coord the CALLER derived from * ft_tt_coord.h against live ARC telemetry, in the TRANSLATED system — that is * what a TLB is aimed with (docs/tt_driver_notes.md §8, proven on this card by * a DRAM round-trip at translated (17,12), a coordinate that does not exist in * NoC0 space). Nothing here guesses, defaults, or scans. A wrong endpoint does * not fail a read, it hangs the chip, and only a physical power cycle recovers * it (spec §7.3). That has already happened here once. * * --------------------------------------------------------------------------- * THE COPY SHAPE, AND WHY IT IS NOT A memcpy * --------------------------------------------------------------------------- * The aperture is device MMIO. tt-umd moves 16 x 16 B per 256 B block because * that is what keeps the PCIe transaction count where it wants it — but their * device_memcpy.cpp is AVX2 and this host has NO AVX at all (spec §8.4), so * their binary SIGILLs here exactly like tt_umd did. We keep the 16 B unit and * the 256 B block and issue them as SSE2 `movdqu`, which is baseline x86-64. * On any non-x86 host (the Mac, for compile checks) it degrades to a 64-bit * store loop of the same shape. * * Measured through the raw window layer (spec §9.8): host->card 287 MB/s WC vs * 53 MB/s UC; card->host 2.3 MB/s in BOTH modes. So: WC for bulk, UC for * registers. The 125x read asymmetry is a property of non-posted reads with no * device->host DMA on Blackhole — it is a design input, not a tuning target. * * @status VERIFIED ON THE CARD 2026-08-11 (docs/tt_bringup_log.md §9): L1 * round-trip on a derived Tensix, all 8 DRAM banks, a 5 MiB transfer across * four windows, and alignment refusals that never aim the window. The copy * shape was checked in the disassembly on the card host — no VEX instructions. * INFERRED, not measured: the UC cache mode through these helpers (only WC * was exercised), and the 64 B DRAM read alignment, which is transliterated * from the arch constants and enforced conservatively rather than probed. * Everything that touches the NoC is behind FT_TT_PHASE1B_NOC_ENABLED and * returns FT_TT_EPERM in a default build. * @issues None * @todo None */ #ifndef FT_TT_NOC_H #define FT_TT_NOC_H #include #include #include #include "ft_tt_coord.h" #include "ft_tt_device.h" #include "ft_tt_kmd.h" #include "ft_tt_memmap.h" #include "ft_tt_noc_copy.h" #include "ft_tt_tlb.h" /* ================================================================ * Copy shape * ================================================================ * * FT_TT_NOC_COPY_UNIT_BYTES, FT_TT_NOC_COPY_BLOCK_BYTES, * ft_tt_noc_transfer_width and ft_tt_noc_transfer_width_bytes() now live in * ft_tt_noc_copy.h, included above, so that the userspace-driver transport * (ft_tt_udrv_noc.h) moves bytes through the SAME code without depending on * the tt-kmd layer this file is built on. Callers see no change. */ /* ================================================================ * Memory spaces and their alignment rules * ================================================================ */ /** Which device memory a transfer targets. The two differ in alignment, in * size, and in how a caller obtains a coordinate for them. */ typedef enum ft_tt_memory_space { FT_TT_MEM_SPACE_L1 = 0, /**< one Tensix's 1536 KiB L1 */ FT_TT_MEM_SPACE_DRAM = 1 /**< one GDDR bank, 0xff000000 bytes */ } ft_tt_memory_space; /** "L1" / "DRAM". Never NULL. */ const char *ft_tt_memory_space_string(ft_tt_memory_space space); /** * Required alignment, in bytes, of both the device address and the length of a * transfer. * * Blackhole's architectural alignments are L1 16 B and DRAM/PCIe 64 B * (ft_tt_memmap.h). The vendor's runtime applies the 64 B figure to the READ * direction and permits 16 B writes to DRAM, so that asymmetry is reproduced * here rather than flattened to the strictest value. * * @param space L1 or DRAM * @param is_read true for card->host * @return 16 or 64; 0 for an unknown space * * Thread-safe: yes Reentrant: yes Signal-safe: yes */ uint32_t ft_tt_noc_alignment(ft_tt_memory_space space, bool is_read); /** Largest device address + 1 in this space: L1 1536 KiB, DRAM 0xff000000. */ uint64_t ft_tt_noc_space_size(ft_tt_memory_space space); /** * Whether a transfer is legal before any device is touched: address and length * both aligned per ft_tt_noc_alignment(), and the whole span inside the space. * * This is a pure function so the rules can be proven off-hardware — the point * of separating it out. The helpers below all call it first and refuse without * issuing anything. * * @return FT_TT_OK, or FT_TT_EINVAL (the reason is logged) * * Thread-safe: yes Reentrant: yes Signal-safe: no (logs on failure) */ int ft_tt_noc_check_transfer(ft_tt_memory_space space, bool is_read, uint64_t address, size_t bytes); /** * Whether a DEVICE-TO-DEVICE transfer is legal — each end against its own space, AND the * two ends against EACH OTHER. * * **ALIGNMENT IS A CONGRUENCE, AND `ft_tt_noc_check_transfer()` cannot express it.** That * function scores one end at a time, so two addresses that each pass their own test can * still name a transfer the NoC will not do: what it cannot do is move bytes between two * addresses sitting at DIFFERENT offsets inside an alignment window. The vendor's check tests exactly * that and nothing weaker — * `if ((worker_addr & alignment_mask) != (noc_addr & alignment_mask))` — and it is watcher-only, so it does not exist in any image we run. * * The window is the STRICTER of the two ends' alignments, which is the same rule a derived * L1 base already broke once: a 16- and 32-byte-aligned circular buffer that was the LOCAL * end of a DRAM read needed 64, and every tile came back byte-exact up to element 992 of * 1024 and wrong in its last 64 bytes, with no refusal anywhere (DEV_NOTES.md §3). * * @param local_space space of the end this core owns * @param local_address that end's address * @param remote_space space of the other end * @param remote_address that end's address * @param is_read true when the transfer READS the remote end * @param bytes length * @return FT_TT_OK, or FT_TT_EINVAL (the reason is logged) * * Thread-safe: yes Reentrant: yes Signal-safe: no (logs on failure) */ int ft_tt_noc_check_transfer_pair(ft_tt_memory_space local_space, uint64_t local_address, ft_tt_memory_space remote_space, uint64_t remote_address, bool is_read, size_t bytes); /** * Whether a transfer is legal for `width` alone — address and length both a * multiple of ft_tt_noc_transfer_width_bytes(). This is everything * ft_tt_noc_transfer_raw() can check, because a raw transfer names no memory * space and so has no bounds to test against. * * Pure, so the rule is provable off-hardware. * * @return FT_TT_OK, or FT_TT_EINVAL (the reason is logged) * * Thread-safe: yes Reentrant: yes Signal-safe: no (logs on failure) */ int ft_tt_noc_check_raw_transfer(ft_tt_noc_transfer_width width, uint64_t address, size_t bytes); /* ================================================================ * The window walk, as arithmetic * ================================================================ */ /** * How much of a transfer fits in one window placement, and where. * * The walk is the part of this file that can silently corrupt a neighbouring * address rather than fail, so it is separated out as pure arithmetic that a * unit test on the Mac can prove wrong — a wrong endpoint hangs the chip * (spec §7.3), and a wrong OFFSET quietly writes somewhere else on the right * one. `ft_tt_noc_window_slice` and the pure form of the computation are in * ft_tt_noc_copy.h; this is that plus a log line and an FT_TT_* return. */ /** * First slice of a transfer of `bytes` at `address` through a `window_bytes` * aperture: the window base it needs, the offset inside that window, and how * many bytes fit before the next window boundary. Walk a whole transfer by * advancing address/host/remaining by `bytes` and calling again. * * @param window_bytes aperture size; must be a non-zero power of two * @param out_slice written only on success * @return FT_TT_OK, or FT_TT_EINVAL for a NULL out or a window * size that is not a power of two * * Thread-safe: yes Reentrant: yes Signal-safe: no (logs on failure) */ int ft_tt_noc_window_slice_at(uint64_t address, size_t bytes, size_t window_bytes, ft_tt_noc_window_slice *out_slice); /* ================================================================ * The data path * ================================================================ */ /** * One host<->card data path: a claimed TLB window plus the bookkeeping needed * to slide it. Caller-owned storage — declaring it rather than allocating it * keeps the layer allocation-free and its lifetime obvious. Treat the fields as * private; read them through the accessors. * * The path OWNS its window and is the only thing that aims it, which is what * makes the "already aimed where I need it" fast path sound. */ typedef struct ft_tt_noc_path { ft_tt_ctx *ctx; ft_tt_tlb_window *window; size_t window_bytes; uint64_t aimed_base; uint32_t aimed_x; uint32_t aimed_y; bool aimed; bool owns_window; /**< false when the window came from the caller */ uint64_t aim_count; /**< windows moved since open — the re-windowing proof */ uint64_t bytes_written; /**< host->card bytes through this path */ uint64_t bytes_read; /**< card->host bytes through this path */ /** * PCIe ACCESSES, not bytes — and this link is priced in accesses. * * A 64-byte read at WORD32 is SIXTEEN non-posted round trips; the same 64 bytes at * BLOCK16 is four. The byte counters above cannot tell those apart, and more than one * recorded per-descriptor host cost on this backend has turned out to be a transaction * count seen through a division. Counted as `bytes / width` where the units are * actually issued, so it is the machine's own number and not a caller's model of it. */ uint64_t write_transactions; /**< host->card store units issued through this path */ uint64_t read_transactions; /**< card->host load units issued through this path */ } ft_tt_noc_path; /** * Claim a window and build a path over it. * * @param ctx open device context * @param cache FT_TT_KMD_CACHE_WC for bulk data (287 MB/s measured), * FT_TT_KMD_CACHE_UC for registers (53 MB/s) * @param path caller-owned storage, zeroed and filled on success * @return FT_TT_OK; FT_TT_EINVAL; FT_TT_ENOMEM when all 201 windows are * in use; FT_TT_EIO otherwise * * Thread-safe: yes Reentrant: yes Signal-safe: no */ int ft_tt_noc_path_open(ft_tt_ctx *ctx, enum ft_tt_kmd_cache_mode cache, ft_tt_noc_path *path); /** * Build a path over a window the CALLER already claimed, for a caller that has * its own reason to own the window's lifetime (ft_tt_launch's run control * does: the window outlives any one transfer and is freed with the session). * * The window is BORROWED. ft_tt_noc_path_close() will not free it, and the * caller must still call ft_tt_tlb_window_free(). * * The path must be the ONLY thing that aims this window for as long as it is * attached — the "already aimed where I need it" fast path is unsound * otherwise, and unsound there means writing to a stale endpoint. * * @param window a window from ft_tt_tlb_window_alloc(), of a power-of-two size * @param path caller-owned storage, zeroed and filled on success * @return FT_TT_OK; FT_TT_EINVAL; FT_TT_EIO for a window size that is * not a power of two * * Thread-safe: yes Reentrant: yes Signal-safe: no */ int ft_tt_noc_path_attach(ft_tt_ctx *ctx, ft_tt_tlb_window *window, ft_tt_noc_path *path); /** * Blank the path, releasing the window only if this path claimed it (i.e. it * came from ft_tt_noc_path_open(), not ft_tt_noc_path_attach()). Safe on a * zeroed or already-closed path. * @return FT_TT_OK, FT_TT_EINVAL, or FT_TT_EIO (logged). * * Thread-safe: no (mutates the path) Reentrant: no Signal-safe: no */ int ft_tt_noc_path_close(ft_tt_noc_path *path); /** Number of times this path has aimed its window. A transfer that stays inside * one 2 MiB window at an endpoint the window already points at costs ZERO — * which is how a test tells a re-windowing run from a lucky in-window one. */ uint64_t ft_tt_noc_path_aim_count(const ft_tt_noc_path *path); /** Store units this path has issued host->card. 0 for a NULL path. */ uint64_t ft_tt_noc_path_write_transactions(const ft_tt_noc_path *path); /** Load units this path has issued card->host — the number a read-bound arm is * actually priced in. 0 for a NULL path. */ uint64_t ft_tt_noc_path_read_transactions(const ft_tt_noc_path *path); /* ================================================================ * The unchecked entry point the named helpers are built on * ================================================================ */ /** * Move `bytes` between host memory and (coord, address), sliding the window as * needed, with NO memory-space bounds check and a caller-chosen store width. * * **This is the low-level seam, not the API to reach for.** It exists because * two legitimate callers cannot use the named helpers below: * * - the per-Tensix soft-reset register at 0xFFB121B0, which is neither in L1 * nor in a DRAM bank and must be touched 4 bytes at a time * (docs/tt_driver_notes.md §5); and * - the mailbox protocol, whose go message is 4 bytes — under the 16 B * minimum ft_tt_write_l1() correctly enforces. * * Everything ELSE should go through ft_tt_{read,write}_{l1,dram}, which are * this function plus ft_tt_noc_check_transfer(). Skipping the space check means * an out-of-range address is not caught here; it becomes a NoC transaction. * * Still enforced, before anything reaches the device: a non-NULL path, window * and host buffer; `address` and `bytes` aligned to the width; and a coordinate * inside the TLB's 6-bit x_end/y_end fields, because a coordinate that does not * fit is silently truncated into a DIFFERENT live endpoint. * * @param path an open or attached path * @param coord TRANSLATED coordinate from ft_tt_coord.h. NEVER a guess * @param address tile-local address, aligned to the width * @param host source when writing, destination when reading * @param bytes multiple of the width. Zero is a legal no-op * @param is_read true for card->host * @param width FT_TT_NOC_WIDTH_BLOCK16 for data, WORD32 for registers * @return FT_TT_OK; FT_TT_EINVAL; FT_TT_EPERM when the Phase 1b fence * is closed; FT_TT_EIO * * Thread-safe: no (the path is stateful) Reentrant: no Signal-safe: no */ int ft_tt_noc_transfer_raw(ft_tt_noc_path *path, ft_tt_noc_coord coord, uint64_t address, void *host, size_t bytes, bool is_read, ft_tt_noc_transfer_width width); /* ================================================================ * Registers and mailbox fields — the WORD32 named pair * ================================================================ */ /** * Write `bytes` from host memory to (target, address) one 32-bit store per word. * * This is the third memory "space", and it is the one that has no bounds to check: a * per-Tensix soft-reset register at 0xFFB121B0 is in neither L1 nor a DRAM bank, and a * 4-byte mailbox field is under the 16 B minimum ft_tt_write_l1() correctly enforces. * Width is not a tuning choice here — a 16 B store over a 4-byte register writes three * neighbours with it. * * ft_tt_launch.h's run control is the caller. It used to reach * ft_tt_noc_transfer_raw() through a private helper of its own, which meant the WORD32 * decision lived in a file that owns neither the width enum nor the copy; this pair is * that helper, folded back onto the module that owns both. * * @param path an open or attached path * @param target TRANSLATED coordinate from ft_tt_coord.h. NEVER a guess (spec §7.3) * @param address tile-local address, 4-byte aligned * @param src host source * @param bytes multiple of 4. Zero is a legal no-op * @return FT_TT_OK; FT_TT_EINVAL; FT_TT_EPERM when the Phase 1b fence is * closed; FT_TT_EIO * * Thread-safe: no (the path is stateful) Reentrant: no Signal-safe: no */ int ft_tt_write_register_words(ft_tt_noc_path *path, ft_tt_noc_coord target, uint64_t address, const void *src, size_t bytes); /** * Read `bytes` from (target, address) one 32-bit load per word. Same rules as * ft_tt_write_register_words(). */ int ft_tt_read_register_words(ft_tt_noc_path *path, ft_tt_noc_coord target, uint64_t address, void *dst, size_t bytes); /* ================================================================ * L1 * ================================================================ */ /** * Write `bytes` from host memory into one Tensix's L1, sliding the window as * the span crosses 2 MiB boundaries. (L1 is 1536 KiB so at most one boundary is * in play, but the same code path serves DRAM where there are thousands.) * * @param path an open path * @param core TRANSLATED coordinate of the target Tensix, from * ft_tt_coord_tensix_at() / _by_index(). NEVER a guess * @param address L1 byte address, 16 B aligned, with address + bytes <= 1536 KiB * @param src host source * @param bytes multiple of 16. Zero is a legal no-op * @return FT_TT_OK; FT_TT_EINVAL on a bad argument, misalignment, an * out-of-L1 span, or a coordinate outside the 6-bit TLB fields; * FT_TT_EPERM when the Phase 1b fence is closed; FT_TT_EIO * * Thread-safe: no (the path is stateful) Reentrant: no Signal-safe: no */ int ft_tt_write_l1(ft_tt_noc_path *path, ft_tt_noc_coord core, uint64_t address, const void *src, size_t bytes); /** * Read `bytes` out of one Tensix's L1. Same rules as ft_tt_write_l1(); reads * are non-posted and run at ~2.3 MB/s in either cache mode, so this is for * verification and diagnostics, not for moving results in bulk. */ int ft_tt_read_l1(ft_tt_noc_path *path, ft_tt_noc_coord core, uint64_t address, void *dst, size_t bytes); /* ================================================================ * DRAM * ================================================================ */ /** * Write `bytes` into one GDDR bank, sliding the window across as many 2 MiB * spans as the transfer covers. * * @param path an open path * @param bank_port TRANSLATED coordinate of a bank port, from * ft_tt_coord_dram_bank() / _dram_port(). On this card those * live at x 17-18, y 12-23 — coordinates absent from NoC0 * space entirely, which is why passing a NoC0 coordinate here * is a silent, expensive mistake * @param address byte offset within the bank, 16 B aligned * @param bytes multiple of 16 * @return as ft_tt_write_l1() */ int ft_tt_write_dram(ft_tt_noc_path *path, ft_tt_noc_coord bank_port, uint64_t address, const void *src, size_t bytes); /** * Read `bytes` out of one GDDR bank. Address and length must be 64 B aligned — * the DRAM/PCIe read alignment, stricter than the 16 B the write direction * takes (ft_tt_noc_alignment()). */ int ft_tt_read_dram(ft_tt_noc_path *path, ft_tt_noc_coord bank_port, uint64_t address, void *dst, size_t bytes); #endif /* FT_TT_NOC_H */