← 返回日报
精读 预计 9 分钟

Reverse Engineering the Qualcomm NPU Compiler

摘要

文章讲作者针对 Qualcomm 的 NPU 编译器(QAIRT SDK,libHtpPrepare.so)进行逆向分析,试图理解模型在 Hexagon NPU 上的部署与内存调度逻辑。核心关注点是 VTCM(片上 scratch memory)与 DDR 之间的数据搬移成本,以及编译器如何决定是否发生 spill/fill。 作者观察到不同 SoC 在模型(如 Qwen 0.8B)上 DDR 访问差异巨大,但编译输出中 VTCM 容量字段却表现异常一致,同时引入 spillFillBufferSize 作为是否完全片上放置的信号。 在调度层面,编译器使用 Priority BFS + 拓扑排序启发式来控制计算顺序,以压缩 “活跃 tensor 集合” 的峰值大小;在更精细的路径中,则通过递归回溯的 3D tile 放置算法进行 VTCM 分配。 更关键的是,作者发现部分路径并非纯启发式,而是将 VTCM 规划建模为 MILP 问题,并交由 HiGHS 求解,其优化目标主要是最小化 DDR 读写与跨核传输的总字节数。 此外,编译器可能在 placement 阶段自动进行精度变换(float32/FP16/BF16),以缓解内存压力,这意味着数值精度可能被 “隐式降级”。 在性能估计方面,系统内置名为 Hextimate 的分析模拟器,用于在真实硬件执行前估算 compute 与 memory cost,并显式建模 contention、roofline 等因素,从而支持调度决策与 spill 判断。 整体结论是:该 NPU 编译器的行为更接近 “以字节流动成本为目标函数的优化器”,而非纯规则启发式系统,同时存在可用于判断模型是否溢出的工程级信号(如 spillFillBufferSize)。

荐读理由

正文摘录指出编译后二进制携带 spillFillBufferSize 元数据字段,值为0时模型权重可完全驻留在VTCM上,任何人可据此静态判断针对SM8350芯片是否需要量化,避免盲目尝试

原文

My work is to maximise the usage of NPUs to make edge deployment faster for whatever models we want to run on them. But NPU documentation on the web is basically nonexistent, and the little that's out there was so disappointing that at one point I thought of quitting - so I reverse engineered the compiler instead. I previously wrote a small primer on NPUs: what, and where they break, it should be enough to understand for what's coming next.

For none of the SoC's did Qualcomm publish the memory capacity of VTCMs. How am I supposed to understand whether my tensors were spilling all over? Or whether quantisation is really needed? Add to that my curiousty to know how they simulate the working of a model even before it runs on the actual hardware, and which optimization algorithms are involved.

I (equipped with Claude Code) doubled down on the *.so files of QNPU SDK (v2.46.0.260424), and banked on the unmangled names that survived stripping, the raw machine code decompiled with Ghidra, and some empirical parameter sweeping on my Linux.

Some of the novel findings are (no one has the attention span to read the entire writeup anyway):

  1. HTP solves VTCM placement as an MILP and solves it using HiGHS (optimization solver rather than heuristics) which was completely unknown publicly

  2. VTCM placement uses a recursive backtracking allocator operating in a 3D coordinate space

  3. The compiler can automatically alter weight precision (without you knowing) during placement to relieve memory pressure

  4. The effective fit/spill boundary depends on the target architecture even when different SoCs report the same vtcmSize

  5. HTP contains a hidden analytical simulator called Hextimate from where we recovered roofline equations and contention models

The appendix contains more information, which I could not fit in my content body.

That's the gist, rest of this piece will cover three things I found, which I think were never publicly documented anywhere on the internet, and will benefit anyone willing to do edge deployment on Qualcomm NPUs.

The memory wall

The Hexagon chip has a small pool of on chip scratch memory called VTCM (vector tightly coupled memory). On the other side we have the DDR which is the main memory, but it's slow. The significant bottleneck for ML inference is caused by how much it takes to move your data around. The entire job of the compiler in this case is to decide what gets to sit on the VTCM at each moment, because anything that doesn't fit has to be taken out to DDR and fetched back later, which is expensive and energy inefficient.

Every tensor in your model has a lifetime. At any instant during the execution, some set of tensors is alive (can be termed as working set). If that fits in VTCM, then everything will be fast. If not, then the compiler will start inserting spill operations (pushing a tensor out to DDR) and fill operatoins (pull it back). This is the cliff I wanted find out.

Using the same model (Qwen 0.8B), on an SM8350 the compiler reported spilling 5.46 MB and filling 33.9 MB, with total DDR of 37.9 MB, whereas on an SM8650 (V75), nothing spilled out and the DDR read 1.15 MB. 33x time jump in DDR read traffic from nothing but the target chip (which is expected). But for some peculiar reason, the chips reported the same VTCM size in the compiled output - a field that just says 4. Now I don't know 4 "what", or whether it's some code. The behaviour is completely different anyway. I didn't recover the actual capacities, that's something I wish to do next.

The compiled binary carries a metadata field called spillFillBufferSize, which when 0 indicates that the model weights fit entirely on chip. It can help anyone determine quickly to find the causal relation for their inference being slow.

Now I can confidently spend my time on quantising my model if my target chip is an SM8350, instead of second guessing it.

One more thing decides whether you fit, and things get a little more exciting there.

The scheduler playing tetris with time

The order in which the chip runs operations decides how long each tensor stays alive, which decides how big the working set gets, which decides whether you hit the cliff. Hence the task of the compiler's scheduler is to find the correct order which will keep the working set smallest and within the limits of VTCM.

It does this with something called "Priority BFS". It walks the graph, and measures the peak VTCM that order would require, and then peak_tcm <= tcm_capacity-s it. It returns SMALL/LARGE after this, indicating no spill/spill respectively. The fill/spill decision is therefore the outcome of whether the best order it found keeps the peak working set under capacity. Then the ordering metric underneath is an op's position in a depth first topological sort of the graph, which has a nice property of keeping a value's producer and consumer as close together in time as possible. When two ops are tied, that tie is broken with a stack of heuristics, some of them being graph distance between an op's inputs and outputs, depth within a branch, scheduling the heaviest branch first etc.

Once the order is fixed, the compiler has to physically place each tensor on the VTCM. It sorts tensors with the longest lived one first, and eventually the short lived ones next, to cut down fragmentation. The placement is not a simple 1D address by the way, the code assigns each block a three dimensional coordinate (d0, d1, d2) in the tile space, and the packer (which is recursive) backtracks. The exact retry condition lived in a few kilobytes of inlined SIMD which I didn't try to decode, yet.

That backtracking packer is the fallback path. When the compiler runs its full optimizer instead, placement takes a more striking form, which is a formal optimization problem I had seen before elsewhere.

It's pretty evident by now that placing tensors in VTCM is the same kind of problem as packing suitcases into a small trunk, where we are allowed to leave some suitcases at home but it costs us to fetch them later. When it can, the compiler doesn't wing this with heuristics, but instead it writes the whole thing out as a formal Mixed Integer Linear Program, and hands it over to HiGHS which is a well known open source optimization package.

The compiler dumps the exact problem it's handling into a standard solver file format .mps for debugging. I found some trivial things from the decompiled binary after that:

  1. The goal it minimizes is total moved (sum of bytes spilled to DDR, filled back from there, and on multi core chips sent and received between cores)

  2. Two tensors that are alive at the same time can't occupy the same on chip bytes

  3. Either a tensor lives on the workbench at some valid address, or it's banished to DDR. It's semi continuous.

Your model's numeric precision can be rewritten without you being informed. There are operations called relaxed_precision_cast that convert tensors between float, FP16 and BF16 during placement, in order to relieve memory pressure. Hence now we know that the compiler can insert a downgrade on its own, our float32 tensor can become a FP16 if that's how the bytes fit. (I confirmed these casts are inserted during placement; whether they're a variable inside the solver itself or a separate pass, I can't say from what I pulled.)

There's a lot more in the optimizer than I can fit here. Some of them are;

  1. Graph are partitioned with a min-cut to decide where to cut the model for spilling

  2. A separate, neat little algorithm makes concatenation free in the common case by having producers write directly into the final layout instead of copying

  3. Convolutions are turned into matrix multiplies via im2col

  4. Duplicate operations are found by checksumming their contents and merged

Hexagon + estimate = Hextimate (??)

All of this - the scheduler hunting for the smallest working set, the optimizer minimizing bytes moved rests on one thing the compiler can't actually have. To choose between two schedules, or to decide a spill is worth avoiding, it has to know how expensive each option is, in time. But the chip isn't there. The compiler is running on my Linux box, placing thousands of bets against hardware that won't exist until the model ships to a phone. So how does it price anything?

It guesses, and it has a whole simulator built just for guessing. It's called Hextimate (Hexagon estimate, right?) and the compiler is full of references to it. It's a small analytical model of the entire chip which pretends to run your model and adds up the cost piece by piece, in two buckets - compute and memory movement.

It also models contention (what happens when two parts of the chip want the same resource and one has to wait?) and it doesn't return a single number but a range. It happens to run the workload once assuming everything overlaps perfectly, once assuming nothing does, and reports both ends. This should qualify it as a simulator rather than a lookup table interface.

Most of Hextimate's shape I pieced together from the names of its internal pieces. But for the memory side I got the actual formula, straight out of the machine code (thanks Sonnet 4.6), and it's the textbook roofline model every performance engineer already knows:

bandwidth = channels * width * efficiency * frequency
time = bytes/bandwidth

The simulator keeps a separate cost for certain things instead of lumping them together, which are think are pretty trivial. Although, I could read the names of these separate cost factors, the actual values stored in them I was not able to extract.

  1. Integer and floating point compute are priced separately (trivial)

  2. Writing one piece of data to many places at once (multicast) is priced apart from writing it one at a time (also trivial)

  3. KV cache tensors and weights get their own "fast DDR" factor.

It also carries dedicated detectors that recognizes FlashAttention, MoEs, KVCache, rotary embeddings etc.

Conclusion

So what?

The key takeaways from this RE attempt, which I think are useful for everyone who are into edge ML:

  1. It tells the custom scheduler authors that placement quality is solver bounded, and not bounded by heuristics, and that the objective is pure byte traffic.

  2. A custom scheduler can mirror the beliefs of Hextimate to predict the compiler's decisions

  3. The cost model is biased toward LLM workloads at the coefficient level (hence recognizable things KV/weight tensors are a big plus)

  4. Precision is a knob the solver will pull whether you asked or not, hence quality can differ in actual hardware

  5. spillFillBufferSize can be used a static fit/spill oracle

Whether it directly helps anyone, or simply quenches anyone's curiousity is a separate question in itself, but I got the answers I was unable to find everywhere. If you've done edge work on Hexagon and want to compare notes - or tell me where I'm wrong - I'd like that.

Notes

The question is how much to trust it. The file (libHtpPrepare.so) was our sole target (x86_64 host build, QAIRT 2.46.0.260424, BuildID 63e60947ee8df89fe11592a8af12a30ddedb91cd and sha1 8048df5fe605d743a20337a6968aebc6f1930f4b). My previous RE attempts were easy/medium crackmes, not proprietary compilers, and this one was only possible with the help of Claude Code. Hence read every claim with that grain of salt. I will wait till I figure out all the legalities before publishing the steps I perfomed to get the insights.

  1. The only finds which I could not place anywhere in NPU related literature are the ones stated at the very beginning of this writeup. "Not publicly documented in this SDK version" is a claim I can stand behind.

  2. This is one SDK version of one binary. The numbers (the 33x DDR jump, the 5.46/33.9 MB spill/fill, the spillFillBufferSize verdicts) are reproducible on my box with Qualcomm's own tools as the oracle, but a different QAIRT release could reshuffle any of it.

  3. The binary is littered with names like dp_tcm_threshold_selector, TCM_THRESH_75_predictor, special_mlp_features, it really looks like as if there's a trained model in there choosing when to spill. There isn't, at least not in this binary. I could not find any weight matrix, no forward pass, nothing that smells like inference. special_mlp_features turned out to be a function that exports features to Python i.e. training data going out. The cost path is a plain CSV lookup table. So the learned part, if it exists, lives upstream at Qualcomm's training time and ships as a table. I could not prove a weight bearing model runs at compile time, and I'd bet against it

Lobsters · 1 赞 · 0 评 讨论 → 阅读原文 →

这条对你有帮助吗?