Foundry/tt/build_tensix_kernel.sh

306 lines
18 KiB
Bash
Executable File

#!/usr/bin/env bash
# SPDX-License-Identifier: AGPL-3.0-or-later
# Copyright (C) 2026 David (Corey) Herrera
#
# build_tensix_kernel.sh
# Compiles one Tensix compute kernel into three TRISC ELFs (unpack, math, pack) plus the
# three TRISC firmware ELFs they link against, from Foundry sources only (tt/fw/,
# tt/kernels/) with the SFPI compiler. No Tenstorrent source tree is read.
# Blackhole is the only implemented target; every architecture- and board-shaped value
# lives in tensix_kernel_build_arch.sh, which refuses any other part by name.
#
# Usage: build_tensix_kernel.sh <compute-kernel.c> [outdir]
# Env: TT_ARCH which part to build for (default blackhole; only blackhole is implemented)
# TT_NUM_L1_BANKS / TT_NUM_DRAM_BANKS
# the BOARD's live counts, baked into every image (default 140 / 8). These
# must stay in step with FT_TT_KERNEL_BUILD_* in ft_tt_memmap.h
# CTARGS comma-separated KERNEL_COMPILE_TIME_ARGS (default 1,1,1)
# CB_TABLE CB table file driving ft_tensix_cb_formats.h. Unset means the legacy
# uniform Float16_b / 32x32 / 4-face / 2048 B set. Format and derivation
# rules: tools/emit_tensix_cb_formats.py
# KERNEL_DEFINES extra -D flags for a compile-time selection the source cannot make
# LIGHTWEIGHT_ASSERTS 1 (default) keeps the kernels' assert checks as `ebreak`
# KERNEL_TEXT_BASE_TRISC<n> link this TRISC's kernel text at an explicit L1 address
# instead of at __fw_export_text_end (see KERNEL TEXT RELOCATION below)
# Output: <outdir>/trisc{0,1,2}.elf (+ the fw_trisc{0,1,2}.elf they link against), each with
# a .src sidecar and a .prov provenance record.
# Companion: build_tensix_dataflow_kernel.sh builds BRISC/NCRISC; build_tensix_program.sh
# drives both for a whole program.
#
# One compute source becomes three programs: it is compiled once per TRISC with
# FT_TRISC_{UNPACK,MATH,PACK} and COMPILE_FOR_TRISC=n, which select the statements each
# thread keeps, and each is LTO-linked with Foundry's entry (fw/ft_trisc_entry.c).
#
# A kernel ELF is linked against a WEAKENED copy of its firmware ELF through
# --just-symbols (docs/tt_kernel_toolchain.md 4): an un-weakened one makes LTO drop the
# kernel's own `_start` and link an empty image.
#
# Status: verified on the p150a card host 2026-08-11 and since; staged-tree paths removed
# 2026-10-08 (clean-room Phase 7) with every image byte-identical.
# Issues: Quoted -D values cannot survive $DEF word-splitting.
# Todo: None
set -euo pipefail
SFPI=${SFPI:-/opt/tenstorrent/sfpi}
GCC=$SFPI/compiler/bin/riscv-tt-elf-gcc
# THE BRACES IN THIS USAGE MESSAGE USED TO CREATE A FILE. Bash ends a `${1:?word}`
# expansion at the FIRST unescaped `}`, which in `<compute-kernel.{c,cpp}>` is the one
# closing `{c,cpp}` — so the remainder `> [outdir]}` parsed as a REDIRECTION and every
# invocation dropped a zero-byte `[outdir]}` into the caller's cwd, which for a program
# rebuild is the checkout root. Reproduced in one line off-hardware. No braces here now.
KERNEL=${1:?usage: build_tensix_kernel.sh <compute-kernel.c> [outdir]}
OUT=${2:-/tmp/ttk}
KERNEL=$(cd "$(dirname "$KERNEL")" && pwd)/$(basename "$KERNEL")
mkdir -p "$OUT"
# ---- C KERNEL PATH ----------------------------------------------------------
# THE THREE-WAY COMPILATION IS THE WHOLE DIFFICULTY, and it is not the language. One
case $KERNEL in
*.c) KERNEL_LANGUAGE=c ;;
*.cpp) echo "error: C++ kernels are not supported -- the kernel entry is Foundry's C" \
"(fw/ft_*_entry.c, clean-room plan Phase 2); port '$KERNEL' to C" >&2; exit 2 ;;
*) echo "error: kernel must be .c, got '$KERNEL'" >&2; exit 2 ;;
esac
HERE=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
# Every architecture- and board-shaped value comes from here and nowhere else. A part
# other than blackhole is REFUSED by name, with the list of what a port owes.
# shellcheck source=tensix_kernel_build_arch.sh
. "$HERE/tensix_kernel_build_arch.sh"
# ---- 1. The CB descriptors header the compute kernels include -------------
# ft_tensix_cb_formats.h IS the compile-time half of the CB contract: whatever the host
# writes into the L1 CB config array must match what is baked in here, and there is no
# runtime negotiation. It used to be generated inline as ONE uniform descriptor set, so
# exactly one tile shape had ever been compiled and any other page size or format got
# silently wrong descriptors. It now comes from the real CB table when one is given.
if [ -n "${CB_TABLE:-}" ]; then
python3 "$HERE/tools/emit_tensix_cb_formats.py" -o "$OUT/ft_tensix_cb_formats.h" \
--cb-table "$CB_TABLE"
else
python3 "$HERE/tools/emit_tensix_cb_formats.py" -o "$OUT/ft_tensix_cb_formats.h" \
--uniform-default
fi
# ---- 2. Include set, flags and defines ------------------------------------------
# Kernel bodies include Foundry's kernels/ft_tensix_*.h by relative path; the only include
# directory they need is $OUT, for the generated ft_tensix_cb_formats.h.
INC="-I$OUT"
# C11 for _Static_assert, which kernels/ft_tensix_*.h use to pin binary contracts.
KERNEL_CFLAGS="-mcpu=$TT_MCPU_COMPUTE -std=c11 -flto=auto -ffast-math -O3 \
-Wall -Wextra -Wno-unused-variable -Wno-unused-function"
# LIGHTWEIGHT_KERNEL_ASSERTS (default on): the kernels' and entries' assert checks compile
# to a bare `ebreak` (kernels/ft_tensix_circular_buffer.h FT_TENSIX_CB_ASSERT, the CB
# overrun guard; fw/ft_dataflow_entry.c, the post-kernel NoC checks). Measured 2026-08-13:
# no wall-clock cost (matmul 464-465 GFLOP/s either way), +296..+404 B of text a kernel.
# LIGHTWEIGHT_ASSERTS=0 builds without them, for an A/B against numbers that predate it.
ASSERT_DEFINE=""
if [ "${LIGHTWEIGHT_ASSERTS:-1}" = 1 ]; then
ASSERT_DEFINE="-DLIGHTWEIGHT_KERNEL_ASSERTS"
fi
DEF="-DTENSIX_FIRMWARE -DLOCAL_MEM_EN=0 $ASSERT_DEFINE $TT_ARCH_DEFINE -DKERNEL_BUILD -DPROGRAMMABLE_CORE_TYPE=0 \
-DDISPATCH_MESSAGE_ADDR=0 -DKERNEL_COMPILE_TIME_ARGS=${CTARGS:-1,1,1} $TT_BOARD_DEFINES"
# ---- KERNEL_DEFINES: a compile-time SELECTION the source cannot make for itself -------
# One kernel source with two legal renderings -- resident_matmul_dst_sync.h's SyncHalf and
# SyncFull -- is a program-directory difference, not a source difference, so the choice has
# to reach the compiler and then be RECORDED. It rides in the recipe as `defines=` and is
# OMITTED when empty, so every program built before this existed keeps its recipe line
# byte for byte and nothing goes stale for having gained an unused field.
#
# Unquoted -D values only, per this file's own Issues note: $DEF is word-split.
KERNEL_DEFINES=${KERNEL_DEFINES:-}
if [ -n "$KERNEL_DEFINES" ]; then
DEF="$DEF $KERNEL_DEFINES"
echo "### KERNEL_DEFINES: $KERNEL_DEFINES"
fi
# ---- WATCHER: not supported ---------------------------------------------------------
# Foundry's firmware and kernel entries (tt/fw/) have no watcher, so there is nothing for
# WATCHER=1 to turn on; refuse it rather than record a recipe that means nothing.
if [ "${WATCHER:-0}" != 0 ]; then
echo "error: WATCHER=${WATCHER} -- Foundry's firmware has no watcher (clean-room Phase 7)" >&2
exit 2
fi
# ---- `| head -40` KILLS THE COMPILER, AND SILENTLY -------------------------
# Every compile below used to be piped straight into `head -40`. Under `set -o pipefail`
# that is a trap rather than a convenience: `head` exits after the 40th line, the compiler
# takes SIGPIPE, the pipeline reports failure, `set -e` ends the script -- and because the
# 41st line onward was never read, the ERROR TEXT IS NEVER PRINTED. A kernel that merely
# emits many WARNINGS is enough to trigger it, so the failure looks like a build that
# stopped for no reason. It cost a round on 2026-08-13.
#
# The fix is to let the compiler write a whole log and to head THE LOG. Nothing about the
# compilation changes -- same flags, same order -- so the instruction-stream gate is
# unaffected; only the reporting is.
run_compile() {
local log=$1; shift
if "$@" > "$log" 2>&1; then
head -40 "$log"
return 0
fi
echo "### COMPILE FAILED -- full log: $log"
head -80 "$log"
return 1
}
for T in UNPACK MATH PACK; do
case $T in UNPACK) id=0;; MATH) id=1;; PACK) id=2;; esac
low=$(echo $T | tr A-Z a-z)
echo "### building trisc$id ($T)"
# -MMD: the depfile is the input closure tools/record_tensix_image_provenance.py records
# beside the image -- the compiler's own list of what it opened, not a scan of #includes.
# A FAILED COMPILE MUST NOT LINK THE PREVIOUS OBJECT. Both objects are deleted first and a
# failure ends the build: until 2026-10-05 `|| true` left the old .o in place, and the
# reduce family's PACK image was a stale 2026-10-04 object for every regen since.
rm -f "$OUT/trisc$id.o" "$OUT/kernel_body_trisc$id.o"
# The entry is Foundry's (fw/ft_trisc_entry.c): C, the kernel body's own flags, and only
# the tt/ directory on its include path.
run_compile "$OUT/trisc$id.compile.log" \
$GCC $KERNEL_CFLAGS $DEF -DFT_TRISC_$T -DCOMPILE_FOR_TRISC=$id -DPROCESSOR_INDEX=$((id+2)) \
-I"$HERE" -MMD -MF "$OUT/trisc$id.d" -c \
"$HERE/fw/ft_trisc_entry.c" -o "$OUT/trisc$id.o" ||
{ echo "### ABORT: trisc$id ($T) did not compile"; exit 1; }
ls -l "$OUT/trisc$id.o" 2>/dev/null || echo "FAILED trisc$id"
# The C body, once per thread, with this thread's $DEF: FT_TRISC_* / COMPILE_FOR_TRISC
# are what select which statements it keeps.
if [ "$KERNEL_LANGUAGE" = c ]; then
run_compile "$OUT/kernel_body_trisc$id.compile.log" \
$GCC $KERNEL_CFLAGS $DEF -DFT_TRISC_$T -DCOMPILE_FOR_TRISC=$id -DPROCESSOR_INDEX=$((id+2)) \
$INC -MMD -MF "$OUT/kernel_body_trisc$id.d" -c "$KERNEL" \
-o "$OUT/kernel_body_trisc$id.o" ||
{ echo "### ABORT: kernel body trisc$id ($T) did not compile"; exit 1; }
ls -l "$OUT/kernel_body_trisc$id.o" 2>/dev/null || echo "FAILED kernel body trisc$id"
fi
done
echo __BUILD_DONE__
# ---- 3. Build the TRISC firmware ELF (kernel link needs its __fw_export_* symbols)
NM=$SFPI/compiler/bin/riscv-tt-elf-nm
SIZE=$SFPI/compiler/bin/riscv-tt-elf-size
LDFLAGS="-flto=auto -fno-exceptions -fno-rtti -nostdlib -Wl,-z,max-page-size=16 -Wl,-z,common-page-size=16 -Wno-stringop-overflow"
# Foundry's startup and C-library objects (fw/), shared by the firmware and kernel links.
rm -f "$OUT/ft_libc.o" "$OUT/ft_crt0.o"
$GCC -mcpu=$TT_MCPU_DM -std=c11 -O3 -Wall -Wextra -c "$HERE/fw/ft_libc.c" -o "$OUT/ft_libc.o" ||
{ echo "### ABORT: fw/ft_libc.c did not compile"; exit 1; }
$GCC -mcpu=$TT_MCPU_DM -c "$HERE/fw/ft_crt0.S" -o "$OUT/ft_crt0.o" ||
{ echo "### ABORT: fw/ft_crt0.S did not assemble"; exit 1; }
# The runtime ARCHIVE: our memcpy (fw/ft_libc.c -DFT_LIBC_RUNTIME) and binary32 helpers
# (fw/ft_softfloat.c), the only runtime calls GCC can emit here. Every link is -nostdlib, so no
# toolchain library reaches an image; built WITHOUT LTO and linked as an archive so a member
# joins only an image that calls it and every other image is unchanged.
rm -f "$OUT/ft_rt_libc.o" "$OUT/ft_softfloat.o" "$OUT/libft_rt.a"
AR=$SFPI/compiler/bin/riscv-tt-elf-ar
RT_CFLAGS="-mcpu=$TT_MCPU_DM -std=c11 -Os -Wall -Wextra -ffreestanding \
-fno-tree-loop-distribute-patterns"
# RUNTIME_DEFINES: red builds only (e.g. -DFT_SOFTFLOAT_RED_COMPARE_INVERTED); never shipped.
RT_CFLAGS="$RT_CFLAGS ${RUNTIME_DEFINES:-}"
[ -n "${RUNTIME_DEFINES:-}" ] && echo "### RUNTIME_DEFINES (RED BUILD): $RUNTIME_DEFINES"
$GCC $RT_CFLAGS -DFT_LIBC_RUNTIME -c "$HERE/fw/ft_libc.c" -o "$OUT/ft_rt_libc.o" &&
$GCC $RT_CFLAGS -c "$HERE/fw/ft_softfloat.c" -o "$OUT/ft_softfloat.o" &&
$AR rcs "$OUT/libft_rt.a" "$OUT/ft_rt_libc.o" "$OUT/ft_softfloat.o" ||
{ echo "### ABORT: runtime archive did not build"; exit 1; }
for id in 0 1 2; do
case $id in 0) T=UNPACK;; 1) T=MATH;; 2) T=PACK;; esac
echo "### firmware trisc$id"
# Foundry's firmware (fw/ft_trisc_fw.c, clean-room Phase 5): C, the kernel flags.
rm -f "$OUT/fw_trisc$id.o"
run_compile "$OUT/fw_trisc$id.compile.log" \
$GCC $KERNEL_CFLAGS $DEF -DFW_BUILD -DFT_TRISC_$T -DCOMPILE_FOR_TRISC=$id \
-DPROCESSOR_INDEX=$((id+2)) -I"$HERE" -I"$HERE/kernels" -c "$HERE/fw/ft_trisc_fw.c" \
-o "$OUT/fw_trisc$id.o" || { echo "### ABORT: firmware trisc$id did not compile"; exit 1; }
python3 "$HERE/tools/emit_tensix_linker_script.py" --cc "$GCC" --memmap "$HERE/ft_tt_memmap.h" \
--processor trisc$id --type firmware --template "$HERE/fw/ft_tensix_firmware.ld" \
-o "$OUT/firmware_trisc$id.ld" || { echo "### ABORT: firmware_trisc$id.ld"; exit 1; }
$GCC -mcpu=$TT_MCPU_COMPUTE $LDFLAGS -T"$OUT/firmware_trisc$id.ld" \
"$OUT/ft_crt0.o" "$OUT/fw_trisc$id.o" "$OUT/ft_libc.o" "$OUT/libft_rt.a" -o "$OUT/fw_trisc$id.elf" 2>&1 | head -20
[ -f "$OUT/fw_trisc$id.elf" ] && $SIZE "$OUT/fw_trisc$id.elf" || echo "FW LINK FAILED trisc$id"
done
# ---- KERNEL TEXT RELOCATION -------------------------------------------------
# main.ld places a kernel's .text at __fw_export_text_end, i.e. in the TAIL of its own
# processor's firmware hole -- and above that hole is the NEXT processor's firmware, not
# free L1 (docs/tt_launch_notes.md 2, corrected). The tails are tiny: TRISC0 gets 1360 B,
# TRISC1 1952 B, TRISC2 1392 B. vector_add fitted with 68 B to spare. Measured 2026-08-12,
# nothing else does:
#
# matmul_compute trisc0 1404 (44 B OVER) trisc1 900 ok trisc2 1316 ok
# rms_norm_compute trisc0 2448 (1088 OVER) trisc1 2504 OVER trisc2 2548 OVER
#
# So relocation is not an optimisation, it is the precondition for having an op library at
# all. __fw_export_text_end arrives from the WEAKENED firmware ELF via --just-symbols, so a
# strong --defsym overrides it and the linker script follows: .text lands at the given
# address and __kernel_data_lma right behind it, both of which the loader and the kernel's
# own _start already read from the ELF rather than assume. Verified: the relocated image is
# byte-for-byte the same text, entry == first segment vaddr, _start still 'T' (the
# --just-symbols trap of docs/tt_kernel_toolchain.md 4 does not fire).
#
# The host side must load a relocated image with ft_tt_op_image_load(), NOT
# ft_tt_launch_load_image() -- the latter enforces the in-hole rule this deliberately
# breaks. The addresses come from foundry/tt/ft_tt_op_l1_layout.h, which
# build_tensix_program.sh parses -- one source of truth, nothing to keep in step.
kernel_text_defsym() {
local base
eval "base=\${KERNEL_TEXT_BASE_TRISC$1:-}"
[ -n "$base" ] && printf -- "-Wl,--defsym=__fw_export_text_end=%s" "$base"
}
# ---- 4. Link the kernel ELFs against the firmware export symbols -----------
for id in 0 1 2; do
echo "### kernel trisc$id"
python3 "$HERE/tools/emit_tensix_linker_script.py" --cc "$GCC" --memmap "$HERE/ft_tt_memmap.h" \
--processor trisc$id --type kernel --template "$HERE/fw/ft_tensix_kernel.ld" \
-o "$OUT/kernel_trisc$id.ld" || { echo "### ABORT: kernel_trisc$id.ld"; exit 1; }
TE=$($NM "$OUT/fw_trisc$id.elf" | awk "/__fw_export_text_end/{print \$1}")
LE=$($NM "$OUT/fw_trisc$id.elf" | awk "/__fw_export_ldm_end/{print \$1}")
echo " __fw_export_text_end=0x$TE __fw_export_ldm_end=0x$LE"
$SFPI/compiler/bin/riscv-tt-elf-objcopy --weaken "$OUT/fw_trisc$id.elf" "$OUT/fw_trisc${id}_weak.elf"
KERNEL_BODY_OBJ=""
[ "$KERNEL_LANGUAGE" = c ] && KERNEL_BODY_OBJ="$OUT/kernel_body_trisc$id.o"
$GCC -mcpu=$TT_MCPU_COMPUTE $LDFLAGS -T"$OUT/kernel_trisc$id.ld" \
$(kernel_text_defsym $id) \
-Wl,--just-symbols="$OUT/fw_trisc${id}_weak.elf" \
"$OUT/trisc$id.o" $KERNEL_BODY_OBJ "$OUT/ft_libc.o" "$OUT/libft_rt.a" -o "$OUT/trisc$id.elf" 2>&1 | head -20
[ -f "$OUT/trisc$id.elf" ] && $SIZE "$OUT/trisc$id.elf" || echo "KERNEL LINK FAILED trisc$id"
# Record which kernel this ELF came from; tools/emit_tensix_kernel_header.py reads the
# sidecar for the generated header's source_name. Nothing above depends on it.
basename "$KERNEL" > "$OUT/trisc$id.elf.src"
# ...and record WHAT IT WAS BUILT FROM, by content -- the check the `.src` sidecar's
# mtime comparison cannot make (tools/tensix_image_provenance.py).
#
# THE CB TABLE IS AN --extra-input BECAUSE THE COMPILER NEVER SEES IT. It is read by
# tools/emit_tensix_cb_formats.py, which writes the ft_tensix_cb_formats.h these three
# images are compiled against, so its content is part of the image and appears in no
# depfile. The same goes for that emitter itself.
CB_TABLE_PATH=${CB_TABLE:-}
PROVENANCE_EXTRA=(--extra-input "$HERE/tools/emit_tensix_cb_formats.py")
[ -n "$CB_TABLE_PATH" ] && PROVENANCE_EXTRA+=(--extra-input "$CB_TABLE_PATH")
PROVENANCE_DEPS=(--depfile "$OUT/trisc$id.d")
# AN ARRAY, NOT A `${VAR:+...}` EXPANSION. Measured 2026-08-26: written that way the
# word-split leaves literal quotes in the key and the pair never reaches the recorder --
# the variant image was built correctly and recorded a recipe IDENTICAL to the shipped
# one, which is a program directory that rebuilds itself into the wrong kernel.
RECIPE_DEFINES=()
[ -n "$KERNEL_DEFINES" ] && RECIPE_DEFINES=(--recipe "defines=$KERNEL_DEFINES")
[ "$KERNEL_LANGUAGE" = c ] && PROVENANCE_DEPS+=(--depfile "$OUT/kernel_body_trisc$id.d")
python3 "$HERE/tools/record_tensix_image_provenance.py" \
--tt-root "$HERE" --image "$OUT/trisc$id.elf" --source "$KERNEL" \
"${PROVENANCE_DEPS[@]}" "${PROVENANCE_EXTRA[@]}" \
--recipe "toolchain=$($GCC -dumpversion)" \
--recipe "ctargs=${CTARGS:-1,1,1}" \
--recipe "watcher=${WATCHER:-0}" \
--recipe "lightweight_asserts=${LIGHTWEIGHT_ASSERTS:-1}" \
--recipe "relocate_kernel_text=${RELOCATE_KERNEL_TEXT:-0}" \
--recipe "kernel_text_base=$(eval echo \${KERNEL_TEXT_BASE_TRISC$id:-})" \
--recipe "cb_table=${CB_TABLE_PATH##*/}" "${RECIPE_DEFINES[@]}"
done
echo __LINK_DONE__