Pantheon/docs/architecture/accel-foundry.md

17 KiB
Raw Permalink Blame History

Accelerator / Die-Manager Layer and Foundry CUDA Base

Covers src/accel/die_manager.c|h and foundry/cuda/fc_cuda.c|h.


1. Die abstraction

A die is one GK210 GPU — the unit of VRAM ownership in Pantheon. A K80 card physically packages two GK210 dies connected by an on-card PLX switch. They appear to CUDA as two separate devices with independent contexts, streams, and VRAM pools. The die manager hard-codes this pairing: die_id / 2 = card_id (pn_dm_card_of, die_manager.h:82) and {2i, 2i+1} are card-mates (die_mate, die_manager.c:195). The die manager supports up to 16 dies (PN_DM_MAX_DIES, die_manager.h:41; mirroring PN_MEM_MAX_DIES).

K80 hardware constraints

Reflected in the kernel target (fc_cuda.h:17):

  • Compute capability sm_37 — no FP16 math, no tensor cores.
  • __shfl_down() (Kepler) rather than __shfl_down_sync() (Pascal+).
  • VRAM bandwidth ~240 GB/s; K80 decode is launch-bound (~80 µs/launch, fc_cuda.h:562), not compute-bound — the fused-layer and graph-capture mechanisms exist specifically to reduce launch count.

2. CUDA device → die mapping under CUDA_VISIBLE_DEVICES

pn_dm_init_cuda (die_manager.c:72) enumerates CUDA ordinals [0, n_dies) by calling fc_cuda_init_device(i) for each. Because CUDA_VISIBLE_DEVICES re-numbers the visible devices starting at 0, die ordinal i inside Pantheon always equals CUDA device i within whatever the variable permits. Dies with ordinals not visible to the process are not reachable: cuDeviceGet(i) returns error 101 when i >= CUDA_VISIBLE_DEVICES count — the benign probe that terminates the enumeration loop (die_manager.c:89):

fc_cuda_ctx *ctx = fc_cuda_init_device(i);
if (!ctx || !fc_cuda_ctx_available(ctx))
    break;   /* ordinals are contiguous — stop */

fc_cuda_init_device calls p_cuDeviceGet(&ctx->device, device_id) (fc_cuda.c:910); on error it logs cuDeviceGet(%d) failed: %d and returns NULL, which is the break condition above.


3. VRAM budgeting and admission at the die level

Reserve

The die budget is total_vram − PN_DIE_RESERVE_BYTES (default 1 GiB, pn_memwatch.h:42; overridable via PN_DIE_RESERVE_BYTES). pn_dm_init_cuda computes this per die (die_manager.c:95):

uint64_t budget = die_total > reserve ? die_total - reserve : 0;

External occupancy

VRAM already consumed by another process at init time is detected and credited to vram_used so Pantheon doesn't over-commit (die_manager.c:101):

uint64_t external = (die_total - die_free) > reserve
                  ? (die_total - die_free) - reserve : 0;
g_dm.die[i].vram_used = external < budget ? external : budget;

Two tallies

The die manager maintains two numbers per die (die_manager.c:37):

Field Meaning
vram_budget Usable bytes (total − reserve). Set at init, never changes.
vram_used Bytes committed by bindings + external occupancy. Mutated by bind/release.

Free bytes at any moment = vram_budget − vram_used (die_free_bytes, die_manager.c:183).

memwatch feed

After computing each die's budget, pn_dm_init_cuda calls pn_memwatch_set_die(i, die_free, budget, fc_ctx_alloc_bytes(ctx)) (die_manager.c:105) so MemoryAware admission sees the same per-die truth.


4. Bind / release lifecycle

pn_dm_place (auto-placement, die_manager.c:200)

Does not reserve — only selects. Accepts n_dies_wanted of 1 or 2.

2-die request: Scans even ordinals, looking for a same-card pair {a, a+1} both with die_free_bytes ≥ per_die. Prefers a fully-idle pair; among ties picks the pair with the most aggregate free space (die_manager.c:219).

1-die request: Prefers a die whose card-mate is already occupied ("pack") so free same-card pairs remain available for future 2-die models. Among candidates picks the most-free die (die_manager.c:244).

pn_dm_bind (die_manager.c:270)

Commits a placement under the global mutex:

  1. Finds a free slot in g_dm.binding[] (cap PN_DM_MAX_BINDINGS = 16).
  2. Re-checks die_free_bytes(id) >= per_die for every die in the set — guards against TOCTOU between pn_dm_place and pn_dm_bind.
  3. Writes the binding (name, die list, bytes_per_die).
  4. Increments g_dm.die[id].vram_used by per_die for every die in the set.

The whole operation is atomic under the lock; on any failure nothing is partially written (goto refuse, die_manager.c:313).

Same-name multi-card rule (die_manager.c:283): a model name may be bound multiple times to DISJOINT die-sets (one entry per copy, for load-balanced multi-card serving). The code does not reject duplicate names at bind time. Overlap is caught by the per-die room check (step 2): if a second binding would use the same die, die_free_bytes will be too small and the bind refuses. pn_dm_release is first-match on name (die_manager.c:326), so N bindings with the same name require N pn_dm_release calls to free all of them.

pn_dm_release (die_manager.c:318)

Walks the binding table, finds the first matching name, subtracts bytes_per_die from each die's vram_used (clamped to 0), zeros the slot, and returns. Only releases one binding per call.


5. Foundry CUDA primitives

5.1 Driver loading (fc_cuda.c:517)

fc_driver_load() (called lazily by fc_cuda_init_device):

  1. dlopen("libcuda.so.1") — falls back to unversioned libcuda.so.
  2. Resolves all driver API symbols via fc_load_cuda_symbols (fc_cuda.c:414). _v2 variants are preferred for context/memory ops (required by cuBLAS); a fall-back to the unversioned name is attempted for each.
  3. cuInit(0).
  4. dlopen("libcublas.so.11") (optional — custom PTX kernels work without it).

The resolved function-pointer table is process-global and shared by all contexts (fc_cuda.c:152). The table includes optional symbols for peer copies, graph capture, and events; missing ones leave their pointer NULL and the corresponding feature self-disables gracefully.

5.2 Per-device context (fc_cuda_init_device, fc_cuda.c:896)

One fc_cuda_ctx per die. Sequence:

  1. p_cuDeviceGet(&ctx->device, device_id) — binds the ordinal.
  2. p_cuCtxCreate(&ctx->context, 0, ctx->device) — creates a CUDA context; leaves it current.
  3. p_cuStreamCreate(&ctx->stream, 0) — one stream per context.
  4. cublasCreate + cublasSetStream on the stream (non-fatal if cuBLAS absent).
  5. fc_ctx_load_kernels — cuModuleLoadData of all embedded PTX blobs into this context's private module slots. Optional kernels (graph-capture variants, 8-wide tile variants, slot-attention kernels) are looked up tolerantly and left NULL if absent.
  6. fc_ctx_leave() — pops the new context off the thread's stack.
  7. fc_registry_add(ctx) — registers in the live-context table for crash-time teardown.

The driver function table is shared; module handles and kernel function handles are per-context (copied into each fc_cuda_ctx's module fields, fc_cuda.c:224).

5.3 Context isolation (push/pop, fc_cuda.c:577)

Every device op calls fc_ctx_enter(ctx) → cuCtxPushCurrent before issuing work and fc_ctx_leave() → cuCtxPopCurrent on return. This keeps concurrent dies from bleeding into each other even when ops from different dies interleave on the same host thread.

5.4 VRAM accounting (ctx->alloc_bytes)

Every cuMemAlloc incremented path (fc_buffer_from_ptr_ctx at fc_cuda.c:1156, fc_buffer_alloc_ctx at fc_cuda.c:1184) adds bytes to ctx->alloc_bytes. fc_buffer_free subtracts it (clamped, fc_cuda.c:1220). This counter is the predictive side of admission; fc_ctx_alloc_bytes(ctx) exposes it. pn_dm_init_cuda passes it to pn_memwatch_set_die so both tallies start consistent.

5.5 Memory operations

Function Operation
fc_buffer_from_ptr_ctx cuMemAlloc + cuMemcpyHtoD (blocking)
fc_buffer_alloc_ctx cuMemAlloc only (output buffer)
fc_buffer_free cuMemFree + decrement alloc_bytes
fc_buffer_to_cpu_n cuMemcpyDtoH (blocking)
fc_buffer_to_cpu_n_async cuMemcpyDtoHAsync on ctx stream
fc_buffer_copy_d2d_ctx cuMemcpyDtoDAsync on ctx stream
fc_buffer_copy_peer cuMemcpyPeer (blocking, cross-die)
fc_buffer_copy_peer_async cuMemcpyPeerAsync on stream_ctx stream
fc_buffer_offset_view Non-owning wrapper (struct + pointer offset only; no cuMemAlloc/Free)

Peer copies require fc_cuda_enable_peer(a, b) first, which calls cuDeviceCanAccessPeer and cuCtxEnablePeerAccess in both directions (fc_cuda.c:1400). Error 704 (already-enabled) is treated as success.

5.6 Synchronization

fc_sync_ctx(ctx) (fc_cuda.c:4468) calls cuStreamSynchronize on ctx->stream (falling back to cuCtxSynchronize if the stream is NULL). This blocks the host thread until all work queued on the stream is done.

Events (fc_event_*, fc_cuda.c:4391): created with CU_EVENT_DISABLE_TIMING (no timing overhead). fc_event_record_ctx queues cuEventRecord on the ctx stream; fc_event_query calls cuEventQuery and returns 0 (done), 1 (CUDA_ERROR_NOT_READY = still running), or -1 (error). The async serve scheduler uses events to poll all 8 card streams without blocking any host thread — the alternative, fc_sync_ctx, would spin/yield a core per card and starve other cards' launch turns on a 2-core host (fc_cuda.c:4383).

5.7 Graph capture (fc_graph_*, fc_cuda.c:4296)

Optional (symbols checked at load; all six must be non-NULL or fc_graph_begin_ctx returns -1). fc_graph_begin_ctx calls cuStreamBeginCapture(stream, mode=0 = GLOBAL). fc_graph_end_ctx calls cuStreamEndCapture then cuGraphInstantiate. fc_graph_launch replays via cuGraphLaunch. Used by the decode-attention block for K80 launch-bound relief (one graph replay replaces hundreds of cuLaunchKernel calls).

5.8 Back-compat single-die path

fc_init() creates a process-default context on device 0 (_fc_default_ctx). All non-_ctx functions (fc_buffer_from_ptr, fc_sgemm, fc_sync, …) delegate to it. Multi-die callers use the explicit fc_cuda_ctx* variants.


6. Correctness notes — multi-card same-name bindings

pn_dm_bind intentionally does not prevent duplicate names (die_manager.c:283): N copies of a model on N different card-pairs all register under the same name to support load-balanced serving. This is correct as long as:

  1. Disjoint die-sets: enforced by the per-die die_free_bytes < per_die check. If two bindings were attempted on overlapping dies, the second would see reduced free space and refuse — provided the first binding's bytes_per_die correctly covers the die's actual weight bytes.
  2. N releases required: pn_dm_release is first-match; releasing a multi-copy model requires exactly N calls (one per binding slot). A single call frees only one copy's VRAM accounting. A caller that calls release once and assumes the die is fully reclaimed will have a stale vram_used.
  3. pn_dm_get reports only the first bound model name for a die (die_manager.c:167); n_models counts all bindings touching that die. Status consumers that read only model_name will not see co-resident same-card models.

No gap in the overlap-rejection path: the per-die room check is atomic under the global lock, so two concurrent binds on the same die cannot both succeed if there is only room for one.


7. Second backend — Tenstorrent Blackhole p150a (USE_TT=1)

CUDA is not the only accelerator behind the die manager. pn_dm_init_tt() (die_manager.h:114) enumerates a Tenstorrent Blackhole p150a instead of K80 dies; the host driver is src/accel/tt/pn_tt_{device,residency,oncard}.c over Foundry's tt/ backend.

Host: any Linux machine with the card; the reference host is a small 2-core / 8 GB box (no AVX) that also drives Tesla K80s. With tt-kmd the card appears as /dev/tenstorrent/<n>.

Build

make USE_TT=1          # Linux only; adds -DUSE_TT -DFT_TT_PHASE1B_NOC_ENABLED
                       # writes ./pantheon-tt, then points ./pantheon at it

This build writes pantheon-tt, not pantheon — the backends have separate output names now, so a TT build on a host that also runs CUDA can no longer overwrite a deployed pantheon-cuda. It does re-point the pantheon symlink, and on a host that also serves CUDA that has to be put back: ln -sfn pantheon-cuda pantheon (no rebuild needed). Confirm with ./pantheon --backend before deploying anything.

FT_TT_PHASE1B_NOC_ENABLED is deliberately not a second knob: without it ft_tt_mem_info() cannot read a bank count and pn_dm_init_tt() refuses every device, so a build could link the backend and then silently decline to see the card. Device kernels are not built here — the kernel ELFs and Tensix programs are pre-baked into foundry/tt/ft_tt_kernels.h / ft_tt_programs.h, the same trick cuda/ uses for PTX, so building needs no SFPI (SFPI is only needed to rebuild those programs).

Run-time dependency: a way to reach the card, nothing else. The default transport is tt-kmd (/dev/tenstorrent/<n>); FT_TT_DRIVER=udrv uses Foundry's own userspace driver over PCI sysfs with no kernel module, which needs root or the udev rule foundry/tt/tools/60-foundry-blackhole.rules. No tt-metal runtime, no TT-NN, no Python, and no C++ anywhere — host or device. That boundary is the point of the backend, not an incidental property. Foundry's TT layer was rewritten from public documentation and measured behaviour; no tt-metal, TT-NN, tt-umd or tt-llk source is compiled, linked or vendored. The vLLM/tt-metal stack cannot run on the reference host at all: 2 cores / 8 GB and no AVX, so TT's prebuilt wheels SIGILL.

Runtime knobs

Variable Effect
PN_TT_PROGRAM_DIR override only. Read Tensix programs from this directory instead of the ones embedded in the binary. Unset — the default — means embedded. An override that cannot be satisfied REFUSES; it never falls back to embedded
PN_TT_DISPATCH dispatch-path selection
PN_TT_ONCARD on-card op families (reduce / eltwise / top-k)
PN_TT_CORE, PN_TT_CORE_{REDUCE,ELTWISE,SAMPLE} pin a residency to a Tensix index

What it can serve today

pantheon serve runs MiniCPM5-1B on the card over the OpenAI-compatible endpoint, multi-turn, across idle gaps: 4.37 tok/s best, 4.14 sustained (2026-08-15). Eight op families are green on silicon.

Three limits to plan around:

  1. Everything measured is ONE core of 140. Multi-core is not built. The forward runs 169 matvecs on a single matmul core. The figure quoted elsewhere as 8.94 decode tok/s is composed from measured per-op costs (3.745 ms/layer × 24 + 22.0 ms lm_head) — no end-to-end forward pass has been run to produce it. The 4.37 tok/s above is the measured end-to-end number.
  2. MoE is not built. Routed-MoE models (qwen35moe, e.g. Nex-N2) do not run on the card. The dense qwen35 hybrid (Gated-DeltaNet + attention, e.g. Ornith) does, byte-identical to the CPU forward (engine.c, PN_ARCH_BONSAI branch; Foundry ft_tt_op_gdn).
  3. Open correctness defect. A chat-templated prompt — i.e. every request a serve receives — costs +22.1% perplexity and disagrees with the CPU oracle on 16.9% of decode steps. Mechanism unidentified.

Weights are not GGUF on the card: foundry/tt/convert/ turns a GGUF into a tiled FTTW artifact (bf16 / Bfp8_b / Bfp4_b) and the loader is a byte copy. BFP4 is refuted on quality (+40.2% perplexity) and is no faster than BFP8.

Operational hazards — these cost hardware, not a retry

  • A wrong NoC coordinate hangs the chip and costs a physical power cycle. Coordinates come from ARC telemetry; never probe or guess one.
  • PHASE1B=1 is mandatory for on-card builds; off-hardware suites are built without the NoC fence so they cannot reach the card even when wrong.
  • Never assert the Tensix BACKEND soft-reset bits — it silently and permanently corrupts that core's compute until a host reboot.
  • Retire by asking, never by reset (FT_TT_QUEUE_OP_STOP / stop_request). assert_reset on a blocked resident kernel is what wedges cores, and a resident triad must never be parked.
  • Never more than 4 destinations per multicast — 5 wedges reliably.
  • On-card runs are detached and polled, never held over an SSH session.
  • Card→host DMA does not exist on Blackhole. Host→card is 287 MB/s on the reference host's x1 Gen2 link and readback is 2.3–4.7 MB/s, so on-card sampling is mandatory rather than an optimisation. A card reset drops the link to Gen1 — retrain and verify afterwards, or every host-involved number is silently halved.
  • On-card residencies are bounded and exit on an idle spin limit — and a serve idles by definition. pn_tt_oncard_rearm() is what recovers them.

Full detail: foundry/tt/DEV_NOTES.md (the authority on hazards), foundry/tt/README.md, foundry/docs/tenstorrent.md.