Skip to content

Commit 0abe158

Browse files
Dobiasdclaude
andcommitted
Add use_causal_mask to MultiHeadAttention and approximate GELU
The MultiHeadAttention layer now respects the per-call use_causal_mask=True kwarg: scores at (t, k) for k > t are set to -inf before softmax. The converter extracts the flag from the layer's first inbound node and bakes it into the JSON config so the runtime can pick it up. GELU gains the tanh-approximation form. A serializable gelu_approximate helper in convert_model lets Keras models reference it via activation=gelu_approximate; the converter rewrites that to a plain "gelu" activation with approximate=True in the config, which the C++ gelu_layer applies via the appropriate formula. Both features are exercised by new cases in the exhaustive test model. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 7cbc07c commit 0abe158

6 files changed

Lines changed: 141 additions & 9 deletions

File tree

include/fdeep/import_model.hpp

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1329,11 +1329,11 @@ namespace internal {
13291329
const get_param_f&, const nlohmann::json& data,
13301330
const std::string& name)
13311331
{
1332+
bool approximate = false;
13321333
if (json_obj_has_member(data, "config") && json_obj_has_member(data["config"], "approximate") && !data["config"]["approximate"].is_null()) {
1333-
const bool approximate = data["config"]["approximate"];
1334-
assertion(approximate == false, "Gelu with approximate = True is not supported.");
1334+
approximate = data["config"]["approximate"];
13351335
}
1336-
return std::make_shared<gelu_layer>(name);
1336+
return std::make_shared<gelu_layer>(name, approximate);
13371337
}
13381338

13391339
inline activation_layer_ptr create_softsign_layer(
@@ -1473,6 +1473,9 @@ namespace internal {
14731473
const std::size_t key_dim = data["config"]["key_dim"];
14741474
const std::size_t value_dim = data["config"]["value_dim"];
14751475
const bool use_bias = data["config"]["use_bias"];
1476+
const bool use_causal_mask = json_obj_has_member(data["config"], "use_causal_mask")
1477+
? data["config"]["use_causal_mask"].get<bool>()
1478+
: false;
14761479
const auto weight_shapes = create_vector<std::vector<std::size_t>>(fplus::bind_1st_of_2(
14771480
create_vector<std::size_t, decltype(create_size_t)>, create_size_t),
14781481
get_param(name, "weight_shapes"));
@@ -1485,7 +1488,8 @@ namespace internal {
14851488
},
14861489
weight_shapes, weight_values);
14871490
return std::make_shared<multi_head_attention_layer>(name,
1488-
num_heads, key_dim, value_dim, use_bias, weights_and_biases);
1491+
num_heads, key_dim, value_dim, use_bias, use_causal_mask,
1492+
weights_and_biases);
14891493
}
14901494

14911495
inline layer_ptr create_lstm_layer(const get_param_f& get_param,

include/fdeep/layers/gelu_layer.hpp

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,16 +17,20 @@ namespace internal {
1717

1818
class gelu_layer : public activation_layer {
1919
public:
20-
explicit gelu_layer(const std::string& name)
20+
explicit gelu_layer(const std::string& name, bool approximate = false)
2121
: activation_layer(name)
22+
, approximate_(approximate)
2223
{
2324
}
2425

2526
protected:
2627
tensor transform_input(const tensor& in_vol) const override
2728
{
28-
return transform_tensor(gelu_activation, in_vol);
29+
return approximate_
30+
? transform_tensor(gelu_approximate_activation, in_vol)
31+
: transform_tensor(gelu_activation, in_vol);
2932
}
33+
bool approximate_;
3034
};
3135

3236
}

include/fdeep/layers/multi_head_attention_layer.hpp

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,13 @@ namespace internal {
1919
public:
2020
explicit multi_head_attention_layer(const std::string& name,
2121
std::size_t num_heads, std::size_t key_dim, std::size_t value_dim,
22-
bool use_bias, const std::vector<tensor>& weights_and_biases)
22+
bool use_bias, bool use_causal_mask,
23+
const std::vector<tensor>& weights_and_biases)
2324
: layer(name)
2425
, num_heads_(num_heads)
2526
, key_dim_(key_dim)
2627
, value_dim_(value_dim)
28+
, use_causal_mask_(use_causal_mask)
2729
, query_dense_(create_dense_layers(weights_and_biases, use_bias, num_heads, 0, key_dim, name + "_query_dense"))
2830
, value_dense_(create_dense_layers(weights_and_biases, use_bias, num_heads, 2, value_dim, name + "_value_dense"))
2931
, key_dense_(create_dense_layers(weights_and_biases, use_bias, num_heads, 1, key_dim, name + "_key_dense"))
@@ -88,12 +90,35 @@ namespace internal {
8890
// https://dmol.pub/dl/attention.html#multi-head-attention-block
8991
// https://github.com/keras-team/keras/blob/v2.14.0/keras/layers/attention/multi_head_attention.py
9092
// https://gist.github.com/sevagh/b71d253a347a9b59c026580625452fc5
91-
const tensor scores = dot_product_tensors(query, transpose(key), std::vector<int>({ 2, 1 }), false);
93+
tensor scores = dot_product_tensors(query, transpose(key), std::vector<int>({ 2, 1 }), false);
9294
const std::size_t query_size = query.shape().depth_;
93-
const tensor distribution = softmax(transform_tensor(fplus::multiply_with(1 / std::sqrt(query_size)), scores));
95+
scores = transform_tensor(fplus::multiply_with(1 / std::sqrt(query_size)), scores);
96+
if (use_causal_mask_) {
97+
apply_causal_mask(scores);
98+
}
99+
const tensor distribution = softmax(scores);
94100
return dot_product_tensors(distribution, value, std::vector<int>({ 2, 1 }), false);
95101
}
96102

103+
static void apply_causal_mask(tensor& scores)
104+
{
105+
// Scores have shape (..., T, S) with width=T (query positions)
106+
// and depth=S (key positions). Mask out s > t.
107+
const auto& s = scores.shape();
108+
const float_type neg_inf = -std::numeric_limits<float_type>::infinity();
109+
for (std::size_t d5 = 0; d5 < s.size_dim_5_; ++d5) {
110+
for (std::size_t d4 = 0; d4 < s.size_dim_4_; ++d4) {
111+
for (std::size_t y = 0; y < s.height_; ++y) {
112+
for (std::size_t t = 0; t < s.width_; ++t) {
113+
for (std::size_t k = t + 1; k < s.depth_; ++k) {
114+
scores.set_ignore_rank(tensor_pos(d5, d4, y, t, k), neg_inf);
115+
}
116+
}
117+
}
118+
}
119+
}
120+
}
121+
97122
protected:
98123
tensors apply_impl(const tensors& input) const override
99124
{
@@ -111,6 +136,7 @@ namespace internal {
111136
std::size_t num_heads_;
112137
std::size_t key_dim_;
113138
std::size_t value_dim_;
139+
bool use_causal_mask_;
114140
std::vector<dense_layer> query_dense_;
115141
std::vector<dense_layer> value_dense_;
116142
std::vector<dense_layer> key_dense_;

include/fdeep/recurrent_ops.hpp

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,15 @@ namespace internal {
7575
return static_cast<float_type>(0.5) * x * (static_cast<float_type>(1) + static_cast<float_type>(std::erf(x / std::sqrt(static_cast<float_type>(2)))));
7676
}
7777

78+
inline float_type gelu_approximate_activation(float_type x)
79+
{
80+
// 0.5 * x * (1 + tanh(sqrt(2/pi) * (x + 0.044715 * x^3)))
81+
const float_type sqrt_2_over_pi = static_cast<float_type>(0.7978845608028654);
82+
const float_type c = static_cast<float_type>(0.044715);
83+
const float_type inner = sqrt_2_over_pi * (x + c * x * x * x);
84+
return static_cast<float_type>(0.5) * x * (static_cast<float_type>(1) + std::tanh(inner));
85+
}
86+
7887
inline float_type softsign_activation(float_type x)
7988
{
8089
return x / (std::abs(x) + static_cast<float_type>(1));

keras_export/convert_model.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,21 @@
1111

1212
import numpy as np
1313
import numpy.typing # pylint: disable=unused-import
14+
import keras
1415
from keras import backend as K, Layer
1516
from keras.layers import Input, Embedding, CategoryEncoding
1617
from keras.models import Model, load_model
1718
from keras.src import Functional
1819

20+
21+
@keras.saving.register_keras_serializable(package="fdeep")
22+
def gelu_approximate(x): # type: ignore[no-untyped-def]
23+
"""Tanh-approximation form of GELU, registered so saved models that use
24+
it can be loaded by this converter. The conversion step rewrites it to a
25+
plain ``gelu`` activation with an extra ``approximate=True`` flag in the
26+
layer config, which the C++ runtime picks up."""
27+
return keras.activations.gelu(x, approximate=True)
28+
1929
__author__ = "Tobias Hermann"
2030
__copyright__ = "Copyright 2017, Tobias Hermann"
2131
__license__ = "MIT"
@@ -968,6 +978,62 @@ def calculate_hash(model: Model) -> str:
968978
return hash_m.hexdigest()
969979

970980

981+
_ACTIVATION_FUNCTION_REWRITES = {
982+
'fdeep>gelu_approximate': ('gelu', {'approximate': True}),
983+
}
984+
985+
986+
def rewrite_custom_activations(arch: Any) -> None:
987+
"""Walk the serialized architecture and replace registered custom
988+
activation functions with a plain string activation plus extra config
989+
flags, so the C++ runtime sees a known activation type."""
990+
if isinstance(arch, dict):
991+
cfg = arch.get('config')
992+
if isinstance(cfg, dict):
993+
act = cfg.get('activation')
994+
if isinstance(act, dict) and act.get('class_name') == 'function':
995+
rewrite = _ACTIVATION_FUNCTION_REWRITES.get(act.get('config'))
996+
if rewrite is not None:
997+
new_name, extra = rewrite
998+
cfg['activation'] = new_name
999+
cfg.update(extra)
1000+
for v in arch.values():
1001+
rewrite_custom_activations(v)
1002+
elif isinstance(arch, list):
1003+
for v in arch:
1004+
rewrite_custom_activations(v)
1005+
1006+
1007+
def inject_mha_call_kwargs(arch: Any, model: Model) -> None:
1008+
"""``use_causal_mask`` is a per-call kwarg of ``MultiHeadAttention``, not a
1009+
layer config field, so ``model.to_json()`` doesn't include it. Look the
1010+
flag up from the layer's first inbound node and bake it into the JSON
1011+
config so the C++ runtime can read it."""
1012+
if isinstance(arch, dict):
1013+
if arch.get('class_name') == 'MultiHeadAttention':
1014+
cfg = arch.get('config') or {}
1015+
layer_name = cfg.get('name')
1016+
if layer_name:
1017+
try:
1018+
layer = model.get_layer(layer_name)
1019+
except (ValueError, KeyError):
1020+
layer = None
1021+
if layer is not None and layer._inbound_nodes:
1022+
kwargs = layer._inbound_nodes[0].arguments.kwargs
1023+
if kwargs.get('use_causal_mask'):
1024+
cfg['use_causal_mask'] = True
1025+
if kwargs.get('attention_mask') is not None:
1026+
raise NotImplementedError(
1027+
f"MultiHeadAttention layer {layer_name!r} was "
1028+
"called with an explicit attention_mask, which "
1029+
"the frugally-deep runtime does not yet support.")
1030+
for v in arch.values():
1031+
inject_mha_call_kwargs(v, model)
1032+
elif isinstance(arch, list):
1033+
for v in arch:
1034+
inject_mha_call_kwargs(v, model)
1035+
1036+
9711037
def model_to_fdeep_json(model: Model, no_tests: bool = False) -> Mapping[str, Any]:
9721038
"""Convert any Keras model to the frugally-deep model format."""
9731039

@@ -983,6 +1049,8 @@ def model_to_fdeep_json(model: Model, no_tests: bool = False) -> Mapping[str, An
9831049
json_output = {}
9841050
print('Converting model architecture.')
9851051
json_output['architecture'] = json.loads(model.to_json())
1052+
rewrite_custom_activations(json_output['architecture'])
1053+
inject_mha_call_kwargs(json_output['architecture'], model)
9861054
json_output['image_data_format'] = K.image_data_format()
9871055
json_output['input_shapes'] = list(map(get_layer_input_shape_tensor_shape, get_model_input_layers(model)))
9881056
json_output['output_shapes'] = list(map(keras_shape_to_fdeep_tensor_shape, as_list(model.output_shape)))

keras_export/generate_test_models.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,11 @@
55
import sys
66
from typing import Tuple, List, Union
77

8+
# convert_model lives next to this script; importing it pulls in the
9+
# ``gelu_approximate`` serializable activation that the converter then
10+
# rewrites to ``gelu`` with ``approximate=True``.
11+
from convert_model import gelu_approximate
12+
813
import numpy as np
914
from keras import activations
1015
from keras.layers import ActivityRegularization
@@ -413,6 +418,8 @@ def get_test_model_exhaustive() -> Model:
413418
outputs.append(Dense(3, use_bias=True)(inputs[14]))
414419
outputs.append(Dense(4, use_bias=False)(inputs[16]))
415420
outputs.append(Dense(4, use_bias=False, activation='tanh')(inputs[18]))
421+
outputs.append(Dense(4, use_bias=True, activation='gelu')(inputs[18]))
422+
outputs.append(Dense(4, use_bias=True, activation=gelu_approximate)(inputs[18]))
416423
outputs.append(Dense(4, use_bias=False)(inputs[20]))
417424

418425
outputs.append(Reshape(((2 * 3 * 4 * 5 * 6),))(inputs[0]))
@@ -541,6 +548,20 @@ def get_test_model_exhaustive() -> Model:
541548
num_heads=2, key_dim=3, value_dim=5,
542549
use_bias=True, output_shape=None, attention_axes=None)(inputs[49], inputs[50], inputs[51]))
543550

551+
# use_causal_mask=True: triangular attention so position i only attends to <=i.
552+
outputs.append(MultiHeadAttention(
553+
num_heads=1, key_dim=2, value_dim=None,
554+
use_bias=True, output_shape=None, attention_axes=None)(
555+
inputs[50], inputs[50], use_causal_mask=True))
556+
outputs.append(MultiHeadAttention(
557+
num_heads=2, key_dim=3, value_dim=4,
558+
use_bias=True, output_shape=None, attention_axes=None)(
559+
inputs[50], inputs[50], inputs[50], use_causal_mask=True))
560+
outputs.append(MultiHeadAttention(
561+
num_heads=3, key_dim=2, value_dim=2,
562+
use_bias=False, output_shape=None, attention_axes=None)(
563+
inputs[50], inputs[50], use_causal_mask=True))
564+
544565
# GroupedQueryAttention: shared Q/K/V seq, separate K/V seq, with/without gate.
545566
outputs.append(GroupQueryAttention(head_dim=4, num_query_heads=6,
546567
num_key_value_heads=2)(inputs[49], inputs[49], inputs[49]))

0 commit comments

Comments
 (0)