File size: 6,783 Bytes
3da88e3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
import json

import mlx.core as mx
import numpy as np
import pytest
import torch
import torch.nn.functional as F

from h3_mlx.config import canvas_size, load_sigmas
from h3_mlx.layout import image_positions, patchify, unpatchify
from h3_mlx.ops import attention, layer_norm, modulate, rms_norm, rotate, silu
from h3_mlx.weights import Linear, WeightStore


def array(t):
    return mx.array(t.float().numpy()).astype(
        mx.bfloat16 if t.dtype == torch.bfloat16 else mx.float32
    )


def numpy(x):
    return np.asarray(x.astype(mx.float32))


def assert_bf16_near(actual, expected, *, equality=0.9999):
    a, b = numpy(actual), expected.float().numpy()
    # CPU/Metal reduction orders may cross a BF16 rounding midpoint. Require
    # one ULP at most, while detecting the much larger wrong-arithmetic case.
    aa = np.asarray(actual.view(mx.uint16)).astype(np.int32)
    bb = expected.view(torch.int16).numpy().view(np.uint16).astype(np.int32)
    assert np.max(abs(aa - bb)) <= 1
    assert np.mean(a == b) >= equality


@pytest.mark.parametrize("width", [128, 5376])
def test_distinct_aten_and_qwen_rms_rounding(width):
    gen = torch.Generator().manual_seed(9)
    x = torch.randn(17, width, generator=gen).bfloat16()
    w = torch.randn(width, generator=gen).bfloat16()
    aten = F.rms_norm(x, (width,), w, 1e-5)
    qwen = (
        x.float() * torch.rsqrt(x.float().square().mean(-1, keepdim=True) + 1e-5)
    ).bfloat16() * w
    assert_bf16_near(rms_norm(array(x), array(w)), aten)
    assert_bf16_near(rms_norm(array(x), array(w), rounded=True), qwen)
    assert not torch.equal(aten, qwen)


def test_bf16_bias_epilogue_and_silu():
    torch.manual_seed(21)
    x, w, b = (
        torch.randn(13, 128).bfloat16(),
        torch.randn(256, 128).bfloat16(),
        torch.randn(256).bfloat16(),
    )
    layer = Linear(array(w), array(b))
    assert_bf16_near(layer(array(x)), F.linear(x, w, b), equality=0.998)
    np.testing.assert_array_equal(numpy(silu(array(x))), F.silu(x).float().numpy())


def test_attention_dense_gqa_and_causal_chunks():
    torch.manual_seed(8)
    q, k, v = (
        torch.randn(1, 37, 8, 128),
        torch.randn(1, 37, 2, 128),
        torch.randn(1, 37, 2, 128),
    )
    ref = F.scaled_dot_product_attention(
        q.transpose(1, 2),
        k.transpose(1, 2),
        v.transpose(1, 2),
        is_causal=True,
        enable_gqa=True,
    ).transpose(1, 2)
    for chunk in (0, 11):
        out = attention(array(q), array(k), array(v), causal=True, query_chunk=chunk)
        np.testing.assert_allclose(numpy(out), ref.numpy(), atol=2e-6, rtol=2e-5)


def test_rotary_keeps_passthrough_and_bf16_products():
    torch.manual_seed(41)
    x = torch.randn(1, 17, 3, 128).bfloat16()
    angles = torch.randn(1, 17, 1, 48)
    angles = torch.cat((angles, angles), dim=-1)
    cos, sin = angles.cos().bfloat16(), angles.sin().bfloat16()
    a = x[..., :96]
    ref = torch.cat(
        (a * cos + torch.cat((-a[..., 48:], a[..., :48]), -1) * sin, x[..., 96:]), -1
    )
    np.testing.assert_array_equal(
        numpy(rotate(array(x), array(cos), array(sin))), ref.float().numpy()
    )


def test_layer_norm_affine_does_not_round_normalized_input():
    torch.manual_seed(98)
    x = torch.randn(17, 1024).bfloat16()
    w, b = torch.randn(1024).bfloat16(), torch.randn(1024).bfloat16()
    ref = F.layer_norm(x, (1024,), w, b, 1e-5)
    assert_bf16_near(layer_norm(array(x), array(w), array(b)), ref, equality=0.999)


def test_safetensors_bf16_bit_loading_and_grouped_qkv(tmp_path):
    from safetensors.torch import save_file

    tensor = torch.arange(2 * 3 * 4 * 8, dtype=torch.float32).reshape(24, 8).bfloat16()
    save_file({"qkv.weight": tensor}, str(tmp_path / "test.safetensors"))
    store = WeightStore(tmp_path)
    np.testing.assert_array_equal(
        numpy(store.get("qkv.weight")), tensor.float().numpy()
    )
    layer = Linear.load(store, "qkv", grouped_heads=(2, 4))
    ref = tensor.reshape(2, 3, 4, 8).permute(1, 0, 2, 3).reshape(24, 8)
    np.testing.assert_array_equal(numpy(layer.weight), ref.float().numpy())
    store.clear()


def test_patch_order_matches_native_einsum():
    x = torch.arange(24 * 8 * 10, dtype=torch.float32).reshape(1, 24, 8, 10)
    ref = torch.einsum("nchpwq->nhwcpq", x.reshape(1, 24, 4, 2, 5, 2)).reshape(-1, 96)
    rows = patchify(array(x))
    np.testing.assert_array_equal(numpy(rows), ref.numpy())
    np.testing.assert_array_equal(numpy(unpatchify(rows, 8, 10)), x.numpy())


def test_positions_are_native_ref2va_grid():
    out = numpy(image_positions(7, 8, 12, [(4, 6)]))
    assert out.shape == (7 + 6 + 24, 3)
    np.testing.assert_array_equal(out[:7, 0], np.arange(7))
    np.testing.assert_array_equal(out[7:13, 0], 7)
    np.testing.assert_array_equal(out[13:, 0], 8)
    np.testing.assert_allclose(out[13:, 1].min(), (1 - 8 / np.sqrt(96)) * 16, rtol=1e-6)


def test_native_schedule_and_canvas_validation(tmp_path):
    assert canvas_size(1024, 1024) == (2048, 2048)
    assert canvas_size(512, 768, 1024) == (512, 768)
    for width, height in [(0, 2048), (512, 0), (-1, 32)]:
        with pytest.raises(ValueError):
            canvas_size(width, height)
    (tmp_path / "pdd_config.json").write_text(json.dumps({"sigmas": [1, 0.5, 0]}))
    with pytest.raises(ValueError, match="six-step"):
        load_sigmas(tmp_path)


@pytest.mark.parametrize("threads", [128, 256, 512])
def test_metal_modulation_with_odd_row_count(threads):
    from h3_mlx.kernels import norm_modulate

    mx.random.seed(71)
    x = mx.random.normal((65, 5376)).astype(mx.bfloat16)
    w = mx.random.normal((5376,)).astype(mx.bfloat16)
    s = (mx.random.normal((3, 5376)) * 0.1).astype(mx.bfloat16)
    ids = mx.arange(65) % 3
    ref = modulate(x, w, s, s, ids)
    out = norm_modulate(x, w, s, s, ids, threads=threads)
    a, b = numpy(ref), numpy(out)
    # Different parallel reduction orders are allowed only within one BF16 ULP.
    assert np.sqrt(np.mean((a - b) ** 2) / (np.mean(a * a) + 1e-20)) < 1e-4
    assert np.mean(a == b) > 0.9999


def test_fused_qk_norm_rope_layout_and_rounding():
    from h3_mlx.kernels import qk_norm_rope

    mx.random.seed(33)
    qkv = mx.random.normal((67, 3, 7, 128)).astype(mx.bfloat16)
    w = mx.random.normal((128,)).astype(mx.bfloat16)
    angles = mx.random.normal((1, 67, 1, 48))
    angles = mx.concatenate((angles, angles), axis=-1)
    cos, sin = mx.cos(angles).astype(mx.bfloat16), mx.sin(angles).astype(mx.bfloat16)
    actual = qk_norm_rope(qkv, w, w, cos, sin)
    for i, out in enumerate(actual):
        ref = rotate(rms_norm(qkv[None, :, i], w), cos, sin)
        a, b = numpy(ref), numpy(out)
        assert np.sqrt(np.mean((a - b) ** 2) / (np.mean(a * a) + 1e-20)) < 1e-4
        assert np.mean(a == b) > 0.9999