Manifold-constrained hyper-connections

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.

How it works

We start by copying our embeddings into n streams, so we go from [b,t,dmodel] to [b,t,n,dmodel] where n is a hyperparameter. The original DeepSeek paper used 4, but others have tried fewer streams, which still work quite well.

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.

Read

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:

(1)Hpre=σ(αpre(xinputϕpre)+βpre)

Here, ϕpre is a linear projection of the input, of shape [n⋅dmodel,n], while αpre is a learned scale of shape [1] and βpre is a learned bias per stream, of dimension [n]. These are both independent of input and the same for every token. We also apply the sigmoid function to get the n values between 0 and 1. Hpre is of size [b,t,n], so we get 4 values per token which decide how its input should be built from its four residual vectors. xinput is defined as [b,t,n,dmodel] and is flattened to [b,t,n⋅dmodel] for the projection.

We then multiply Hpre with the input to get the input to our sublayer.

(2)hin=Hprexinput

where hin is of shape [b,t,dmodel].

Write

After each sublayer, say attention or an FFN, we have a [b,t,dmodel] output, and we need to inject the output back into our residual stream so it can utilise it for later layers. To do this, we need to turn the single stream back into four streams, and we do this the same way as the read operation. We construct four values, which we use to scale the output of the sublayer and add to each of the inputs of our residual stream. These four values are constructed from the layer's input, not its output, using:

(3)Hpost=2σ(αpost(xinputϕpost)+βpost)

Here, ϕpost is a linear projection of the input, of shape [n⋅dmodel,n], while αpost is a learned scale of shape [1] and βpost is a learned bias per stream, of dimension [n]. These are both independent of input and the same for every token. We also apply the sigmoid function to get the n values between 0 and 1, and since σ(0)=0.5, the 2× factor makes the write weights start at 1 as at the start of training we want the output to be fully written to each stream. Hpost is of size [b,t,n], so we get 4 values per token which decide how much of the sublayer output should be added to each of the four residual streams. xinput is defined as [b,t,n,dmodel].

We then multiply Hpost with the sublayer output to get what is written into each stream.

(4)xwrite=Hpostxout

where xout is of shape [b,t,dmodel] and xwrite is of shape [b,t,n,dmodel]. During implementation, we need to turn the n values of Hpost into a column vector and xout into a row vector, so we can have an outer product where each of the four n values multiplies the token vector to give four token vectors, one for each residual stream. In code, we do this by expanding xout dimension and Hpost dimensions.

# 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 xout to compute Hpost so that output can decide how to mix itself into the residual stream, and this is likely a better alternative. However, that would make mHC sublayer-dependent and sequential, impacting efficient inference. The ability to compute all the weights from the sublayer's input, independent of its output, affords parallelism.

Mix

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.

(5)x1′=w11x1+w12x2+w13x3+w14x4x2′=w21x1+w22x2+w23x3+w24x4x3′=w31x1+w32x2+w33x3+w34x4x4′=w41x1+w42x2+w43x3+w44x4

where xi is the i-th stream. We can see that we need 16 wij values for the mixing. We can ask the model to predict these values from the input, much like Hpost and Hpre:

(6)Hres=αres(xinputϕres)+βres

Here, ϕres is a linear projection of the input, of shape [n⋅dmodel,n2], while αres is a learned scale of shape [1] and βres is a learned bias of dimension [n2]. These are both independent of input and the same for every token. Hres is of shape [b,t,n2] so we get 16 values per token, which, once normalised, are our 16 mixing ratios.

To preserve the input scale and encourage even mixing, we need to ensure that the wij values used to produce each stream are non-negative and sum to one. For this, we organise our values in a 4×4 matrix and ensure the rows and columns sum to one.

(7)[w11w12w13w14w21w22w23w24w31w32w33w34w41w42w43w44]

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 M of size n×n, we first make the values positive by applying exp:

(8)Mij(0)=exp⁡(Hijres)

where i and j are the row and column indices. Next we divide each column by the sum of that column.

(9)Cij=Mij∑kMkj

Which causes each of the columns to sum to one. We then do the same for each of the rows, starting from C:

(10)Rij=Cij∑kCik

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.

A naive PyTorch implementation

Here, we present a simple PyTorch implementation of mHC. A few notes to help understand the implementation:

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)

Results

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.

image-20261022113020371

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