Prisma's discrepancy analyzer against Dialyzer

Copy Markdown View Source

How often the discrepancy analyzer (mode => discrepancy, success typing) is right, how it compares with Dialyzer inside the same category of warning, and what that costs in time, CPU and memory. Corpus: RabbitMQ. Harness: bench/.

The gradual (-spec contract) and oxbow checks are a different analyzer with different tools to compare against; they appear here only where they change the cost of the discrepancy analysis (section 6) or explain a number.

1. Summary

Prisma (discrepancy)Dialyzer
Warnings on rabbit (234 modules)5, all true (wrong -specs)0 (40 notes)
Warnings on 7 applications (440 modules)45, all true or defensive0 (4 notes)
Seeded faults detected (125 mutants)125 (4 as warnings, see 3.5)123
New warnings outside the mutated function0201
rabbit, wall / CPU / peak memory1.08 s / 1.25 s / 296 MiB6.7 s / 26.0 s / 405 MiB
440 modules, wall / CPU / peak memory3.0 s / 3.2 s / 536 MiB17.3 s / 67.5 s / 1,457 MiB

Three things to keep in mind when reading it:

  • RabbitMQ runs Dialyzer in its own CI, so zero warnings from Dialyzer is the expected answer. Every warning Prisma gives there is a false positive or something Dialyzer cannot see. The first run gave 51 and 44 of them were false positives; the work below is what took them to zero.
  • The correctness work cost speed. Over the same code the analysis is 55% slower than at the start (0.71 s to 1.08 s, user CPU +60%, memory +1% to +11%), and no feature was added that Dialyzer lacks to explain it: it is the price of looking inside more constructs and of trusting declared -specs (section 6).
  • Everything is one corpus on one machine. A second corpus (Elixir, a different style of Erlang) has not been measured.

2. Method and environment

ParameterSpecification
HardwareApple MacBook Pro (MacBookPro17,1), Apple M1 (8 cores), 16 GB
OSmacOS 27.0.1 (Darwin arm64)
Erlang/OTP29 (erts-17.1.1, JIT)
Benchmark toolhyperfine 2.0.0 with --metrics all, 1 warm-up, 5 runs (3 for the 440 modules)
Corpusrabbitmq-server 5dacb85 (bench/corpus.config.json): rabbit (234 modules) and 7 applications (rabbit, rabbit_common, ra, khepri, cuttlefish, ranch, cowlib: 440 modules)
Prismacommit 464afae, discrepancy mode (prisma check --mode discrepancy --no-oxbow; the timed runs of section 5 went through the API with the same options, before the escript had --mode)
DialyzerOTP 29, --no_check_plt, default warnings, base PLT of erts, kernel, stdlib
xrefundefined calls and unused locals, with the dependencies on the code path

Tools are only compared inside a category. prisma check mixes four kinds of warning (discrepancy, gradual contract, dead code, oxbow) and Dialyzer reports the first plus some dead code, so the full counts of the two are not comparable. bench/adapters.py normalizes each tool's output.

3. Correctness

3.1 What each tool says

CorpusPrisma (discrepancy mode)Dialyzerxref (undefined calls)
rabbit (234 modules)5: 5 dead code0 (40 "callback info not available" notes)0
7 applications (440 modules)45: 10 discrepancy, 35 dead code0 (4 notes)7

At the start (same corpus, same mode) the numbers were 51 and 101. Dead code is a warning in Prisma's levels (certain, but it does not fail when the code runs), so in discrepancy mode these are 0 errors and 5 and 45 warnings; the errors are the discrepancies and the missing functions.

3.2 The false positives, how each was found, and the fix

Every false positive has a minimal reproduction in bench/repro/ and a test.

CauseFound byWarningsFix
[H | T] typed as a proper list when it is used as a pair (?MSG(Index, Header))triage37an improper cell is improper_list(H, T) unless T is known to be a list; tl/hd, patterns and meet follow
orelse / andalso with a non-boolean right operandtriage2only the left operand must be boolean; A andalso B is false | B
overloaded -spec across modules: only the first clause countedtriage4each clause is a whole vector of argument types; a call is accepted if some vector fits
Time == 0 with a float (== compares by value)triage1== is value comparison
X > 0 read as "X is a positive integer" (also true for 0.5 and for any non-number)triage of the 4403> and >= refine only a variable already known to be a number
joining the environments of A orelse B kept a constraint from one branchtriage of the 4401only variables that every branch constrains keep a type
-1 inferred as 1 (unary minus kept the operand's type)PropEr0 on RabbitMQnegate_type/1
case true andalso 0 of ..., case [0 | 0] of [H | T] when is_integer(T) -> ...PropEr0 on RabbitMQsee above
a fun typed as any_fun clashed with a declared fun((A) -> B)Prisma analyzing itself2a fun stays any
{amqp_error, _, _, _} disjoint from #amqp_error{}RabbitMQ after looking inside catchseverala tuple whose tag is the record's name is a value of the record type
unresolved names (t(), other:t(), opaque, type variable) treated as impossiblePrisma analyzing itself—unknown, not impossible
Checksum -> ok; _ -> error read as a catch-all, so error vanishedcontract triage—only patterns without variables, or with only new variables, subtract in a case
any + 1 typed numbercontract triage—an unknown operand gives any
a function that never returns clashed with a callback typecontract triage1none fits any return in a function-type meet
widen/1 collapsed a union of more than 16 literals to atom() (lookup_method_name/1, over a hundred method names)contract triage1a flat union of literals is kept up to 1024 members
a tuple pattern bound its variables from every tuple of the union, not only the matching tagcontract triage1each alternative is bound on its own and the results are joined
meet(binary(), bitstring()) kept the weaker unit, so byte_size(Bin) turned a binary into a bitstringfound after the previous one1the meet of <<_:B, _:_*U>> is the sizes both allow
a map pattern against an unknown type claimed the map had only the pattern's keyscontract triage41it binds a plain map()
a call that the code protects itself: a case on erlang:function_exported/3 or erlang:system_info(otp_release) (decided on the system that runs the analysis: the branch it takes is checked, the others are not), or a try that catches error:undefRabbitMQ with the PPLT of OTP (os:list_env_vars/0, OTP < 24 code)1the call is not checked there; a branch that depends on the running system is not dead code; a try that catches every error is a warning (handled_undef), not an error; branches that depend on the system and are not taken here are counted, not reported

One false positive remains known: rabbit_ff_controller:run_callback/5, whose -spec is overloaded by the value of Command. Its code is right, but the return fits neither overload alone; telling it needs the correlation between the parameter and the spec's clause, which Prisma does not follow.

3.3 The true warnings: wrong specs

Each was read against the source, its callers and its docs. None is a bug in the code; they are -specs that contradict a clause that does run.

WhereWhat is true
rabbit_amqp_util:has_capability/2, clause (_, undefined)its only caller passes a record field that is undefined when the peer omits it; the spec only allows {array, symbol, [...]}
rabbit_fifo_index:append/2, clause (undefined, _)rabbit_fifo_v3:convert_msg/1 builds messages with a dummy index undefined; the spec says integer() (the sibling delete/2 already says integer() | undefined)
rabbit_peer_discovery:select_node_to_join/1, clause ([])when the backend already selected a node and it is down, [] is passed; the spec says nonempty_list(...)
rabbit_definitions:import_parsed/2, import_parsed_with_hashing/2the arity-1 siblings accept a list, the arity-2 specs say a map of lists; the code accepts a list in both
cuttlefish_error:xlate/1 (24 clauses)the spec takes 2-tuples and 24 clauses take tuples of 3 or more; the spec was not widened when the alias errors were added
rabbit_misc:amqp_error/4 calls in rabbit_reader (7) and rabbit_channel (2)the 4th argument must be an amqp_method_name() and the calls pass none, the default of the record field
rabbit_data_coercion:to_list_of_binaries/1, rabbit_date_time:parse_duration/1, cuttlefish_unit:path/2a clause the spec leaves out is reachable (certain, certain, probable)
cow_date:pad_int/1, khepri_sproc:to_standalone_fun/1, rabbit_vhost_limit (2 clauses)unproven, defensive, and dead on purpose (the comments say so)

Dialyzer confirms five of these only with -Woverspecs / -Wunderspecs, buried in 942 spec-difference warnings for rabbit and 2,229 for the 440 modules; its default warnings give none. Prisma reports exactly the cases where a spec contradicts a clause. The lists are not subsets of each other.

3.4 Property-based tests (PropEr)

The oracle is semantic, so no human triage is needed. rebar3 proper, 10 properties, all passing.

  • Type algebra (test/prop_prisma_type.erl): member(Term, Type) decides whether a concrete term belongs to a ground type, generators build terms of a type. Subtyping is reflexive and sound, widening is accepted, join is an upper bound, meet is sound and keeps common values, union normalization keeps members and is idempotent. One limit by design: the type language has no non-empty improper list, so the meet of improper_list(E, T) with a nonempty_list is a safe superset.
  • Inference and warnings against execution (test/prop_prisma_infer.erl): PropEr generates small well-typed modules (bindings, case, integers, floats, booleans, atoms, pairs, lists, [H | T] cells, overloaded specs, calls from a plain body, a fun, a comprehension, a record field and a try), compiles and runs them on 25 samples. The arguments and result must be inside the inferred types, and no discrepancy or dead_code warning may point at a line that ran without crashing: a line that ran refutes a warning that says "this fails". It passes 1,500 programs x 25 runs. Against the sources before each fix it fails (7 to 388 cases), so it does not pass because it went blind.
  • A property is as good as what it generates. Every real finding the generator missed became a production in it: floats and ==, comparisons with 0, guards joined by orelse, record patterns in heads, callers and callees with specs.
  • Self-analysis: a test analyzes Prisma's own beams and requires 0 warnings. It caught three false positives that the generators did not.

3.5 False negatives: mutation testing

bench/mutate.escript injects one fault in the abstract code of a compiled module (keeping every line number); bench/run_mutants.py puts the mutant in a copy of the application's ebin, runs the tools, and counts a fault as detected when the tool gives, inside the mutated function, a warning it did not give for the unmodified directory (keyed by module, line, kind and, for Prisma, the type found). erlc is counted only for warnings that say the code fails.

OperatorFault
r1a function whose -spec returns a simple type returns an atom
r2a call passes an atom where the callee's -spec asks for a simple type
r3a BIF gets an argument of the wrong type (length(foo), byte_size(foo)...)
r4a call to a function that does not exist, in a module of the corpus
r6{Tag, X} = f(...) with a Tag that the -spec of f never returns

Sample: seed 1, up to 25 mutants per operator over ra, khepri, cuttlefish, cowlib and ranch (144 modules): 125 mutants.

OperatorMutantsPrisma at the startPrisma nowDialyzererlc
r1 spec return252525250
r2 argument vs spec252125240
r3 BIF argument256252423
r4 missing function25025250
r6 pattern vs spec25025250
all12552 (42%)125 (100%)123 (98%)23 (18%)

Prisma alone sees two that Dialyzer does not (a call in the generated conf_parse, a byte_size in cow_qpack); Dialyzer alone sees none.

Four of the 125 are warnings, not errors. Four r4 mutants (a call to a function that does not exist, in ra_log, ra_server, ra_file and ra_server_proc) are inside the log macros of ra, which expand to a try ... catch _:_: the code does not crash, but the line of log is never written. Prisma lists them as handled_undef with the level warning (it would hide a misspelled function for good); --no-warnings leaves them out, and then 121 of the 125 are reported.

At the start Prisma missed most of what Dialyzer found, and the misses followed one thing: where the fault is. Four gaps, closed one at a time (reproductions in bench/repro/false_negatives/); the same 125 mutants after each:

FixPrisma
before52 / 125
1. look inside fun, try, catch, receive, comprehensions, records and binaries57
2. BIFs with arguments of the wrong type inside guards (each test of a guard is checked, even if another alternative saves the clause)74
3. calls to a function that does not exist (unknown_function), and calls inside the target of another call100
4. the return type of a call comes from the callee's -spec (other modules and the same one)124
5. measurement fix: a line that already warned, now for another type, is a new finding125

The last miss was not Prisma's. cuttlefish_unit:generate_config/3 already had a true warning on the line of its return in the unmodified code ({error, atom(), any()} is not [any()]); the mutation changed the type to mutant_atom and Prisma said so, on the same line and of the same kind, so a comparison on (module, line, kind) took it for the old one.

A failure is reported where it is and only there. Dialyzer names every function above the faulty one ("no local return"), without saying why. Prisma must not copy that: callers trust the declared -spec of a callee over what was inferred from its body. Measured on the 125 mutants, the new warnings outside the mutated function are 0 for Prisma and 201 for Dialyzer (they were 6 for Prisma before gap 4). A unit test covers a fault three calls deep, with specs on the callers and calls from a fun, a try and a comprehension.

What the sample does not say: 125 mutants of five kinds injected on purpose are not the distribution of real bugs; "must fail" is by contract or by BIF, so a mutant on code that is never reached still counts; and Dialyzer ran with its default warnings and the erts/kernel/stdlib PLT. The first version of the runner looked for warnings between the first and last line of the function and missed Dialyzer's invalid-spec warnings, which sit on the -spec line; it was caught by looking at the mutants one by one before the big batch.

3.6 Precision lost, and limits

  • hd([]) is no longer reported: the type has no "non-empty improper list" to tell it from hd([1 | 2]) (the compiler already warns about it). In exchange tl(X) + 1 is no longer a false badarith.

  • Two rabbit_vhost_limit clauses ({ok, Limit} when Limit < 0, dead on purpose) are no longer reported: seeing them needs "a float that is not negative", which the type language lacks. The sound answer is "cannot tell".
  • What a fun, try, receive or comprehension binds does not reach the code after it (patterns inside bind to any, the result is any): faults that depend on those values are missed.
  • The exact meet of two improper lists with list-able tails is cut at depth 2 and then a safe superset (without the cut the PPLT of OTP's compiler took minutes).
  • In the gradual check, a value known only by a wide type (integer(), number(), [T]) against a narrower contract (non_neg_integer(), pos_integer(), [T, ...]) is a gap in what is proven, not a contradiction, and is not reported (prisma_checker:accepts/2). Literals, floats, atoms, [] for a non-empty list and N - 1 with N :: pos_integer() for pos_integer() still are.

4. Where Prisma and Dialyzer differ

PrismaDialyzer
Methodsuccess typing per function plus modular .pplt summaries; callers trust the callee's declared -specwhole-program success typing over a monolithic PLT
Reports a faultonly in the function that has itin every function above it ("no local return")
Spec vs clausereported by default (5 on rabbit)only with -Woverspecs / -Wunderspecs, among 942
Missing function in a known moduleunknown_function"unknown functions" list, no per-call location
Cold startnone: the .pplt of each package is built once per versionbase PLT: 13.2 s, 44.7 s of CPU, 768 MiB
Seeded faults125 / 125123 / 125

5. Benchmark

All figures: hyperfine 2.0.0 --metrics all --ignore-failure, mean of the runs, other applications running on the machine (variance in the tables).

hyperfine --metrics all --runs 5 --warmup 1 --ignore-failure \
  -n "Prisma (discrepancy)" "prisma check --mode discrepancy --no-oxbow /path/to/deps/rabbit/ebin" \
  -n "Dialyzer"             "dialyzer --no_check_plt /path/to/deps/rabbit/ebin"

rabbit (234 modules)

MetricPrisma (discrepancy)Prisma (all kinds)Dialyzer
Wall time1.076 s ± 0.0251.180 s ± 0.0666.745 s ± 0.206 (6.3x)
CPU time1.251 s1.364 s25.991 s (20.8x)
User / system1.005 s / 245 ms1.107 s / 257 ms23.799 s / 2.192 s
Peak memory295.5 MiB ± 8.2287.8 MiB ± 19.8405.1 MiB ± 9.8 (1.4x)
Instructions retired11.4 B12.1 B176.1 B (15.4x)

440 modules (7 applications)

MetricPrisma (discrepancy)Prisma (all kinds)Dialyzer
Wall time3.020 s ± 0.1063.376 s ± 0.09817.274 s ± 0.454 (5.7x)
CPU time3.247 s3.569 s67.548 s (20.8x)
User / system2.827 s / 420 ms3.140 s / 430 ms61.703 s / 5.845 s
Peak memory536.0 MiB ± 3.2546.1 MiB ± 29.41,456.7 MiB ± 30.1 (2.7x)
Instructions retired33.7 B36.4 B467.2 B (13.9x)

Cold start

Dialyzer needs a PLT of the OTP base applications before it can check anything (dialyzer --build_plt --apps erts kernel stdlib): 13.2 s, 44.7 s of CPU, 768 MiB on this machine. Prisma builds the .pplt of a package once per version and loads it on demand.

StepWallPeak memoryWarnings (all kinds)
build of the 6 dependency apps of rabbit (206 modules, 441 KiB file)1.45 s344 MiBn/a
check rabbit without --pplt1.2 - 1.4 s333 - 342 MiB177
check rabbit with the dependencies' .pplt1.7 s343 - 379 MiB221

The cache is not free (it adds about 0.4 s) and it changes the result: the extra 44 warnings are calls into the dependencies, checked against their real specs. The "no cold start" of Prisma is only true when dependencies are not analyzed. Dialyzer was run without the PLT of the dependencies, as above; a run with a full PLT is pending.

The PPLT of OTP's kernel and stdlib (202 modules, 1.1 MB): 2.4 s, 419 MiB. It did not end in 5 minutes before the fix below: calendar, uri_string, file_sorter and dist_util made the inference ask for meets of unions of nested improper lists that grew without end (the 100 modules of stdlib had taken 3 min 34 s once).

6. What the correctness work cost

Same corpus, same machine and harness (rabbit, discrepancy mode, 5 runs), start of the correctness work (14226fd) against now:

startnow
Wall time0.71 s1.08 s+55%
User CPU0.62 s1.00 s+60%
System CPU0.25 s0.25 sequal
Peak memory281 MB284 - 313 MB+1% to +11%
build of the 6 dependency apps3.9 s, 2.4 MB, 331 MiB1.45 s, 441 KiB, 344 MiBfaster, smaller
.pplt of OTP compiler (60 modules)0.51 s, 2,065 KiB~0.40 s, ~360 KiBfaster, smaller

The analysis is slower because it does more (it looks inside fun, try, receive and comprehensions, trusts declared specs, subtracts patterns in every case, bounds types with widen/1); the PPLT is smaller because it no longer keeps the abstract code of each function.

A performance mistake, how it was found, and what changed. The fix for improper cells made the inferred type of some recursive functions grow with every pass of the inference. It was missed because the measurement was check on deps/rabbit and the PPLT of OTP's compiler, not the PPLT of RabbitMQ's dependencies: building that one went from 3.9 s to 235 s and the operating system killed the process for its memory. A bisection found one module (rabbit_binary_parser) at 48,900 million words once written out from 9,000 in memory, a factor of 113,000: Erlang shares equal sub-terms in memory, and term_to_binary and any recursion over the type expand them as if it were a tree. Fixes: the members of a union of proper lists make one nonempty_list (a cons distributed over K clauses gave K, K², K³ members); inferred types are bounded by prisma_type:widen/1 (nesting beyond four levels, and unions of more than 16 members, become any; flat literals up to 1,024); the .pplt no longer stores func_contract.clauses; normalize_union/1 has shortcuts for the lists of one or two types it nearly always gets. Written out and in memory now match on the 440 modules (10 / 10 million words, factor 1.0).

Optimizations tried since, with measurements:

IdeaResult
hash-consing (a map from id to type)not built: the types are about 1.8 MB of a 67 MB process, and a perfect table would shrink them 3 to 4 times; type algebra is 13% of the profile; widen/1 already bounds the explosion
caching declared_return per calleeno gain (1.479 s to 1.475 s, noise) and it was reverted: eprof counted 1M cheap calls
taking the abstract forms from the debug_info chunk already read instead of decoding the module again as abstract_code (twice per module)-8.2% wall time, identical warnings (erl_parse:anno_from_term was 19% of own time in fprof)
bounding meet (a chain whose tail is a big union of improper lists, and unions of structured types with more than 256 pairs of members, answer the smaller operand: a safe superset)kernel + stdlib PPLT from over 5 minutes to 2.4 s; same warnings on rabbit and the 440 modules, 125 / 125 mutants, collateral 0; rabbit 1.116 s to 1.085 s
not re-inferring functions whose callees did not change between passes (a function is inferred again only if it calls, or is, a function of its module whose type changed in the last pass; the first pass infers all)-13% wall time and -13% CPU on rabbit, -19% and -17% on the 440 modules, 17% and 24% fewer instructions; memory 301 to 287 MiB and 542 to 524 MiB, which is within the noise (so it did not grow); identical warnings in the four combinations tried (rabbit and the 440 modules, discrepancy and hybrid), 125 / 125 mutants

The profile (fprof on rabbit_common, own time): prisma_infer 26%, erl_parse 19% before the change above, lists 17%, prisma_type 13%, maps 9.5%.

7. Not yet measured, and what is left

  • Gradual contract mismatches (92 on rabbit, 221 on the 440 modules) against Gradualizer and Eqwalizer (tasks #12 and #17). Of the 55 that the return-type-from-spec change added to the hybrid mode, all were read: 40 are true by contract (the spec says less than the code), 14 are no longer reported, 1 is the known false positive above.
  • Oxbow (80 on rabbit) against xref's unused locals: xref reports 0 because the compiler already warns on them, so most of the 80 are "ignored parameter" findings with no counterpart.
  • Dialyzer with a full PLT of the dependencies: this run lacks it, so Dialyzer sees fewer calls.
  • A second corpus, to know whether the 5 / 45 warnings and 125 / 125 mutants generalize.
  • Speed: the correctness work made the analysis 55% slower; the dependency tracking of the passes took back part of it (rabbit 1.10 s to 0.95 s; the start was 0.71 s).