Residual connections were introduced by the ResNet paper and helped train deeper and deeper models. Attention Is All You Need brought residual connections to the transformer architecture, but Hyper-Connections expanded its capabilities by introducing multiple streams and read, write and mixing matrices. mHC improved their stability and scalability.
We start by copying our embeddings into
Our aim with mHC is to provide each sublayer (attention and FFN) the ability to read from and write into our residual stream, which is more powerful than simply adding the input to the output as done in vanilla architectures. We also want to give the model a method to mix the information across the streams, as that is a powerful bias for the model to have. It can then choose to mix information as it pleases or keep all streams separate. It can turn one of the streams into a skip connection or specialise them to the sublayers. The more control we give to the network in general, the better.
With mHC, before each sublayer, the model gets to decide the input. It isn't as simple as the output of the previous layer anymore, since we now have 4 residual streams holding the information. We also need to turn the four streams into one. We do this by producing four values, which are the mixture weights of each of the streams. Then we perform a weighted combination of the four streams to get one stream. We build these weights using the input and some learned scales and biases. Eq. 1 outlines how we do this:
Here,
We then multiply
where
After each sublayer, say attention or an FFN, we have a
Here,
We then multiply
where
# H_post.unsqueeze(-1) = [b, t, n, 1]
# x_out.unsqueeze(-2) = [b, t, 1, d_model]
out = H_post.unsqueeze(-1) @ x_out.unsqueeze(-2) # output: [b, t, n, d_model]
It might make more sense to use
In between the sublayers, we'd like a way to share information between the residual streams. The DeepSeek paper found mixing alone accounts for most of the gains of mHC. We could argue that the read and write operations have some mixing built in, but the constraint of having to compress four streams into one, go through a sublayer and then fan out to four streams seems to constrain the mixing too much. A better method is to mix directly across the streams.
To mix, for each stream we need to add to it the remaining streams, but we'd like the model to have control over how much of each stream should mix with the other. This would look something like Eq. 5.
where
Here,
To preserve the input scale and encourage even mixing, we need to ensure that the
We can also see that the diagonal entries represent how much each stream keeps of itself, while the off-diagonal entries are how much each stream contributes to the others. By asking the rows to sum to one, we ensure that each stream takes the linear combination of the others and by asking the columns to sum to one, we ensure each stream contributes to others only that which keeps the total contributions weight sum at 1. By asking both the rows and columns to sum to one, we limit how much this matrix can stretch/amplify a unit vector. This helps stabilise training in the forward pass and backward pass. Without it, the mixing could collapse where a few streams dominate. We use the Sinkhorn–Knopp algorithm to ensure that the rows and columns sum to one by iteratively normalising them. This is done for 20 steps in the DeepSeek paper.
The Sinkhorn–Knopp algorithm is an alternating column-wise and row-wise softmax. Given an unconstrained matrix
where
Which causes each of the columns to sum to one. We then do the same for each of the rows, starting from
Which causes the rows to sum to one. However, we can't guarantee the columns still sum to one. So we perform the column and row normalisation alternately for 20 steps and the resulting matrix will have both rows and columns summing to one. Quite simple, yet beautiful.
Here, we present a simple PyTorch implementation of mHC. A few notes to help understand the implementation:
x_res in the code), they can be computed once using a combined matmul as done in the gemm_projection function. If we stack the projection matrices for each of these in the width dimension, we can do one matmul and break off each of the outputs.class mHC(nn.Module):
def __init__(self, cfg, layer_idx):
super().__init__()
n = cfg["n"]
d_model = cfg["d_model"]
# Read gate: scalar scale and per-stream bias
# [] and [n]
self.alpha_pre = nn.Parameter(torch.tensor(0.01))
beta_pre = torch.full((n,), -4.0)
beta_pre[layer_idx % n] = 4.0
self.beta_pre = nn.Parameter(beta_pre)
# Write gate: scalar scale and per-stream bias
# [] and [n]
self.alpha_post = nn.Parameter(torch.tensor(0.01))
self.beta_post = nn.Parameter(torch.zeros(n))
# Residual mixing: scalar scale and flattened identity-biased logits
# [] and [n*n]
self.alpha_res = nn.Parameter(torch.tensor(0.01))
beta_res = torch.full((n, n), -4.0)
beta_res.fill_diagonal_(0.0)
self.beta_res = nn.Parameter(beta_res.flatten())
# Joint projection for input-dependent read, write and mixing gates
# [b, t, n*d_model] @ [n*d_model, 2*n+n*n] -> [b, t, 2*n+n*n]
self.phi = nn.Linear(n*d_model, 2*n+n*n, bias=False)
def gemm_projection(self, x_res):
# [b, t, n, d_model]
b, t, n, d_model = x_res.shape
# Flatten all residual streams
# [b, t, n, d_model] -> [b, t, n*d_model]
x_res_flat = x_res.reshape(b, t, n*d_model)
# Compute inverse RMS across all streams
# [b, t, n*d_model] -> [b, t, 1]
r = torch.rsqrt(
x_res_flat.pow(2).mean(-1, keepdim=True, dtype=torch.float32) + 1e-20
)
# Project and normalize gate logits
# [b, t, n*d_model] @ [n*d_model, 2*n+n*n] -> [b, t, 2*n+n*n]
output = self.phi(x_res_flat) * r
# Split logits into read, write and residual mixing
# [b, t, 2*n+n*n] -> [b, t, n], [b, t, n], [b, t, n*n]
self.read_m = output[:, :, :n]
self.write_m = output[:, :, n:2*n]
self.mix_m = output[:, :, 2*n:]
def read(self, x_res):
# [b, t, n, d_model]
b, t, n, d_model = x_res.shape
# Generate input-dependent read weights
# [b, t, n] * [] + [n] -> [b, t, n]
h_pre = torch.sigmoid(self.alpha_pre * self.read_m + self.beta_pre)
# Weighted sum of residual streams
# [b, t, 1, n] @ [b, t, n, d_model] -> [b, t, 1, d_model]
out = h_pre.unsqueeze(-2) @ x_res
# [b, t, 1, d_model] -> [b, t, d_model]
return out.squeeze(-2)
def write(self, x_res, x_layer):
# x_res: [b, t, n, d_model]
# x_layer: [b, t, d_model]
b, t, n, d_model = x_res.shape
# Generate input-dependent write weights
# [b, t, n] * [] + [n] -> [b, t, n]
h_post = 2.0 * torch.sigmoid(
self.alpha_post * self.write_m + self.beta_post
)
# Distribute sublayer output across residual streams
# [b, t, n, 1] @ [b, t, 1, d_model] -> [b, t, n, d_model]
out = h_post.unsqueeze(-1) @ x_layer.unsqueeze(-2)
# [b, t, n, d_model]
return out
def mix(self, x_res):
# [b, t, n, d_model]
b, t, n, d_model = x_res.shape
# Generate input-dependent residual mixing logits
# [b, t, n*n] * [] + [n*n] -> [b, t, n, n]
h_res = (
self.alpha_res * self.mix_m + self.beta_res
).reshape(b, t, n, n)
# Normalize into a doubly stochastic mixing matrix
# [b, t, n, n] -> [b, t, n, n]
h_res = self.sinkhorn(h_res)
# Mix residual streams
# [b, t, n, n] @ [b, t, n, d_model] -> [b, t, n, d_model]
out_res = h_res @ x_res
# [b, t, n, d_model]
return out_res
def sinkhorn(self, logits, n_iters=20):
# logits: [b, t, n, n]
assert logits.shape[-2] == logits.shape[-1]
# Convert logits to FP32
# [b, t, n, n] -> [b, t, n, n]
z = logits.float()
for _ in range(n_iters):
# Normalize columns in log-space
# [b, t, n, n] - [b, t, 1, n] -> [b, t, n, n]
z = z - torch.logsumexp(z, dim=-2, keepdim=True)
# Normalize rows in log-space
# [b, t, n, n] - [b, t, n, 1] -> [b, t, n, n]
z = z - torch.logsumexp(z, dim=-1, keepdim=True)
# Convert log probabilities to mixing weights
# [b, t, n, n] -> [b, t, n, n]
return z.exp().to(logits.dtype)
The plot below shows the held-out cross-entropy loss of a vanilla 124M model vs the same model with mHC trained for 2.5B tokens on the FineWeb-Edu dataset. We can see a ~1% improvement in validation loss. That might not look like much, but the difference is still growing. mHC also has synergies with Engram.
For a deeper analysis of mHC, please review the Nenya technical report.

Figure 1: Held-out validation loss comparing vanilla transformer vs vanilla transformer with mHC.