Skip to content

Commit a1034ca

Browse files
authored
Merge pull request #208 from ausimian/claude/nif-static-analysis-tools-t3rmhg
Add clang-tidy static analysis for the NIF
2 parents 29b90ac + 5e5dcc9 commit a1034ca

10 files changed

Lines changed: 229 additions & 12 deletions

File tree

.clang-tidy

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
# clang-tidy configuration for the C++ NIF under c_src/.
2+
#
3+
# Run it with `mix clang.tidy` (which supplies the MLX/Fine/ERTS build
4+
# env) — see lib/mix/tasks/clang.tidy.ex and the `clang-tidy` target in
5+
# the Makefile.
6+
#
7+
# A focused, high-signal set: bug patterns, the clang static analyzer
8+
# (clang-analyzer-*), and performance checks. Deliberately not the
9+
# modernize/readability/cppcoreguidelines firehose yet — broaden once this
10+
# baseline stays green.
11+
#
12+
# Disabled checks and why:
13+
# * bugprone-easily-swappable-parameters — fires on nearly every NIF
14+
# entry point (many same-typed args) without pointing at a real defect.
15+
# * performance-unnecessary-value-param — every NIF takes its
16+
# fine::ResourcePtr args by value because Fine's FINE_NIF macro decodes
17+
# each BEAM term into a value and passes it in; the signature is the
18+
# binding convention, not a stray copy (the cppcheck build suppresses
19+
# the same thing as passedByValueCallback).
20+
# * bugprone-throwing-static-initialization — FINE_NIF / FINE_RESOURCE
21+
# register callbacks at static-init time via throwing constructors;
22+
# this is inherent to Fine's registration model, across ~25 macro
23+
# expansions we don't own.
24+
# * performance-enum-size — Opcode and ref::Kind are int64_t on purpose
25+
# (they pack into the int64 refs the Elixir lowerer emits); shrinking
26+
# the base type would break that ABI.
27+
#
28+
# HeaderFilterRegex scopes diagnostics to our own headers; MLX and Fine
29+
# arrive via -isystem and are skipped, exactly as the compiler skips them.
30+
Checks: '-*,bugprone-*,clang-analyzer-*,performance-*,-bugprone-easily-swappable-parameters,-performance-unnecessary-value-param,-bugprone-throwing-static-initialization,-performance-enum-size'
31+
WarningsAsErrors: '*'
32+
HeaderFilterRegex: 'c_src/'
33+
FormatStyle: none

.github/workflows/ci.yml

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,57 @@ jobs:
220220
- name: Run cppcheck
221221
run: make cppcheck
222222

223+
# C++ static analysis of the NIF sources with clang-tidy (which also runs
224+
# the clang static analyzer via its clang-analyzer-* checks). Unlike
225+
# cppcheck, clang-tidy compiles each TU, so it needs the MLX/Fine/ERTS
226+
# headers — hence the Beam setup and the MLX cache. It reuses the MLX
227+
# install the precommit lane builds: the restore-keys pick up main's
228+
# cache even when this branch's Makefile hash differs, since only the
229+
# headers are needed and the MLX version is unchanged. clang-tidy ships
230+
# with LLVM (brew), not stock Xcode. Runs on macOS to match the platform
231+
# the NIF ships on, via the same `mix clang.tidy` a developer runs.
232+
clang-tidy:
233+
name: clang-tidy (NIF static analysis)
234+
if: github.event_name != 'push' || github.ref_type == 'branch'
235+
runs-on: macos-14
236+
env:
237+
MIX_ENV: test
238+
EMILY_MLX_VARIANT: aot
239+
EMILY_CACHE: ~/Library/Caches/emily
240+
steps:
241+
- uses: actions/checkout@v6
242+
243+
- name: Setup Beam
244+
uses: erlef/setup-beam@v1
245+
with:
246+
version-file: .tool-versions
247+
version-type: strict
248+
249+
- name: Install LLVM (clang-tidy)
250+
run: brew install llvm
251+
252+
- name: Cache deps
253+
uses: actions/cache@v5
254+
with:
255+
path: deps
256+
key: deps-${{ runner.os }}-${{ hashFiles('mix.lock') }}
257+
restore-keys: deps-${{ runner.os }}-
258+
259+
- name: Cache MLX and NIF objects
260+
uses: actions/cache@v5
261+
with:
262+
path: ~/Library/Caches/emily
263+
key: mlx-${{ runner.os }}-aot-${{ hashFiles('c_src/**', 'Makefile', 'mix.exs', 'scripts/build-mlx.sh') }}
264+
restore-keys: mlx-${{ runner.os }}-aot-
265+
266+
- run: mix deps.get
267+
268+
- name: Run clang-tidy
269+
run: |
270+
export CLANG_TIDY="$(brew --prefix llvm)/bin/clang-tidy"
271+
export SDKROOT="$(xcrun --show-sdk-path)"
272+
mix clang.tidy
273+
223274
# ASan CI deferred: requires OTP built with --enable-sanitizers=address
224275
# (macOS SIP blocks DYLD_INSERT_LIBRARIES, and late-loaded libasan
225276
# fails). See Makefile and RELEASE.md for details.

MAINTAINING.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,23 @@ the `cppcheck` job). See the `cppcheck` target in the `Makefile` for the
205205
enabled checks and suppressions; use inline `// cppcheck-suppress <id>`
206206
for one-off false positives.
207207

208+
### Static analysis of the NIF (clang-tidy)
209+
210+
```sh
211+
brew install llvm # one-time; clang-tidy isn't in stock Xcode
212+
mix clang.tidy
213+
```
214+
215+
Runs clang-tidy (including the clang static analyzer, via its
216+
`clang-analyzer-*` checks) over `c_src/` and exits non-zero on any finding
217+
— the same tool the `clang-tidy` CI job runs. Unlike cppcheck it compiles
218+
each translation unit, so it needs the MLX/Fine/ERTS headers and build
219+
flags; `mix clang.tidy` supplies that env (reusing the cached MLX) and
220+
drives the `clang-tidy` Makefile target, so `make clang-tidy` on its own
221+
will refuse to run. Enabled checks and the header filter live in the
222+
repo-root `.clang-tidy`; use inline `// NOLINT(<check>)` for one-off false
223+
positives. Point at a specific binary with `CLANG_TIDY=/path/to/clang-tidy`.
224+
208225
### Build MLX in isolation
209226

210227
```sh

Makefile

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ ifeq ($(EMILY_ASAN),1)
4747
LDFLAGS += -fsanitize=address
4848
endif
4949

50-
.PHONY: all clean bench-native cppcheck
50+
.PHONY: all clean bench-native cppcheck clang-tidy
5151

5252
all: $(NIF_SO) $(METALLIB)
5353

@@ -89,6 +89,33 @@ CPPCHECK_FLAGS := --enable=warning,performance,portability \
8989
cppcheck:
9090
$(CPPCHECK) $(CPPCHECK_FLAGS) $(SOURCES)
9191

92+
# ------------------------------------------------------------------
93+
# clang-tidy: static analysis (incl. the clang static analyzer, via the
94+
# clang-analyzer-* checks) of the first-party NIF sources.
95+
#
96+
# Unlike cppcheck, clang-tidy actually *compiles* each translation unit,
97+
# so it needs the MLX / Fine / ERTS headers and the exact build flags —
98+
# it reuses this Makefile's `$(CXXFLAGS)` verbatim via the trailing `--`.
99+
# That means it needs the same env the NIF build gets (MLX_INCLUDE_DIR,
100+
# FINE_INCLUDE_DIR, ERTS_INCLUDE_DIR), which `make` alone does not set.
101+
# Run it through `mix clang.tidy`, which supplies that env (reusing the
102+
# already-built/cached MLX) exactly like `mix bench.native` does; the
103+
# recipe below refuses to run without it rather than emit a confusing
104+
# clang error about an empty `-isystem`.
105+
#
106+
# Enabled checks and the header filter (diagnostics scoped to c_src/,
107+
# never MLX/Fine which arrive via -isystem) live in the repo-root
108+
# `.clang-tidy`. Install the tool with `brew install llvm`; override the
109+
# binary with CLANG_TIDY=/path/to/clang-tidy.
110+
# ------------------------------------------------------------------
111+
CLANG_TIDY ?= clang-tidy
112+
113+
clang-tidy:
114+
@test -n "$(MLX_INCLUDE_DIR)" || { \
115+
echo "clang-tidy needs the NIF build env — run 'mix clang.tidy', not 'make clang-tidy'." >&2; \
116+
exit 1; }
117+
$(CLANG_TIDY) --quiet $(SOURCES) -- $(CXXFLAGS)
118+
92119
# ------------------------------------------------------------------
93120
# bench-native: standalone C++ microbenchmarks under bench/native/.
94121
#

c_src/emily/async.hpp

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,13 +44,16 @@ namespace emily {
4444

4545
namespace mx = mlx::core;
4646

47-
namespace __async {
47+
namespace async_detail {
4848

4949
// Build a binary term in msg_env from a null-terminated C string.
5050
inline ERL_NIF_TERM make_binary_from_cstr(ErlNifEnv *msg_env, const char *s) {
5151
size_t len = std::strlen(s);
5252
ERL_NIF_TERM term;
5353
unsigned char *data = enif_make_new_binary(msg_env, len, &term);
54+
// The destination is a length-counted BEAM binary of exactly `len`
55+
// bytes, not a C string, so a trailing NUL is neither needed nor wanted.
56+
// NOLINTNEXTLINE(bugprone-not-null-terminated-result)
5457
std::memcpy(data, s, len);
5558
return term;
5659
}
@@ -80,7 +83,7 @@ error_reason_from_current_exception(ErlNifEnv *msg_env) {
8083
}
8184
}
8285

83-
} // namespace __async
86+
} // namespace async_detail
8487

8588
// Run `build_payload` on the worker thread of `w` and post the
8689
// result back to the caller PID as a message. Returns a fresh ref
@@ -132,7 +135,7 @@ fine::Term async_reply(ErlNifEnv *env,
132135
msg_env, ref_in_msg,
133136
enif_make_tuple2(
134137
msg_env, fine::encode(msg_env, emily::atoms::error),
135-
__async::error_reason_from_current_exception(msg_env)));
138+
async_detail::error_reason_from_current_exception(msg_env)));
136139
}
137140
}
138141

c_src/emily/op_cores.hpp

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -154,8 +154,8 @@ inline mx::array sliding_windows_view(
154154
out_dims.assign(rank, 0);
155155
mx::Shape new_shape;
156156
mx::Strides new_strides;
157-
new_shape.reserve(2 * rank);
158-
new_strides.reserve(2 * rank);
157+
new_shape.reserve(2 * static_cast<std::size_t>(rank));
158+
new_strides.reserve(2 * static_cast<std::size_t>(rank));
159159

160160
for (int i = 0; i < rank; ++i) {
161161
int64_t eff = (window_shape[i] - 1) * dilations[i] + 1;
@@ -251,11 +251,11 @@ inline mx::array window_scatter_core(
251251
bool is_max,
252252
mx::Stream &s) {
253253
int rank = static_cast<int>(window_shape.size());
254-
auto original_shape = tensor.shape();
254+
const auto &original_shape = tensor.shape();
255255

256256
// 1. Pad input with init_value.
257257
auto padded = do_pad(tensor, pad_lo, pad_hi, init_value, s);
258-
auto padded_shape = padded.shape();
258+
const auto &padded_shape = padded.shape();
259259

260260
// 2. Sliding-window view (dilation is implicitly 1 per axis for scatter).
261261
std::vector<int64_t> dilations(rank, 1);
@@ -329,7 +329,7 @@ inline mx::array window_scatter_core(
329329

330330
// 7. Reshape source so each index tuple is a single-point write.
331331
mx::Shape source_reshape;
332-
source_reshape.reserve(2 * rank);
332+
source_reshape.reserve(2 * static_cast<std::size_t>(rank));
333333
for (int i = 0; i < rank; ++i)
334334
source_reshape.push_back(static_cast<mx::ShapeElem>(out_dims[i]));
335335
for (int i = 0; i < rank; ++i)

c_src/emily/opcodes.hpp

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -280,7 +280,7 @@ inline double f64_from_bits(int64_t bits) {
280280
return d;
281281
}
282282

283-
namespace __op {
283+
namespace op_detail {
284284

285285
inline const mx::array &arg1(const std::vector<mx::array> &in,
286286
const char *name) {
@@ -338,13 +338,13 @@ inline bool keepdims_attr(const std::vector<std::vector<int64_t>> &a,
338338
return v[0] != 0;
339339
}
340340

341-
} // namespace __op
341+
} // namespace op_detail
342342

343343
// Replay one instruction: apply `op` to its resolved operands + attrs.
344344
inline mx::array dispatch_op(Opcode op, const std::vector<mx::array> &in,
345345
const std::vector<std::vector<int64_t>> &iattrs,
346346
mx::Stream &s) {
347-
using namespace emily::__op;
347+
using namespace emily::op_detail;
348348
switch (op) {
349349
// --- Binary arithmetic / bitwise ---
350350
case Opcode::Add:

c_src/emily/program.hpp

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,11 @@ class Program {
122122
// * the recycled-`fun_id` collision the on-worker erase guards against
123123
// can't fire on a stopping worker — it runs no further compiles.
124124
// So on the declined path we simply let `drop` destruct below (issue #172).
125+
//
126+
// post_to_worker constructs a std::function (may throw bad_alloc); a throw
127+
// from this best-effort cleanup destructor is unrecoverable and would
128+
// std::terminate regardless, so the escape is accepted here.
129+
// NOLINTNEXTLINE(bugprone-exception-escape)
125130
~Program() {
126131
for (auto &kv : compiled) {
127132
CompiledEntry &entry = kv.second;

c_src/emily/worker.hpp

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,10 @@ class Reaper {
148148
}
149149
}
150150

151+
// shutdown() joins the reaper thread; std::thread::join can in theory
152+
// throw std::system_error, but a throw from this best-effort teardown
153+
// destructor is unrecoverable and would std::terminate regardless.
154+
// NOLINTNEXTLINE(bugprone-exception-escape)
151155
~Reaper() { shutdown(); }
152156

153157
private:
@@ -198,6 +202,10 @@ class WorkerThread {
198202

199203
// Non-blocking: signal stop and hand the thread to the Reaper to join
200204
// off-scheduler. Pending tasks are cancelled with {:error, :stopped}.
205+
//
206+
// retire() takes a lock and moves the thread to the reaper; a throw here
207+
// is unrecoverable from a destructor and would std::terminate regardless.
208+
// NOLINTNEXTLINE(bugprone-exception-escape)
201209
~WorkerThread() { Reaper::instance().retire(state_.get()); }
202210

203211
// Enqueue a task. Throws if the worker has been stopped or the queue is

lib/mix/tasks/clang.tidy.ex

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
defmodule Mix.Tasks.Clang.Tidy do
2+
@moduledoc """
3+
Run clang-tidy over the C++ NIF sources in `c_src/`.
4+
5+
Unlike `make cppcheck`, clang-tidy compiles each translation unit, so it
6+
needs the MLX / Fine / ERTS headers and the exact build flags. This task
7+
supplies the same env `elixir_make` uses when building the NIF — reusing
8+
the already-built (cached) MLX rather than fetching a second copy — and
9+
then invokes the `clang-tidy` Makefile target, which analyses the NIF
10+
sources with the build's `$(CXXFLAGS)`.
11+
12+
## Usage
13+
14+
mix clang.tidy
15+
16+
Requires clang-tidy on `PATH` (`brew install llvm`); point at a specific
17+
binary with `CLANG_TIDY=/path/to/clang-tidy`. The enabled checks and the
18+
header filter live in the repo-root `.clang-tidy`.
19+
"""
20+
21+
use Mix.Task
22+
23+
@shortdoc "Run clang-tidy over the C++ NIF sources"
24+
25+
@impl Mix.Task
26+
def run(_args) do
27+
# Ensure MLX is available (its headers are what clang-tidy parses). The
28+
# :emily_mlx compiler alias reuses an existing install and only builds
29+
# from source on a cold cache — same as `mix bench.native`.
30+
Mix.Task.run("compile.emily_mlx", [])
31+
32+
# Ask the mix project for its make_env — the same map `elixir_make`
33+
# passes to `make` — and add ERTS_INCLUDE_DIR, which elixir_make sets
34+
# itself at build time (so make_env/0 omits it) but a standalone `make`
35+
# invocation does not get.
36+
env =
37+
Mix.Project.config()
38+
|> Keyword.fetch!(:make_env)
39+
|> case do
40+
f when is_function(f, 0) -> f.()
41+
m when is_map(m) -> m
42+
end
43+
|> Map.put("ERTS_INCLUDE_DIR", erts_include_dir())
44+
|> Enum.to_list()
45+
46+
make = System.get_env("MAKE") || "make"
47+
48+
Mix.shell().info("Running: #{make} clang-tidy")
49+
50+
{_out, status} =
51+
System.cmd(make, ["clang-tidy"],
52+
env: env,
53+
into: IO.stream(:stdio, :line),
54+
stderr_to_stdout: true
55+
)
56+
57+
if status != 0 do
58+
Mix.raise("clang.tidy reported findings (exit #{status})")
59+
end
60+
end
61+
62+
# Mirror how elixir_make derives ERTS_INCLUDE_DIR: the Erlang headers
63+
# (erl_nif.h, reached through fine.hpp) live under the OTP install. Honour
64+
# an explicit override if one is already exported.
65+
defp erts_include_dir do
66+
System.get_env("ERTS_INCLUDE_DIR") ||
67+
Path.join([
68+
to_string(:code.root_dir()),
69+
"erts-#{:erlang.system_info(:version)}",
70+
"include"
71+
])
72+
end
73+
end

0 commit comments

Comments
 (0)