Deep dive
How it works
In: received soft or hard symbol pairs (rate 1/2), code polynomials · Out: the maximum-likelihood input bit sequence · State: one path metric and one decision bit per trellis state, per step (64 states for K=7) · Cost: 64 add-compare-selects per decoded bit for K=7; traceback memory 2^(K-1) x depth bits
Encoder (K=7, rate 1/2): state = last 6 input bits; for input u form the 7-bit register reg = u<<6 | state; output parity(reg & 0o133), parity(reg & 0o171); new state = reg >> 1. Append K-1 = 6 zero tail bits so the path ends in state 0 (cost: 6 extra input steps; tail-biting, below, avoids it).
Decoder:
- Per received symbol pair, a branch metric for each of the 4 possible code pairs: correlation
sum(±llr)for soft input, Hamming distance for hard input. - For each new state ns there are exactly two predecessors,
(ns<<1 | b) & 63, b in {0,1}; the input bit is the top bit of ns. Add each predecessor's path metric to its branch metric, keep the better one (add-compare-select, ACS), and record the decision bit. - After the last symbol, start in state 0 (terminated) or the best state (unterminated) and trace back:
state = (state<<1 | decision[t][state]) & 63, emitting the top bit of each state.
predecessors new state ns = (u, s5 s4 s3 s2 s1) u = input bit
(s5..s1, 0) ──┐ p0 = (s5..s1, 0) p1 = (s5..s1, 1)
(s5..s1, 1) ──┴──► ns p0 and p1 differ only in the dropped bit,
so 32 butterflies, each with 2 sources, 2 targets
Code
const K: usize = 7; // constraint length
const NS: usize = 1 << (K - 1); // 64 states
const G: [u32; 2] = [0o133, 0o171]; // generator polynomials (802.11 order)
fn parity(x: u32) -> u32 { x.count_ones() & 1 }
/// Rate 1/2 encoder. Appends K-1 zero bits so the trellis ends in state 0.
pub fn encode(bits: &[u8]) -> Vec<u8> {
let mut state = 0u32; // last K-1 input bits, newest in bit 5
let mut out = Vec::with_capacity(2 * (bits.len() + K - 1));
for &u in bits.iter().chain([0u8; K - 1].iter()) {
let reg = ((u as u32) << (K - 1)) | state; // newest bit at bit K-1
out.push(parity(reg & G[0]) as u8);
out.push(parity(reg & G[1]) as u8);
state = reg >> 1;
}
out
}
/// Soft-decision Viterbi. `soft[i] > 0` means "coded bit i is probably 0"
/// (LLR-like, any scale); +-1 gives hard decisions. Returns the info bits.
pub fn viterbi(soft: &[i16]) -> Vec<u8> {
let steps = soft.len() / 2;
// Expected output pair for each (state, input): precomputed, 2 bits each.
let mut expect = [[0u8; 2]; NS * 2];
for s in 0..NS as u32 {
for u in 0..2u32 {
let reg = (u << (K - 1)) | s;
expect[(s * 2 + u) as usize] = [parity(reg & G[0]) as u8, parity(reg & G[1]) as u8];
}
}
let mut pm = [i32::MIN / 2; NS]; // path metric: larger = better (correlation)
pm[0] = 0; // encoder starts in state 0
let mut decisions = vec![0u64; steps]; // 1 bit per state: which predecessor won
for t in 0..steps {
let (l0, l1) = (soft[2 * t] as i32, soft[2 * t + 1] as i32);
let mut next = [i32::MIN; NS];
let mut dec = 0u64;
for ns in 0..NS {
let u = (ns >> (K - 2)) as usize; // input bit = MSB of the new state
let (mut best, mut pick) = (i32::MIN, 0);
for b in 0..2 { // two predecessors differ in the dropped LSB
let s = ((ns << 1) & (NS - 1)) | b;
let e = expect[s * 2 + u];
let bm = (if e[0] == 0 { l0 } else { -l0 }) + (if e[1] == 0 { l1 } else { -l1 });
let m = pm[s].saturating_add(bm);
if m > best { best = m; pick = b; } // compare-select
}
dec |= (pick as u64) << ns;
next[ns] = best;
}
pm = next;
decisions[t] = dec;
}
// Terminated code: trace back from state 0.
let mut state = 0usize;
let mut bits = vec![0u8; steps];
for t in (0..steps).rev() {
bits[t] = (state >> (K - 2)) as u8;
let b = ((decisions[t] >> state) & 1) as usize;
state = ((state << 1) & (NS - 1)) | b;
}
bits.truncate(steps - (K - 1)); // drop the tail
bits
}
Verified by round trip (clean and with four isolated bit flips) and a Monte-Carlo BER run (see below).
Complexity and constants
- Encode: 2 AND+popcount per input bit; effectively free.
- Decode per info bit: 64 states x 2 branches = 128 adds, 64 compares/selects, 8 bytes of decision storage (one bit per state). Cost scales as 2^(K-1): K=9 is 256 states, K=15 is 16,384.
- Traceback memory:
steps x 2^(K-1)bits; a 1000-bit frame at K=7 is about 8 KB. - Measured here (this page's scalar code, one Apple-silicon core, 100k-bit frames): order of 10-15 Mbit/s of decoded bits. Production SIMD decoders are far faster.
- Measured BER, 1000-bit frames, AWGN, BPSK, 300 frames (300k bits) per point, tail included:
| Eb/N0 | hard decision | 3-bit soft | 8-bit soft |
|---|---|---|---|
| 2 dB | 1.1e-1 | 6.8e-3 | 5.1e-3 |
| 3 dB | 3.1e-2 | 8.0e-4 | 4.7e-4 |
| 4 dB | 5.7e-3 | 3e-5 | 0 errors seen |
Efficiency
- Bound: compute on 64 independent ACS operations per step; the working set (64 path metrics) fits in registers or L1, so it is not memory bound. Decision storage is the only growing state.
- Use soft input: at 3 dB the measured error rate drops from 3.1e-2 to 8e-4 for the same code and decoder size. Cost: a few bits per symbol instead of one. CCSDS recommends at least 3-bit quantization when decoder complexity permits.
- Branch-metric sharing: only 2 distinct metric magnitudes per step (
l0+l1,l0-l1, and their negatives); compute them once, not per state. - 8/16-bit saturating path metrics and SIMD: the 32 butterflies are independent, so one vector instruction handles 16-32 of them (libfec, srsRAN use SSE/AVX2/NEON).
- Truncated traceback: decide bits with a fixed decision depth (rule of thumb: several times K) instead of storing the whole frame, and trace back every N steps; memory and latency become constant.
- Puncturing: rate 3/4 or 7/8 costs no extra decoder, only skipped transmissions; fewer coded bits means fewer symbols on air (energy) at lower coding gain.
- Cheaper alternative: a short code (K=3..5) or hard decisions when the link margin is large; a Hamming or CRC-only scheme when errors are rare.
Implementation notes
- Renormalize path metrics (subtract the minimum every few steps, or use wrap-around arithmetic with modular comparison) so they never overflow; the code above saturates for clarity.
- Use a contiguous
[u64]of decision words (64 states = one word) so traceback touches one word per step; this is a single bit-test per step. - Soft input convention matters: keep one sign convention from demapper to decoder (here positive = bit 0) and scale LLRs consistently; clip to about ±127 for 8-bit.
- CCSDS 131.0-B inverts the G2 output and emits G1 first; 802.11 emits 133 then 171. Both ends must agree on order, inversion and bit ordering of the polynomials (write them as taps, newest bit first or last), or the decoder will "work" at 50 % BER.
- Puncture on the encoder by deleting coded bits per a periodic pattern; on the decoder insert zero-confidence (llr = 0) symbols in the deleted slots and run the same trellis. CCSDS tables: 2/3, 3/4, 5/6, 7/8.
- Tail-biting (LTE control channels): start in the state equal to the last K-1 input bits so no tail is sent; the decoder runs the circular trellis (wrap-around or try all start states).
Pitfalls
- Hard-decision decoding when a soft demapper is available throws away roughly the gain that makes the code worthwhile.
- Forgetting tail bits (or the matching terminated traceback) leaves the last few bits unprotected.
- Starting traceback from state 0 on an unterminated stream: pick the best path metric instead.
- Mixing up which polynomial is first, or the bit order of the octal polynomial; both ends must use the same one.
- Traceback depth too short: errors appear at the window end; punctured high rates need deeper windows (tune by simulation).
- Burst errors: a convolutional code corrects scattered errors, but a Viterbi decoder that goes wrong emits a burst; interleave and add Reed-Solomon outside it.
Variants and alternatives
In production
IEEE 802.11a/g/n/ac (K=7, 133/171, punctured to 2/3, 3/4, 5/6), DVB-S and DVB-T (K=7 with puncturing, outer Reed-Solomon), CCSDS telemetry (K=7, optional outer Reed-Solomon), GSM (K=5 speech/control channels), LTE and 5G NR control channels (LTE: K=7 tail-biting rate 1/3; NR moved to polar codes), NASA deep-space links (K=7 on Voyager-era and later missions). The same Viterbi search decodes hidden Markov models in speech recognition (Rabiner).
Implementations
| name | lang | repo | install | verdict | licence | health | as_of | notes |
|---|---|---|---|---|---|---|---|---|
| srsRAN 4G | C | srsran/srsRAN_4G | git clone + cmake build | — | — | — | — | LTE K=7 rate 1/3 Viterbi with SSE, AVX2 and NEON kernels (lib/src/phy/fec/convolutional/) |
| GNU Radio gr-fec | C++ | gnuradio/gnuradio | brew install gnuradio | — | — | — | — | cc_decoder_impl.cc, generic polynomials, puncturing |
| AFF3CT | C++ | aff3ct/aff3ct | git clone + cmake build | — | — | — | — | FEC simulation toolbox incl. RSC/BCJR decoders |
Sources
- Andrew J. Viterbi, Error bounds for convolutional codes and an asymptotically optimum decoding algorithm, IEEE Trans. Information Theory 13(2), 1967. paper
- G. David Forney Jr., The Viterbi algorithm, Proc. IEEE 61(3), 1973. paper
- J. A. Heller, I. M. Jacobs, Viterbi decoding for satellite and space communication, IEEE Trans. Communication Technology 19(5), 1971. paper
- Y. Yasuda, K. Kashiki, Y. Hirata, High-rate punctured convolutional codes for soft decision Viterbi decoding, IEEE Trans. Communications 32(3), 1984. paper
- Phil Karn, libfec (
viterbi27,viterbi29; reference code). code - Lawrence R. Rabiner, A tutorial on hidden Markov models and selected applications in speech recognition, Proc. IEEE 77(2), 1989. paper
- CCSDS, TM Synchronization and Channel Coding, CCSDS 131.0-B-5, Sept 2023 (K=7 polynomials, G2 inversion, puncturing patterns, 3-bit soft quantization). spec