Per-layer staged-image engine for the eleven translated denoiser Swin layers: descriptors (verified offsets per phase), cut recipes that splice an isolated native phase into NVIDIA's PTX, wave templates, epilogue notes and GPU probes. The isolated phase-8 splice produces a byte-identical final 4K frame but is slower than the translated body (14.90 vs 6.59 ms) - a correctness experiment, not a shipping improvement; docs/rr-engine.md records the design and the next direct-converter win (B operands built from transposed accumulators).
320 lines
14 KiB
Python
Executable File
320 lines
14 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Mirror-cut splice for cuda_dldn_engine_swin_enc3_kernel.
|
|
|
|
`recipe()` is what kernels/tex/make_ptx.py calls for this layer (see its
|
|
`kernel_spec`): it returns the recipe dict that replaces NVIDIA's 936 mma of
|
|
48 phases with one `call.uni` into kernels/rr/rrswin_enc3.hip. Without
|
|
D4R_RRSWIN_DEBUG in the environment it returns the cut; with
|
|
D4R_RRSWIN_DEBUG=w<n> it returns make_ptx.py's `debug` recipe instead, which
|
|
keeps NVIDIA's whole body and stores phase n's accumulator registers -- that is
|
|
the oracle kernels/rr/rrswin_enc3.hip is checked against.
|
|
|
|
Layer facts (kernels/rr/rrswin_enc3_epilogues.md, `python3 kernels/rr/rr_layer_spec.py enc3`):
|
|
|
|
* grid (21, 13, 1) x block (32, 1, 4): four waves per block, `%tid.z` in 0..3.
|
|
The waves are not redundant -- each reads its own 24576-byte slice of the
|
|
weight image (s9765 `mul.lo.s32 %r21780, %r12, 12288` -> `mul.wide.u32
|
|
%rd406, %r21780, 2`), so the oracle's per-block debug slots must be
|
|
wave-distinct (see debug_block).
|
|
* parameter block 144 bytes; the weight image (arg040) is 315732 bytes.
|
|
* the shared arena `_ZZ...smem` is 12800 bytes. The mma region rewrites it
|
|
(E10 stages P11..P14's A operand, E46 stages P47's), and E48 -- which stays
|
|
in PTX -- reads *other waves'* E48(a) rows out of it, so the body may use
|
|
the whole array as scratch: E48 writes every word it later reads.
|
|
|
|
Cut ABI. The mma region is statements s6124-s16860, at file lines 9812-41980
|
|
of 0016-PREPASS_ENTRYPOINT_NAME.ptx; an mma occupies four physical lines, so
|
|
`cut_call` spans 9812..(41980+4). Both numbers are re-derived from the module
|
|
below rather than transcribed, so they cannot drift.
|
|
|
|
Registers crossing the cut *in*: nothing. The 24 staged A fragments are read
|
|
into registers at lines 9771-9794, immediately before the first mma, and the
|
|
body is free to re-read them out of the arena at `smem + 16*laneid + 512*i`
|
|
(the arena still holds them: its next writer is E10, s10491). The B operands
|
|
are loaded from the weight image inside the region and the C seeds are either
|
|
zero or a previous phase's accumulator, so both are recomputable. That keeps
|
|
the parameter list at the three pointers every layer shares.
|
|
|
|
Registers crossing the cut *out*: eight, four of them phase 48's last two
|
|
accumulator pairs (%r23106/%r23107 and %r23116/%r23117, the D registers of P48's
|
|
mma 6 and 7, stored by E48 at s41999/42004/42018/42021). The other four are
|
|
not accumulators and rr_layer_spec.py's "returns" line misses them because it
|
|
only looks at mma destinations:
|
|
|
|
* %r22182, %r22183 -- the (ex, ey) extents, re-loaded from `param_0+0` inside
|
|
E46 (s16487) and read by E48's bounds tests (s42050, s42052, s42263...). The
|
|
splice re-loads them instead of the body returning them.
|
|
* %r23356 -- `%tid.z << 4`, built inside E47 (s41894) and read by E48(a)
|
|
(s41993). The splice re-derives it from `%tid.z`.
|
|
* %r23368 -- the shared arena's base address (s41891), read by E48(a) at
|
|
s41998 and s42017. The splice re-materialises the symbol.
|
|
|
|
The four accumulators cannot travel as `call.uni` return values (ZLUDA lowers
|
|
those to the function's LLVM return type -- llvm_zluda/ptx/src/pass/llvm/
|
|
emit.rs, emit_call: one value, or an get_or_create_struct_type struct for
|
|
several, distributed with LLVMBuildExtractValue; there is no .param-space object
|
|
behind them, so an N x i32 C++ struct return lowers to an sret pointer, which
|
|
is a different LLVM signature and will not link). They go through shared
|
|
memory instead, in the last 512 bytes of the arena, which E48 never touches
|
|
(its own window ends at word 397, byte 1588).
|
|
"""
|
|
|
|
import os
|
|
import re
|
|
from pathlib import Path
|
|
|
|
FILE = "0016-PREPASS_ENTRYPOINT_NAME.ptx"
|
|
ENTRY = "cuda_dldn_engine_swin_enc3_kernel"
|
|
SMEM = "_ZZ33cuda_dldn_engine_swin_enc3_kernel33DldnEngineSwinEncParamsStructBaseE4smem"
|
|
SMEM_BYTES = 12800
|
|
|
|
CALLEE = "d4r_swin_enc3"
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# The four accumulators that cross the cut back, and the three shared pointers.
|
|
#
|
|
# An mma statement is four physical lines (`mma.sync... {d},`, the A, the B and
|
|
# the C operand lines, the last ending in `;`). D2R has REG_EXT_REGS mapping
|
|
# register name -> (phase, chain index, half) for every mma destination; the
|
|
# entries below are P48's chains 2 and 3, i.e. the last two of its eight mma.
|
|
RET_REGS = ["%r23106", "%r23107", "%r23116", "%r23117"]
|
|
RET_SLOT = SMEM_BYTES - 4 * len(RET_REGS) * 32 # the last 512 bytes: 4 b32 per lane
|
|
|
|
EXTERN = f""".extern .func {CALLEE}
|
|
(
|
|
\t.param .b64 d4r_w, .param .b64 d4r_v, .param .b32 d4r_s
|
|
)
|
|
;
|
|
"""
|
|
|
|
|
|
def module_text():
|
|
"""The corpus module make_ptx.py will splice. D4R_DLSS_PTX_DIR is what
|
|
kernels/build.sh hands both of them (RR_PTX_DIR)."""
|
|
d = Path(os.environ.get("D4R_DLSS_PTX_DIR", Path.home() / ".cache/d4r-rr-corpus"))
|
|
return (d / FILE).read_text()
|
|
|
|
|
|
def statements(lines):
|
|
"""(line number, text) of every statement of the entry body, in the same
|
|
linearisation rr_layer_spec.py documents: a line that is neither blank nor
|
|
a bare `//` comment starts a statement, and an `mma.sync` -- this entry's
|
|
only multi-line statement, four physical lines -- is joined into one
|
|
statement numbered at its first line."""
|
|
out = []
|
|
i = 0
|
|
while i < len(lines):
|
|
ln = lines[i]
|
|
if not ln.strip() or ln.strip() == "//":
|
|
i += 1
|
|
continue
|
|
if ln.startswith("mma.sync"):
|
|
start = i
|
|
while not lines[i].rstrip().endswith(";"):
|
|
i += 1
|
|
out.append((start + 1, " ".join(p.strip() for p in lines[start:i + 1])))
|
|
i += 1
|
|
continue
|
|
out.append((i + 1, ln))
|
|
i += 1
|
|
return out
|
|
|
|
|
|
RE_MMA = re.compile(r"^mma\.sync\.aligned\.m16n8k32\.row\.col\.f16\.e4m3\.e4m3\.f16 "
|
|
r"\{([^}]*)\}, \{([^}]*)\}, \{([^}]*)\}, \{([^}]*)\};?$")
|
|
|
|
|
|
def phases(lines):
|
|
"""[(first line, last mma line, [(D regs, C regs)])] over the entry, as
|
|
physical lines of the module file.
|
|
|
|
Two mma belong to the same phase iff no other statement separates them --
|
|
NVIDIA emits one contiguous mma block per GEMM, so the gaps between those
|
|
blocks are exactly the epilogues."""
|
|
stmts = statements(lines)
|
|
idx = [i for i, (_, t) in enumerate(stmts) if RE_MMA.match(t)]
|
|
out = []
|
|
for j, i in enumerate(idx):
|
|
if j == 0 or i != idx[j - 1] + 1:
|
|
out.append([stmts[i][0], stmts[i][0], []])
|
|
m = RE_MMA.match(stmts[i][1])
|
|
out[-1][1] = stmts[i][0]
|
|
out[-1][2].append((tuple(r.strip() for r in m.group(1).split(",")),
|
|
tuple(r.strip() for r in m.group(4).split(","))))
|
|
return out
|
|
|
|
|
|
def accumulators(mmas):
|
|
"""The D pairs a phase's accumulators end up in: the last mma of every
|
|
accumulator chain, in chain order.
|
|
|
|
A banded phase (P10, P15..P45, P47, P48) chains several mma into one output
|
|
tile, so its intermediate D registers are overwritten by the next k-step and
|
|
no native body reproduces them -- only the surviving accumulators are
|
|
comparable. The chain partition is rr_layer_spec.chains_of's: an mma joins
|
|
the chain that produced one of its C registers, otherwise it starts one."""
|
|
owner = {}
|
|
chains = []
|
|
for dregs, cregs in mmas:
|
|
hit = {owner[r] for r in cregs if r in owner}
|
|
ci = hit.pop() if len(hit) == 1 else (chains.append([]) or len(chains) - 1)
|
|
chains[ci].append(dregs)
|
|
for r in dregs:
|
|
owner[r] = ci
|
|
return [c[-1] for c in chains]
|
|
|
|
|
|
def cut_range(lines):
|
|
"""(first mma's line, last mma's line + 4): make_ptx.py splices
|
|
`lines[:before-1] + call + lines[after-1:]`, so the region removed is the
|
|
first mma's line through the last mma's fourth (statement-terminating)
|
|
line, and `lines[after-2]` must end in `;`."""
|
|
ph = phases(lines)
|
|
assert len(ph) == 48, f"enc3: {len(ph)} phases, expected 48"
|
|
return ph[0][0], ph[-1][1] + 4
|
|
|
|
|
|
def entry_lines(lines):
|
|
"""Every line of the module up to the entry's closing brace. The slice
|
|
starts at line 0 so the line numbers phases() derives are the module's own
|
|
physical lines, which is what make_ptx.py's cut_call and debug recipes
|
|
index. A `}` alone on a line also closes the epilogues' `{ cvt ... ;`
|
|
blocks, so the extent is found by brace depth from the entry's opening
|
|
brace, exactly as make_ptx.py does."""
|
|
start = next(i for i, ln in enumerate(lines) if ln.startswith(f".visible .entry {ENTRY}("))
|
|
b0 = next(i for i in range(start + 1, len(lines)) if lines[i].strip() == "{")
|
|
depth = 0
|
|
for i in range(b0, len(lines)):
|
|
depth += lines[i].count("{") - lines[i].count("}")
|
|
if i > b0 and depth == 0:
|
|
return lines[:i]
|
|
raise SystemExit("enc3: entry closing brace not found")
|
|
|
|
|
|
def call():
|
|
"""The splice: hand the callee the weight image, the parameter block and the
|
|
arena's base address; read the four accumulators back out of the arena and
|
|
re-materialise the four values E48 reads that were computed inside the
|
|
region."""
|
|
gets = "\n".join(f"\tld.shared.b32 {r}, [%rswin2+{4 * i}];" for i, r in enumerate(RET_REGS))
|
|
return f"""{{
|
|
.reg .b32 %rswin<3>;
|
|
.reg .b64 %rswinp<2>;
|
|
.param .b64 pw;
|
|
.param .b64 pv;
|
|
.param .b32 ps;
|
|
ld.param.u64 %rswinp0, [{ENTRY}_param_0+40];
|
|
mov.b64 %rswinp1, {ENTRY}_param_0;
|
|
mov.u32 %rswin0, {SMEM};
|
|
add.s32 %rswin1, %rswin0, {RET_SLOT};
|
|
st.param.b64 [pw+0], %rswinp0;
|
|
st.param.b64 [pv+0], %rswinp1;
|
|
st.param.b32 [ps+0], %rswin0;
|
|
\tcall.uni {CALLEE}, (pw, pv, ps);
|
|
mad.lo.s32 %rswin2, %laneid, {4 * len(RET_REGS)}, %rswin1;
|
|
{gets}
|
|
// E48 reads these four inside the cut and cannot recompute them.
|
|
mov.b64 %rswinp0, {ENTRY}_param_0;
|
|
ld.param.v2.u32 {{%r22182, %r22183}}, [%rswinp0+0];
|
|
mov.u32 %rswin2, %tid.z;
|
|
shl.b32 %r23356, %rswin2, 4;
|
|
mov.u32 %r23368, {SMEM};
|
|
}}"""
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# debug oracle: keep NVIDIA's body, store one phase's accumulators in the plane
|
|
# arena past everything the kernel writes.
|
|
|
|
DEBUG_BASE = 4194304 # bytes into arg048 past everything the kernel writes
|
|
|
|
|
|
def debug_base():
|
|
"""Byte offset into the plane arena (arg048) where a stage's payload lands.
|
|
|
|
NOT the shared 46,000,000: enc3's arena starts at byte 37,683,200 of a
|
|
65,945,600-byte allocation, so only 28,262,400 bytes exist and 46,000,000 is
|
|
past the end of it. Measured: with the base at 524,288 the payload window
|
|
comes back byte-identical to the plain translated run -- something later in
|
|
the frame rewrites arena bytes 524,288..2,473,984, so a stage store there is
|
|
destroyed before the dump is taken. 4,194,304 is above that window, and
|
|
4 MiB + 64 * 16384 = 5,242,880 stays inside the arena. The output buffer
|
|
(param+56, arena+1,884,160) is below the base, so the surface comparison is
|
|
unaffected. kernels/rr/rrswin_enc3.hip's debug_store uses the same default
|
|
and honours D4R_RRSWIN_DEBUG_BASE."""
|
|
return int(os.environ.get("D4R_RRSWIN_DEBUG_BASE", DEBUG_BASE))
|
|
|
|
|
|
DEBUG_SLOT = 8192 # the per-block slot floor (32 lanes x 64 registers x 4 bytes)
|
|
GRID_X = 21 # grid (21, 13, 1)
|
|
WAVES = 4 # block (32, 1, 4)
|
|
|
|
|
|
def debug_slot(nregs):
|
|
"""Per-block slot for a stage of NREGS registers: 32 lanes x NREGS x 4 bytes, at least
|
|
DEBUG_SLOT, rounded up to a power of two so the splice's `shl` can form block * slot."""
|
|
want = max(DEBUG_SLOT, 32 * nregs * 4)
|
|
return 1 << (want - 1).bit_length()
|
|
|
|
|
|
def debug_block(regs):
|
|
"""PTX that stores REGS (u32 each) to plane arena + DEBUG_BASE + block*slot + lane*len*4.
|
|
|
|
This layer runs four waves per block and each reads a different slice of the
|
|
weight image, so the slot index carries `%tid.z` too: block =
|
|
(ctaid.x + 21*ctaid.y)*4 + tid.z, and only the first 64 of those are stored.
|
|
kernels/rr/rrswin_enc3.hip's debug_store computes the same index.
|
|
"""
|
|
slot = debug_slot(len(regs))
|
|
base = debug_base()
|
|
stores = "\n".join(
|
|
f" @%pdbg1 st.global.v4.u32 [%rddbg3+{i * 16}], {{{', '.join(regs[i * 4:i * 4 + 4])}}};"
|
|
for i in range((len(regs) + 3) // 4))
|
|
return f"""// d4r debug stage
|
|
{{
|
|
.reg .pred %pdbg<2>;
|
|
.reg .b32 %rdbg<8>;
|
|
.reg .b64 %rddbg<4>;
|
|
ld.param.u64 %rddbg1, [{ENTRY}_param_0+48];
|
|
mov.u32 %rdbg1, %ctaid.x;
|
|
mov.u32 %rdbg2, %ctaid.y;
|
|
mad.lo.s32 %rdbg3, %rdbg2, {GRID_X}, %rdbg1;
|
|
mov.u32 %rdbg7, %tid.z;
|
|
mad.lo.s32 %rdbg3, %rdbg3, {WAVES}, %rdbg7;
|
|
setp.lt.u32 %pdbg1, %rdbg3, 64;
|
|
shl.b32 %rdbg4, %rdbg3, {slot.bit_length() - 1};
|
|
mov.u32 %rdbg5, %laneid;
|
|
mad.lo.s32 %rdbg6, %rdbg5, {len(regs) * 4}, %rdbg4;
|
|
add.s32 %rdbg7, %rdbg6, {base};
|
|
cvt.u64.u32 %rddbg2, %rdbg7;
|
|
add.s64 %rddbg3, %rddbg1, %rddbg2;
|
|
{stores}
|
|
}}
|
|
"""
|
|
|
|
|
|
def debug_stages(lines):
|
|
"""w<n> -> (line to insert the store after, registers to store), for n = 1..48.
|
|
|
|
The insert point is the fourth line of the phase's last mma -- the line the
|
|
`;` is on. The registers are the phase's surviving accumulators (see
|
|
accumulators()), which for a single-k-step phase is every mma's D pair in
|
|
mma order: m-tile major, n-tile minor, the (row g, row g+8) pair each mma
|
|
writes. kernels/rr/rrswin_enc3.hip emits the same order."""
|
|
out = {}
|
|
for n, (first, last, mmas) in enumerate(phases(lines), 1):
|
|
out[f"w{n}"] = (last + 4, [r for pair in accumulators(mmas) for r in pair])
|
|
return out
|
|
|
|
|
|
def recipe():
|
|
lines = entry_lines(module_text().split("\n"))
|
|
stage = os.environ.get("D4R_RRSWIN_DEBUG", "")
|
|
if stage:
|
|
stages = debug_stages(lines)
|
|
if stage not in stages:
|
|
raise SystemExit(f"swin_enc3_recipe: no debug stage {stage!r} "
|
|
f"(have w1..w{max(int(k[1:]) for k in stages)})")
|
|
return dict(file=FILE, debug=stages[stage], debug_block=debug_block)
|
|
first, after = cut_range(lines)
|
|
return dict(file=FILE, cut_call=(first, after), extern=EXTERN, call=call()) |