Skip to content

Commit e0d4d1e

Browse files
committed
Tools: Check in the selftest corpus classifier
The porting assessment was produced by a one-off script that scored every program under tools/testing/selftests/bpf/progs against the compiler's envelope; the notes recommended keeping it so the numbers can be regenerated after each feature lands. This is that script, rebuilt: it strips comments, skips include-shims and header-only fixtures, and reports per program the hard blockers (language gaps) and soft flags (porting costs), with a histogram and a near-miss listing. The helper, map and construct lists at the top are the envelope and are maintained by hand. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DLMW3X7opqAPH6bjmTVkr7
1 parent 5e70b8f commit e0d4d1e

1 file changed

Lines changed: 327 additions & 0 deletions

File tree

‎tools/selftest-audit.py‎

Lines changed: 327 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,327 @@
1+
#!/usr/bin/env python3
2+
"""Score the kernel's BPF selftest programs against what PythonBPF can express.
3+
4+
Point it at a checkout of tools/testing/selftests/bpf/progs and it prints, for
5+
every real program there, the constructs that keep it out of PythonBPF today.
6+
Programs with no hard blocker are the porting candidates; the soft flags on
7+
them say what a port has to rewrite by hand.
8+
9+
python3 tools/selftest-audit.py path/to/linux/tools/testing/selftests/bpf/progs
10+
python3 tools/selftest-audit.py progs/ --json > audit.json
11+
python3 tools/selftest-audit.py progs/ --histogram
12+
13+
This is a heuristic scan of the C source, not a compiler: it has false
14+
negatives in both directions, and the envelope it encodes (the helper, map and
15+
construct lists below) must be kept in step with the compiler by hand. Re-run
16+
it after a feature lands to see what the feature unlocked. The lists were last
17+
reconciled against pythonbpf/helper and pythonbpf/maps when scalar @bpfglobal
18+
support and integer signedness merged.
19+
"""
20+
21+
import argparse
22+
import json
23+
import re
24+
import sys
25+
from collections import Counter
26+
from pathlib import Path
27+
28+
# ── the envelope ─────────────────────────────────────────────────────────────
29+
30+
# Kernel helpers pythonbpf/helper can emit (bpf_trace_printk is `print`).
31+
SUPPORTED_HELPERS = {
32+
"bpf_get_current_cgroup_id",
33+
"bpf_get_current_comm",
34+
"bpf_get_current_pid_tgid",
35+
"bpf_get_current_uid_gid",
36+
"bpf_get_prandom_u32",
37+
"bpf_get_smp_processor_id",
38+
"bpf_get_stack",
39+
"bpf_ktime_get_ns",
40+
"bpf_map_delete_elem",
41+
"bpf_map_lookup_elem",
42+
"bpf_map_update_elem",
43+
"bpf_perf_event_output",
44+
"bpf_printk",
45+
"bpf_trace_printk",
46+
"bpf_probe_read",
47+
"bpf_probe_read_kernel",
48+
"bpf_probe_read_kernel_str",
49+
"bpf_ringbuf_output",
50+
"bpf_ringbuf_reserve",
51+
"bpf_ringbuf_submit",
52+
"bpf_skb_store_bytes",
53+
}
54+
55+
# BPF_MAP_TYPE_* that pythonbpf/maps lowers (ArrayMap is still a stub).
56+
SUPPORTED_MAP_TYPES = {"HASH", "PERF_EVENT_ARRAY", "RINGBUF"}
57+
58+
# Things that look like helper calls but are libbpf macros, not helpers.
59+
NOT_HELPERS = {
60+
"bpf_htons",
61+
"bpf_ntohs",
62+
"bpf_htonl",
63+
"bpf_ntohl",
64+
"bpf_be64_to_cpu",
65+
"bpf_cpu_to_be64",
66+
"bpf_printk_",
67+
}
68+
69+
# (label, kind, regex). kind is "hard" (a language gap) or "soft" (a porting
70+
# cost a careful rewrite can absorb). Order does not matter.
71+
PATTERNS = [
72+
# control flow
73+
("loop", "hard", r"\b(for|while)\s*\(|\bbpf_for\b|\bbpf_repeat\b|\bbpf_loop\s*\("),
74+
("goto", "hard", r"\bgoto\s+\w+"),
75+
("switch", "hard", r"\bswitch\s*\("),
76+
("ternary", "soft", r"\?[^?:]*:"),
77+
# entry-point shape
78+
(
79+
"typed_prog_macro",
80+
"hard",
81+
r"\bBPF_(PROG|PROG2|KPROBE|KRETPROBE|KSYSCALL|KPROBE_SYSCALL|UPROBE|URETPROBE|USDT|"
82+
r"TRACE_\w+|ITER\w*|LSM\w*)\s*\(",
83+
),
84+
("struct_ops", "hard", r'SEC\s*\(\s*"\.?struct_ops'),
85+
("freplace", "hard", r'SEC\s*\(\s*"freplace'),
86+
("sleepable_or_special_sec", "soft", r'SEC\s*\(\s*"\?'),
87+
# verifier-test harness
88+
(
89+
"verifier_annotation",
90+
"hard",
91+
r"\b__(failure|success|msg|retval|naked|log_level|flag|arch_\w+|description|"
92+
r"jited|xlated|caps_unpriv|load_if_JITed|not_msg|failure_unpriv|success_unpriv)\b",
93+
),
94+
# functions
95+
("subprog_call", "hard", r"\b__noinline\b|\b__weak\b|\bSEC\s*\(\s*\"\?"),
96+
(
97+
"static_helper",
98+
"soft",
99+
r"\bstatic\s+(__always_inline\s+|inline\s+|__noinline\s+)?\w[\w\s\*]*\s+\**\w+\s*\([^;]*\)\s*\{",
100+
),
101+
# kernel features
102+
(
103+
"kfunc",
104+
"hard",
105+
r"__ksym\b|bpf_experimental\.h|bpf_kfuncs\.h|\bbpf_(obj_new|obj_drop|refcount|task_from|"
106+
r"task_acquire|task_release|cgroup_acquire|cgroup_release|cpumask_\w+|rbtree_\w+|list_\w+|"
107+
r"rcu_read_lock|rcu_read_unlock|arena_\w+|key_put|lookup_user_key|dynptr_\w+|iter_\w+|"
108+
r"wq_\w+|timer_\w+|throw|percpu_obj_\w+|res_spin_\w+|preempt_\w+|local_irq_\w+|"
109+
r"session_\w+|get_dentry_xattr|get_file_xattr|kptr_xchg|sk_assign|xdp_\w+|skb_\w+)\s*\(",
110+
),
111+
("inline_asm", "hard", r"\basm\s*(volatile)?\s*\(|__asm__"),
112+
("atomic", "hard", r"__sync_\w+|__atomic_\w+|\bbpf_spin_(lock|unlock)\b"),
113+
("tail_call", "hard", r"\bbpf_tail_call\w*\s*\("),
114+
(
115+
"core_read",
116+
"hard",
117+
r"\bBPF_CORE_READ\w*\b|\bbpf_core_\w+|__builtin_preserve\w*|\bbpf_probe_read_user\w*",
118+
),
119+
(
120+
"builtin",
121+
"hard",
122+
r"__builtin_(memcpy|memset|memcmp|bswap\w*|ctz|clz|popcount|expect)\b|\b(memcpy|memset|memcmp)\s*\(",
123+
),
124+
(
125+
"endian_macro",
126+
"hard",
127+
r"\bbpf_(htons|ntohs|htonl|ntohl|be64_to_cpu|cpu_to_be64)\s*\(",
128+
),
129+
("kconfig_or_extern", "hard", r"__kconfig\b|^\s*extern\s"),
130+
("arena_or_iter_sec", "hard", r'SEC\s*\(\s*"(iter|arena)'),
131+
# data
132+
("ctx_field_write", "soft", r"\bctx\s*->\s*\w+\s*(\+|-|\||&|\^|<<|>>)?=[^=]"),
133+
("local_struct", "soft", r"^\s+struct\s+\w+\s+\w+\s*(=\s*\{|;)"),
134+
(
135+
"local_array",
136+
"soft",
137+
r"^\s+(const\s+)?(char|__u8|__u16|__u32|__u64|int|long|unsigned|u8|u16|u32|u64|__s\d+)\s+(?!_?_?license\b)\w+\s*\[[^\]]*\]",
138+
),
139+
(
140+
"string_or_char_global",
141+
"hard",
142+
r"^(static\s+)?(volatile\s+)?(const\s+)?(volatile\s+)?char\s+\w+\s*\[[^\]]*\]\s*(SEC\s*\(\s*\"\.rodata\"\s*\))?\s*=",
143+
),
144+
]
145+
146+
# File-scope declarations that are not ordinary scalar globals.
147+
ARRAY_GLOBAL = re.compile(
148+
r"^(static\s+)?(volatile\s+)?(const\s+)?(volatile\s+)?"
149+
r"(struct\s+\w+|__?[us]\d+|u\d+|s\d+|int|long|short|char|bool|unsigned\s+\w+|"
150+
r"uintptr_t|size_t|__wsum|__be\d+|__le\d+|\w+_t)\s*\**\s*\w+\s*\[",
151+
re.M,
152+
)
153+
STRUCT_GLOBAL = re.compile(
154+
r"^(static\s+)?(volatile\s+)?(const\s+)?(volatile\s+)?struct\s+\w+\s+\w+\s*(=|;)",
155+
re.M,
156+
)
157+
ANON_STRUCT_GLOBAL = re.compile(r"^struct\s*\{", re.M)
158+
MAP_DECL = re.compile(r"__uint\s*\(\s*type\s*,\s*BPF_MAP_TYPE_(\w+)\s*\)")
159+
SEC_RE = re.compile(r'SEC\s*\(\s*"([^"]+)"\s*\)')
160+
HELPER_CALL = re.compile(r"\b(bpf_\w+)\s*\(")
161+
INCLUDE_C = re.compile(r'^\s*#include\s+"[^"]+\.c"', re.M)
162+
BTF_DUMP_FIXTURE = re.compile(r"btf_dump|btf__|__attribute__\(\(btf_decl_tag", re.I)
163+
164+
165+
def strip_comments(src: str) -> str:
166+
src = re.sub(r"/\*.*?\*/", "", src, flags=re.S)
167+
return re.sub(r"//[^\n]*", "", src)
168+
169+
170+
def classify(path: Path) -> dict | None:
171+
raw = path.read_text(errors="replace")
172+
src = strip_comments(raw)
173+
secs = [s for s in SEC_RE.findall(src) if s not in ("license", ".maps", "version")]
174+
is_prog = bool(secs) and not INCLUDE_C.search(src)
175+
if not is_prog:
176+
return None # wrapper shim, header-only fixture, or library file
177+
178+
hard: set[str] = set()
179+
soft: set[str] = set()
180+
detail: dict[str, list[str]] = {}
181+
182+
for label, kind, rx in PATTERNS:
183+
if re.search(rx, src, re.M):
184+
(hard if kind == "hard" else soft).add(label)
185+
186+
maps = set(MAP_DECL.findall(src))
187+
bad_maps = sorted(m for m in maps if m not in SUPPORTED_MAP_TYPES)
188+
if bad_maps:
189+
hard.add("unsupported_map")
190+
detail["unsupported_map"] = bad_maps
191+
if re.search(r'SEC\s*\(\s*"\.maps"\s*\)', src) and not maps:
192+
# a map with no __uint(type) is a legacy bpf_map_def or an extern
193+
hard.add("legacy_map_def")
194+
195+
helpers = set(HELPER_CALL.findall(src)) - NOT_HELPERS
196+
helpers = {
197+
h for h in helpers if not h.startswith("bpf_map_") or h in SUPPORTED_HELPERS
198+
}
199+
unsupported = sorted(
200+
h
201+
for h in helpers
202+
if h not in SUPPORTED_HELPERS
203+
and not h.startswith(("bpf_core_", "bpf_probe_read_user"))
204+
)
205+
if unsupported:
206+
hard.add("unsupported_helper")
207+
detail["unsupported_helper"] = unsupported
208+
209+
# file-scope globals that scalar @bpfglobal cannot hold. Map declarations
210+
# are anonymous structs too, so take them out first.
211+
no_maps = re.sub(
212+
r"struct\s*\{[^}]*\}\s*\w+\s*SEC\s*\(\s*\"\.maps\"\s*\)\s*;",
213+
"",
214+
src,
215+
flags=re.S,
216+
)
217+
body_stripped = re.sub(r"\{[^{}]*\}", "{}", no_maps) # crude: drop innermost bodies
218+
for _ in range(6):
219+
body_stripped = re.sub(r"\{[^{}]*\}", "{}", body_stripped)
220+
top = "\n".join(
221+
line
222+
for line in body_stripped.splitlines()
223+
if not line.lstrip().startswith(("#", "SEC", "}", "{"))
224+
)
225+
if ARRAY_GLOBAL.search(top) and not re.search(
226+
r"^char\s+_?_?license", top, re.M | re.I
227+
):
228+
hard.add("array_global")
229+
elif ARRAY_GLOBAL.search(top):
230+
# licence/version aside, any other array is still a blocker
231+
others = [
232+
m.group(0)
233+
for m in ARRAY_GLOBAL.finditer(top)
234+
if "license" not in m.group(0).lower()
235+
]
236+
if others:
237+
hard.add("array_global")
238+
if STRUCT_GLOBAL.search(top) or ANON_STRUCT_GLOBAL.search(top):
239+
hard.add("struct_global")
240+
241+
# a global written from a static helper etc. is fine; a global at all is
242+
# informational now that scalars are supported
243+
if re.search(
244+
r"^(volatile\s+)?(const\s+)?(volatile\s+)?(__?[us]\d+|u\d+|s\d+|int|long|short|bool|unsigned\s+\w+|uintptr_t|size_t)\s+\w+\s*(=[^=]|;)",
245+
top,
246+
re.M,
247+
):
248+
soft.add("scalar_global")
249+
250+
return {
251+
"file": path.name,
252+
"bytes": len(raw),
253+
"sections": sorted(set(secs)),
254+
"hard": sorted(hard),
255+
"soft": sorted(soft),
256+
"detail": detail,
257+
}
258+
259+
260+
def main() -> int:
261+
ap = argparse.ArgumentParser(
262+
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
263+
)
264+
ap.add_argument(
265+
"progs", type=Path, help="tools/testing/selftests/bpf/progs checkout"
266+
)
267+
ap.add_argument(
268+
"--json", action="store_true", help="emit one JSON object per program"
269+
)
270+
ap.add_argument(
271+
"--histogram", action="store_true", help="print the blocker histogram"
272+
)
273+
ap.add_argument(
274+
"--all",
275+
action="store_true",
276+
help="list every program, not only the portable ones",
277+
)
278+
ap.add_argument(
279+
"--max-hard",
280+
type=int,
281+
default=0,
282+
help="list programs with at most this many hard blockers",
283+
)
284+
args = ap.parse_args()
285+
286+
results = []
287+
skipped = 0
288+
for c in sorted(args.progs.glob("*.c")):
289+
r = classify(c)
290+
if r is None:
291+
skipped += 1
292+
else:
293+
results.append(r)
294+
295+
if args.json:
296+
for r in results:
297+
print(json.dumps(r))
298+
return 0
299+
300+
real = len(results)
301+
clean = [r for r in results if not r["hard"]]
302+
print(
303+
f"{real} real programs ({skipped} shims/fixtures skipped); {len(clean)} with no hard blocker\n"
304+
)
305+
306+
if args.histogram:
307+
hist = Counter(b for r in results for b in r["hard"])
308+
for label, n in hist.most_common():
309+
print(f" {label:28s} {n:4d} {100 * n / real:4.0f}%")
310+
print()
311+
312+
rows = (
313+
results if args.all else [r for r in results if len(r["hard"]) <= args.max_hard]
314+
)
315+
rows.sort(key=lambda r: (len(r["hard"]), r["bytes"]))
316+
for r in rows:
317+
flags = " ".join(r["hard"]) or "-"
318+
soft = " ".join(r["soft"]) or "-"
319+
extra = "; ".join(f"{k}={','.join(v)}" for k, v in r["detail"].items())
320+
print(
321+
f"{r['file']:44s} {r['bytes']:6d} hard: {flags:30s} soft: {soft} {extra}"
322+
)
323+
return 0
324+
325+
326+
if __name__ == "__main__":
327+
sys.exit(main())

0 commit comments

Comments
 (0)