Skip to content

[Build] Target torch 2.10 + add the Triton codegen route (draft) - #305

Draft
YWHyuk wants to merge 35 commits into
developfrom
feature/triton-codegen
Draft

[Build] Target torch 2.10 + add the Triton codegen route (draft)#305
YWHyuk wants to merge 35 commits into
developfrom
feature/triton-codegen

Conversation

@YWHyuk

@YWHyuk YWHyuk commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator

Targets torch 2.10 and adds an opt-in second codegen route: Inductor's own
Triton backend produces the kernel, and triton-npu lowers it to a RISC-V ELF.

Draft. The route now runs end to end on elementwise kernels — values and
cycles both — but the gap list below is still long, and the environment change
needs a base image rebuild and a full test-suite run against torch 2.10.

Why torch 2.10

triton-npu pins triton 3.6 because 3.6 pins LLVM 23, and both sides of its
textual IR seam must be the same LLVM. torch 2.10 is the first release whose
Inductor targets 3.6, so the version skew the Triton route had to shim around on
2.8 disappears.

torch 2.8 torch 2.10
triton 3.4.0 3.6.0
torchvision 0.23.0 0.25.0

Editing Dockerfile.base moves the base-image tag automatically
(sha256(thirdparty/github-releases.json + Dockerfile.base)), so CI builds
torchsim_base:thirdparty-dde0ff29a141 on this PR. The GitHub release pins
(gem5 / riscv-llvm / spike) are tied to ubuntu 22.04 and python 3.11, not to
torch, and are unchanged. The rest of the pip set resolves clean (numpy 1.26.4,
transformers 4.43.4, diffusers 0.34.0).

Device-side review

No conflicts found, verified against torch 2.10.0+cpu in an isolated venv:

  • the C++ extension builds with 0 errors; eager add on npu:0 gives max_err 0
  • DeviceGuardImplInterface has the same 8 pure virtuals as on 2.8
  • c10::Allocator changed only additively; PrivateUse1HooksInterface only a
    comment typo; every registration macro we use still exists
  • python side: rename_privateuse1_backend,
    generate_methods_for_privateuse1_backend, register_interface_for_device
    and the DeviceInterface method set are unchanged;
    register_backend_for_device gained only optional parameters
  • the MLIR production route still passes tests/ops/elementwise/test_add.py

Only one signature moved: TritonKernel.call_kernel gained deallocate_ws, so
our override takes **kwargs.

The Triton route

Off by default; TORCHSIM_TRITON_CODEGEN=1 selects it at device registration.
One module per seam, described in
PyTorchSimFrontend/triton_backend/README.md.

Measured on x + y (1024 elements): Inductor codegen, spec generation and tnpu
stages 1-5 all succeed and link 05-triton_npu_fused_add_0.elf. The lowering is
structurally right — three togsim.transfer ops, and Inductor's xmask arrives
as a masked DMA.

The launch has both halves:

  • timing — the post-vcix IR becomes a trace.so + trace_cycles.tsv that
    TOGSim consumes. gem5 samples the tile at 19 cycles; TOGSim reports 650,
    with DRAM traffic of exactly the 8 work-items × 2 loads × 512 B the kernel
    should move. The MLIR route on the same x + y reports 251 — the same order,
    higher here because tnpu emits synchronous DMA, so nothing overlaps.
  • functional — the launch's tensors are written as .raw, Spike runs the
    ELF, and the outputs are read back into the caller's tensors. x + y and the
    fused (x + y) * 2 - x both match torch to 0.0 max error.

A Triton kernel body is one program instance, so the grid that enumerates
instances is supplied by lower_to_emitc.WorkItem rather than read out of the
kernel. The timing path takes it at run time, so one trace serves every shape.
The functional binary does not: tnpu bakes the grid, the scalar values and the
memref extents in, so a dynamic-shape launch is rejected with ShapeMismatch
naming the compiled extent instead of running against the wrong bounds.

Still open, in order: shape-specialised functional launch, double buffering,
triton_helpers, reductions (blocked in tnpu — no lane-aware reduction path),
and matmul timing. tests/system/test_triton_codegen.py pins the reduction
boundary so it fails loudly rather than producing wrong numbers.

Depends on PSAL-POSTECH/triton-npu#1 for the timing and functional entry points;
thirdparty/triton-npu.json pins the commit.

CI for the route

.github/workflows/triton_npu.yml, separate from the main CI: the toolchain
layer is ~1.8 GiB no other job needs. It will fail at preflight until
secrets.TNPU_TOKEN is set
PSAL-POSTECH/triton-npu is private and the
default Actions token is scoped to this repository. That is expected on this PR.

What to expect from CI here

  • docker-image.yml builds the new base image and runs the full suite three
    times (3 TOGSim configs) against torch 2.10. Only test_add was verified
    locally, so other jobs may surface 2.10 regressions — that signal is the
    reason this PR is open.
  • triton_npu.yml builds the toolchain image and runs
    tests/system/test_triton_codegen.py.

🤖 Generated with Claude Code

YWHyuk added 8 commits July 27, 2026 17:37
Wire an opt-in second codegen route for the npu device: Inductor's own Triton
backend produces the kernel, and triton-npu (tnpu) lowers it to a RISC-V ELF,
in place of the hand-written MLIR emission in PyTorchSimFrontend/mlir.

Off by default. TORCHSIM_TRITON_CODEGEN=1 selects it at device registration;
the MLIR route is unchanged and still passes tests/ops/elementwise/test_add.py.

Flow, one module per seam:
  scheduling.py      TritonNPUScheduling.define_kernel intercepts the generated
                     Triton source; TritonNPUKernel emits a plain call instead
                     of triton's .run(grid=..., stream=...)
  kernel_spec.py     Inductor kernel -> tnpu KernelSpec: collects arg roles /
                     dtypes / numels while V.graph is live, strips the
                     triton_heuristics decorator and torch imports, pins the
                     block sizes
  codecache.py       one directory per source hash, mirroring extension_codecache
  tnpu_bridge.py     runs tnpu out of process
  _triton_compat.py  torch 2.8 Inductor against triton 3.6, no GPU

Measured today on x + y (1024 elements): Inductor codegen, spec generation and
tnpu stages 1-5 all succeed and link 05-triton_npu_fused_add_0.elf. The lowering
is structurally right -- three togsim.transfer ops, and Inductor's xmask arrives
as a masked DMA (masked_axes / masked_fill). The run then stops in
TritonNPULauncher.__call__, which is not wired: the functional launch needs
tensor marshalling into runtime/*.raw and the timing launch needs the build_tog
adapters. Both are tracked, with the rest of the gaps, in
PyTorchSimFrontend/triton_backend/README.md.

Three decisions are recorded there rather than in commit history: block sizes
are fixed at codegen time (Inductor defers the grid to a runtime autotuner that
this route has no equivalent of), tnpu runs in its own process (LLVM 23 vs 20
bindings cannot share an interpreter), and the frontend is shimmed onto triton
3.6 rather than the reverse (3.6 pins LLVM 23, which the IR seam requires).
…ton 3.6

triton-npu pins triton 3.6 because 3.6 pins LLVM 23, and both sides of its
textual IR seam must be the same LLVM. torch 2.10 is the first release whose
Inductor targets 3.6, so moving the image to it removes the version skew the
Triton route had to shim around on 2.8.

Dockerfile.base:
  torch 2.8.0 -> 2.10.0, torchvision 0.23.0 -> 0.25.0 (resolver-confirmed pair)
  add triton==3.6.0 -- Inductor imports triton while GENERATING a kernel, and it
  is not a dependency of the CPU torch wheels

Editing Dockerfile.base is enough to re-tag the base image: the tag is the
sha256 of thirdparty/github-releases.json plus that file, so the pin moves
1bb94039a884 -> dde0ff29a141 and CI builds the new base. The GitHub release pins
themselves (gem5 / riscv-llvm / spike) are tied to ubuntu 22.04 and python 3.11,
not to torch, and are unchanged. The rest of the pip set resolves clean against
2.10 (numpy 1.26.4, transformers 4.43.4, diffusers 0.34.0).

_triton_compat shrinks to what is actually still needed. The triton_key
injection is now guarded by _torch_handles_triton() and does not run on 2.10,
which reaches the symbol through torch._inductor.runtime.triton_compat. What
remains is not a version shim: triton_hash_with_backend() asks the triton
runtime for the current target and raises "0 active drivers" on a box with no
GPU, so it is short-circuited to a deterministic cache key.

TritonNPUKernel.call_kernel takes **kwargs: 2.10 added a deallocate_ws
parameter, and none of these apply to a route with no triton launcher.

Verified against torch 2.10.0+cpu in an isolated venv:
  device C++ extension builds with 0 errors; eager add on npu:0 gives max_err 0
  the MLIR production route still passes tests/ops/elementwise/test_add.py
  the Triton route still compiles through to 05-triton_npu_fused_add_0.elf

Device-side API review found no conflicts: DeviceGuardImplInterface has the same
8 pure virtuals as on 2.8, c10::Allocator changed only additively,
PrivateUse1HooksInterface only a comment typo, and every registration macro we
use still exists. On the python side rename_privateuse1_backend,
generate_methods_for_privateuse1_backend, register_interface_for_device and the
DeviceInterface method set are unchanged; register_backend_for_device gained
only optional parameters.
Stands up CI for the Triton codegen route, sourcing the harness from
PSAL-POSTECH/triton-npu.

Kept off the main CI path. The route is WIP, and its toolchain (LLVM 23, the
CONFIG_DESC-capable spike, the triton runtime) is ~1.8 GiB compressed that no
other job needs, so it is a separate image and a separate workflow rather than
part of torchsim_base.

  thirdparty/triton-npu.json   pins, deliberately NOT in github-releases.json:
                               that file plus Dockerfile.base is hashed into the
                               torchsim_base tag, so keeping these apart means
                               the main image is not rebuilt when tnpu moves
  scripts/ci/tnpu_base_pin.sh  sha256(manifest + Dockerfile.tnpu), same scheme
  scripts/ci/tnpu_asset_env.sh resolves the three release asset ids
  Dockerfile.tnpu              torchsim_base -> torchsim_tnpu_base
  triton_npu.yml               preflight, image build, then the jobs

Jobs: tnpu-baselines runs the harness's own add/mul/relu/gemm/bmm through Spike
and gates, since nothing downstream means anything if the toolchain regressed.
triton-route runs test_triton_codegen.py and reports without gating, because its
launch is deliberately unimplemented. mlir-route-regression runs test_add.py --
this layer adds a second LLVM and a second triton to the image, and that job
checks the production path did not notice.

Dockerfile.tnpu mirrors setup/restore.sh --prebuilt rather than calling it, and
says why in place: that script clones a triton_shared fork that no longer
exists, and setup/package.sh collects the triton tree with
`find python -name '*.so'`, which misses python/triton/backends/{amd,nvidia,
triton_shared} -- symlinks the build creates, so present in neither the tarball
nor git, and without them `import triton` dies in entry-point discovery. Both
are worth fixing upstream; both are worked around here.

TWO PREREQUISITES, BOTH OUTSIDE THIS REPO, neither satisfiable from here:

  1. secrets.TNPU_TOKEN -- PSAL-POSTECH/triton-npu is private and the default
     Actions token is scoped to this repository, so it can neither clone the
     repo nor read its releases. The existing gem5 / riscv-llvm / spike pins
     need no secret because those repos are public.
  2. A release tagged toolchain-llvm23 on PSAL-POSTECH/triton-npu carrying
     llvm23-install.tar.gz, spike-install.tar.gz, triton-runtime.tar.gz. That
     repo currently has NO releases -- the assets exist only on the fork it came
     from and have to be mirrored across.

The preflight job reports which of the two is missing instead of letting a
docker build fail deep. Asset resolution was verified end to end against the
repo that does carry the release: it resolves all three ids, and against
PSAL-POSTECH it fails with the message above.
The two things Dockerfile.tnpu worked around are fixed upstream now
(PSAL-POSTECH/triton-npu e686799): restore.sh no longer clones a triton_shared
fork that does not exist, and it recreates the triton backend symlinks the
release tarballs cannot carry.

So the image just clones the harness and runs its own setup/restore.sh
--prebuilt. Every pin lives in that repo's setup/versions.env, which also
removes the duplicated triton pin and the asset-id resolution here
(scripts/ci/tnpu_asset_env.sh deleted -- restore.sh resolves them).

thirdparty/triton-npu.json pins `ref` to a commit rather than a branch, so an
upstream change moves this image's tag.
The toolchain-llvm23 release now exists on PSAL-POSTECH/triton-npu, so
secrets.TNPU_TOKEN is the only thing left to set up.
YWHyuk added 7 commits July 27, 2026 22:44
It prints nothing. The method walks the IR and attaches TOG nodes; `bfs` and
`display` do the printing. The name came from the C++ pass this file ports,
where one method does both -- the docstring now records that so the
correspondence is still findable.
A Triton kernel describes a single program instance: the tile loop is not in the
kernel, it is the launch grid outside it. The trace producer already wants that
same split -- togsim_kernel_tile per work-item, enumerated by togsim_kernel
(docs/design/togsim_cpp_trace.md sec 9.3) -- so the two models agree and only
the enumeration was missing. This teaches the pipeline to accept a kernel in
that shape, rather than requiring the loop nest PyTorchSim's codegen emits.

build_tog
  _find_kernel falls back to the module's only func.func; the name comes from
  the Triton kernel, not the fixed "kernel".
  _build roots at top-level loops carrying a ROLE attribute, and treats the whole
  body as one work-item when there are none. Keying on the attribute matters on
  its own: bank_vectorize also leaves a bare top-level affine.for for the tile's
  vector work, and rooting at that one made every DMA a sibling the traversal
  never reached.
  A DMA's tensor identity comes from the producer's dram_arg attribute when the
  operand is a view of the argument rather than the argument. Inferring it would
  mean chasing memref view ops back, and there are nine of them with no
  ViewLikeOpInterface in the python bindings to ask generically.
  DMA nodes are recorded in a list so _collect_dma_nodes can seed from them; it
  only descended from loop nodes, and a DMA outside any loop was dropped before
  reaching the skeleton.

lower_to_emitc
  WorkItem + _materialize_grid_loop supply the grid, on the trace artifact only:
  the body is wrapped in a loop per axis, tagged outer_loop, with each
  program-id argument replaced by its induction variable. It must run before
  _rewrite_signature, which erases the arguments and first asserts none are
  still used. Everything after is unchanged -- _parallel_loop_chain finds the
  tagged loop, the outliner threads the induction variable through iv[], and the
  loop left behind becomes the dispatch enumeration.
  Two things about building that nest are easy to get wrong and only show at
  rank >= 2: a nested loop is created before the enclosing yield (an
  InsertionPoint on a block appends, and an scf.for body is already terminated),
  and every bound is created before the first loop, so that a bound made after
  an outer loop does not end up below it while an inner loop uses it.
  A parallel loop may be scf.for as well as affine.for; the role is carried by
  the attribute, not the dialect.
  _strip_aux keeps the kernel the caller resolved instead of matching on a name.

Every change is a fallback: the existing conditions are tested first, so
PyTorchSim's own codegen takes exactly the path it did before.
Closes the timing loop: one torch.compile now produces a cycle count. The
launcher emits the trace producer from tnpu's post-vcix IR and hands it to
TOGSim, reusing PyTorchSim's existing trace pipeline unchanged.

timing.py
  emit_trace     04-custom.mlir -> build_skeleton -> trace.so + trace_cycles.tsv
  run_togsim     hand the kernel directory to TOGSimulator.run_standalone
  work_item_for  derives the program-id argument positions from the signature
                 layout (pointers, user scalars, then triton-shared's own
                 gridX,Y,Z / pidX,Y,Z) and the grid from the pinned block sizes

codecache persists meta.json beside the artifacts so the timing step can run
standalone, and TritonNPULauncher.__call__ simulates instead of raising.
kernel_spec._grid becomes grid_of: the timing path needs the same extents to
enumerate work-items, so it is computed in one place.

The test drives a 2-D grid: that it verifies as MLIR, nests one loop per axis,
and dispatches both indices. Checking the module and not only the C++ it becomes
is the point -- the emitc lowering hoists constants to a flat scope, so it hides
a bound that does not dominate its use. Inductor cannot reach this path here (it
uses y/z only when x would overflow), so it gets a test rather than waiting for
a kernel to exercise it.

Measured on Inductor's `x + y` (1024 elements, XBLOCK 128, grid 8): TOGSim
totals 573 cycles, and channel-0 DRAM traffic of 16 reads x 32 B x 16 channels
is 8192 B -- exactly the 8 work-items x 2 loads x 512 B the kernel should move,
so every dispatch in the enumeration really ran. The cycle table is a
placeholder until gem5 sampling lands, and says so on every emit.

Output tensors are NOT written; marshalling them through Spike is the remaining
functional half. The launcher logs that on every call rather than letting an
undefined value pass for a computed one, and the test asserts the timing
artifacts exist instead of comparing values.
The cycle table held a placeholder, so TOGSim modelled the DMA but charged
nothing for compute. It is now a measurement.

measure_tile_cycles chains three pieces that already existed: build_tog's sample
mode annotates the post-vcix IR (inline-asm markers around each compute node,
every loop rewritten to one trip, so what runs is one tile), `python -m
tnpu.cycle` lowers that to a RISC-V binary in tnpu's own process -- the
Gemmini/VCIX lowering and its LLVM live there -- and CycleSimulator runs it under
gem5, reading one numCycles per marker pair. build_cycle_table then turns the
list into the tsv, keyed by tile_id.

Sampling runs before build_skeleton because both consume the same post-vcix IR
and build_skeleton rewrites it in place.

Failure is not fatal: any step that does not produce a measurement falls back to
the placeholder table and says so, since a kernel that simulates with the wrong
compute cost is more useful than one that will not simulate -- as long as it
announces which it is.

Measured on `x + y` (1024 elements, grid 8): the tile samples at 19 cycles and
TOGSim's total moves 573 -> 650. The MLIR route reports 251 on the same
computation; the remaining gap is double buffering, which tnpu does not emit
yet, so nothing overlaps.
The grid machinery handles N axes and is tested to, but grid_of only ever
computed one from xnumel, so work_item_for could only ever build a 1-D
WorkItem -- general plumbing behind a caller that never used it.

grid_of now walks every parallel axis, and fixed_config_for pins a block per
axis. Parallel vs reduction is Inductor's own test (a prefix starting with "r"
is looped inside the kernel, not gridded), and the block name is
f"{prefix.upper()}BLOCK", so neither is guessed.

Two orderings meet here and they are not the same: grid_of returns axes
OUTERMOST first (z, y, x -- x is Inductor's contiguous axis), while
triton-shared always appends the program ids as pidX, pidY, pidZ. work_item_for
therefore builds the argument list per axis instead of as a range; zipping the
two blindly would pair the outermost loop with the wrong id.

Block sizes: the outermost axis gets the lane count, because that is the tile
dimension bank_vectorize spreads over the lanes. The rest get 1, which leaves
the tile exactly the verified [lanes] shape and lets the grid cover everything
else. That is correct but pathological -- an inner block of 1 makes each
work-item move a strided column -- so a multi-axis kernel logs a warning saying
it is not a tiling worth measuring. Choosing real tile sizes is the block-size
policy gap in the README.

Verified: axes/grid/parallel_args come out ['x']/(8,)/[pidX],
['y','x']/(2,1024)/[pidY,pidX] and ['z','y','x']/[pidZ,pidY,pidX]. End-to-end on
a real multi-axis kernel is still untested -- Inductor reaches for y/z only when
x would overflow, which the shapes this route handles do not.
Only the NUMBER of grid axes has to be compiled in -- how many loops to nest and
how many iv[] slots to fill. The trip counts are just values, and the producer
ABI already carries them: togsim_kernel(ctx, shape_args, n). Baking them in was
what forced a recompile per shape.

A WorkItem extent of None now means "read it from shape_args". The bound cannot
be wired when the loop is built -- shape_args does not exist until
_rewrite_signature adds it -- so the loop takes a placeholder and
_bind_runtime_bounds replaces it once the signature is there. The loops stay in
the entry function (the outliner moves only their bodies), so the read is in
scope where the bound is used.

timing.write_shape computes the grid per launch and writes trace_shape.txt.
The launch already knows the real extents: Inductor appends the numels after the
tensor arguments, so the trailing values are them. Only the PARALLEL numels ride
along -- a reduction axis is looped inside the kernel and never passed, so
counting it would misalign the mapping.

TOGSim reads that sidecar the same way it reads trace_cycles.tsv, from the
kernel directory. main.cc passed nullptr for shape_args; absent file still
means nullptr, so a producer with its bounds baked in is unaffected.

Measured. One trace.so (md5 identical across all three), torch.compile(
dynamic=True), a single kernel directory reused:

  n=1024  grid  8   650 cycles
  n=2048  grid 16  1316 cycles
  n=4096  grid 32  2586 cycles

The functional path still compiles per shape -- tnpu's spec bakes the tensor
extents into the flat memref view -- so this opens the timing half only.
A persistent reduction declares R0_BLOCK in the kernel BODY
(R0_BLOCK: tl.constexpr = 64), not in its signature -- Inductor has already
chosen it, and there is nothing left to pin. Passing it anyway put a name in
the spec that the kernel does not take, and tnpu's stage 1 died on
"'R0_BLOCK' is not in list" -- a malformed spec masquerading as a reduction
problem.

fixed_config entries absent from the signature are now skipped, so the
"block size is unset" guard fires only when the block really is ours to choose.

What that uncovers is the actual boundary: a reduction now reaches tnpu and
fails THERE. triton-shared hands over a linalg.reduce plus a linalg.transpose,
and tnpu has no lane-aware reduction to lower them with -- the scratchpad is
lane-banked, so the reduced axis has to stay inside a lane. Measured on
x.sum(dim=1): 02-ttshared carries linalg.reduce, linalg.transpose and
tts.scatter, and stage 3 stops.

The test pins that boundary rather than leaving it undocumented. It passes
while a reduction is REFUSED; a reduction that compiles would mean either the
lane path landed (delete the check) or the kernel is simulating compute the
hardware would not do.
@YWHyuk
YWHyuk force-pushed the feature/triton-codegen branch from 2208d64 to 8da4bbc Compare July 27, 2026 14:43
YWHyuk added 7 commits July 27, 2026 23:56
triton-shared keeps the user scalars in the lowered signature ahead of its own
six grid/pid arguments:

  func.func @k(%arg0..2: memref<*xf32>, %arg3: i32 xnumel,
               %arg4,5,6: gridX,Y,Z, %arg7,8,9: pidX,Y,Z)

tnpu's wrapper reads them from spec.extra["scalar_args"], and the generated spec
had no extra at all, so the call passed six i32 where the kernel takes seven.
Every argument after the tensors landed one slot early: pidX got pid_y, which the
grid loop never varies, so program 0 ran eight times and only the first block of
the output was written -- the rest stayed zero. Measured on x + y, 1024 elements:
896 of 1024 wrong, and the wrong part was silently zero rather than garbage.

The values are compile-time constants, like the grid, so they come from the same
numels the grid is computed from. On x + y this now gives 0.0 max error over the
whole tensor under Spike.

This only ever affected the functional path: the timing path reads the argument
positions from the lowered MLIR signature, which was always right.
The launcher simulated the kernel but left the caller's output tensors
untouched, so a compiled graph returned undefined values and the test could only
assert that the timing artifacts existed.

functional.py marshals the launch: every argument is written to runtime/<arg>.raw
-- outputs as zeros, since the wrapper loads and dumps by argv position -- tnpu's
stage 6 runs the ELF on Spike, and the output files are read back into the
caller's tensors. Spike runs before TOGSim so the values survive a timing
failure; the two halves are independent.

Gated on pytorchsim_functional_mode, as the MLIR route is. With it off the
launcher says so rather than letting an undefined value pass for a computed one.

The binary is compiled for ONE shape: the spec bakes the grid, the scalar values
and the memref extents in. A launch whose shapes differ is rejected with
ShapeMismatch naming the compiled extent, instead of running against the wrong
bounds. The timing path has no such limit -- it takes the grid at run time -- so
the error points at pytorchsim_functional_mode: False for cycle-only studies.

Measured: x + y and the fused (x + y) * 2 - x both match torch to 0.0 max error
over 1024 elements. The test now checks values instead of reporting the gap.
Picks up tnpu.spike, the entry point the functional launch calls. Without it the
launcher raises on every kernel once functional mode is on, which is the default.
triton_backend/README.md is the working reference for the modules: what each
seam does and how to run it. What it does not answer is the question anyone
reviewing the route asks first -- how it differs from the path already in
production, and whether the numbers hold.

The report covers that: where the two paths diverge (the kernel is a loop nest
vs one tile) and where they rejoin (the same trace.so + trace_cycles.tsv, so
TOGSim cannot tell them apart, which is why no hardware model changed), an
item-by-item contrast, and the measurements behind each claim.

Two findings worth having written down. The Triton route is AHEAD on dynamic
shape: develop's trace path calls trace_to_tilegraph(..., nullptr, 0) and passes
no shape arguments at all, so it recompiles per shape, and PR #269 is still open
to fix that -- the Triton route takes the grid at run time and one trace serves
every shape. It is BEHIND on DMA overlap: the generated IR carries three
togsim.transfer and zero togsim.wait, which is the whole of the 251 vs 650 cycle
gap, and the MLIR route already has the barrier machinery to copy.

The two documents cross-reference rather than overlap.
The report read as though one person had built the whole route. Two thirds of
what it describes is not ours: the lowering pass -- Triton IR through
linalg/memref and the tts-level backend passes down to a RISC-V ELF -- is
이정민's, and this work is the PORT of that pass onto the existing PyTorchSim
stack.

The document now says so up front, in the pipeline diagram, in the comparison
diagram and in the contrast table, so a reader cannot mistake the boundary
wherever they enter.

Naming follows the same split. The lowering layer is called the "PyTorchSim
lowering pass" in prose, since that is what it is within this project. Concrete
identifiers keep reading tnpu -- tnpu/passes/, tnpu.spike, the CI job names --
because the document has to stay navigable back to the code.

triton-npu#1 is described for what it is: not a change to the pass, but the
three entry points the port needed to call it. tnpu.cycle to time one tile under
gem5, dram_arg so the TOG builder is told which argument a DMA belongs to
instead of inferring it, and tnpu.spike so stage 6 can run on the caller's own
tensors.
The report ended with four sections that were either backstory (design calls
already reverted, PR bookkeeping) or a wish list. What a reader actually needs
next is narrower: the MLIR route is held up by the op suite under tests/ops, and
the question is how far the Triton route gets on the same ground.

So those four are gone and one section replaces them, built from running the
representative cases rather than predicting them. Two results were not what the
guesses would have been.

a @ b never enters the route at all -- Inductor emits an extern aten.mm, so the
run passes with 0.0 error while touching no simulator. A probe that only checks
values would have recorded that as coverage. The table therefore carries a
"routed" column, taken from whether a work directory appeared.

x.t() + 1 returns exactly x + 1: the transpose is dropped, 4030 of 4096 elements
wrong, and nothing raises. This one is ours, not the lowering pass's. Inductor
folds the transpose into the output buffer's stride and emits an identity index,
so the kernel is right; functional.py is what assumes logical order equals
storage order -- contiguous() on the way in, view_as on the way out. True for
contiguous tensors, which is exactly the set that passed so far.

Reordered so the contrast with the MLIR route comes first: what the route is
differs from is easier to hold than how it is built, and the pipeline reads
better once you know which half of it is new.
Six rounds of edits had left it patched rather than written: the boundary stated
three times in three voices, a hook table stranded where a deleted section used
to reference it, a summary that still read as though everything worked while the
section below reported a silent wrong answer.

Same material, rewritten straight through. The bug found while wiring the launch
now sits inside the measurements it belongs to rather than standing as its own
section, the two remaining top-level sections are the boundary and where the
route actually reaches, and the coverage caveat is in the summary instead of
only in the section that measures it.

Nine sections became five.
YWHyuk added 13 commits July 28, 2026 15:48
…name

Two lines still called it "tnpu stage 6" in running text, which reads as a
separate tool rather than the lowering stage of this route.

What stays is the set of names that exist in the code: tnpu_bridge.py,
tnpu/passes/, tnpu.spike, tnpu.cycle, strip_for_tnpu. Renaming those in prose
would make the document point at files nobody can open. The terminology note
now lists them, so a reader knows the two vocabularies map onto one thing.
Naming it after PyTorchSim read as though the frontend owned it, which is the
opposite of the boundary the document is drawing. It lowers Triton IR for this
NPU and belongs to whoever owns that lowering, so name it for the target.

Prose only. tnpu_bridge.py, tnpu/passes/, tnpu.spike, tnpu.cycle and
strip_for_tnpu are real names and stay quoted as they are.
Section 3 named WorkItem and _materialize_grid_loop but never said what they
reconcile, so the one piece of wiring the port actually turns on read as an
implementation detail.

Triton keeps the grid on the KernelSpec: grid_of computes the per-axis ceil-div
from Inductor's numels and the pinned block sizes, and the kernel body only
learns which slice it is, through pidX/Y/Z. build_tog expects the opposite -- it
roots the TOG at a top-level affine.for carrying a role attribute, so it reads
work-items out of a loop. A Triton kernel has no such loop, and without a root
the TOG comes out empty.

So the grid is stood back up as that loop: one nest per axis, the body moved
inside, every use of a pid argument replaced by the induction variable, and
outer_loop set on the result -- which is the marker build_tog looks for. Both
sides then denote the same thing and the rest of the pipeline is shared
unchanged.
The remaining work was framed as getting the op suite to pass, which is the
milestone rather than the point. What the route has to reach is the models the
MLIR path already runs.

Section 5 now states that goal and splits the way there in two. Ops first,
because a model is a composition of them and one blocked op stops the model at
its first kernel. Models second, ordered by difficulty -- MLP, then
MobileNet/ResNet, then ViT/Transformer, then Llama.

The second stage gets its own list, because what it surfaces is not in the op
tests at all: compile time once there are hundreds of kernels each spawning a
lowering pass subprocess, buffer reuse between kernels, shapes that do not
divide evenly, mixed f16/bf16, and the backward kernels on the training path.
Marked as expectation, not measurement -- unlike the op table above it, none of
it has been run yet.
The remaining-work section had drifted into implementation notes -- the two
functional.py calls that mishandle stride, the pass that refuses a reduction,
the helper the spec writer names when it stops. A reader reaching for what is
left does not have that context and does not need it to understand the shape of
the work.

Each item is now one sentence: what is broken and roughly where. The op probe
table keeps its verdicts but drops the error strings. Anyone who needs the
detail has the code and the sections above.

Section 5 goes from 85 lines to 55.
240 commits: the checkout layout drops the hexagon-mlir nesting, triton_shared
repoints at the PSAL fork, and the lane-axis and reduction passes are rewritten.
tnpu.spike and tnpu.cycle -- the two entry points this route calls -- are
byte-identical to the pinned version, so functional launch and timing are
unaffected.

The pin also asks spike for zvfp8, which the toolchain release does not carry;
an unknown extension stops spike at startup, failing every kernel rather than
the fp8 one. Both jobs pin TNPU_SPIKE and TNPU_SPIKE_ISA=rv64gcv_zfh to the
released build, which tnpu's own doctor() suggests. Costs
kernels/coverage/ops/ops_fp8_roundtrip.py, which CI does not run.

Verified on the new pin with the released spike: doctor clean, add/mul/relu
err 0, gemm/bmm 1.52588e-05.
The triton checkout moves to /workspace/triton-src. A directory named `triton`
shadowed the package for anything run from /workspace, so doctor died on
AttributeError inside the image build -- Dockerfile.tnpu runs it from there.
Dockerfile.tnpu runs doctor as its last build step, and that run had no
TNPU_SPIKE -- so the image build failed on the fp8 spike being absent even
though every job that used the image passed it. Putting both variables in the
image covers the build and the jobs at once, and removes the duplicate.
TORCHSIM_TRITON_CODEGEN is read once at device registration, so every test
under tests/ is already a test of this route -- nothing in the tests needed to
change. What was missing was a runner and somewhere for the failures to land.

triton_route_sweep.py runs the suite with the variable set and splits the
outcome three ways. triton_route_passing.txt is the gate: the tests that pass
today, so coverage can only grow. The rest is a report, bucketed by the layer
that owns the failure and by which tnpu stage the kernel reached. A test that
passes WITHOUT emitting a kernel -- CPU-only, eager fallback, or an op Inductor
sends to an extern call -- is kept out of the allowlist, since counting it
would overstate coverage; matmul, bmm and topk all land there.

Every failure leaves the Inductor kernel that was rejected, the stage IR it
produced, stage.log and the error, so a report needs no rerun. That required
one reorder in codecache: write_spec_file raises for exactly the kernels worth
keeping (triton_helpers, SpecIncomplete), and it ran before kernel.py was
written, so the interesting sources were the ones being thrown away.

First measurement, 69 tests: 11 pass through the route, 5 pass without using
it. Of the failures, 17 are missing test deps present in the CI image; the
rest are 8 tnpu_stage, 7 triton_helpers, 7 spec_incomplete, 6 wrapper_gap,
3 device_op, 2 togsim.

Jobs move to the PSAL Slurm runner farm: runs-on must carry the slurm label,
image builds and the sweep on the big bucket.
Three diagnostics were losing the cause on the way out.

tnpu writes its stage table to stdout and the diagnostic itself to stage.log,
so TnpuError reported "exit 1" and nothing else. It now reads stage.log and
carries the failing line, which is how six of the eight tnpu_stage failures
turned out to be one bug.

That bug: strip_for_tnpu drops `from torch...`, and Inductor takes libdevice
and tl_math from torch._inductor.runtime.triton_helpers -- but those are
re-exports of triton's own symbols. Dropping the import took valid triton names
with it and the kernels died as NameError inside stage 1. tl_math is rebound
from triton.language; test_pointwise now gets through fourteen ops and as far
as the trace producer instead of failing on the first. libdevice cannot be
rebound -- its members are @core.extern with no triton_shared implementation,
so a call returns None -- and is now named the way triton_helpers is.

The sweep also gets -j: tests are independent subprocesses with their own dump
dir, Inductor cache and TOGSim FIFO, so they parallelise with no coordination.
69 tests go from ~50 minutes to 5 at -j 10.

Coverage is unchanged at 11 of 69, but the failures are better attributed:
tnpu_stage 8 -> 2, spec_incomplete 7 -> 13.
11 of 69 tests pass through the route, with every failure attributed to an
owning layer and the pipeline stage it reached. Ranked next steps come from the
measured unblock counts, not estimates.
Adds the softmax kernel end to end as a worked example, the Python -> ttir ->
diagnostic chain for both tnpu pass rejections, and test_cat's togsim.transfer
ops. Also records that the two Spike failures cannot be reproduced by hand
today: tnpu.spike reports only exit 255, and write_inputs rewrites the .raw
files per launch, so a by-hand replay uses stale inputs.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant