Skip to content

Commit 76f6b71

Browse files
committed
Address benchmark and parity review findings
1 parent 8db6939 commit 76f6b71

10 files changed

Lines changed: 135 additions & 30 deletions

e3nn_mlx/ops_tp.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1307,6 +1307,7 @@ def __init__(
13071307
internal_weights: bool | None = None,
13081308
shared_weights: bool = True,
13091309
compile_left_right: bool = True,
1310+
use_custom_kernel: bool = True,
13101311
) -> None:
13111312
irreps_in = Irreps(irreps_in).simplify()
13121313
parsed_filter = None if filter_ir_out is None else [Irrep.parse(ir) for ir in filter_ir_out]
@@ -1324,6 +1325,7 @@ def __init__(
13241325
internal_weights=False,
13251326
shared_weights=True,
13261327
compile_left_right=compile_left_right,
1328+
use_custom_kernel=use_custom_kernel,
13271329
)
13281330
self._execution_irreps_out = self.irreps_out
13291331
grouped_out, index_groups = _group_output_irreps(self._execution_irreps_out)
@@ -1351,6 +1353,7 @@ def __init__(
13511353
internal_weights=internal_weights,
13521354
shared_weights=shared_weights,
13531355
compile_left_right=compile_left_right,
1356+
use_custom_kernel=use_custom_kernel,
13541357
)
13551358
self._execution_irreps_out = self.irreps_out
13561359
self.irreps_in = irreps_in

evals/mlx_cases.py

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,12 @@ def _imports():
2626
return mx, nn, o3, scatter_sum
2727

2828

29+
def _metal_available() -> bool:
30+
from e3nn_mlx.compat import mlx_metal_available
31+
32+
return mlx_metal_available()
33+
34+
2935
def backend_metadata() -> dict[str, Any]:
3036
mx, _, _, _ = _imports()
3137
return {
@@ -125,7 +131,15 @@ def forward(value):
125131
compile_train=_compiled(value_grad, vectors),
126132
dispatch=(
127133
"metal-spherical-harmonics"
128-
if _USE_CUSTOM_KERNELS and config["lmax"] <= 4
134+
if (
135+
_USE_CUSTOM_KERNELS
136+
and _metal_available()
137+
and vectors.ndim == 2
138+
and vectors.shape[0] > 0
139+
and vectors.dtype == mx.float32
140+
and degrees
141+
and max(degrees) <= 4
142+
)
129143
else (
130144
"general-mlx (kernel fallback)"
131145
if _USE_CUSTOM_KERNELS
@@ -301,7 +315,19 @@ def forward(source):
301315
train=lambda: value_grad(values),
302316
compile_train=_compiled(value_grad, values),
303317
dispatch=(
304-
"metal-scatter-sum" if _USE_CUSTOM_KERNELS else "general-mlx"
318+
"metal-scatter-sum"
319+
if (
320+
_USE_CUSTOM_KERNELS
321+
and _metal_available()
322+
and values.ndim >= 2
323+
and values.shape[0] > 0
324+
and values.dtype == mx.float32
325+
)
326+
else (
327+
"general-mlx (kernel fallback)"
328+
if _USE_CUSTOM_KERNELS
329+
else "general-mlx"
330+
)
305331
),
306332
)
307333

@@ -332,9 +358,7 @@ def _model_dispatch(*, supports_kernels: bool) -> str:
332358
return "general-mlx"
333359
if not supports_kernels:
334360
return "general-mlx (model has no kernel toggle)"
335-
from e3nn_mlx.compat import mlx_metal_available
336-
337-
if not mlx_metal_available():
361+
if not _metal_available():
338362
return "general-mlx (kernel fallback)"
339363
return "mixed-model-kernels"
340364

evals/randomized_core_parity.py

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -722,11 +722,6 @@ def _torch_worker(cases: list[dict[str, Any]]) -> dict[str, Any]:
722722

723723

724724
def _mlx_variants(case: dict[str, Any]) -> tuple[tuple[str, bool, bool], ...]:
725-
if (
726-
case["family"] == "tensor_product_wrappers"
727-
and case["kind"] == "tensor_square"
728-
):
729-
return (("default", True, True),)
730725
if case["family"] in {"spherical_harmonics", "scatter", "tensor_product_wrappers"}:
731726
return (
732727
("generic-eager", False, False),
@@ -891,6 +886,7 @@ def _mlx_module_output(
891886
module = o3.TensorSquare(
892887
case["irreps_in1"],
893888
compile_left_right=compiled,
889+
use_custom_kernel=custom,
894890
)
895891
output = module(o3.IrrepsArray(module.irreps_in, left_values)).array
896892
metadata["custom_eligible"] = bool(

evals/torch_cases.py

Lines changed: 47 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,24 @@ def train():
8181
return train
8282

8383

84+
def _forward_with_fixed_radius_graph(
85+
model_module,
86+
module,
87+
data,
88+
edge_index,
89+
):
90+
original_radius_graph = model_module.radius_graph
91+
92+
def fixed_radius_graph(*_args, **_kwargs):
93+
return edge_index
94+
95+
model_module.radius_graph = fixed_radius_graph
96+
try:
97+
return module(data)
98+
finally:
99+
model_module.radius_graph = original_radius_graph
100+
101+
84102
def build_spherical_harmonics(config: dict[str, Any]) -> Task:
85103
torch, o3 = _imports()
86104
vectors = torch.randn(config["items"], 3, requires_grad=True)
@@ -141,7 +159,9 @@ def build_fully_connected_tensor_product(config: dict[str, Any]) -> Task:
141159
module = o3.FullyConnectedTensorProduct(irreps, irreps, irreps)
142160
left = torch.randn(config["items"], irreps.dim)
143161
right = torch.randn(config["items"], irreps.dim)
144-
forward = lambda: module(left, right)
162+
def forward():
163+
return module(left, right)
164+
145165
return _task(
146166
name="fully_connected_tensor_product",
147167
config=config,
@@ -212,7 +232,9 @@ def build_linear(config: dict[str, Any]) -> Task:
212232
irreps = o3.Irreps(spherical_irreps(config["mul"], config["lmax"]))
213233
module = o3.Linear(irreps, irreps)
214234
values = torch.randn(config["items"], irreps.dim)
215-
forward = lambda: module(values)
235+
def forward():
236+
return module(values)
237+
216238
return _task(
217239
name="linear",
218240
config=config,
@@ -267,7 +289,7 @@ def _model_graph(config, *, input_dim, node_attr_dim=0, edge_attr_dim=0):
267289

268290

269291
def build_gate_points_2102(config: dict[str, Any]) -> Task:
270-
torch, o3 = _imports()
292+
_, o3 = _imports()
271293
import e3nn.nn.models.gate_points_2102 as model_module
272294

273295
irreps_in = o3.Irreps("4x0e")
@@ -297,14 +319,20 @@ def build_gate_points_2102(config: dict[str, Any]) -> Task:
297319
input_dim=irreps_in.dim,
298320
node_attr_dim=irreps_node_attr.dim,
299321
)
300-
model_module.radius_graph = lambda _pos, _radius, _batch: edge_index
301322
data = {
302323
"pos": positions,
303324
"x": node_input,
304325
"z": node_attr,
305326
"batch": batch,
306327
}
307-
forward = lambda: module(data)
328+
def forward():
329+
return _forward_with_fixed_radius_graph(
330+
model_module,
331+
module,
332+
data,
333+
edge_index,
334+
)
335+
308336
return _task(
309337
name="gate_points_2102",
310338
config=config,
@@ -315,7 +343,7 @@ def build_gate_points_2102(config: dict[str, Any]) -> Task:
315343

316344

317345
def build_v2106_simple_network(config: dict[str, Any]) -> Task:
318-
torch, o3 = _imports()
346+
_, o3 = _imports()
319347
import e3nn.nn.models.v2106.gate_points_networks as model_module
320348

321349
irreps_in = o3.Irreps("4x0e")
@@ -334,9 +362,16 @@ def build_v2106_simple_network(config: dict[str, Any]) -> Task:
334362
config,
335363
input_dim=irreps_in.dim,
336364
)
337-
model_module.radius_graph = lambda _pos, _radius, _batch: edge_index
338365
data = {"pos": positions, "x": node_input, "batch": batch}
339-
forward = lambda: module(data)
366+
367+
def forward():
368+
return _forward_with_fixed_radius_graph(
369+
model_module,
370+
module,
371+
data,
372+
edge_index,
373+
)
374+
340375
return _task(
341376
name="v2106_simple_network",
342377
config=config,
@@ -347,7 +382,7 @@ def build_v2106_simple_network(config: dict[str, Any]) -> Task:
347382

348383

349384
def build_v2106_attributed_network(config: dict[str, Any]) -> Task:
350-
torch, o3 = _imports()
385+
_, o3 = _imports()
351386
from e3nn.nn.models.v2106.gate_points_networks import (
352387
NetworkForAGraphWithAttributes,
353388
)
@@ -382,7 +417,9 @@ def build_v2106_attributed_network(config: dict[str, Any]) -> Task:
382417
"edge_index": edge_index,
383418
"batch": batch,
384419
}
385-
forward = lambda: module(data)
420+
def forward():
421+
return module(data)
422+
386423
return _task(
387424
name="v2106_attributed_network",
388425
config=config,

tests/test_evals.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,12 @@ def test_mlx_benchmark_reports_actual_tensor_product_dispatch() -> None:
200200
dense = mlx_cases.build_fully_connected_tensor_product(
201201
{"items": 64, "mul": 8, "lmax": 2}
202202
)
203+
harmonics = mlx_cases.build_spherical_harmonics(
204+
{"items": 16, "lmax": 2}
205+
)
206+
scatter = mlx_cases.build_scatter_sum(
207+
{"items": 16, "nodes": 4, "width": 3}
208+
)
203209
mlx_cases.configure(use_custom_kernels=False)
204210
general = mlx_cases.build_fully_connected_tensor_product(
205211
{"items": 16, "mul": 8, "lmax": 2}
@@ -211,6 +217,16 @@ def test_mlx_benchmark_reports_actual_tensor_product_dispatch() -> None:
211217
else "general-mlx (kernel fallback)"
212218
)
213219
assert small.dispatch == expected
220+
assert harmonics.dispatch == (
221+
"metal-spherical-harmonics"
222+
if mlx_metal_available()
223+
else "general-mlx (kernel fallback)"
224+
)
225+
assert scatter.dispatch == (
226+
"metal-scatter-sum"
227+
if mlx_metal_available()
228+
else "general-mlx (kernel fallback)"
229+
)
214230
assert dense.dispatch == "general-mlx (kernel fallback)"
215231
assert general.dispatch == "general-mlx"
216232

@@ -227,8 +243,16 @@ def test_mlx_benchmark_reports_non_metal_kernel_fallback(monkeypatch) -> None:
227243
task = mlx_cases.build_fully_connected_tensor_product(
228244
{"items": 16, "mul": 8, "lmax": 2}
229245
)
246+
harmonics = mlx_cases.build_spherical_harmonics(
247+
{"items": 16, "lmax": 2}
248+
)
249+
scatter = mlx_cases.build_scatter_sum(
250+
{"items": 16, "nodes": 4, "width": 3}
251+
)
230252
model_dispatch = mlx_cases._model_dispatch(supports_kernels=True)
231253
mlx_cases.configure(use_custom_kernels=False)
232254

233255
assert task.dispatch == "general-mlx (kernel fallback)"
256+
assert harmonics.dispatch == "general-mlx (kernel fallback)"
257+
assert scatter.dispatch == "general-mlx (kernel fallback)"
234258
assert model_dispatch == "general-mlx (kernel fallback)"

tests/test_metal_kernels.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -211,7 +211,7 @@ def apply(module, first, second, value):
211211
mx.ones_like(right) * -0.03,
212212
mx.ones_like(weight) * 0.11,
213213
)
214-
with pytest.raises(ValueError, match="Not implemented for CustomKernel"):
214+
with pytest.raises(ValueError, match="CustomKernel"):
215215
mx.jvp(
216216
lambda first, second, value: apply(
217217
kernel, first, second, value

tests/test_randomized_tensor_product_parity_harness.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -74,18 +74,20 @@ def test_comparison_reports_every_execution_variant_and_retains_failures():
7474
],
7575
}
7676
variants = []
77-
for index, (name, _, _) in enumerate(MLX_VARIANTS):
77+
for name, _, use_custom_kernel in MLX_VARIANTS:
7878
output = reference.copy()
79-
if index == 2:
79+
if use_custom_kernel:
8080
output[0, 0] += 0.1
8181
variants.append(
8282
{
8383
"name": name,
8484
"status": "ok",
8585
"shape": [1, 2],
8686
"output": output.tolist(),
87-
"custom_eligible": index == 2,
88-
"kernel_kind": "scalar_paths" if index == 2 else None,
87+
"custom_eligible": use_custom_kernel,
88+
"kernel_kind": (
89+
"scalar_paths" if use_custom_kernel else None
90+
),
8991
}
9092
)
9193
mlx_result = {

tests/test_tensor_product_compatibility_qualification.py

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -286,22 +286,31 @@ def test_shared_tensor_product_weights_are_not_materialized_per_item(
286286
use_custom_kernel=False,
287287
)
288288
left = _array(product.irreps_in1, mx.ones((8, product.irreps_in1.dim)))
289-
right = _array(product.irreps_in2, mx.ones((8, product.irreps_in2.dim)))
289+
right = _array(product.irreps_in2, mx.ones((1, product.irreps_in2.dim)))
290290
shared_weight = product.weight
291-
materialized_shapes = []
291+
broadcasts = []
292292
original_broadcast_to = mx.broadcast_to
293293

294294
def record_broadcast(array, shape, *args, **kwargs):
295-
if array is shared_weight:
296-
materialized_shapes.append(tuple(shape))
295+
broadcasts.append((tuple(array.shape), tuple(shape)))
297296
return original_broadcast_to(array, shape, *args, **kwargs)
298297

299298
monkeypatch.setattr(mx, "broadcast_to", record_broadcast)
300299
output = product(left, right)
301300
mx.eval(output.array)
302301

303302
assert output.shape == (8, product.irreps_out.dim)
304-
assert materialized_shapes == []
303+
assert broadcasts
304+
weight_broadcasts = [
305+
target_shape
306+
for source_shape, target_shape in broadcasts
307+
if source_shape == tuple(shared_weight.shape)
308+
or (
309+
source_shape
310+
and source_shape[-1] == product.weight_numel
311+
)
312+
]
313+
assert weight_broadcasts == []
305314

306315

307316
@pytest.mark.mlx

tests/test_tensor_product_wrappers.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,15 @@ def test_tensor_square_symbolic_full_mode() -> None:
4949
assert {inst.mode for inst in tp.instructions} == {"uvu<v", "uuu"}
5050

5151

52+
def test_tensor_square_propagates_custom_kernel_setting() -> None:
53+
general = TensorSquare("2x0e", use_custom_kernel=False)
54+
requested = TensorSquare("2x0e", use_custom_kernel=True)
55+
56+
assert general.use_custom_kernel is False
57+
assert general._metal_operation is None
58+
assert requested.use_custom_kernel is True
59+
60+
5261
def test_tensor_square_symbolic_fully_connected_mode() -> None:
5362
tp = TensorSquare("2x0e", "1x0e", internal_weights=False)
5463
assert str(tp.irreps_out) == "0e"

tests/test_upstream_o3_linear_norm.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,7 @@ def test_grouped_linear_external_weight_vjp_matches_blockwise_formula() -> None:
158158
module = o3.Linear(
159159
"3x2e + 3x3e + 2x2e",
160160
"3x2e + 3x3e + 3x2e",
161+
instructions=[(0, 0), (1, 1), (2, 2)],
161162
internal_weights=False,
162163
path_normalization="path",
163164
)

0 commit comments

Comments
 (0)