@@ -12,7 +12,7 @@ defmodule Emily.Backend do
1212 * `{:f, 64}` is not supported — Metal cannot execute f64. Operations
1313 that would allocate an f64 tensor raise `ArgumentError` with a
1414 message pointing to f32.
15- * `bitcast`, ` from_pointer`, `to_pointer`, `population_count`, and
15+ * `from_pointer`, `to_pointer`, `population_count`, and
1616 `count_leading_zeros` raise `ArgumentError` — MLX has no primitive.
1717 * Window operations (`window_sum`, `window_scatter_max`, etc.) and
1818 advanced linalg (`lu`, `svd`, `qr`, `cholesky`, `eigh`, `solve`,
@@ -303,9 +303,13 @@ defmodule Emily.Backend do
303303 t |> ref ( ) |> Native . astype ( type ) |> wrap ( out )
304304 end
305305
306+ # bitcast: reinterpret the bits as the output dtype (same element
307+ # size). MLX exposes this as `mx::view`. Used by Nx.Random to move
308+ # between uint and float of matching width.
306309 @ impl true
307- def bitcast ( _out , _t ) ,
308- do: raise ( ArgumentError , "Emily.Backend does not implement bitcast (MLX has no primitive)" )
310+ def bitcast ( % T { type: type } = out , t ) do
311+ t |> ref ( ) |> Native . bitcast ( type ) |> wrap ( out )
312+ end
309313
310314 # =================================================================
311315 # Unary ops
@@ -393,7 +397,15 @@ defmodule Emily.Backend do
393397 # Binary ops
394398 # =================================================================
395399
396- @ renamed_binary [
400+ # Arithmetic + bitwise: cast both operands to `out.type` before
401+ # handing to MLX. Two reasons:
402+ # 1. MLX's cross-type promotion for mixed integer widths (e.g.,
403+ # u64 + s32) falls back to float32 — which then fails on
404+ # integer-only ops like right_shift. `Nx.Random.key` hits this.
405+ # 2. `divide` has `out.type = float` even for integer operands
406+ # (`Nx.Type.to_floating/1`); casting to out.type first produces
407+ # the float division Nx promises.
408+ @ renamed_arith_binary [
397409 subtract: :subtract ,
398410 multiply: :multiply ,
399411 divide: :divide ,
@@ -402,36 +414,62 @@ defmodule Emily.Backend do
402414 atan2: :arctan2 ,
403415 min: :minimum ,
404416 max: :maximum ,
417+ bitwise_and: :bitwise_and ,
418+ bitwise_or: :bitwise_or ,
419+ bitwise_xor: :bitwise_xor ,
420+ left_shift: :left_shift ,
421+ right_shift: :right_shift
422+ ]
423+
424+ for { nx_name , native_name } <- @ renamed_arith_binary do
425+ @ impl true
426+ def unquote ( nx_name ) ( % T { type: type } = out , a , b ) do
427+ ra = Native . astype ( ref ( a ) , type )
428+ rb = Native . astype ( ref ( b ) , type )
429+ Native . unquote ( native_name ) ( ra , rb ) |> wrap ( out )
430+ end
431+ end
432+
433+ # Compare + logical: out.type is `{:u, 8}` (pred), but MLX still
434+ # needs the operands at a matched non-pred type to compare. Cast to
435+ # `Nx.Type.merge(a, b)` so MLX sees a consistent arithmetic type.
436+ @ renamed_pred_binary [
405437 equal: :equal ,
406438 not_equal: :not_equal ,
407439 less: :less ,
408440 less_equal: :less_equal ,
409441 greater: :greater ,
410442 greater_equal: :greater_equal ,
411443 logical_and: :logical_and ,
412- logical_or: :logical_or ,
413- bitwise_and: :bitwise_and ,
414- bitwise_or: :bitwise_or ,
415- bitwise_xor: :bitwise_xor ,
416- left_shift: :left_shift ,
417- right_shift: :right_shift
444+ logical_or: :logical_or
418445 ]
419446
420- for { nx_name , native_name } <- @ renamed_binary do
447+ for { nx_name , native_name } <- @ renamed_pred_binary do
421448 @ impl true
422449 def unquote ( nx_name ) ( out , a , b ) do
423- Native . unquote ( native_name ) ( ref ( a ) , ref ( b ) ) |> wrap ( out )
450+ target = Nx.Type . merge ( a . type , b . type )
451+ ra = Native . astype ( ref ( a ) , target )
452+ rb = Native . astype ( ref ( b ) , target )
453+ Native . unquote ( native_name ) ( ra , rb ) |> wrap ( out )
424454 end
425455 end
426456
427457 @ impl true
428- def add ( out , a , b ) , do: Native . add ( ref ( a ) , ref ( b ) ) |> wrap ( out )
458+ def add ( % T { type: type } = out , a , b ) do
459+ ra = Native . astype ( ref ( a ) , type )
460+ rb = Native . astype ( ref ( b ) , type )
461+ Native . add ( ra , rb ) |> wrap ( out )
462+ end
429463
430464 # See moduledoc: we intentionally use MLX floor_divide for quotient,
431465 # matching its rounding (floor toward -inf) rather than Nx's
432466 # truncate-toward-zero. The two agree for non-negative operands.
433467 @ impl true
434- def quotient ( out , a , b ) , do: Native . floor_divide ( ref ( a ) , ref ( b ) ) |> wrap ( out )
468+ def quotient ( % T { type: type } = out , a , b ) do
469+ ra = Native . astype ( ref ( a ) , type )
470+ rb = Native . astype ( ref ( b ) , type )
471+ Native . floor_divide ( ra , rb ) |> wrap ( out )
472+ end
435473
436474 # logical_xor: MLX has no direct op. Compose via `not_equal` on
437475 # booleanised inputs. With u8 inputs, `not_equal(0)` yields truthy
@@ -543,10 +581,17 @@ defmodule Emily.Backend do
543581
544582 @ impl true
545583 def slice ( % T { } = out , t , starts , lengths , strides ) do
584+ # Nx passes starts as either integers or scalar tensors (dynamic
585+ # slicing). MLX's slice takes integer bounds; under the evaluator
586+ # we materialise scalar-tensor starts to their concrete value.
587+ starts = Enum . map ( starts , & slice_start / 1 )
546588 stops = Enum . zip_with ( starts , lengths , fn s , l -> s + l end )
547589 Native . slice ( ref ( t ) , starts , stops , strides ) |> wrap ( out )
548590 end
549591
592+ defp slice_start ( i ) when is_integer ( i ) , do: i
593+ defp slice_start ( % T { } = t ) , do: t |> Nx . backend_copy ( Nx.BinaryBackend ) |> Nx . to_number ( )
594+
550595 # put_slice: MLX has no direct primitive; route via BinaryBackend.
551596 @ impl true
552597 def put_slice ( out , t , slice , starts ) ,
@@ -609,10 +654,14 @@ defmodule Emily.Backend do
609654 end
610655 end
611656
657+ # Nx's argmax/argmin take `:keep_axis` (singular) on user-facing API
658+ # but the backend callback exposes raw opts whose spelling has drifted
659+ # across Nx versions. Derive `keep` from the shape invariant instead:
660+ # if `out.shape` has the same rank as the input, the axis was kept.
612661 @ impl true
613662 def argmax ( % T { } = out , t , opts ) do
614663 axis = opts [ :axis ] || 0
615- keep = opts [ :keep_axes ] || false
664+ keep = tuple_size ( out . shape ) == tuple_size ( t . shape )
616665
617666 ref ( t )
618667 |> Native . argmax ( axis , keep )
@@ -623,7 +672,7 @@ defmodule Emily.Backend do
623672 @ impl true
624673 def argmin ( % T { } = out , t , opts ) do
625674 axis = opts [ :axis ] || 0
626- keep = opts [ :keep_axes ] || false
675+ keep = tuple_size ( out . shape ) == tuple_size ( t . shape )
627676
628677 ref ( t )
629678 |> Native . argmin ( axis , keep )
@@ -666,20 +715,78 @@ defmodule Emily.Backend do
666715 # Dot product
667716 # =================================================================
668717
669- # Non-batched tensor contraction : tensordot. Batched case: we
670- # reshape-merge batch axes, matmul, reshape back. For now only
671- # handle the non-batched case fully; batched falls back to
672- # BinaryBackend .
718+ # Non-batched: tensordot. Batched: permute to [batch, free, contract]
719+ # on a and [ batch, contract, free] on b, flatten to 3-D, hand to
720+ # MLX matmul (which treats leading dims as batch), reshape back to
721+ # Nx's canonical `batch ++ free_a ++ free_b` layout .
673722 @ impl true
674723 def dot ( % T { } = out , a , contract_a , [ ] , b , contract_b , [ ] ) do
675724 Native . tensordot ( ref ( a ) , ref ( b ) , contract_a , contract_b ) |> wrap ( out )
676725 end
677726
678- # Batched dot is on the transformer-attention critical path. M3
679- # replaces this BinaryBackend bounce with a native matmul-with-
680- # transpose pattern before Bumblebee lands.
681- def dot ( out , a , contract_a , batch_a , b , contract_b , batch_b ) ,
682- do: via_binary ( out , [ a , b ] , & Nx . dot ( & 1 , contract_a , batch_a , & 2 , contract_b , batch_b ) )
727+ def dot ( % T { type: type } = out , a , contract_a , batch_a , b , contract_b , batch_b ) do
728+ # MLX matmul is float-only; ints/preds fall through to BinaryBackend.
729+ # In practice every transformer-attention call is float, so this is
730+ # the hot path.
731+ if float_like? ( type ) do
732+ batched_matmul ( out , a , contract_a , batch_a , b , contract_b , batch_b )
733+ else
734+ via_binary ( out , [ a , b ] , & Nx . dot ( & 1 , contract_a , batch_a , & 2 , contract_b , batch_b ) )
735+ end
736+ end
737+
738+ defp float_like? ( { kind , _ } ) when kind in [ :f , :bf , :c ] , do: true
739+ defp float_like? ( _ ) , do: false
740+
741+ # Nx guarantees batch axes on both tensors are [0, 1, ..., k-1] in
742+ # increasing order, so the permutation simplifies: batch dims stay
743+ # at the front, free axes sort in positional order, contract axes
744+ # in the Nx-given pairing order.
745+ defp batched_matmul (
746+ % T { shape: out_shape } = out ,
747+ % T { shape: as } = a ,
748+ contract_a ,
749+ batch_a ,
750+ % T { shape: bs } = b ,
751+ contract_b ,
752+ _batch_b
753+ ) do
754+ a_rank = tuple_size ( as )
755+ b_rank = tuple_size ( bs )
756+ k = length ( batch_a )
757+
758+ contract_set_a = MapSet . new ( contract_a )
759+ contract_set_b = MapSet . new ( contract_b )
760+
761+ free_a = for i <- k .. ( a_rank - 1 ) // 1 , not MapSet . member? ( contract_set_a , i ) , do: i
762+ free_b = for i <- k .. ( b_rank - 1 ) // 1 , not MapSet . member? ( contract_set_b , i ) , do: i
763+
764+ b_prod = dim_product ( batch_a , as )
765+ m = dim_product ( free_a , as )
766+ n = dim_product ( free_b , bs )
767+ k_prod = dim_product ( contract_a , as )
768+
769+ perm_a = batch_a ++ free_a ++ contract_a
770+ perm_b = batch_a ++ contract_b ++ free_b
771+
772+ ra =
773+ a
774+ |> ref ( )
775+ |> Native . transpose ( perm_a )
776+ |> Native . reshape ( [ b_prod , m , k_prod ] )
777+
778+ rb =
779+ b
780+ |> ref ( )
781+ |> Native . transpose ( perm_b )
782+ |> Native . reshape ( [ b_prod , k_prod , n ] )
783+
784+ Native . matmul ( ra , rb )
785+ |> Native . reshape ( shape_list ( out_shape ) )
786+ |> wrap ( out )
787+ end
788+
789+ defp dim_product ( axes , shape ) , do: Enum . reduce ( axes , 1 , & ( elem ( shape , & 1 ) * & 2 ) )
683790
684791 # =================================================================
685792 # Sort / argsort / top_k / all_close / take / take_along_axis
0 commit comments