Home · ASPLOS 2023 · Compilers & Programming Languages

NNSmith: Generating Diverse and Valid Test Cases for Deep Learning Compilers

Jiawei Liu, Jinkun Lin, Fabian Ruffy, Cheng Tan, Jinyang Li, Aurojit Panda and 1 other

Conference
ASPLOS 2023
Published
2023
Pages
530-543
Citations
83

To find bugs in deep-learning compilers, NNSmith grows random neural networks that are guaranteed valid by a constraint solver, then tunes their weights by gradient descent until no NaN or infinity can blur the verdict — and catches the compiler red-handed by comparison.

The argument in one glance

  1. Problem

    Deep-learning compilers can silently change what a model computes

    TVM, TensorRT, ONNXRuntime and friends rewrite neural networks through dozens of optimization passes. A bug can crash the compiler — or worse, produce a compiled model whose outputs quietly differ from the original, corrupting whatever application sits downstream.

  2. What people did

    Fuzz it: generate random models, compare compiled results against a reference

    Differential testing is the classic recipe for compilers: feed random programs to the system under test and to a trusted reference, and flag disagreements. For deep learning, the random programs are computation graphs of tensor operators.

  3. Why it fell short

    Random graphs are invalid, and random numbers poison the verdict

    Connecting arbitrary operators usually breaks tensor-shape rules, so compilers reject the test before testing anything; prior fuzzers dodged this by restricting operators or patching shapes with slice/pad nodes — which silences exactly the bugs diversity would find. And with random weights, 57% of 20-node models produce NaN or infinity somewhere, making outputs incomparable: up to 41% of prior tools’ reports were false alarms.

  4. This paper's idea

    Solve for validity, descend to numerical safety

    NNSmith grows graphs one operator at a time, using lightweight per-operator specifications and an SMT solver so every generated model is valid by construction — no diversity-killing patches. Then a gradient-guided search tunes weights and inputs until no operator anywhere produces NaN or infinity. The result: 72 new bugs in four major systems, 58 confirmed, 51 fixed.

Why this problem is worth solving

A deep-learning compiler takes a trained model — represented as a computation graph whose nodes are tensor operators like convolution, matrix multiplication and reshape, and whose edges carry tensors of specific shapes and element types — and rewrites it into a faster executable. TVM alone applies up to 61 graph-level passes (constant folding, operator fusion) and 58 low-level ones (loop tiling, arithmetic simplification). These compilers now sit in front of interactive and safety-critical deployments.

Compiler bugs come in two flavors, and the second is the frightening one. A crash is loud and diagnosable. A miscompilation is silent: the compiled model runs happily and returns numbers that are simply not what the input model computes. Since neural network outputs are approximate by nature, nobody eyeballing predictions will notice a subtly wrong optimization pass — the error just becomes part of the application’s behavior.

The established weapon against compiler bugs is fuzzing plus differential testing: generate random programs, run them through the system under test and through a trusted reference, and compare. Decades of this (Csmith and its descendants) hardened C compilers. Doing the same for deep-learning compilers stumbles on two problems specific to the domain, and NNSmith’s contribution is solving both.

What people did before, and where it broke

The validity–diversity tension. A computation graph must “type check”: each operator constrains its input shapes and attributes (matrix multiplication needs inner dimensions to agree; reshape needs element counts to match). Random wiring violates these rules, and the compiler rejects the graph before any optimization pass runs — a wasted test. Prior fuzzers bought validity by sacrificing diversity. LEMON mutates existing pre-trained models but only with shape-preserving single-input operators — and running real models makes it up to 103× slower per test. GraphFuzzer connects operators freely but “fixes” shape mismatches by inserting slice and pad nodes. The paper shows what that costs with a real example: a Conv2d output of shape (1,2,1,48) added to a constant of shape (1,1,48) — a broadcast — triggers a layout-analysis crash in TVM. Insert a slice to align the shapes, and the bug vanishes. The patch that guarantees validity is precisely what hides the bug.

The NaN problem. Test verdicts come from comparing outputs. But floating-point arithmetic has exceptional values — NaN (“not a number”, from things like the square root of a negative) and infinity (from overflow) — and once one appears anywhere in a graph, comparisons stop meaning anything: semantically equivalent implementations may legitimately disagree on how exceptional values propagate. With PyTorch’s default random initialization, 56.8% of 20-node models hit NaN or infinity somewhere. Prior tools ignored this, and up to 41% of their bug reports could be false alarms — while real bugs also hide behind the noise: the paper shows a genuine TVM semantic bug masked because an infinity swallowed the evidence. Subtler still: the poison can be internal — an ArgMax can output normal values from NaN inputs — so checking only the final output for NaNs is not enough.

The NNSmith pipeline

  1. Operator specs new

    Per-operator: allowed input types, validity constraints, and a type-transfer rule. 59 of 73 fit in four lines of code.

  2. Graph generation new

    Insert one random operator at a time, forward or backward; an SMT solver checks constraints incrementally, so every graph is valid by construction.

  3. Attribute binning new

    Extra randomized range constraints stop the solver from always choosing boundary values like all-ones shapes.

  4. Gradient value search new

    Tune weights and inputs by descending per-operator loss functions until no NaN or infinity appears anywhere.

  5. Differential testing

    Compile and run; compare against PyTorch as oracle, re-checking disagreements against the compiler’s own unoptimized mode.

Validity comes from constraint solving, comparability from gradient search — each attacks one of the two failures that break naive fuzzing.

The idea

Specifications: teach the fuzzer the type rules, cheaply

For each operator, the user writes a small specification with three parts: the accepted input/output tensor types (data type and rank), a requires function returning validity constraints as logical predicates (kernel size positive, kernel fits within padded input, …), and a type_transfer function computing output shapes from input shapes symbolically. Shapes are symbolic integers — variables, not numbers — so the same specification describes every possible size at once. The engineering cost is deliberately tiny: 59 of NNSmith’s 73 specifications are at most four lines; even Conv2d, the worst, needs nine inequalities and seven lines. Written once, the specs serve every compiler that accepts ONNX models.

NNSmith builds each model incrementally, starting from a placeholder node and repeatedly inserting a randomly chosen operator, in one of two directions. Forward insertion attaches the new operator downstream of existing outputs; backward insertion replaces a placeholder, growing the graph upstream — together they produce multi-input, multi-output topologies like real multi-modal models. Each insertion point is first filtered by a cheap type-matching heuristic (don’t ask the solver about a boolean edge feeding a float operator), then the accumulated constraints — the new operator’s requirements joined with everything already in the graph — go to an SMT solver (Z3), a program that decides whether a set of logical constraints has a solution and produces one. Constraints are cached and solved incrementally, so each insertion pays only for what changed.

One subtlety earns its own mechanism: solvers are lazy and return boundary values — ask for shapes ≥ 1 and every dimension comes back exactly 1, trivial models that miss bugs (the paper’s Conv2d-broadcast crash needs a non-trivial constant shape to fire). Attribute binning adds randomized range constraints, choosing for each attribute an exponentially sized bucket (like AFL’s hit-count buckets) and sampling a sub-range from it — with a retry-and-drop scheme if the extra constraints make the system unsatisfiable. Binning alone yields 2.07× more unique operator instances and roughly doubles unique branch coverage.

Numerical validity: descend until nothing explodes

Rather than rewriting vulnerable operators to be safe (which changes the graphs being tested — the diversity mistake again), NNSmith fixes the numbers. Each vulnerable operator’s safe domain is written as one or two tensor inequalities — Sqrt needs X ≥ 0; Pow(X, Y) is under-approximated as X > 0 and Y·log(X) ≤ 40 — and each inequality converts mechanically into a loss function that is positive exactly when violated. The search runs the model, finds the first operator (in topological order) producing NaN or infinity, and takes a gradient step on that operator’s loss with respect to all weights and inputs, using the Adam optimizer; where gradients are zero or undefined (ReLU’s flat side, Floor’s steps), hand-chosen proxy derivatives keep the descent moving. Iterate until the whole graph runs clean.

The economics are decisive: the search succeeds for 98% of generated models in 3.5 milliseconds on average — 4.2% of the 83 milliseconds it takes to generate a 10-node model — and beats random sampling by growing margins (1.16–1.34×) as models get bigger.

The verdict: differential testing with a careful oracle

The compiled model runs on the searched inputs, and outputs are compared against PyTorch — whose results the search already computed for free, and whose interpreter is the best-tested implementation available. When PyTorch and the compiler disagree, one more check assigns blame: recompile at optimization level zero. If the unoptimized compiler agrees with PyTorch, the optimization passes are guilty.

One fuzzing round that catches a real TVM bug

Shapes are written (batch, channels, height, width). SMT = satisfiability solver deciding symbolic constraints. The triggering pattern — a Conv2d output broadcast-added to a constant — is the paper's real Listing-1 crash bug; the growth sequence is illustrative.

Step 1 of 6

Seed

The graph begins as a single placeholder — a stand-in node with one output of unknown symbolic shape. Nothing is concrete yet; all decisions live in the constraint system.

graph: [placeholder p0]   constraints: {}

Why it works

Because validity is encoded as solver constraints rather than achieved by restricting operators or patching shapes, the generator explores the full combinatorial space of operator connections — including the broadcasts, multi-input joins and odd attribute values where transformation bugs live. → Because specifications are symbolic, one four-line spec covers every shape an operator can take, and the solver — not hand-written enumeration — finds concrete instantiations. → Because binning forces the solver off its boundary-value habits, attribute diversity comes for free on top of topological diversity. → Because gradient search makes 98% of models numerically clean before any comparison, disagreements between compiler and reference are actual evidence — not floating-point noise — cutting both false alarms and masked bugs. → Therefore each generated test is simultaneously acceptable to the compiler, unusual enough to reach rarely exercised code, and judgeable against a reference — and 72 real bugs followed.

What it costs

  • Someone writes the specifications. The framework and meta-types make each one cheap, but coverage of an operator library is still a human effort, and new compilers with exotic operators need new specs.
  • Constraint solving isn’t free. Generation runs slower per test than shape-patching fuzzers; NNSmith wins on coverage per test, not tests per second.
  • The oracle is imperfect. PyTorch is trusted but not infallible — one of the 72 bugs surfaced ambiguity in PyTorch’s own converter, which is why disagreements get re-checked against the compiler’s unoptimized mode.
  • Vulnerable-operator domains are approximations. Safe domains are under-approximated by simple inequalities; models with genuinely no valid inputs make the search fail (the main failure mode within its 2%).
  • Small models by design. Default graphs of 10–20 nodes keep fuzzing throughput high; bugs that need very deep or very large models to manifest are outside this net.

The evaluation

Four systems under test — TVM, ONNXRuntime, TensorRT, and PyTorch (via its ONNX path) — over roughly seven months of fuzzing, plus controlled 4-hour coverage runs against the state-of-the-art generators LEMON and GraphFuzzer and the TVM-specific fuzzer Tzer.

NNSmith against prior deep-learning compiler fuzzers

PropertyLEMONGraphFuzzerNNSmith
How validity is achieved
Branch coverage (4-hour runs)lowest of the threesecond best
Numerical validity of testsnot addressed
Bugs foundsame theoretical ceiling analysis applies
Coverage: source-level branch coverage, 4-hour budget, ONNXRuntime v1.12 and TVM v0.8; TensorRT excluded from coverage as closed-source. Bug counts over ~7 months on rolling latest versions; graphs of ~10 nodes by default.Underlined values explain themselves when clicked.

Check your understanding

  1. 1. GraphFuzzer guarantees graph validity by inserting slice and pad nodes wherever tensor shapes mismatch. Why does this undermine bug finding?

  2. 2. Why does a NaN produced deep inside a test model invalidate the whole differential test, even when the final outputs look like ordinary numbers?

  3. 3. Predict: NNSmith is run with attribute binning disabled, letting the SMT solver choose any satisfying values. What do the generated models look like, and what happens to bug finding?

  4. 4. When NNSmith’s compiled model disagrees with PyTorch, it recompiles the model with the compiler’s optimizations turned off (O0) before filing a bug. What does this extra step establish?

Related papers on Compilers & Programming Languages

See all 162 →