Skip to content

Commit b3dcdb0

Browse files
committed
Add incremental analysis cache via --cache-dir (M9)
1 parent 94874c8 commit b3dcdb0

21 files changed

Lines changed: 1129 additions & 65 deletions

README.md

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,13 @@ defects with precise source locations.
77

88
This is the MVP scoped in [`docs/spec.md`](docs/spec.md) -- three rules,
99
text/JSON/SARIF output, YAML config with suppression, and one flagship
10-
cross-file rule with real graph-algorithm teeth -- plus five items from
10+
cross-file rule with real graph-algorithm teeth -- plus six items from
1111
the Phase 2+ backlog: SARIF output, three more rules (`use-after-move`,
1212
`unbalanced-lock`, `unchecked-null-result`, `expensive-copy`, two of them
13-
sharing a generic worklist dataflow engine), and parallel translation-unit
14-
analysis (`--jobs`). See [`docs/architecture.md`](docs/architecture.md)
15-
for how it's built internally.
13+
sharing a generic worklist dataflow engine), parallel translation-unit
14+
analysis (`--jobs`), and an incremental analysis cache (`--cache-dir`).
15+
See [`docs/architecture.md`](docs/architecture.md) for how it's built
16+
internally.
1617

1718
## Rules
1819

@@ -49,6 +50,15 @@ means, but -- unlike `lock-order-inversion` -- only tracks explicit
4950
`lock()`/`unlock()` calls, not RAII `lock_guard`/`unique_lock` (which are
5051
balanced by construction; nothing to check).
5152

53+
`--cache-dir <dir>` enables an incremental analysis cache: a translation
54+
unit whose main file, every header it transitively includes, its compile
55+
command, the enabled rule set, `--header-filter`, and the cpp-sentinel
56+
version all still match a prior run's cache entry is served straight from
57+
that entry instead of being re-parsed and re-analyzed. On this project's
58+
own ~50-TU self-scan, a fully warm cache brings a ~14s run down to
59+
~0.5s -- with byte-for-byte identical findings, verified by diffing cold
60+
vs. warm output. Unset (the default) disables caching entirely.
61+
5262
## Building
5363

5464
Requires CMake 3.20+, a C++20 compiler, and LLVM/Clang dev packages
@@ -95,7 +105,8 @@ units are analyzed), `--header-filter <glob>` (also report diagnostics
95105
from headers matching this pattern; by default only each TU's main file
96106
is reported, matching clang-tidy's convention), `--fail-on warning|error`,
97107
`-j`/`--jobs <N>` (translation units analyzed in parallel; default `0` =
98-
all available hardware threads).
108+
all available hardware threads), `--cache-dir <dir>` (enables the
109+
incremental analysis cache; unset by default).
99110

100111
Exit codes: `0` clean, `1` findings at/above `--fail-on`, `2` usage/config
101112
error, `4` a translation unit failed to compile.
@@ -177,9 +188,13 @@ data point).
177188

178189
## Known limitations
179190

180-
- No incremental caching: every TU is re-analyzed from scratch on every
181-
run, even with parallel TU analysis (`--jobs`) doing the work
182-
concurrently.
191+
- The incremental cache (`--cache-dir`) has no explicit invalidate/clear
192+
command beyond deleting the directory -- fine for the common case
193+
(every relevant input is already part of the cache key or dependency
194+
set), but there's no `--cache-stats`-style inspection beyond the
195+
one-line hit-count summary printed to stderr. A TU that fails to
196+
compile is never cached, so it's always retried in full on the next run
197+
rather than caching (and repeatedly serving) a failure.
183198
- `lock-order-inversion`: no alias analysis (two pointers to the same
184199
mutex are different lock ids unless they resolve to the same
185200
`VarDecl`/`FieldDecl`), loop bodies walked once rather than to a

docs/architecture.md

Lines changed: 70 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,17 @@ compile_commands.json
88
CompilationDatabaseLoader (JSONCompilationDatabase / loadFromDirectory)
99
|
1010
AnalysisDriver + llvm::DefaultThreadPool
11-
| one task per TU, each with its own fresh rule instances and
12-
| its own ClangTool -- see "Parallel translation-unit analysis"
11+
| one task per TU, each with its own fresh rule instances -- see
12+
| "Parallel translation-unit analysis"
13+
| IncrementalCache hit? -> skip straight to Rule::restoreState()
14+
| + cached diagnostics, no ClangTool at all -- see "Incremental
15+
| analysis cache"
1316
|
1417
+----+-----------------------------+
1518
| |
1619
RuleFrontendAction -> ASTConsumer |
17-
| per TU (inside one task): |
20+
| per TU (inside one task, |
21+
| only on a cache miss): |
1822
| - MatchFinder dispatches |
1923
| AST-matcher rules |
2024
| (missing-override, |
@@ -87,8 +91,10 @@ default, means "all available hardware threads"). Each task:
8791
1. Builds its own fresh set of rule instances via `RuleRegistry` (the
8892
same rule ids `AnalysisDriver` was constructed with), not the driver's
8993
own instances.
90-
2. Runs its own `clang::tooling::ClangTool` against just that one file,
91-
with its own local `std::vector<Diagnostic>` sink.
94+
2. If an incremental cache is configured, checks it first (see below) --
95+
a hit skips the rest of this list entirely. Otherwise runs its own
96+
`clang::tooling::ClangTool` against just that one file, with its own
97+
local `std::vector<Diagnostic>` sink.
9298

9399
Fresh instances per task, not shared ones, because an AST-matcher rule
94100
stashes its current TU's `TranslationUnitContext` in a member between
@@ -115,6 +121,65 @@ returning -- output would otherwise depend on however the thread pool
115121
happened to interleave tasks, which is a real, if easy to miss,
116122
determinism regression once a driver goes from sequential to parallel.
117123

124+
## Incremental analysis cache
125+
126+
`IncrementalCache` (`include/sentinel/core/IncrementalCache.h`,
127+
`lib/core/IncrementalCache.cpp`) is deliberately Clang-agnostic, in the
128+
same spirit as `dataflow::runForwardWorklist` and `graph/SCC.h`: it knows
129+
about a "primary key" string, a list of `{file, contentHash}`
130+
dependencies, and a `CacheEntry` blob; it has no idea what a TU or an AST
131+
is. All the Clang-specific glue lives in `AnalysisDriver.cpp`.
132+
133+
Two problems make a single "content hash of the file" not enough:
134+
135+
- A TU's result depends on more than the file's own text -- its compile
136+
flags, the enabled rule set, `--header-filter`, and the cpp-sentinel
137+
build itself can all change what gets reported without the file
138+
changing at all. These fold into the **primary key**
139+
(`IncrementalCache::computeKey()`), computable *before* parsing, and
140+
changing any of them invalidates every entry that depended on the old
141+
value (there's no partial invalidation across a key change -- simplest
142+
correct behavior, and a version/flag/rule-set change is rare next to a
143+
source edit).
144+
- The TU's result also depends on every header it transitively includes,
145+
and that set isn't knowable until *after* a real parse. So freshness
146+
for a given primary key is checked lazily: `AnalysisDriver`
147+
(`collectDependencyFiles()` in `RuleFrontendAction.cpp`, wired through
148+
an optional out-parameter on `createRuleFrontendAction()`) walks the
149+
TU's `SourceManager::fileinfo_begin()/end()` after a real analysis and
150+
records every real on-disk file it touched -- the main file plus every
151+
header -- each hashed via the same FNV-1a `sentinel::hashContent()`
152+
`Fingerprint.cpp` already used (extracted to `ContentHash.h` so both
153+
agree on one implementation). `IncrementalCache::lookup()` re-hashes
154+
every recorded dependency's *current* content on every call; any
155+
mismatch (or a deleted file) is a miss.
156+
157+
The trickiest part is `lock-order-inversion` again: its `finalize()`
158+
needs the full cross-TU picture, but a cache-hit TU is never re-parsed,
159+
so there's no fresh `LockOrderFact` list for it that run. `Rule::
160+
serializeState()`/`restoreState()` close that gap generically -- a rule
161+
can serialize whatever per-TU state its `finalize()` needs into an opaque
162+
blob `IncrementalCache` stores alongside a TU's diagnostics, and
163+
reconstitute it on a later cache hit into a fresh per-task instance,
164+
which then flows into `Rule::mergeFrom()` exactly like a freshly-computed
165+
task's state would (see "Parallel translation-unit analysis" above) --
166+
the cache doesn't need a special code path for this at all, because a
167+
cache hit and a cache miss both end up producing "a rule instance with
168+
some state," merged the same way either way. Default no-op, like
169+
`mergeFrom()`; only `LockOrderInversionRule` overrides it, serializing
170+
`factStore_.facts()` to a small JSON array.
171+
`AnalysisDriverCacheTest.CrossFileCycleStillDetectedWithOneCachedAndOneChangedFile`
172+
is the test that actually proves this: two files form a lock-order cycle,
173+
one is touched (forcing a miss) and one isn't (a hit), and the cycle must
174+
still be detected -- exactly the case a cache that only stored
175+
diagnostics (and not this per-rule state) would silently break.
176+
177+
A TU that fails to compile is never cached (`AnalysisDriver.cpp`): its
178+
dependency list is likely incomplete (Clang can bail out before touching
179+
every header a correct parse would), so caching it risks a stale entry
180+
surviving even after the real problem is fixed -- simplest and safest to
181+
just retry a compile failure in full on every run.
182+
118183
## The flagship rule: `lock-order-inversion`
119184

120185
Three moving pieces, in `lib/rules/LockOrderInversionRule.cpp`:

include/sentinel/core/AnalysisDriver.h

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,11 +35,23 @@ class AnalysisDriver {
3535
// The returned diagnostics are sorted by (file, line, column, rule id)
3636
// so output is deterministic regardless of how threads happened to be
3737
// scheduled.
38+
//
39+
// `cacheDir`, if non-empty, enables the incremental cache: a TU whose
40+
// main file, transitively-included headers, compile command, enabled
41+
// rule set, header filter, and tool version all match a prior run's
42+
// cache entry is served from that entry instead of being re-parsed and
43+
// re-analyzed. Empty (the default) disables caching entirely -- every
44+
// TU is always freshly analyzed, exactly as before this existed. If
45+
// `cacheHitCount` is non-null, it's set to how many of `sourcePaths`
46+
// were served from the cache (always 0 when caching is disabled) --
47+
// mainly for tests to observe cache behavior directly rather than
48+
// inferring it from timing or side effects.
3849
std::vector<Diagnostic> run(clang::tooling::CompilationDatabase &compileDb,
3950
const std::vector<std::string> &sourcePaths,
4051
bool *hadCompileErrors = nullptr,
4152
const HeaderFilter &headerFilter = HeaderFilter(),
42-
unsigned jobCount = 0);
53+
unsigned jobCount = 0, const std::string &cacheDir = "",
54+
size_t *cacheHitCount = nullptr);
4355

4456
private:
4557
std::vector<std::unique_ptr<Rule>> rules_;
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
#pragma once
2+
3+
#include <string>
4+
5+
namespace sentinel {
6+
7+
// Hex-encoded FNV-1a 64-bit hash of `data` -- no external dependency,
8+
// deterministic, good enough avalanche behavior for content-addressing
9+
// (fingerprints, incremental-cache keys) that only needs to distinguish
10+
// inputs, not resist adversarial collisions. Shared so every hash in this
11+
// codebase (diagnostic fingerprints, cache keys, dependency-file content
12+
// hashes) agrees on one implementation.
13+
std::string hashContent(const std::string &data);
14+
15+
} // namespace sentinel

include/sentinel/core/HeaderFilter.h

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,12 @@ class HeaderFilter {
1717

1818
bool allows(bool isMainFile, const std::string &file) const;
1919

20+
// The raw pattern string this filter was constructed with -- used by
21+
// AnalysisDriver's incremental-cache key, since changing the header
22+
// filter changes which diagnostics a TU would report and so must
23+
// invalidate any existing cache entry for it.
24+
const std::string &pattern() const { return pattern_; }
25+
2026
private:
2127
// Stored as a plain pattern string and compiled lazily per call (see
2228
// PathFilter): llvm::GlobPattern only holds a StringRef into its source
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
#pragma once
2+
3+
#include "sentinel/core/Diagnostic.h"
4+
5+
#include <optional>
6+
#include <string>
7+
#include <unordered_map>
8+
#include <vector>
9+
10+
namespace sentinel {
11+
12+
// One file this cache entry's result depends on: if its content no longer
13+
// hashes to `contentHash`, the entry is stale. Includes the TU's own main
14+
// file alongside every header it transitively included -- both matter
15+
// equally for whether the cached result is still valid.
16+
struct CacheDependency {
17+
std::string file;
18+
std::string contentHash;
19+
};
20+
21+
// A cached translation unit's analysis result: everything needed to skip
22+
// re-parsing and re-analyzing it entirely on a later run.
23+
struct CacheEntry {
24+
std::vector<CacheDependency> dependencies;
25+
std::vector<Diagnostic> diagnostics;
26+
// ruleId -> opaque state blob, from Rule::serializeState() -- only
27+
// present for rules that had non-nullopt state when this entry was
28+
// written (in practice, only lock-order-inversion today). The cache
29+
// never interprets these; only the owning rule's restoreState() does.
30+
std::unordered_map<std::string, std::string> ruleState;
31+
};
32+
33+
// On-disk incremental-analysis cache, one JSON file per translation unit
34+
// under `cacheDir`, named by a hash of everything determinable *before*
35+
// parsing (main file path, compile command, enabled rule ids, header
36+
// filter, tool version -- see computeKey()). A cache hit additionally
37+
// requires every dependency file recorded in the entry to still hash to
38+
// the same content: knowing the *full* dependency set (every header
39+
// transitively included) isn't possible before parsing, so freshness is
40+
// checked lazily on lookup against whatever was recorded the last time
41+
// this TU was actually analyzed.
42+
class IncrementalCache {
43+
public:
44+
explicit IncrementalCache(std::string cacheDir);
45+
46+
// Everything that could change this TU's analysis result and is known
47+
// before parsing: which file, with which flags, under which enabled
48+
// rules and header-filter pattern, analyzed by which cpp-sentinel
49+
// build. Changing any of these must invalidate old entries, since a
50+
// stale hit would silently serve results computed under a different
51+
// configuration.
52+
static std::string computeKey(const std::string &mainFile,
53+
const std::vector<std::string> &compileCommandArgs,
54+
const std::vector<std::string> &enabledRuleIds,
55+
const std::string &headerFilterPattern,
56+
const std::string &toolVersion);
57+
58+
// Returns the cached entry for `key` if one exists and every recorded
59+
// dependency still hashes to the same content -- std::nullopt on a
60+
// miss (no entry, a malformed entry, or any dependency changed or is
61+
// no longer readable).
62+
std::optional<CacheEntry> lookup(const std::string &key) const;
63+
64+
// Writes (or overwrites) the cache entry for `key`. Creates `cacheDir`
65+
// if it doesn't already exist.
66+
void store(const std::string &key, const CacheEntry &entry) const;
67+
68+
private:
69+
std::string pathFor(const std::string &key) const;
70+
71+
std::string cacheDir_;
72+
};
73+
74+
} // namespace sentinel

include/sentinel/core/Rule.h

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
#include <clang/ASTMatchers/ASTMatchFinder.h>
44

5+
#include <optional>
56
#include <string>
67
#include <vector>
78

@@ -70,6 +71,22 @@ class Rule : public clang::ast_matchers::MatchFinder::MatchCallback {
7071
// lock-order-inversion) need to override this; a rule whose diagnostics
7172
// are already fully reported per-TU has nothing to merge.
7273
virtual void mergeFrom(const Rule &other) { (void)other; }
74+
75+
// Serializes any per-TU accumulated state this instance holds that
76+
// finalize() needs (e.g. lock-order-inversion's LockFactStore) into an
77+
// opaque blob the incremental cache can persist to disk alongside a
78+
// TU's cached diagnostics -- std::nullopt means "nothing to
79+
// serialize" (the default; most rules report everything directly
80+
// per-TU, so finalize() is a no-op for them and there's no state to
81+
// save). The blob's format is entirely up to the rule; IncrementalCache
82+
// never interprets it, only stores and hands it back.
83+
virtual std::optional<std::string> serializeState() const { return std::nullopt; }
84+
85+
// The inverse of serializeState(): merges a blob previously produced
86+
// by it (read back from a TU's cache entry on a later run, when that
87+
// TU is a cache hit and so never gets re-parsed) into this instance,
88+
// exactly as mergeFrom() merges a sibling instance's in-memory state.
89+
virtual void restoreState(const std::string &blob) { (void)blob; }
7390
};
7491

7592
} // namespace sentinel

include/sentinel/core/RuleFrontendAction.h

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,16 @@ namespace sentinel {
2222
// way down through RuleAction/RuleConsumer to TranslationUnitContext: the
2323
// returned FrontendAction outlives this call, so a reference parameter
2424
// bound to a caller's temporary (e.g. the default argument) would dangle.
25+
//
26+
// `dependencyFilesOut`, if non-null, is populated with the path of every
27+
// real on-disk file the TU touched while parsing (the main file plus
28+
// every header transitively included) -- used by AnalysisDriver to build
29+
// an incremental-cache entry's dependency list once a TU is fully
30+
// analyzed. Left null by callers that don't care (every rule unit test).
2531
std::unique_ptr<clang::FrontendAction>
2632
createRuleFrontendAction(std::vector<std::unique_ptr<Rule>> &rules,
2733
std::vector<Diagnostic> &diagnostics,
28-
HeaderFilter headerFilter = HeaderFilter());
34+
HeaderFilter headerFilter = HeaderFilter(),
35+
std::vector<std::string> *dependencyFilesOut = nullptr);
2936

3037
} // namespace sentinel

include/sentinel/rules/LockOrderInversionRule.h

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,15 @@ class LockOrderInversionRule : public Rule {
5151
// spanning TUs handled by different tasks would go undetected.
5252
void mergeFrom(const Rule &other) override;
5353

54+
// Serializes factStore_ to a JSON blob and back -- see Rule::
55+
// serializeState()/restoreState(). Lets the incremental cache skip
56+
// re-parsing a TU whose dependencies haven't changed while still
57+
// handing this rule's accumulated facts for that TU back to a later
58+
// run's finalize(), so a cross-file cycle spanning a cache-hit TU and
59+
// a cache-miss TU is still detected correctly.
60+
std::optional<std::string> serializeState() const override;
61+
void restoreState(const std::string &blob) override;
62+
5463
private:
5564
LockFactStore factStore_;
5665
};

lib/CMakeLists.txt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,13 +24,15 @@ add_library(sentinel-lib OBJECT
2424
core/RuleFrontendAction.cpp
2525
core/AnalysisDriver.cpp
2626
core/Fingerprint.cpp
27+
core/ContentHash.cpp
2728
core/SuppressionState.cpp
2829
core/TranslationUnitContext.cpp
2930
core/PathFilter.cpp
3031
core/LockFactStore.cpp
3132
core/ToolchainDefaults.cpp
3233
core/HeaderFilter.cpp
3334
core/LockIdentity.cpp
35+
core/IncrementalCache.cpp
3436
config/Config.cpp
3537
output/TextFormatter.cpp
3638
output/JsonFormatter.cpp
@@ -63,4 +65,5 @@ execute_process(
6365
)
6466
target_compile_definitions(sentinel-lib PRIVATE
6567
SENTINEL_CLANG_RESOURCE_DIR="${SENTINEL_CLANG_RESOURCE_DIR}"
68+
SENTINEL_VERSION="${PROJECT_VERSION}"
6669
)

0 commit comments

Comments
 (0)