Skip to content

Commit 101c3eb

Browse files
committed
Add masked dot-product attention selection
1 parent 67fde75 commit 101c3eb

2 files changed

Lines changed: 282 additions & 3 deletions

File tree

tests/ops/test_attention_ops.py

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@
3030
attend_argmin,
3131
attend_argmax,
3232
attend_argmax_dot,
33+
attend_argmax_dot_where,
34+
attend_argmin_dot_where,
3335
attend_argmin_where,
3436
attend_argmax_where,
3537
attend_argmin_above_integer,
@@ -920,6 +922,103 @@ def test_attend_argmax_dot_different_queries_per_position():
920922
assert abs(result[3].item() - 10.0) < 0.5
921923

922924

925+
# ---------------------------------------------------------------------------
926+
# attend_argmax_dot_where / attend_argmin_dot_where
927+
# ---------------------------------------------------------------------------
928+
929+
930+
def test_attend_argmax_dot_where_validity_dominates_supported_range():
931+
"""A valid low dot score beats an invalid high dot score."""
932+
query_vector = InputNode("qv", 1, value_range=(-100.0, 100.0))
933+
key_vector = InputNode("kv", 1, value_range=(-100.0, 100.0))
934+
validity = InputNode("validity", 1, value_range=(-100.0, 100.0))
935+
value = InputNode("value", 2, value_range=(-100.0, 100.0))
936+
out = attend_argmax_dot_where(query_vector, key_vector, validity, value)
937+
938+
n_pos = 2
939+
qv_in = torch.ones(n_pos, 1)
940+
# At the default match_gain=200, dot magnitudes 4.5 stay inside the
941+
# documented supported range (abs(match_gain * dot) <= 960).
942+
kv_in = torch.tensor([[-4.5], [4.5]])
943+
validity_in = torch.tensor([[1.0], [-1.0]])
944+
value_in = torch.tensor([[7.0, 1.0], [99.0, 9.0]])
945+
946+
result = _run(
947+
out,
948+
n_pos,
949+
qv=qv_in,
950+
kv=kv_in,
951+
validity=validity_in,
952+
value=value_in,
953+
)
954+
assert torch.allclose(result[1], value_in[0], atol=1e-3), f"pos 1: {result[1]}"
955+
956+
957+
def test_attend_argmin_dot_where_validity_dominates_supported_range():
958+
"""A valid high dot score beats an invalid low dot score."""
959+
query_vector = InputNode("qv", 1, value_range=(-100.0, 100.0))
960+
key_vector = InputNode("kv", 1, value_range=(-100.0, 100.0))
961+
validity = InputNode("validity", 1, value_range=(-100.0, 100.0))
962+
value = InputNode("value", 2, value_range=(-100.0, 100.0))
963+
out = attend_argmin_dot_where(query_vector, key_vector, validity, value)
964+
965+
n_pos = 2
966+
qv_in = torch.ones(n_pos, 1)
967+
kv_in = torch.tensor([[4.5], [-4.5]])
968+
validity_in = torch.tensor([[1.0], [-1.0]])
969+
value_in = torch.tensor([[3.0, 4.0], [99.0, 9.0]])
970+
971+
result = _run(
972+
out,
973+
n_pos,
974+
qv=qv_in,
975+
kv=kv_in,
976+
validity=validity_in,
977+
value=value_in,
978+
)
979+
assert torch.allclose(result[1], value_in[0], atol=1e-3), f"pos 1: {result[1]}"
980+
981+
982+
def test_attend_argmin_dot_where_zero_key_regression():
983+
"""Invalid zero keys must not win argmin over valid positive-dot keys."""
984+
query_vector = InputNode("qv", 2, value_range=(-100.0, 100.0))
985+
key_vector = InputNode("kv", 2, value_range=(-100.0, 100.0))
986+
validity = InputNode("validity", 1, value_range=(-100.0, 100.0))
987+
value = InputNode("value", 3, value_range=(-100.0, 100.0))
988+
out = attend_argmin_dot_where(query_vector, key_vector, validity, value)
989+
990+
n_pos = 3
991+
qv_in = torch.tensor(
992+
[
993+
[1.0, 0.0],
994+
[1.0, 0.0],
995+
[1.0, 0.0],
996+
]
997+
)
998+
# Pos 1 is invalid and has a zero key. Without explicit validity,
999+
# its dot score 0 would beat the valid positive dot scores under argmin.
1000+
kv_in = torch.tensor(
1001+
[
1002+
[1.0, 0.0],
1003+
[0.0, 0.0],
1004+
[2.0, 0.0],
1005+
]
1006+
)
1007+
validity_in = torch.tensor([[1.0], [-1.0], [1.0]])
1008+
value_in = torch.eye(3, 3)
1009+
1010+
result = _run(
1011+
out,
1012+
n_pos,
1013+
qv=qv_in,
1014+
kv=kv_in,
1015+
validity=validity_in,
1016+
value=value_in,
1017+
)
1018+
assert torch.allclose(result[1], value_in[0], atol=1e-3), f"pos 1: {result[1]}"
1019+
assert torch.allclose(result[2], value_in[0], atol=1e-3), f"pos 2: {result[2]}"
1020+
1021+
9231022
# ---------------------------------------------------------------------------
9241023
# attend_most_recent_matching
9251024
# ---------------------------------------------------------------------------

torchwright/ops/attention_ops.py

Lines changed: 183 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@
5050

5151
import torch
5252

53-
from torchwright.graph import Node, Concatenate, Attn
53+
from torchwright.graph import Node, Concatenate, Attn, LiteralValue
5454
from torchwright.graph.asserts import (
5555
assert_in_range,
5656
assert_matches_value_type,
@@ -118,8 +118,9 @@ def _wrap_hard_selection_output(
118118
_QUERY_GAIN = 8.0
119119

120120
# Direct (not gained) logit bonus for valid positions in the simple
121-
# ``_where`` variants (``attend_argmin_where``, ``attend_argmax_where``,
122-
# ``attend_mean_where``). Routed through a dedicated ``d_qk`` column
121+
# scalar ``_where`` variants (``attend_argmin_where``,
122+
# ``attend_argmax_where``, ``attend_mean_where``) and the dot-product
123+
# ``_dot_where`` variants. Routed through a dedicated ``d_qk`` column
123124
# with ``Q = 1.0`` and ``K = ± _VALIDITY_DIRECT``, so the contribution
124125
# to the logit is literally ``± _VALIDITY_DIRECT`` — not multiplied by
125126
# ``_QUERY_GAIN``. Must exceed the one-sided score swing
@@ -143,6 +144,14 @@ def _wrap_hard_selection_output(
143144
# ``attend_argmin_valid_unmasked``).
144145
_MAX_SCORE_ABS = 120.0
145146

147+
# Maximum absolute dot-product contribution supported by
148+
# ``attend_argmax_dot_where`` / ``attend_argmin_dot_where`` *after*
149+
# multiplying by ``match_gain``. Validity contributes
150+
# ``± _VALIDITY_DIRECT`` directly to the logit, so keeping the match
151+
# term inside ±960 leaves the same 40-logit margin as the scalar
152+
# ``_where`` variants in the worst valid-vs-invalid case.
153+
_MAX_DOT_LOGIT_ABS = 960.0
154+
146155
# Penalty (in *logit* space, not key space) applied by
147156
# ``attend_argmin_unmasked`` to masked positions. Must exceed
148157
# ``_QUERY_GAIN * _MAX_SCORE_UNMASKED_ABS`` so a masked position with
@@ -1004,6 +1013,177 @@ def attend_argmax_dot(
10041013
)
10051014

10061015

1016+
def _build_dot_where_attn(
1017+
query_vector: Node,
1018+
key_vector: Node,
1019+
validity: Node,
1020+
value: Node,
1021+
*,
1022+
score_sign: float,
1023+
match_gain: float,
1024+
) -> Attn:
1025+
"""Shared construction for dot-product selection with validity.
1026+
1027+
``score_sign`` is ``+1`` for argmax and ``-1`` for argmin.
1028+
"""
1029+
assert len(query_vector) == len(key_vector), (
1030+
"query_vector and key_vector must have the same width "
1031+
f"(got {len(query_vector)} and {len(key_vector)})"
1032+
)
1033+
assert len(validity) == 1, "attend_*_dot_where expects 1D boolean validity"
1034+
assert match_gain > 0, "attend_*_dot_where expects positive match_gain"
1035+
1036+
W = len(query_vector)
1037+
d_v = len(value)
1038+
# d_qk layout:
1039+
# cols 0..W-1: dot-product score
1040+
# col W: direct validity bonus
1041+
d_qk = W + 1
1042+
1043+
# The validity term needs a query-side constant. A LiteralValue keeps
1044+
# this op independent of PosEncoding while preserving the same direct
1045+
# validity-logit pattern used by the scalar _where variants.
1046+
query_one = LiteralValue(torch.tensor([1.0]), name="dot_where_query_one")
1047+
query_in = Concatenate([query_vector, query_one])
1048+
key_in = Concatenate([key_vector, validity])
1049+
1050+
query_matrix = torch.zeros((W + 1, d_qk))
1051+
for c in range(W):
1052+
query_matrix[c, c] = score_sign * match_gain
1053+
query_matrix[W, W] = 1.0
1054+
1055+
key_matrix = torch.zeros((W + 1, d_qk))
1056+
for c in range(W):
1057+
key_matrix[c, c] = 1.0
1058+
key_matrix[W, W] = _VALIDITY_DIRECT
1059+
1060+
value_matrix = torch.eye(d_v)
1061+
output_matrix = torch.eye(d_v)
1062+
1063+
return Attn(
1064+
query_in=query_in,
1065+
key_in=key_in,
1066+
value_in=value,
1067+
query_matrix=query_matrix,
1068+
key_matrix=key_matrix,
1069+
value_matrix=value_matrix,
1070+
output_matrix=output_matrix,
1071+
)
1072+
1073+
1074+
def attend_argmax_dot_where(
1075+
query_vector: Node,
1076+
key_vector: Node,
1077+
validity: Node,
1078+
value: Node,
1079+
match_gain: float = 200.0,
1080+
assert_hardness_gt: Optional[float] = None,
1081+
) -> Node:
1082+
"""Argmax of ``query_vector · key_vector`` over valid key positions.
1083+
1084+
At each query position, returns ``value`` at the causal-window
1085+
position where ``validity`` is +1 and the dot product with the query
1086+
vector is largest. ``validity`` follows the usual torchwright
1087+
boolean convention: +1.0 means "valid", -1.0 means "invalid".
1088+
1089+
The logit at key position ``i`` seen from query position ``j`` is
1090+
1091+
match_gain · (query_vector[j] · key_vector[i])
1092+
+ _VALIDITY_DIRECT · validity[i]
1093+
1094+
Invalid rows cannot win as long as every caller-provided dot score
1095+
satisfies ``abs(match_gain * dot_score) <= _MAX_DOT_LOGIT_ABS``.
1096+
With the default ``match_gain=200``, this means
1097+
``abs(dot_score) <= 4.8``. If your dot products are larger, scale
1098+
the vectors or lower ``match_gain`` so the validity term has room to
1099+
dominate; tied valid scores produce a soft average.
1100+
1101+
**When no position is valid.** The softmax still returns a weighted
1102+
average over invalid positions. Callers must ensure each consumed
1103+
query has at least one valid causal-window key, or gate the result
1104+
downstream.
1105+
1106+
Compile cost: one attention head (auto-split across multiple
1107+
physical heads by the compiler when ``d_v > d_head``).
1108+
``d_qk = len(query_vector) + 1``, ``d_v = len(value)``.
1109+
1110+
Args:
1111+
query_vector: Width-``W`` node at each query position.
1112+
key_vector: Width-``W`` node at each key position.
1113+
validity: 1D boolean node (+1 valid, -1 invalid).
1114+
value: Node to read at the winning position.
1115+
match_gain: Positive coefficient applied to the dot-product term.
1116+
assert_hardness_gt: If set, checks the winning softmax weight.
1117+
1118+
Returns:
1119+
Attn node of width ``len(value)``.
1120+
1121+
See also:
1122+
:func:`attend_argmin_dot_where` — minimum-dot dual.
1123+
:func:`attend_argmax_dot` — original unmasked dot-product variant.
1124+
"""
1125+
attn = _build_dot_where_attn(
1126+
query_vector,
1127+
key_vector,
1128+
validity,
1129+
value,
1130+
score_sign=+1.0,
1131+
match_gain=match_gain,
1132+
)
1133+
return _wrap_hard_selection_output(
1134+
attn, value, assert_hardness_gt=assert_hardness_gt
1135+
)
1136+
1137+
1138+
def attend_argmin_dot_where(
1139+
query_vector: Node,
1140+
key_vector: Node,
1141+
validity: Node,
1142+
value: Node,
1143+
match_gain: float = 200.0,
1144+
assert_hardness_gt: Optional[float] = None,
1145+
) -> Node:
1146+
"""Argmin of ``query_vector · key_vector`` over valid key positions.
1147+
1148+
Sign-flipped twin of :func:`attend_argmax_dot_where`. This is the
1149+
variant to use when zeroed invalid keys are unsafe: in an argmin, an
1150+
invalid zero key can otherwise beat valid keys with positive dot
1151+
scores.
1152+
1153+
The logit at key position ``i`` seen from query position ``j`` is
1154+
1155+
-match_gain · (query_vector[j] · key_vector[i])
1156+
+ _VALIDITY_DIRECT · validity[i]
1157+
1158+
The same supported range applies:
1159+
``abs(match_gain * dot_score) <= _MAX_DOT_LOGIT_ABS`` for all key
1160+
rows in the consumed causal window. At the default
1161+
``match_gain=200``, that is ``abs(dot_score) <= 4.8``.
1162+
1163+
Args:
1164+
query_vector: Width-``W`` node at each query position.
1165+
key_vector: Width-``W`` node at each key position.
1166+
validity: 1D boolean node (+1 valid, -1 invalid).
1167+
value: Node to read at the winning position.
1168+
match_gain: Positive coefficient applied to the dot-product term.
1169+
assert_hardness_gt: If set, checks the winning softmax weight.
1170+
1171+
Returns:
1172+
Attn node of width ``len(value)``.
1173+
"""
1174+
attn = _build_dot_where_attn(
1175+
query_vector,
1176+
key_vector,
1177+
validity,
1178+
value,
1179+
score_sign=-1.0,
1180+
match_gain=match_gain,
1181+
)
1182+
return _wrap_hard_selection_output(
1183+
attn, value, assert_hardness_gt=assert_hardness_gt
1184+
)
1185+
1186+
10071187
def attend_most_recent_matching(
10081188
pos_encoding: PosEncoding,
10091189
query_vector: Node,

0 commit comments

Comments
 (0)