Repository navigation
RKLLM Runtime Internals
Reverse-engineering recon of Rockchip's closed librkllmrt.so, done 2026-06-12 to assess whether the EAGLE-3 3-layer hidden-state problem (see Eagle-3 Debugging Session) is solvable by patching the runtime. Recon only — no patching done.
librkllmrt.so is a stripped, modified llama.cpp + ggml with a proprietary NPU ggml-backend. This is the single most important fact for any RE work: the runtime is not a black box — its graph structure, KV cache, sampling, and embeddings/output machinery are open-source llama.cpp; only the NPU backend (the matmul offload to /dev/rknpu) is Rockchip-proprietary.
Evidence (string scan of librkllmrt-aarch64-v1.2.3.so, 7.5 MB):
- Build banner:
RKLLM SDK (version: 1.2.3 | target: Linux | build: 77daf2e2 2025-11-22 17:09:29) - llama.cpp internals (mangled symbols in RTTI/asserts):
llama_build_graph,llama_build_graph_defrag/_k_shift/_s_copy,llama_context,llama_batch,llama_tensor_quantize_internal,SchemaConverter(grammar),nlohmann::json. - ggml:
ggml_object,ggml_rope_back,ggml_backend_buffer_*,ggml_cpu_has_sve,GGML_MEM_ALIGN,cplan->work_size. - NPU backend:
/dev/rknpu,/%04d-npu_matmul-,config size_e: %d, NPU not support!,D rkllm: malloc input_embed_data mem error,banks num invalid, input_bank_num/weight_bank_num. - llama.cpp CLI arg strings are linked in (from
common):--model-draft,--gpu-layers-draft,--threads-draft,--embeddings,--embd-normalize,--multiline-input. (Their presence shows the llama.cpp speculative and embeddings code is compiled in; it is not wired to the publicrkllm_*API.)
- ELF aarch64, stripped —
nmshows 0 internal symbols, only 27 exported (allrkllm_*). - Exported API:
rkllm_init,rkllm_run,rkllm_run_async,rkllm_abort,rkllm_destroy,rkllm_clear_kv_cache,rkllm_get_kv_cache_size,rkllm_load_prompt_cache,rkllm_release_prompt_cache,rkllm_load_lora,rkllm_set_chat_template,rkllm_set_cross_attn_params,rkllm_set_function_tools,rkllm_createDefaultParam,rkllm_get_timings,rkllm_print_timings,rkllm_print_memorys,rkllm_accuracy_analysis,rkllm_is_running(+ a few more). -
GET_LAST_HIDDEN_LAYERmaps onto llama.cpp's embeddings output path (lctx.embd_size,n_outputs,output_ids,batch.embd,backend_embd,corrupt output buffer (n_outputs=%d)). I.e. it returns the final-layer hidden states via the same mechanismllama_get_embeddingsuses.
Feasible in principle, hard in practice — but the open-source base changes the calculus.
- The per-layer residual stream in
llama_build_graphis a sequence of ggml tensors. SinceGET_LAST_HIDDEN_LAYERalready returns the last one as CPU-readable floats, intermediate layers are also ggml tensors that flow back from the NPU backend — i.e. capturable, not NPU-opaque. - The clean way to expose 3 layers is the way upstream llama.cpp/EAGLE does it: mark layers L_low/L_mid/L_high outputs as graph outputs. But the binary is stripped, so you can't just call
llama_build_graph; you'd either (a) hook the ggml NPU-backend compute entry and snapshot the residual at the target layer indices, or (b) match the stripped code against the exact llama.cpp base revision to locate the graph-build and output-selection logic. - The NPU backend (the matmul offload) is the only proprietary piece; everything around it is known. A hook at the ggml-backend boundary is the most tractable instrumentation point.
-
Legal:
librkllmrt.sois Apache-2.0 (modification permitted); a patched variant would be a separate artifact, not the unmodified one oRKLLM ships.
Caveats that still apply even with the 3 layers:
- The
GET_LOGITSverify path segfaults withkeep_history(ggml attention-mask assert) — a separate runtime blocker, see the debugging session. - Per-version fragility: any of this is tied to build
77daf2e2/1.2.3 and would need redoing per runtime release.
From a deterministic ARM64 ADRP+ADD reference scan (r2's batch xref analysis couldn't resolve these; a ~40-line Python scanner did — decode every adrp + following add #imm and match the composed target):
| Address | What | How found |
|---|---|---|
0x2937d4 |
ggml_abort — the single assert/abort handler every GGML_ASSERT calls |
738 sites do adrp/add x3=<expr>; adrp/add x2=<"GGML_ASSERT(%s) failed" @0x5c76a8>; adrp/add x0=<file>; mov w1=<line>; bl 0x2937d4
|
0x1cae6c |
llama_output_reserve assert site ((n_outputs_prev+n_outputs_new)*n_embd <= lctx.embd_size) — hidden-state/logits output-buffer sizing |
xref to assert string 0x5db218
|
0x1ba7c0, 0x1bab04
|
decode output-copy (corrupt output buffer (n_outputs=%d) @ 0x5d9a90) — moves graph results into the output buffer |
xref to 0x5d9a90
|
Anchor strings: 0x5c76a8 GGML_ASSERT(%s) failed; 0x5db218 the embd_size assert; 0x5d9a90 corrupt-output.
-
Multi-layer hidden export — NOT a constant flip. The 3 aux layers are never written to the output buffer, so there's nothing in
llama_output_reserve/the copy to redirect. They'd have to be added as graph outputs inllama_build_graph(code insertion → detour/code-cave) + the buffer resized + copied. Deep surgery into stripped optimized ARM64; multi-week, version-fragile, and high-risk for the shared runtime. Not done. -
Verify-crash assert — the only single-site lever is global and unsafe. All asserts funnel through
ggml_abort@0x2937d4; NOP-ing it toret(0xd65f03c0) would bypass everyGGML_ASSERTduring normal inference too (clean aborts → silent corruption), and wouldn't fix the underlyingmask->ne[0]==a->ne[0]dimension mismatch. Bypassing a single specific site needs locating that assert's conditional branch (one of 738) and is still risky. Not applied to the live runtime.
Verify crash — FIXED by a 2-instruction binary patch (2026-06-12, empirically tested). The mask->ne[0]==a->ne[0] assert string is at 0x5e8508; its abort tail is at 0x2b56c8 (sibling mask->ne[1]>=a->ne[1] at 0x2b56b8), inside ggml_soft_max_ext's shape validation. The guarding conditional branches are:
-
0x2b55a8: b.ne 0x2b56c8(54000901) — fires thene[0]assert -
0x2b55b8: b.lt 0x2b56b8(5400080b) — fires thene[1]assert
NOP-ing both (d503201f, little-endian 1f 20 03 d5) on a copy of the .so and testing on-board:
- The EAGLE verify (
GET_LOGITS, token-input,keep_history=true) that previously segfaulted now returns valid logits[151936 × 8]— no crash, no OOB. -
Normal generation is unaffected (
"The capital of France is **Paris**.") — expected, since the asserts pass on the normal path so the NOPs are no-ops there.
This corrects an earlier (wrong) prediction in this wiki that bypassing the mask assert would yield garbage/OOB: empirically the soft_max handles the size difference fine — the assert was stricter than the compute requires for this code path. So the verify crash is fixable with a surgical, safe 2-NOP patch (only those two asserts; all others intact). The patch is reproducible: NOP the two branches above. It does not by itself make Eagle-3 accelerate — see blocker #1 below.
Conclusion (status of the two binary-patchable blockers):
-
Blocker #2 — verify
keep_historycrash: FIXED by the surgical 2-NOP patch above (tested: valid logits, normal gen unaffected). Safe to deploy. ✅ -
Blocker #1 — draft quality (the 3 aux layers): still requires deep work. The 3 layers are never graph outputs, so exposing them needs code insertion into
llama_build_graph(no constant to flip) to capture the raw residual at L_low/mid/high (truncating the layer loop only yieldsoutput_norm(h_L), not the raw residual the EAGLE head'sfcwas trained on). That's the genuine multi-week RE piece (interactive Ghidra + hardware debugger + matched llama.cpp base revision). -
Global
ggml_abortNOP (0x2937d4) — rejected as unsafe for the shared runtime (the surgical 2-NOP above is the correct approach).
So: the verify crash is solved by a binary patch; Eagle-3 acceleration is still gated on the draft producing accepted tokens, which needs either the multi-week graph-output insertion or — the lower-risk route — training a single-last-layer draft head that fits RKLLM's native 1-layer GET_LAST_HIDDEN_LAYER output (no runtime modification). The verify patch is necessary infrastructure for either.
Compared the hidden-state/embeddings machinery across v1.1.0 → v1.2.0 → v1.2.3 (all runtimes are on the board). The embeddings/output path is the same standard llama.cpp code in every version (lctx.embd_size, n_outputs, corrupt output buffer, single-layer); no version exposes multi-layer/aux hidden states or eagle support. So:
- There is no older/newer runtime that returns the 3 layers — single-layer is fundamental.
- Version-diffing (a good technique) here yields a useful negative: it rules out a version shortcut and confirms the hidden-state code is the stable upstream llama.cpp path, so it can be matched against open-source llama.cpp to understand it. To do function-level diffing for locating code, you'd need Ghidra BinDiff/Diaphora (raw byte diff of stripped 5 MB binaries is too noisy — every address shifts).
On core dumps: useful for confirming the residual-buffer layout, but they don't sidestep the hard part — llama.cpp reuses one ggml residual buffer and overwrites it each layer, so the 3 intermediate layers are never simultaneously resident. Capturing them requires hooking the per-layer compute (in-loop snapshot), not reading a static address.
Located the graph-builder layer loops via xrefs to the "l_out" graph-node name (0x5deb38): 0x233bac, 0x234648, 0x235058, 0x235714, 0x236270, 0x23726c (one per model-arch builder; the Qwen3 one is among them). Disassembling the first builder's loop tail maps the registers needed for a multi-layer-capture patch:
0x233ba0 mov x24, x0 ; x24 = cur (this layer's output residual tensor*)
0x233ba8 ldr x1, [x19,#0xf8]; x1 = cb std::function object (the build callback)
0x233bac add x0, x0, #0xb38 ; x0 = "l_out" name string
0x233bb4 stp x24, x0, [sp,#0x120]
0x233bd8 blr x4 ; cb(cur, "l_out", il) — per-layer callback
0x233bd4 add x20, x20, #1 ; x20 = il (layer index)
0x233be0 ldr x0, [x19,#0x38]; x0 = n_layer (loop bound)
0x233be8 b.le 0x233d58 ; exit when n_layer <= il
This is the clean injection target. Each layer's raw residual cur (x24) and index il (x20) pass through a callback here — exactly where llama.cpp's EAGLE/aux-hidden capture attaches. The remaining notes about eval_callback: the runtime does not link ggml_backend_sched_set_eval_callback (only alloc_splits/split_graph), so the post-compute per-node callback route isn't available — the build-time cb above is the hook.
What a working multi-layer patch still requires (the multi-week part):
- A code cave / detour at
0x233bd8(or in thecbbody) that, foril ∈ {low,mid,high}, callsggml_set_output(cur)so those residuals survive compute (needggml_set_output's address; the NPU backend must then sync them to host). - Patch the hidden-state extraction (
llama_output_reserve0x1cae6c+ the copy0x1ba7c0) to gather the 3 marked tensors into the result buffer withembd_size = 3×n_embdinstead of justresult_norm. - Addon side: stop the 3×-replication, consume the real 3-layer concat. Each step needs on-board iteration. This is a genuine multi-week effort with proper tooling (Ghidra + a hardware debugger on a dev board) — the injection point is found, but the cross-function code insertion + NPU-sync behaviour are the substantial, risky remainder.
Mapped how librkllmrt.so drives the NPU, to assess reusing Rockchip's NPU offload with an upstream llama.cpp (which has native EAGLE-3 / hidden-state support).
The NPU is NOT a registered ggml-backend. The ggml-backend registry in the binary only references the CPU backend (ggml_backend_cpu_buffer_type_alloc_buffer, ggml_backend_reg_init_backend_from_str, ggml_backend_registry_count). There is no ggml_backend_rknpu registration. So the NPU matmul is not a clean, swappable backend behind the ggml_backend_i vtable — it's hooked directly into ggml's compute.
The NPU controller is a low-level regcmd/task-submission layer (raw driver programming, not the high-level RKNN API):
- opens
/dev/rknpudirectly (failed to open … need to insmod rknpu driver!,failed to check rknpu hardware version) - builds register command buffers (
failed to read regcmd: must read size…) - submits an NPU task queue:
failed to submit, op id: %d, op name: %s, flags, task start, task number, run task counter, int status— i.e. it programs NPU tasks and polls completion/interrupt status - profiles ops as
/%04d-npu_matmul- - also links an OpenCL path (
clCreateCommandQueue/clReleaseCommandQueue— likely a Mali/GPU fallback for some ops) and uses DRM (/dev/dri,DRM_IOCTL_VERSION)
Feasibility verdict (marry NPU controller → newer llama.cpp): impractical as an extraction; viable only as a from-scratch backend.
- Extracting Rockchip's offload from the stripped monolith and linking it to a new llama.cpp won't work: it's not a self-contained ggml-backend (no clean ABI boundary), it's fused into their specific ggml fork, and it's coupled to the
.rkllmweight format (NPU-quantized weights, "banks"). A newer llama.cpp has a different ggml ABI → incompatible. - The sound version of the idea is writing a fresh
ggml_backend_rknpu(the proper registrable kind) that targets the/dev/rknpuregcmd/task-submission interface, then using it with current llama.cpp (inheriting EAGLE-3, multi-layer hidden output, speculative decoding for free). But the regcmd/task protocol is undocumented, and the NPU matmul kernels + weight-bank/quantization layout are the proprietary hard part. That's a multi-month RE + kernel-authoring project — strictly bigger than the in-place patch, but the only sustainable architecture (you'd stop fighting a frozen llama.cpp fork).
Implication for the multi-layer goal: because the NPU computes matmuls via regcmd submission and writes results back into ggml tensors, intermediate-layer residuals are produced — but whether marking them as graph outputs forces a host sync depends on this custom offload, and must be tested on-board (it's not the standard ggml-backend sync path).
Disassembled builder #1's post-loop tail (0x233d58–0x233e28) — how the final hidden becomes the returned embedding:
0x233d78 bl 0x1b0b64 ; build final norm -> x20 = result tensor
0x233d88 add x1, "result_norm"(0x5deb40)
0x233dac blr x4 ; cb(result, "result_norm", -1)
0x233df8 blr x4 ; cb(_, "result_embd", -1) (0x5deb50)
0x233e08 bl 0x2bf5c4 ; graph-finalize / build_forward_expand(gf, result) ← retains/marks output
0x233e28 ret
So 0x2bf5c4 is the "add this tensor to the compute graph as a retained output" primitive (called once on the final result). The per-layer cb(cur,"l_out",il) callback object is at [x19+0xf8] (loaded repeatedly, e.g. 0x233d80).
Refined multi-layer-capture patch design (for the next session):
- In the layer loop, right after
curis finalized (x24@0x233ba0), foril ∈ {low,mid,high}call0x2bf5c4(gf, cur)so those residuals are computed and retained (instead of overwritten). Need: thegfgraph pointer's location during the loop (TBD — trace what0x2bf5c4takes as args at0x233e08:x0/x1set at0x233e00-0x233e04), and a code cave for the conditional. - Patch the hidden-state extraction (
0x1cae6creserve +0x1ba7c0copy, which key off the"result_norm"tensor0x1c724c) to also emit the 3 retained tensors →embd_size = 3×n_embd. - Confirm the NPU offload syncs the retained intermediate tensors to host (the open risk — it's not the standard ggml-backend sync path; must test on-board).
- Identify which of the 6 builders Qwen3 uses (
l_outsites0x233bac/0x234648/0x235058/0x235714/0x236270/0x23726c) — patch the right one.
This is the concrete worklist; each item is a separate RE+on-board-test increment.
Don't start with core dumps. Start with: (1) identify the llama.cpp base revision the build tracks (diff behaviour/strings against tagged llama.cpp releases near 2025-11), (2) locate the ggml NPU-backend compute function (it reads/writes the residual tensors), (3) prototype a hook (LD_PRELOAD on a ggml-backend symbol if one is dynamically resolved, else a trampoline) that copies the residual at chosen layer indices. This is a multi-week effort and version-fragile; weigh it against training a single-last-layer draft head (works within RKLLM's native 1-layer output — see the debugging page's options) which needs no runtime changes.