Tutorial 08: Feed-Forward Networks and SiLU Gating¶
After attention gathers information across tokens, the feed-forward network (FFN) processes each token independently. The FFN is where most of the model's knowledge is stored. This tutorial covers the standard FFN, the gated variant with SiLU activation, and how they fit together.
1. The Feed-Forward Network¶
Every transformer block contains an FFN after the attention sublayer. The basic structure is:
where:
- W_up projects from hidden_size to intermediate_size (typically 4× larger)
- W_down projects from intermediate_size back to hidden_size
- activation introduces nonlinearity (without it, two linear layers collapse to
one: W2 @ (W1 @ x) = (W2 @ W1) @ x)
Dimensions¶
For Qwen3-8B with hidden_size=4096 and intermediate_size=14336:
The expansion ratio of 4× is a design choice. Larger ratios give the model more capacity but cost more memory and compute.
2. Why Gating?¶
Modern LLMs (GPT-3, Llama, Qwen, Mistral) do not use a plain FFN. They use a gated FFN with two parallel branches:
The key idea is the * (elementwise multiply):
W_gate @ xproduces the gate — a set of values that control which features pass through.W_up @ xproduces the up-projection — the actual content.silu(gate) * upmultiplies them together: the gate controls how much of each feature flows through.
This is like a learned switch for each neuron in the intermediate layer.
3. The SiLU Activation¶
SiLU (Sigmoid Linear Unit), also known as Swish, is defined as:
where σ(x) is the sigmoid function.
Properties¶
- For large positive
x:silu(x) ≈ x(passes through) - For large negative
x:silu(x) ≈ 0(suppresses) - For
x = 0:silu(0) = 0.5 * 0 = 0 - Smooth and differentiable everywhere
Numerically Stable Implementation¶
The naive x * (1 / (1 + exp(-x))) can overflow when x is a large negative
number (exp(-(-1000)) = exp(1000) = inf). The stable version uses a trick:
def silu(x: mx.array) -> mx.array:
"""
SiLU activation: x * sigmoid(x), numerically stable.
For x >= 0: sigmoid(x) = 1 / (1 + exp(-x)) — standard form
For x < 0: sigmoid(x) = exp(x) / (1 + exp(x)) — avoids exp(large positive)
We compute sigmoid using abs(x) to avoid overflow in both directions,
then use mx.where to select the numerically stable branch.
"""
z = mx.exp(-mx.abs(x))
# For x >= 0: 1 / (1 + exp(-x)) is fine (exp(-positive) is small)
# For x < 0: exp(x) / (1 + exp(x)) avoids exp(positive) overflow
sigmoid = mx.where(x < 0, z / (1 + z), 1 / (1 + z))
return x * sigmoid
Why SiLU Instead of ReLU?¶
ReLU: max(0, x) — simple but has a "dead zone" where gradients are zero.
SiLU: x * σ(x) — smooth, never exactly zero, and the gradient flows
throughout. Empirically, SiLU produces better language models.
4. SwiGLU: The Fused Kernel¶
The gated FFN computes silu(gate) * up. When the Metal extension is available,
we can fuse this into a single GPU kernel instead of doing it in two separate
operations:
# Pure Python path (two operations):
result = silu(gate) * up
# Fused Metal kernel (one operation):
result = swiglu(gate, up)
The fused kernel is faster because:
1. It reads gate and up from memory once (not twice).
2. The elementwise multiply happens in registers, not in global memory.
3. One GPU dispatch instead of two.
In the MLP layer:
class MLP:
def __init__(self, layer_prefix, config, weights, features):
self.w_gate = weights.linear(f"{layer_prefix}.mlp.gate_proj")
self.w_up = weights.linear(f"{layer_prefix}.mlp.up_proj")
self.w_down = weights.linear(f"{layer_prefix}.mlp.down_proj")
self.use_fast_swiglu = features.kernels # True if Metal extension is available
def __call__(self, x):
gate = linear(x, self.w_gate)
up = linear(x, self.w_up)
# Choose between fused and unfused paths
if self.use_fast_swiglu:
hidden = swiglu(gate, up) # single Metal kernel
else:
hidden = silu(gate) * up # two MLX operations
return linear(hidden, self.w_down)
5. Why Three Weight Matrices?¶
The gated FFN has three weight matrices (gate_proj, up_proj, down_proj)
instead of the two in a standard FFN. This seems wasteful, but the math shows
why it is worth it:
Standard FFN: 2 × hidden × intermediate parameters
Gated FFN: 3 × hidden × intermediate parameters
The extra matrix buys nonlinearity in the gating mechanism. Without it, the gating is just a linear transform, which collapses:
W_down @ ((W_gate @ x) * (W_up @ x)) ← nonlinear (gated)
W_down @ (W_gate @ x * W_up @ x) ← still nonlinear
If we tried to merge W_gate and W_up into one matrix, the elementwise
multiply would produce a quadratic function of x — but only in a very
specific way that limits expressiveness. Three separate matrices give the model
full control over what to gate and what to project.
6. Complete FFN Code¶
import mlx.core as mx
def linear(x, w, bias=None):
"""The foundation of every layer: y = x @ w^T (+ bias)."""
if bias is not None:
return mx.matmul(x, w.T) + bias
return mx.matmul(x, w.T)
def silu(x):
"""SiLU activation: x * sigmoid(x), numerically stable."""
z = mx.exp(-mx.abs(x))
sigmoid = mx.where(x < 0, z / (1 + z), 1 / (1 + z))
return x * sigmoid
class MLP:
"""
Gated feed-forward network with SiLU activation.
Architecture: down(silu(gate(x)) * up(x))
This is the standard FFN in Llama, Qwen, Mistral, and most modern LLMs.
"""
def __init__(self, config, gate_weight, up_weight, down_weight):
"""
Args:
config: Model config (for dimensions).
gate_weight: Weight for the gate projection. Shape: (intermediate, hidden)
up_weight: Weight for the up projection. Shape: (intermediate, hidden)
down_weight: Weight for the down projection. Shape: (hidden, intermediate)
"""
self.w_gate = gate_weight
self.w_up = up_weight
self.w_down = down_weight
def __call__(self, x):
"""
Forward pass.
Args:
x: Input tensor. Shape: (batch, seq_len, hidden_size)
Returns:
Output tensor. Shape: (batch, seq_len, hidden_size)
"""
# Step 1: Compute gate and up projections (both are linear layers)
gate = linear(x, self.w_gate) # (B, L, intermediate_size)
up = linear(x, self.w_up) # (B, L, intermediate_size)
# Step 2: Gate the features
# silu(gate) produces values in [0, ...] that control feature flow
# Multiplying by up selects and scales features
hidden = silu(gate) * up # (B, L, intermediate_size)
# Step 3: Project back to hidden dimension
output = linear(hidden, self.w_down) # (B, L, hidden_size)
return output
7. Where FFN Fits in the Transformer Block¶
The FFN processes each token independently — there is no interaction between tokens in the FFN. This is what makes it different from attention. The attention layer mixes information across tokens; the FFN processes each token's representation in isolation.
8. Summary¶
| Component | Purpose | Parameters |
|---|---|---|
gate_proj |
Controls which features pass through | hidden × intermediate |
up_proj |
Provides the content to be gated | hidden × intermediate |
down_proj |
Compresses back to model dimension | intermediate × hidden |
| SiLU | Nonlinear gating activation | 0 (it is an operation) |
The gated FFN is where most of the model's "knowledge" lives. The weights encode factual knowledge, linguistic patterns, and reasoning capabilities. The attention layer merely determines which tokens to combine — the FFN is where the actual processing happens.
Next: Tutorial 09 — Transformer Block and Full Model Assembly