Files
TheRock/rocm-systems/emulation/rocjitsu/docs/dbt-design.md
T
derek.holloway 7502bf64a4
CodeQL / Analyze (actions) (push) Canceled after 0s
CodeQL / Analyze (python) (push) Canceled after 0s
pre-commit / pre-commit (push) Canceled after 0s
Multi-Arch CI / setup (push) Canceled after 0s
Publish build_manylinux_x86_64 images / publish_build_manylinux_x86_64 (push) Canceled after 0s
Setup (Multi-Arch) / setup (push) Canceled after 0s
Multi-Arch CI / Linux::${{ fromJSON(needs.setup.outputs.linux_build_config || '{}').build_variant_label || 'skip' }} (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Publish no_rocm_image_ubi10 image / publish_no_rocm_image_ubi10 (push) Canceled after 0s
Multi-Arch CI / Windows::${{ fromJSON(needs.setup.outputs.windows_build_config || '{}').build_variant_label || 'skip' }} (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Multi-Arch CI / Manifest Diff (push) Canceled after 0s
TheRock Manifest Diff Report / Generate Manifest Diff Report (push) Canceled after 0s
Multi-Arch CI / CI Summary (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04 (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04_ocl_rt images / publish_no_rocm_image_ubuntu24_04_ocl_rt (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04_rocgdb (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04_rocgdb images / publish_no_rocm_image_ubuntu24_04_rocgdb (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04_ocl_rt (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Unit Tests / Unit Tests :: ubuntu-24.04 (push) Canceled after 0s
Unit Tests / Unit Tests :: windows-2022 (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04_media (push) Canceled after 0s
Unit Tests / Unit Tests Summary (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04_openmpi (push) Canceled after 0s
Flatten subprojects into a single base monorepo
2026-09-02 16:34:47 -07:00

22 KiB

DBT Design

Rocjitsu's dynamic binary translator (DBT) turns an AMDGPU code object for a guest ISA into a loadable code object for a host ISA.

The Dynamic Binary Translation (DBT) system translates AMDGPU code objects compiled for one ISA to execute on a different ISA. The initial target pairs are CDNA4 (GFX950) → RDNA4 (GFX1200/1201) and CDNA4 (GFX950) → CDNA3 (GFX942), but the architecture is designed to support any directional ISA pair.

The central design choice is:

Translate one kernel scope at a time, then relocate that scope as a unit.

DBT does not edit .text as one flat stream. A code object can contain several kernels, shared helpers, PC-relative control flow, and more than one runtime variant of a kernel. Treating each kernel as a relocation unit makes those relationships explicit.

The Short Version

 guest ELF
    |
    v
+---------------------+
| Read kernel          |  kernel descriptors identify roots and ABI state
| descriptors          |
+---------------------+
    |
    v
+---------------------+
| Build source CFG     |  direct edges + recovered static indirect edges
+---------------------+
    |
    v
+---------------------+
| Form kernel scopes   |  reachable blocks for each entry and variant
+---------------------+
    |
    v
+---------------------+
| Translate each scope |  semantic expansion, LDS lowering, re-encoding
| into a local buffer  |  and resource accounting
+---------------------+
    |
    v
+---------------------+
| Place and relocate   |  entry stubs and control-flow PC builders
+---------------------+
    |
    v
+---------------------+
| Commit the ELF       |  descriptors, .text, sidecars, metadata, e_flags
+---------------------+
    |
    v
 host ELF

Runtime host configuration

DBT guest mode keeps the synthetic guest identity separate from the target that runs translated code. DbtGuestConfig describes the advertised guest ISA and device, while its DbtHostConfig describes the host ISA, topology GPU ID, and execution backend. The backend is an enum with two modes:

  • hardware forwards execution-facing operations to a real host GPU.
  • simulator delegates host KFD execution to SimulatedKfd and runs the translated code on a RocJITsu VM.

A simulator-backed DBT config supports two layouts. When simulator_config is set, it names an external host simulator config; relative paths are resolved beside the DBT guest config, and that external file overrides any simulator VM/topology in the DBT guest file. This is the normal composition form and lets guest_gfx950_on_simulated_gfx942.json reuse the golden gfx942_cdna3_kmd.json config. When simulator_config is omitted, the DBT guest block and simulator VM/topology are read from the same file. The self-contained form is useful for tests and generated temporary configs.

Both layouts use the same translation and validation path. Before discovery, the runtime rejects guest execution limits that the selected simulator device cannot provide, including LDS size, scratch slots, waves per SIMD, and wavefront size.


There are three different address spaces to keep straight:

source .text offsets  ->  kernel-local buffer offsets  ->  final .text offsets

DBT records mappings between these spaces instead of assuming that every instruction moves by one constant delta.

A Small Relocation Example

Suppose a source kernel has three blocks:

source .text

0x100  block A: conditional branch to block C
0x104  block B: guest_op
       ...
0x120  block C: s_endpgm

On the host, guest_op needs a longer instruction sequence. The translated layout might look like this (offsets are illustrative):

kernel-local buffer

0x00   block A: [branch patch window]
0x08   block B: host_op_1
                 host_op_2
                 host_op_3
                 ...
0x34   block C: s_endpgm

The source branch targeted 0x120; it must now target local offset 0x34 and, after final placement, the corresponding final .text address. DBT therefore records the branch while emitting block A and patches it only after every block has a known location.

This example captures the reason for most of the design:

  • instruction sizes can change;
  • fallthrough should remain adjacent;
  • explicit branches must be relocated; and
  • descriptors must point at the final entry, not the temporary buffer.

Input and Output Shape

A source code object is approximately:

AMDGPU ELF
  ELF header
    e_machine = EM_AMDGPU
    e_flags   = guest machine id + AMDGPU feature bits

  LOAD segment
    .text
      kernel bodies and helpers

    descriptor storage
      kernel_a.kd  ----+
      kernel_b.kd  ----+----> entries in .text

An AMDHSA dispatch packet's kernel_object is the loaded address of a kernel descriptor. The entry PC is computed from that descriptor and its signed KERNEL_CODE_ENTRY_BYTE_OFFSET:

entry_pc = descriptor_address + kernel_code_entry_byte_offset

This is why moving code and moving descriptors cannot be handled independently.

The translated object is approximately:

Translated AMDGPU ELF
  ELF header
    e_flags = host machine id + preserved upper feature bits

  LOAD segment
    .text
      [kernel_a entry stub, when needed]
      [kernel_a translated body]
      [kernel_a sidecar entry stub and body, when needed]
      [kernel_b entry stub, when needed]
      [kernel_b translated body]
      ...

    existing descriptor storage
      kernel_a.kd  ----> translated kernel_a entry
      kernel_b.kd  ----> translated kernel_b entry

    loaded descriptor tail, when sidecars exist
      kernel_a virtual-LDS descriptor  ----> sidecar entry

  non-ALLOC sections
    .rocjitsu.lds   runtime sidecar metadata, when needed

Existing application-visible descriptors are reused and patched. Sidecar descriptors are loaded data appended after the translated text storage; they are not exported as ordinary application-visible kernel symbols.

Translation Phases

1. Build Descriptor Plans

Kernel descriptor symbols identify the source kernel entries. For each descriptor, KernelDescriptorTranslator builds a KdTranslation plan that records:

  • the source descriptor and entry locations;
  • target wave and register resource fields;
  • LDS, private-memory, and kernarg sizes;
  • descriptor-controlled initial SGPR state;
  • any target entry prologue; and
  • whether this is a normal or sidecar variant.

Descriptor translation happens early because CFG construction needs the kernel roots. Some final resource values are intentionally deferred until instruction lowering has chosen its scratch registers.

Wave-size changes are not emulated by the instruction stream. If the guest wave size cannot be preserved on the host, descriptor translation reports the kernel as unsupported.

2. Build the Source CFG

BasicBlock::build decodes source text and creates block leaders at:

  • the first decoded instruction;
  • every descriptor entry;
  • every firmware preload entry;
  • every direct branch or call target;
  • every recovered indirect target; and
  • the instruction after a terminator, when more text follows.

It then adds ordinary CFG edges for branches and fallthrough, and separate call edges for validated calls.

Why Calls Have Their Own Edge Type

A call has two relevant destinations:

             +------------+
call site -->| callee body |
     |       +------------+
     |
     +------> continuation after the callee returns

The callee is included in the kernel scope, but a return through s_setpc_b64 is valid only for the continuation and saved return-SGPR pair of that call. Modeling calls separately avoids connecting every callee return to every possible continuation.

Direct s_call_b64 and recovered s_swappc_b64 transfers become call edges only when DBT can validate a matching return. Otherwise, a recovered concrete target is treated conservatively as ordinary control flow.

Static Indirect Branch Recovery

Indirect recovery recognizes common sequences shaped like:

s_getpc_b64  s[target:target+1]
... scalar arithmetic adjusts the PC ...
s_setpc_b64  s[target:target+1]       ; jump

or:

s_getpc_b64  s[target:target+1]
... scalar arithmetic adjusts the PC ...
s_swappc_b64 s[return:return+1], s[target:target+1]  ; call

The analysis first builds a temporary direct CFG. It gathers local PC-builder facts, propagates them across simple block boundaries with bounded dataflow, and accepts only small concrete target sets.

Unknown writes, conflicting builders, or saturated target sets are not guessed. If an unrecovered indirect transfer is reachable in a relocated kernel, normal translation fails closed.

3. Form Kernel Scopes

A kernel scope contains the source blocks reachable from one descriptor entry. For a kernarg-preload kernel, the firmware entry at entry + 256 is an additional root of the same scope.

Scope traversal follows:

  • branch and fallthrough successors; and
  • validated call edges into callees.

It stops at another kernel's hardware entry. Shared helpers are therefore duplicated when multiple kernels reach them:

source CFG                    translated .text

kernel A --+                 [kernel A body]
           +--> helper         [copy of helper]
kernel B --+                 [kernel B body]
                               [copy of helper]

Duplication is deliberate. Every branch, call, and return in a translated body can resolve through that body's own source-to-target map.

Normal and sidecar variants are separate scopes even when they start from the same source entry. They can have different descriptors, prologues, LDS lowering, scratch requirements, and final entries.

4. Translate One Scope

Blocks are emitted into a kernel-local byte buffer in source-address order. That preserves ordinary fallthrough without adding branches between adjacent blocks.

For an ordinary instruction, the decision order is:

source instruction
       |
       v
handwritten semantic EXPAND rule matched?
       | yes                         | no
       v                             v
emit replacement sequence     virtual-LDS lowering matched?
                                     | yes                 | no
                                     v                     v
                              emit LDS replacement    legalization says EXPAND?
                                                           | yes        | no
                                                           v            v
                                                          fail     generated
                                                                   re-encoding

Control-flow instructions take a related but separate path: DBT emits or reserves a relocation window and records a fixup instead of trusting the source PC-relative value.

Semantic Equivalence and Generated Translation

The ISA XML describes structure—encodings, operands, opcodes, and fields—not execution semantics. DBT's equivalence contract is:

  • the same mnemonic on two ISAs has the same semantics; and
  • a different mnemonic is equivalent only when it is explicitly registered as an alias or reviewed rename. An XML AliasedInstructionNames entry is valid evidence for adding such an alias to legalization.

Structural similarity alone never proves semantic equivalence.

The generator uses that equivalence information and the structural XML to choose a legalization action:

  • Identity: the target form is encoding-compatible;
  • Substitute: the encoding is compatible but the opcode changes;
  • Lower: the semantic equivalent has a different encoding layout and needs generated decode/re-encode work; or
  • Expand: no single generated target encoding is sufficient.

Identity, Substitute, and Lower flow through the generated encoding translator. An Expand entry must be handled by a handwritten semantic rule; otherwise DBT rejects the kernel.

Semantic rules may emit several instructions and explicit waits. They request scratch registers, private spill storage, or descriptor growth through TranslationContext. Virtual-LDS mode is also visible to the rules so special operations such as load-to-LDS and transposed LDS reads can use pair-specific lowerings.

There is no global instruction scheduler or optimization pass. Each source instruction is handled when the append-only cursor reaches it.

5. Place the Body and Repair Control Flow

After local emission, append_relocated_kernel_text chooses the body's final position in output .text. It:

  • places the descriptor-visible entry at the source entry's residue modulo 256;
  • emits descriptor-visible entry stubs when required;
  • rebases block placements and fixups into final .text coordinates; and
  • records the final descriptor entry and relocated body entry separately.

Only then does DBT repair control flow.

Direct Branches

A direct branch gets a patch window chosen from its source distance and the resources available to the kernel. DBT does not reserve the largest possible window for every branch because that would significantly bloat large kernels.

The final repair choices are:

target in SOPP range
  -> patch the target ISA branch immediate

target out of range + long window + descriptor-backed SGPR pair
  -> build target PC in the scratch pair, then setpc/swappc

eligible branch target out of range + no long-branch SGPR pair
  -> route through skipped branch-island pools inserted in the body

supported out-of-range conditional branch
  -> invert the condition, skip over an unconditional long transfer

Far source branches reserve a long window when a legal scratch pair is available. Kernels that cannot reserve such a pair receive skipped island pools during emission. Direct calls cannot use branch islands; they need an encodable direct form or a reserved long form. A late range increase still fails if neither the reserved window nor an eligible island chain can represent it.

Unused words in a branch window are filled with target NOPs.

Recovered Indirect Transfers

For one effective recovered target, DBT reserves a fixed transfer window at the setpc or swappc consumer. Final repair emits either:

  • a direct branch or call when the target is close; or
  • a canonical get-PC, add-delta, set-PC/swap-PC sequence using the original target SGPR pair.

If one dynamic consumer has multiple recovered targets, one direct window cannot preserve it. DBT keeps the indirect consumer and rewrites each recovered source-side PC-builder range to compute the relocated target instead.

6. Feed Resources Back into the Descriptor

Semantic expansion can discover requirements that were unknown during the initial descriptor pass:

initial descriptor resources
          |
          v
  instruction lowering
     |       |       |
     v       v       v
   VGPRs   SGPRs   private spill bytes
     \       |       /
      +------v------+
      recompute this variant's descriptor

Scratch allocation prefers a liveness-dead VGPR window. Using a register beyond the original allocation requests descriptor growth. Selected semantic rules can instead borrow an allowed live VGPR window and save/restore it through per-lane private memory.

Transient spill slots are reusable between independent instruction expansions. Persistent slots are separate and hold values that must survive across multiple translated sequences, such as a spilled virtual-LDS base pointer.

SGPR allocation is stricter. DBT can use descriptor-backed growth or a liveness-proven dead pair for specific rules, but it has no general SGPR spill stack.

After body emission and branch repair, DBT recomputes the affected descriptor if the required VGPR count, SGPR count, or private segment size grew. The recomputed entry prologue must be byte-for-byte identical to the one already emitted. A change would require a second layout pass, so the current implementation rejects it.

7. Commit the Code Object

Translation is built on a private CodeObjectPatcher copy. The completed plan is committed only after all kernel scopes succeed (or are replaced by allowed skip stubs).

The commit step:

  1. Pads translated .text to at least its original size.
  2. Applies normal descriptor resource and entry patches.
  3. Replaces .text.
  4. Appends loaded sidecar descriptors, when present.
  5. Appends .rocjitsu.lds as non-ALLOC metadata, when present.
  6. Replaces only the low AMDGPU machine-id bits in e_flags.

If .text or the descriptor tail grows, the ELF patcher also updates affected:

  • section file offsets and allocated virtual addresses;
  • LOAD segment offsets, sizes, and virtual addresses;
  • symbol values;
  • relocation offsets; and
  • descriptor entry offsets when descriptor storage itself moves.

Inserted file padding preserves the required p_offset % p_align == p_vaddr % p_align congruence for LOAD segments. Upper AMDGPU feature bits in e_flags, such as XNACK and SRAMECC state, are preserved.

Any commit failure discards the private patcher copy and returns the original code object with diagnostics.

Entry Stubs and Kernarg Preload

Most kernels have one hardware-visible entry. If descriptor translation needs a prologue, DBT places a small stub before the relocated body:

descriptor entry
  [target ABI prologue]
  s_branch relocated body

relocated body
  [translated source entry block]
  ...

A nonzero kernarg-preload specification creates two hardware-visible entries exactly 256 bytes apart:

target entry
  [prologue]
  s_branch translated source entry

target entry + 256
  [same prologue]
  s_branch translated block for source entry + 256

translated body
  [blocks reachable from either source entry]

Both source addresses are CFG leaders and scope roots. The normal stub must fit without overlapping the entry + 256 slot, and both stub branches must reach their body entries.

The descriptor's preload range can extend beyond its declared kernarg_size. DBT preserves the larger of kernarg_size and the exact declared preload extent. Any alignment needed for DBT-owned wrapper fields is padding after that preserved prefix; DBT does not read undeclared source bytes merely to align it.

Kernarg Extension

Some translated variants need DBT-owned dispatch data. DBT preserves guest kernarg offsets by placing a byte-for-byte guest prefix at the start of a wrapper:

kernarg wrapper

  offset 0
  +----------------------------------+
  | preserved guest kernarg bytes    |  guest offsets remain unchanged
  +----------------------------------+
  | padding to 8-byte alignment      |
  +----------------------------------+
  | original kernarg pointer (u64)   |
  +----------------------------------+
  | aligned DBT payload 0            |
  +----------------------------------+
  | aligned DBT payload 1 ...        |
  +----------------------------------+

The entry prologue reads DBT payloads from the wrapper, then restores the guest-visible kernarg pointer before entering translated guest code when the source ABI supplied that pointer.

If the source descriptor did not request a kernarg pointer SGPR, a sidecar may add a target-only pair when the 16 initialized user-SGPR limit permits it. The prologue then repairs any source-visible initialized SGPRs whose target positions shifted.

Sidecars and Virtual LDS

A sidecar is an additional implementation of a kernel, selected by the rocjitsu runtime rather than by normal application symbol lookup.

application-visible kernel object
             |
             v
      normal descriptor ----> normal translated body
             |
             | .rocjitsu.lds maps normal to sidecar
             v
      sidecar descriptor ---> virtual-LDS translated body

The metadata stores ELF virtual addresses. After loading, the HSA hook derives the image load base from the normal kernel object and resolves the loaded sidecar descriptor address.

Why Virtualize LDS?

CDNA4 can request more LDS for a workgroup than a CDNA3 host can provide. DBT therefore creates a virtual-LDS sidecar when the static requirement exceeds the host limit or the scope may use LDS and could exceed the limit because of the dispatch-time group-segment request.

The normal body keeps hardware LDS. The sidecar:

  • advertises zero fixed hardware LDS;
  • uses a global-memory allocation as per-workgroup LDS storage; and
  • receives the allocation description through a kernarg wrapper.

At dispatch time, the hook uses:

requested_lds = max(descriptor_static_lds, packet.group_segment_size)

The AQL group_segment_size is the total group allocation request, not an extra amount to add to the descriptor's static value.

requested_lds <= host limit
  -> keep normal kernel object and packet

requested_lds > host limit
  -> allocate GPU-visible backing storage
  -> build a kernarg wrapper and VirtualLdsDispatchState
  -> switch kernel_object to the sidecar descriptor
  -> set packet group_segment_size to zero
  -> use the sidecar private_segment_size

The 24-byte runtime payload is:

VirtualLdsDispatchState
  uint64 backing_base
  uint32 stride_x
  uint32 stride_y
  uint32 stride_z
  uint32 reserved

The sidecar entry prologue computes a private slice for the current workgroup:

virtual_lds_base = backing_base
                 + workgroup_id_x * stride_x
                 + workgroup_id_y * stride_y
                 + workgroup_id_z * stride_z

A singleton grid dimension gets a zero runtime stride. A dimension with more than one workgroup requires the corresponding descriptor-initialized workgroup ID SGPR.

Every sidecar instruction that accesses LDS storage must be translated away from hardware LDS. Pair-specific semantic rules handle special cases such as MUBUF load-to-LDS and transposed reads; the virtual-LDS layer handles supported ordinary DS loads and stores. Cross-lane DS operations that do not access LDS storage may remain native. An unsupported storage operation rejects the sidecar instead of silently using hardware LDS with a zero-LDS descriptor.

Lowered accesses preserve address/data aliasing with temporaries or private spills and emit conservative waits where the source LDS completion behavior requires them.

DBT prefers a permanent descriptor-backed SGPR pair for virtual_lds_base. If that is impossible, spill-per-use mode saves the pointer in persistent private memory and borrows/restores an SGPR pair around each lowered access.