Skip to content

Commit 8433389

Browse files
borisbatclaude
andauthored
dasLLAMA metal lens: derive @ROLE's read/write axis from kernel bodies (#3570)
* dasLLAMA metal lens: derive @ROLE's read/write axis from kernel bodies The shared kernel-access classifier (the Vulkan lens's analyzer) gains a field mode for class-shaped Metal kernels: tracked names are the class's @ssbo fields, matched as bare names or explicit self.<name> (float4 component names shadow the classic buffer names, so plain ExprField name matching is wrong); no recursion into free callees (intrinsic shims' locals collide with bare-name matching — self-helpers hit the ratchet); claims keyed by source location since infer clones subtrees. simdgroup store/load + tg_store_float4 join the intrinsic table. [metal_dispatch] now derives read/write/readwrite per @ssbo field from the [metal_kernel] body and emits hz from the derivation; a declared role on that axis is a cross-checked assertion (mismatch = compile error, access_force=true restores the declared contract), weight/alias stay declared (frame policy / hazard ownership are not kernel facts) with weight-writes refused. The 110 read/write-axis declarations on lensed classes are deleted; un-lensed classes keep their 258 declarations, now cross-checked against the same derivation by the census lint — every role in the metal modules is machine-verified, none is trust-me. Deriving while all 592 declarations were still present validated the migration and caught 3 stale hand roles (moe_wscale seli readwrite->read, suppress_row x readwrite->write, pre_add_rms a readwrite->read — the last one's comment still described the older in-place implementation). Gates: metal modules compile clean, kernels suite 2/2 under STRICT. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q8bZnWkVWeXy8jfrxSojso * skills/babysit: the reviewer is the tool, you are the judge — hard acceptance bar + all-reject terminal state Acceptance bar is exactly two items: an actual reachable bug, or an actual factual error in prose. Everything else rejects — no nits, no never-happens bugs, no asteroid-hit guards, no structural tightening of proven-correct code, no determinism cosmetics, no "cheap to fix". An all-reject round is the expected outcome. The merge terminal state is a JUDGED round where nothing got accepted — not Copilot running out of comments after accept-and-repush cycles. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q8bZnWkVWeXy8jfrxSojso --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 8bae40b commit 8433389

6 files changed

Lines changed: 437 additions & 158 deletions

File tree

modules/dasLLAMA/dasllama/dasllama_kernel_access.das

Lines changed: 111 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -14,22 +14,49 @@ require daslib/rtti
1414
// passed whole as an argument to a call it does not model — lands in `err` so the lens can fail
1515
// the compile instead of silently under-tracking (the strictness ratchet).
1616

17+
let private ACCESS_DEBUG = false // flip for macro-time access-derivation prints
18+
1719
struct AccessResult {
1820
reads : table<string>
1921
writes : table<string>
2022
err : string // non-empty = unanalyzable construct; the lens turns it into a compile error
2123
}
2224

23-
// write-root of an assignment target: peel subscripts/fields/swizzles down to the base variable
24-
def private write_root(e : Expression?) : ExprVar? {
25+
// write-root of an assignment target: peel subscripts/fields/swizzles down to the base variable.
26+
// In field mode the peel STOPS at the first ExprField whose name is tracked (a class-field
27+
// buffer) and reports that name; `node` carries the claimable expression either way.
28+
// an ExprField that names a tracked buffer AND whose base is the kernel class itself — the
29+
// name check alone collides with float4 component access (`wv.y`), whose names shadow the
30+
// classic buffer names (x/y/w)
31+
// bodies are PRE-infer at lens time: buffer fields appear as bare ExprVar names, and member
32+
// access is a parser-level ExprField with untyped base — so an ExprField counts as a buffer
33+
// access only when written explicitly as self.<name> (float4 components like `wv.y` shadow the
34+
// classic buffer names and must NOT match)
35+
def private is_self_field(ef : ExprField const?; tracked : table<string>) : bool {
36+
if (!key_exists(tracked, string(ef.name))) {
37+
return false
38+
}
39+
var base = ef.value
40+
if (base is ExprRef2Value) {
41+
base = (base as ExprRef2Value).subexpr
42+
}
43+
return base is ExprVar && (base as ExprVar).name == "self"
44+
}
45+
46+
def private write_root_name(e : Expression?; tracked : table<string>; fieldMode : bool; var node : Expression const?&) : string {
2547
var cur = e
2648
while (true) {
2749
if (cur is ExprAt) {
2850
cur = (cur as ExprAt).subexpr
2951
} elif (cur is ExprSafeAt) {
3052
cur = (cur as ExprSafeAt).subexpr
3153
} elif (cur is ExprField) {
32-
cur = (cur as ExprField).value
54+
let ef = cur as ExprField
55+
if (fieldMode && is_self_field(ef, tracked)) {
56+
node = cur
57+
return string(ef.name)
58+
}
59+
cur = ef.value
3360
} elif (cur is ExprSwizzle) {
3461
cur = (cur as ExprSwizzle).value
3562
} elif (cur is ExprRef2Value) {
@@ -38,7 +65,14 @@ def private write_root(e : Expression?) : ExprVar? {
3865
break
3966
}
4067
}
41-
return cur is ExprVar ? cur as ExprVar : null
68+
if (cur is ExprVar) {
69+
let ev = cur as ExprVar
70+
if (key_exists(tracked, string(ev.name))) {
71+
node = cur
72+
return string(ev.name)
73+
}
74+
}
75+
return ""
4276
}
4377

4478
def private is_setop2(op : das_string) : bool {
@@ -51,24 +85,31 @@ def private is_setop1(op : das_string) : bool => op == "++" || op == "--" || op
5185

5286
class private AccessVisitor : AstVisitor {
5387
tracked : table<string>
88+
fieldMode : bool // tracked names are class FIELDS (metal): match self.<name>
89+
// ExprField accesses only (see is_self_field)
5490
reads : table<string>
5591
writes : table<string>
5692
callees : table<string> // non-intrinsic call names, for the driver's same-module recursion
57-
claimed : table<uint64> // ExprVar nodes already classified as pure-write targets
93+
claimed : table<string> // pure-write target tokens, keyed name:line:column — infer
94+
// CLONES subtrees during rewrites, so pointer identity misses
5895
err : string
5996

6097
def is_tracked(name : das_string) : bool => key_exists(tracked, string(name))
6198

6299
// record a write; `also_read` covers read-modify-write forms (+=, ++). A pure store claims
63-
// the target node so the generic ExprVar pass does not also count it as a read.
100+
// the target node so the generic read pass does not also count it as a read.
64101
def note_write(target : Expression?; also_read : bool) {
65-
var v = write_root(target)
66-
if (v == null || !is_tracked(v.name)) {
102+
var node : Expression const?
103+
let name = write_root_name(target, tracked, fieldMode, node)
104+
if (empty(name)) {
67105
return
68106
}
69-
writes |> insert(string(v.name))
107+
writes |> insert(name)
70108
if (!also_read) {
71-
claimed |> insert(intptr(v))
109+
claimed |> insert("{name}:{node.at.line}:{node.at.column}")
110+
}
111+
if (ACCESS_DEBUG) {
112+
print("[access-dbg] write {name} at={int(node.at.line)}:{int(node.at.column)} also_read={also_read}\n")
72113
}
73114
}
74115

@@ -98,41 +139,70 @@ class private AccessVisitor : AstVisitor {
98139

99140
def override preVisitExprCall(expr : ExprCall?) : void {
100141
let cname = string(expr.name)
101-
// dasSpirv intrinsics that take a whole array and write (or only read) through it
102-
if (cname == "coopmatStore") {
142+
// GPU intrinsics that take a whole array and write (or only read) through it:
143+
// dasSpirv coopmat pair + the dasMetal simdgroup pair store through arg 1;
144+
// tg_store_float4(dst, idx, v) stores through arg 0
145+
if (cname == "coopmatStore" || cname == "simdgroup_store") {
103146
if (length(expr.arguments) >= 2) {
104147
note_write(expr.arguments[1], false)
105148
}
106149
return
107150
}
108-
if (cname == "coopmatLoad") {
109-
return // src argument is a plain read — the generic ExprVar pass records it
151+
if (cname == "tg_store_float4") {
152+
if (!empty(expr.arguments)) {
153+
note_write(expr.arguments[0], false)
154+
}
155+
return
156+
}
157+
if (cname == "coopmatLoad" || cname == "simdgroup_load") {
158+
return // src argument is a plain read — the generic read pass records it
110159
}
111160
callees |> insert(cname)
112-
// the ratchet: a tracked global passed WHOLE to a call we do not model could be written
113-
// through a var parameter — refuse to guess
161+
// the ratchet: a tracked buffer passed WHOLE to a call we do not model could be written
162+
// through a var parameter — refuse to guess. In field mode `self` passed whole is the
163+
// same hole (a class-method helper could touch any field), and field-mode analysis does
164+
// NOT recurse into free callees (bare-name matching would collide with their locals).
114165
for (a in expr.arguments) {
115166
var cur = a
116167
if (cur is ExprRef2Value) {
117168
cur = (cur as ExprRef2Value).subexpr
118169
}
170+
let escape = fieldMode ? "declare the @role (access_force)" : "declare [vk_access]"
119171
if (cur is ExprVar && is_tracked((cur as ExprVar).name)) {
120-
err = "tracked global '{(cur as ExprVar).name}' passed whole to call '{cname}' — teach the intrinsic table or declare [vk_access]"
172+
err = "tracked buffer '{(cur as ExprVar).name}' passed whole to call '{cname}' — teach the intrinsic table or {escape}"
173+
} elif (fieldMode && cur is ExprField && is_self_field(cur as ExprField, tracked)) {
174+
err = "tracked buffer '{(cur as ExprField).name}' passed whole to call '{cname}' — teach the intrinsic table or {escape}"
175+
} elif (fieldMode && cur is ExprVar && (cur as ExprVar).name == "self") {
176+
err = "'self' passed whole to call '{cname}' — the classifier cannot see through method helpers; use access_force with declared roles"
121177
}
122178
}
123179
}
124180

125181
def override visitExprVar(var expr : ExprVar?) : ExpressionPtr {
126-
if (is_tracked(expr.name) && !key_exists(claimed, intptr(expr))) {
182+
if (is_tracked(expr.name) && !key_exists(claimed, "{expr.name}:{expr.at.line}:{expr.at.column}")) {
183+
reads |> insert(string(expr.name))
184+
if (ACCESS_DEBUG) {
185+
print("[access-dbg] read {expr.name} at={int(expr.at.line)}:{int(expr.at.column)}\n")
186+
}
187+
}
188+
return expr
189+
}
190+
191+
def override visitExprField(var expr : ExprField?) : ExpressionPtr {
192+
if (fieldMode && is_self_field(expr, tracked) && !key_exists(claimed, "{expr.name}:{expr.at.line}:{expr.at.column}")) {
127193
reads |> insert(string(expr.name))
194+
if (ACCESS_DEBUG) {
195+
print("[access-dbg] read {expr.name} at={int(expr.at.line)}:{int(expr.at.column)} (field)\n")
196+
}
128197
}
129198
return expr
130199
}
131200
}
132201

133-
def private analyze_one(fn : Function?; tracked : table<string>; var res : AccessResult; var callees : table<string>) {
202+
def private analyze_one(fn : Function?; tracked : table<string>; fieldMode : bool; var res : AccessResult; var callees : table<string>) {
134203
var astVisitor = new AccessVisitor()
135204
astVisitor.tracked := tracked
205+
astVisitor.fieldMode = fieldMode
136206
make_visitor(*astVisitor) $(adapter) {
137207
visit(fn, adapter)
138208
}
@@ -153,18 +223,19 @@ def private analyze_one(fn : Function?; tracked : table<string>; var res : Acces
153223
}
154224
}
155225

156-
//! Classify which `tracked` globals `fn` reads and writes, recursing into same-module callees
157-
//! (helpers access the globals directly; passing a tracked global BY ARGUMENT is refused — see
158-
//! AccessResult.err). Recursion is name-keyed over `mod`'s functions with a visited-set guard.
159-
def classify_global_access(fn : Function?; mod : Module?; tracked : table<string>) : AccessResult {
226+
def private classify_access(fn : Function?; mod : Module?; tracked : table<string>; fieldMode : bool) : AccessResult {
160227
var res : AccessResult
161228
var pending : table<string>
162229
var visited : table<string>
163230
var callees : table<string>
164-
analyze_one(fn, tracked, res, callees)
231+
analyze_one(fn, tracked, fieldMode, res, callees)
165232
visited |> insert(string(fn.name))
233+
// field mode is intra-body only: free callees are shader intrinsics whose das shims would
234+
// false-positive on bare-name matching, and self-helpers hit the ratchet instead
166235
for (c in keys(callees)) {
167-
pending |> insert(c)
236+
if (!fieldMode) {
237+
pending |> insert(c)
238+
}
168239
}
169240
while (!empty(pending)) {
170241
var name = ""
@@ -180,7 +251,7 @@ def classify_global_access(fn : Function?; mod : Module?; tracked : table<string
180251
var next : table<string>
181252
mod |> for_each_function(name) $(cfn) {
182253
if (cfn.name == name) {
183-
analyze_one(cfn, tracked, res, next)
254+
analyze_one(cfn, tracked, fieldMode, res, next)
184255
}
185256
}
186257
for (c in keys(next)) {
@@ -195,3 +266,17 @@ def classify_global_access(fn : Function?; mod : Module?; tracked : table<string
195266
delete callees
196267
return <- res
197268
}
269+
270+
//! Classify which `tracked` globals `fn` reads and writes, recursing into same-module callees
271+
//! (helpers access the globals directly; passing a tracked global BY ARGUMENT is refused — see
272+
//! AccessResult.err). Recursion is name-keyed over `mod`'s functions with a visited-set guard.
273+
def classify_global_access(fn : Function?; mod : Module?; tracked : table<string>) : AccessResult {
274+
return <- classify_access(fn, mod, tracked, false)
275+
}
276+
277+
//! The field-mode twin for class-shaped kernels (metal): `tracked` names are the class's buffer
278+
//! FIELDS — pre-infer bodies spell them as bare names (or explicit self.<name>); member access
279+
//! on anything else (float4 components like wv.y) never matches.
280+
def classify_field_access(fn : Function?; mod : Module?; tracked : table<string>) : AccessResult {
281+
return <- classify_access(fn, mod, tracked, true)
282+
}

0 commit comments

Comments
 (0)