Lstm in c++/training tips

i’ll give other posters the choice on whether to v.2 my previous thread on rnns in c with my lstm code. instead of posting it all first,

i have a 2 layer lstm (i think!) using 96 node “hot one” char i/o layers, tried resizing the hidden layers and the BPTT timesteps but so far i’ve only successfully managed to take a single sentence down to a low error coefficient. i’ve been using a translation of the tao te ching at around 56k chars, which i’m assured ought to be within scope for two hidden layers of say 64 or 128 nodes. (256 nodes is too heavy for my little machine.)

having spent some time now training eg. incorrect architectures, i’m aware of various dynamics and trends in ‘training behaviour,’ but i’m finding lstm to be quite evasive. if left unsupervised the error coefficient doesn’t just stop progressing but can climb and climb astronomically. i often observe the same learning rate train through a few times with excellent progress then eat its way well past the ~3.0 threshold of “just plain guessing now” when it begins the next test.

the architecture trains successfully on elementary tests (“abababab”) and the overfit training sentence, am i misinformed about the capacity of such a model to store the data?

i don’t really want to restructure my code for a single hidden layer, though this would of course be sensate, just avoidable work since the two layer source allows me to build variable layer architecture in the future.

i’ve been using gradient normalisation at 1.0, gradient clipping at 1.0 is faster, occasionally ADAM at 1e-8 and if i drop the learning rate just right i can get close to 2.0 if i’m super lucky but one wrong corner and it’s toast. basically i’m still looking at 3.0 or 2.68 again if i step away for a moment. it loves to stick and loves ~3.0.

okay… going through the hochreiter and schmidhuber paper on lstm, it doesn’t behave like they describe so i believe my implementation is incorrect,

i accidentally “hid” this post by using 20th cent. british vernacular with a “b”, i think it’s now unhid.. since then i’ve u/led code to git hub. com/atomictraveller/scary-mess (unworking code repository), the lstm forward and backward pass in “rnn-lstm.h”

instead of just code, here are the particularities of method:

all biases are model initialised at zero except forget gates are set to 1.0
all weights are x.g. initialised in the range of -/+ sqrt(6/(inputs + outputs))

for each training data round, long and short term memory cells are set to 0.

at the end of each BPTT, the long and short term states (‘c’ and ‘h’) for the last time step are stored to use for the t-1 values for next BPTT’s step 0.

for form, here’s my lstm hidden layer forward pass,
for (unsigned int i = 0; i < hidd; i++) { // forget gate HIDDEN LAYER 2
float sum = hfbias[i];
for (unsigned int j = 0; j < hidd; j++) sum += nnhif[j][i] * h0[twin][j];
for (unsigned int j = 0; j < hidd; j++) sum += nnhhf[j][i] * h[tprev][j];
hf[twin][i] = sum = 1.f / (1.f + exp(-sum));
c[twin][i] = c[tprev][i] * sum;
}
for (unsigned int i = 0; i < hidd; i++) { // input gate
float sum = hibias[i]; // input gate
for (unsigned int j = 0; j < hidd; j++) sum += nnhii[j][i] * h0[twin][j];
for (unsigned int j = 0; j < hidd; j++) sum += nnhhi[j][i] * h[tprev][j];
hi[twin][i] = sum = 1.f / (1.f + exp(-sum));
float sum2 = hmbias[i]; // input node/potential memory
for (unsigned int j = 0; j < hidd; j++) sum2 += nnhim[j][i] * h0[twin][j];
for (unsigned int j = 0; j < hidd; j++) sum2 += nnhhm[j][i] * h[tprev][j];
hm[twin][i] = sum2 = tanh(sum2);
c[twin][i] += sum * sum2;
}
for (unsigned int i = 0; i < hidd; i++) { // output gate
float sum = hobias[i];
for (unsigned int j = 0; j < hidd; j++) sum += nnhio[j][i] * h0[twin][j];
for (unsigned int j = 0; j < hidd; j++) sum += nnhho[j][i] * h[tprev][j];
ho[twin][i] = sum = 1.f / (1.f + exp(-sum));
h[twin][i] = tanh(c[twin][i]) * sum;
}

noting my [i][j] dimensions are swapped, maybe you have lived through such things before.. (edits were made for correct [input][output] dimensions going into the first hidden layer)

my lstm uses consts idimfor input/output layer size and hidd for hidden layer size.
BPTT window length is defined by wind. winm and winp are wind-1 and wind+1 respectively. arrays for ‘c’ and ‘h’ are sized at [winp] so the t-1 time step between windows is stored in index wind out of the way :slight_smile:

dear god what a mess, whole thing is at bottom of rnn-lstm.h

BPTT:
for (int iter = winm; iter > -1; iter--) { // BPTT 'back propogation through time' int iprev = (bool)iter ? iter - 1 : wind; for (unsigned int i = 0; i < idim; i++) { dobias[i] += netout[iter][i]; for (unsigned int j = 0; j < hidd; j++) dnno[j][i] += netout[iter][i] * h[iter][j]; } for (int i = 0; i < hidd; i++) { float sum = dh_next[i]; for (int j = 0; j < idim; j++) sum += nno[i][j] * netout[iter][j]; dh[i] = sum; } for (int i = 0; i < hidd; i++) { float tanh_c = tanh(c[iter][i]); float dc_total = dc_next[i] + (dh[i] * ho[iter][i] * (1.f - tanh_c * tanh_c)); float df_raw = dc_total * c[iprev][i] * (hf[iter][i] * (1.f - hf[iter][i])); float di_raw = dc_total * hm[iter][i] * (hi[iter][i] * (1.f - hi[iter][i])); float dm_raw = dc_total * hi[iter][i] * (1.f - hm[iter][i] * hm[iter][i]); float do_raw = dh[i] * tanh_c * (ho[iter][i] * (1.f - ho[iter][i])); dhf_raw[i] = df_raw; dhi_raw[i] = di_raw; dhm_raw[i] = dm_raw; dho_raw[i] = do_raw; hfbias[i] += df_raw; hibias[i] += di_raw; hmbias[i] += dm_raw; hobias[i] += do_raw; dh0[i] = 0.f; for (unsigned int j = 0; j < hidd; j++) { dnnhhf[j][i] += df_raw * h[iprev][j]; dnnhhi[j][i] += di_raw * h[iprev][j]; dnnhhm[j][i] += dm_raw * h[iprev][j]; dnnhho[j][i] += do_raw * h[iprev][j]; dnnhif[j][i] += df_raw * h0[iter][j]; dnnhii[j][i] += di_raw * h0[iter][j]; dnnhim[j][i] += dm_raw * h0[iter][j]; dnnhio[j][i] += do_raw * h0[iter][j]; dh0[i] += nnhif[j][i] * df_raw + nnhii[j][i] * di_raw + nnhim[j][i] * dm_raw + nnhio[j][i] * do_raw; } dc_next[i] = dc_total * hf[iter][i]; } unsigned int cidx = cin[iter]; // thank you google ai :) for (int i = 0; i < hidd; i++) { float tanh_c = tanh(c0[iter][i]); float dc_total = dc0_next[i] + (dh0[i] * h0o[iter][i] * (1.f - tanh_c * tanh_c)); float df_raw = dc_total * c0[iprev][i] * (h0f[iter][i] * (1.f - h0f[iter][i])); float di_raw = dc_total * h0m[iter][i] * (h0i[iter][i] * (1.f - h0i[iter][i])); float dm_raw = dc_total * h0i[iter][i] * (1.f - h0m[iter][i] * h0m[iter][i]); float do_raw = dh0[i] * tanh_c * (h0o[iter][i] * (1.f - h0o[iter][i])); dh0f_raw[i] = df_raw; dh0i_raw[i] = di_raw; dh0m_raw[i] = dm_raw; dh0o_raw[i] = do_raw; h0fbias[i] += df_raw; h0ibias[i] += di_raw; h0mbias[i] += dm_raw; h0obias[i] += do_raw; dnnh0if[cidx][i] += df_raw; dnnh0ii[cidx][i] += di_raw; dnnh0im[cidx][i] += dm_raw; dnnh0io[cidx][i] += do_raw; for (unsigned int j = 0; j < hidd; j++) { dnnh0hf[j][i] += df_raw * h0[iprev][j]; // def. swapped dnnh0hi[j][i] += di_raw * h0[iprev][j]; dnnh0hm[j][i] += dm_raw * h0[iprev][j]; dnnh0ho[j][i] += do_raw * h0[iprev][j]; } dc0_next[i] = dc_total * h0f[iter][i]; } for (int i = 0; i < hidd; i++) { float sum0 = 0.f; float sum = 0.f; for (int j = 0; j < hidd; j++) { sum0 += nnh0hf[j][i] * dh0f_raw[j] + nnh0hi[j][i] * dh0i_raw[j] + nnh0hm[j][i] * dh0m_raw[j] + nnh0ho[j][i] * dh0o_raw[j]; sum += nnhhf[j][i] * dhf_raw[j] + nnhhi[j][i] * dhi_raw[j] + nnhhm[j][i] * dhm_raw[j] + nnhho[j][i] * dho_raw[j]; } dh0_next[i] = sum0; dh_next[i] = sum; } } memcpy(h0[wind], h0[winm], sizeof h0[wind]); memcpy(h[wind], h[winm], sizeof h[wind]); memcpy(c0[wind], c0[winm], sizeof c0[wind]); memcpy(c[wind], c[winm], sizeof c[wind]);

“great eyes!” to any comments, and thank you :slight_smile:

if anyone is amused by such things, “my c rnn also!” is famous on github, thankfully working quite well and hopefully not leading the world astray.
git hub. com/atomictraveller/rnn
(then i freaked out about how everything automatically turns links into sparkly webpage events, explaining link fragmentation to force text)

Hmm… for now, based on what can be said from this alone:


I would not take this as evidence that two 64/128-unit layers are too small yet.

abababab and the one-sentence overfit are useful smoke tests, but they may not exercise the window boundary, the full two-layer backward path, or the optimizer state in the same way as the complete text.

The first useful split is the loss scale.

If the error coefficient is ordinary mean negative log-likelihood using natural logs over 96 outputs, then zero logits give:

double random_nll = log(96.0);

/* 4.564348... */

So under that definition, 3.0 would already be better than uniform guessing.

If your coefficient is normalized or scaled differently, that is fine too; I would just establish its zero-logit baseline before reading too much into 3.0, 2.68, etc.

The second split is normalization versus clipping.

These are not equivalent:

double scale = 1.0;

if (grad_norm > 1.0) {
    scale = 1.0 / grad_norm;
}

/* Apply scale to all gradients. */

That is clipping: a gradient with norm 0.5 remains at 0.5.

By contrast:

scale = 1.0 / grad_norm;

always normalizes to 1.0, so a small gradient can be enlarged near a good solution. The usual global-norm clipping definition only scales gradients down when they exceed the limit.

A third split is the LSTM state at a BPTT boundary.

With updates disabled, these two paths should produce the same logits and final states:

/* Path A: one continuous forward pass. */
forward(sequence, 2 * T, h0, c0);

/* Path B: two windows. */
forward(sequence,     T, h0, c0);
forward(sequence + T, T, hT, cT);

Both h and c need to cross the window boundary. The forward state may continue while the backward history stops there; that is the usual distinction in truncated BPTT.

If the two paths differ before any weight update, I would fix that before changing hidden size or learning rate.

Finally, ADAM at 1e-8 has two very different branches:

learning_rate = 1e-8;  /* Extremely small update. */
epsilon       = 1e-8;  /* Common numerical stabilizer. */

If those basic invariants all pass, the next high-information test would be a tiny deterministic two-layer case, for example:

vocab  = 3
hidden = 2
steps  = 3
double precision
no clipping
no Adam

Then compare the analytical gradients against finite differences.

That does not require restructuring the main source into a permanent one-layer model; the existing variable-layer code can remain intact and just run a very small test configuration.

So my current branch order would be:

loss baseline
-> clipping vs unconditional normalization
-> h/c window equivalence
-> tiny two-layer gradient check
-> Adam state
-> only then BPTT depth and capacity

The capacity question still matters, but the sudden runaway behaviour sounds more informative about state, updates, or measurement than about raw storage size by itself.

i think there’s an issue with my implementation since my experience is disparate with others. unfortunately i hid my second post by using an out of fashion expression. it might read better for people with wider monitors than i. (or it’s in rnn-lstm.h at git hub. com/atomictraveller/scary-mess )

must be negative log likelihood as training often initialises around 4.5. “my lstm” can send error into the 20s and up which i don’t think i’ve seen before with any net. i have toxostomas that tweet when i think about math or sleep heheh.

which explains why [j][i] is swapped :slight_smile: i think this is as far as i go with ML programming, i enjoy generation, my hardware in a number of senses is probably better scaled for procedural development.

i think this is as far as i go with ML programming,

Hmm, I do think that knowing how to use C (though I suppose this is a skill only if someone can “actually use it properly”, I imagine this is hard for people who only use modern programming languages to understand…) is an advantage.:thinking: A lot of current AI/ML libraries use Python as the glue around performance-critical C++, C, or Rust code, so performance constraints are hardly unique to this project… Anyway, now that the code is visible, I can be a little more specific:


This looks less like a capacity problem and more like a few local bookkeeping paths in the current rnn-lstm.h.

The first one I would change is the bias accumulation inside BPTT.

The current code has:

hfbias[i] += df_raw;
hibias[i] += di_raw;
hmbias[i] += dm_raw;
hobias[i] += do_raw;

But the code already has separate bias-gradient buffers:

dhfbias
dhibias
dhmbias
dhobias

Those are the arrays included in gradient normalization/clipping and later consumed by SGD/Adam, so I suspect the intended code was:

dhfbias[i] += df_raw;
dhibias[i] += di_raw;
dhmbias[i] += dm_raw;
dhobias[i] += do_raw;

and likewise for the first layer:

dh0fbias[i] += df_raw;
dh0ibias[i] += di_raw;
dh0mbias[i] += dm_raw;
dh0obias[i] += do_raw;

As written, the model biases are modified directly during BPTT, before the learning rate, clipping and Adam are applied. They are also changed with +=, while the later optimizer update subtracts the gradient. That could plausibly produce the sudden runaway loss by itself.

The second issue follows from the current forward convention:

/* W[input][output] */
sum += nnhif[j][i] * h0[twin][j];

For that convention, the gradient returned to input j is:

dh0[j] += nnhif[j][i] * df_raw;

rather than:

dh0[i] += nnhif[j][i] * df_raw;

So the upper-layer contribution could be accumulated approximately like this:

/* Begin with the recurrent contribution from the next time step. */
for (int j = 0; j < hidd; ++j) {
    dh0[j] = dh0_next[j];
}

for (int i = 0; i < hidd; ++i) {
    /* df_raw, di_raw, dm_raw and do_raw belong to output i. */

    for (int j = 0; j < hidd; ++j) {
        dh0[j] += nnhif[j][i] * df_raw
                + nnhii[j][i] * di_raw
                + nnhim[j][i] * dm_raw
                + nnhio[j][i] * do_raw;
    }
}

The same transpose rule should then be checked in the calculations of dh_next and dh0_next: for W[input][output], the input gradient uses W[input][output] * doutput[output].

You have already noticed the [j][i] issue; I would also compare the generation path against the training forward path. I can still see both:

weight[i][j] * state[j]

and:

weight[j][i] * state[j]

in different forward paths. Since the matrices are square, both stay in bounds, but they describe transposed networks. That can make the displayed samples disagree with the network being trained even after the training loss is fixed.

I would therefore take the shortest branch first:

bias -> dbias
fix dh routing
make training and generation use one matrix convention
fresh run

These look like local and repairable indexing/accumulator issues, not evidence that the overall LSTM design—or using C/C++ for it—has failed. I would also treat the earlier capacity curves as inconclusive until there is a fresh run after these fixes.

The flat storage does not need to disappear. Even a small enum for the four gates, plus separate parameter/gradient views and one shared matrix accessor, would make these particular mix-ups harder to write without requiring a higher-level rewrite.

For a nearby example of that general direction, low-level projects such as ggml keep performance-sensitive kernels low-level while placing a narrower contract around them. That also leaves the actual hot loops available for compiler vectorization or later SIMD work; current MSVC already has an auto-vectorizer, so the bookkeeping paths do not all need to be flattened for speed.

solution at git hub. com/atomictraveller/lstm

screenshot.png shows a 7750 char document training to error of 0.18. no unusual behaviour, straight comprehension at a fixed learning rate of 0.125 with gradnorm and nowt else.

i found visualisation of the hidden (short term) states amusing from a dsp standpoint for their adversion to central or edge conditions. :slight_smile: funny.

my error was determining [source][destination] was keen but of course, standard [i][j] looping is the other way around, i came to my sleepless senses and just made it all [dest][source]. don’t ever do sleeplessness.

so, i have this lstm with vanishing/explosion adversion to apply to ..4 decades? of procedural programming. i’ve never been very good at business but more into hominid evolutionary catalysis. so i thank you. i belabour you with comparisons to past reception. superlative superlative, my dumb luck or heaven. your response here has markedly improved a significant chunk of my vital experience. six months of good luck or spiritual currency. and the bit about appreciating programming. i’ve always liked doing what i can to help general articulacy

modern life presents novel obstacles but this happens!

It reminded me of the time when I used to play around with signal processing in C and inline assembly… I never did a large amount of serious DSP programming myself, and I have not touched C in more than twenty years, but when I compare then with now, the areas that seem to have changed most are the hardware landscape and the surrounding ecosystem. For now, I tried to summarize some of that here:

AI Numerical Programming for Experienced C and DSP Developers


The reported change is a meaningful result. The current training path now produces a steadily decreasing mean negative log-likelihood instead of the earlier unstable behaviour, and the main forward and BPTT expressions are substantially more consistent with the stated [destination][source] convention.

The displayed error is accumulated from -log(p_target) in the current source. If 0.18 is the mean value over the training stream in natural-log units, it corresponds to a training-stream perplexity of approximately exp(0.18) = 1.20. That is strong evidence that the network is learning the 7,750-character document. It should still be described as training performance rather than generalisation performance unless a separate held-out stream is evaluated.

One useful clarification is that the current GRADNORM path is effectively global norm clipping, not unconditional gradient normalization:

if (norm > clip) {
    scale = clip / sqrt(norm);
    /* scale all gradients */
}

Gradients below the threshold are unchanged; gradients above it are rescaled to the threshold. This is broadly the same policy represented by PyTorch’s clip_grad_norm_.

A static reading of the current source at commit 994cfc6 suggests one further mechanical pass before treating the next run as a clean baseline:

keep [destination][source] as the single matrix convention
    -> apply it through the complete parameter lifecycle
    -> initialize a genuinely fresh model
    -> run one memory-instrumented update
    -> begin the next training baseline

For the reported plain-SGD and GRADNORM configuration, the highest-priority paths are:

  1. new-model initialization;
  2. global-norm accumulation and scaling;
  3. the plain-SGD update.

The elementwise clipping and Adam paths contain similar patterns and can be aligned before those paths are used again.

These are local indexing and state-lifecycle items rather than reasons to change the LSTM architecture or abandon the native implementation.

Non-square matrices: one complete lifecycle sweep

The two important non-square parameter families are declared as:

/* [destination][source] */

float nnh0if[hidd][idim];
float nnh0ii[hidd][idim];
float nnh0im[hidd][idim];
float nnh0io[hidd][idim];

float nno[idim][hidd];

The corresponding gradient and Adam-state arrays have the same shapes.

With this convention, the complete traversal rules are:

/* Character input -> first hidden layer. */

for (int dest = 0; dest < hidd; ++dest) {
    for (int source = 0; source < idim; ++source) {
        nnh0if[dest][source] = ...;
    }
}

/* Second hidden layer -> character output. */

for (int dest = 0; dest < idim; ++dest) {
    for (int source = 0; source < hidd; ++source) {
        nno[dest][source] = ...;
    }
}

The same bounds and axis meanings should apply to:

Lifecycle stage Objects that must retain the same shape
Initialization weights and biases
Forward weight reads
Backward weight reads and gradient writes
Gradient processing norm, scaling and clipping
Optimizer weights, gradients, first moments and second moments
Persistence weights, moments, optimizer step and dimension metadata

New-model initialization

The current newmodel() still traverses the first-layer input matrices using:

for (i = 0; i < idim; ++i)
    for (j = 0; j < hidd; ++j)
        nnh0if[i][j] = ...;

For an array declared as nnh0if[hidd][idim], the first index is the hidden destination and the second index is the character source. The matching traversal is therefore:

for (int dest = 0; dest < hidd; ++dest) {
    for (int source = 0; source < idim; ++source) {
        nnh0if[dest][source] = random_weight();
        nnh0ii[dest][source] = random_weight();
        nnh0im[dest][source] = random_weight();
        nnh0io[dest][source] = random_weight();
    }
}

The output matrix currently has the opposite residual pattern. For nno[idim][hidd], initialization should use:

for (int dest = 0; dest < idim; ++dest) {
    for (int source = 0; source < hidd; ++source) {
        nno[dest][source] = random_weight();
    }
}

This matters because idim is 96 and hidd is 128.

For the first-layer input arrays, indexing the second dimension up to 127 does not match its declared extent of 96. Some accesses may remain within the larger contiguous allocation while still crossing C/C++ array-row boundaries, repeating parts of the storage and leaving other parts untouched.

For the output array, using a first index up to 127 exceeds the declared 96 destination rows. That can access storage outside the output matrix.

The exact runtime effect depends on the build and memory layout. This observation comes from the declared types and index bounds; it is not a report from an executed sanitizer run.

Gradient norm and scaling

The current GRADNORM path contains the same residual traversal for the non-square gradients.

A direct form is:

double norm2 = 0.0;

for (int dest = 0; dest < hidd; ++dest) {
    for (int source = 0; source < idim; ++source) {
        norm2 += (double)dnnh0if[dest][source]
               * (double)dnnh0if[dest][source];

        norm2 += (double)dnnh0ii[dest][source]
               * (double)dnnh0ii[dest][source];

        norm2 += (double)dnnh0im[dest][source]
               * (double)dnnh0im[dest][source];

        norm2 += (double)dnnh0io[dest][source]
               * (double)dnnh0io[dest][source];
    }
}

for (int dest = 0; dest < idim; ++dest) {
    for (int source = 0; source < hidd; ++source) {
        norm2 += (double)dnno[dest][source]
               * (double)dnno[dest][source];
    }
}

The recurrent matrices are square, so their two bounds are both hidd. They can use the same semantic naming even though swapping equal bounds would not produce an obvious range error:

for (int dest = 0; dest < hidd; ++dest) {
    for (int source = 0; source < hidd; ++source) {
        norm2 += (double)dweight[dest][source]
               * (double)dweight[dest][source];
    }
}

The scaling pass should use exactly the same traversals as the accumulation pass:

double norm = sqrt(norm2);
double scale = 1.0;

if (norm > max_norm) {
    scale = max_norm / (norm + 1e-30);
}

scale_all_gradients(scale);

Using a double accumulator for the norm is inexpensive and reduces avoidable rounding during a large reduction, while parameters and gradients can remain float.

Plain SGD

The non-Adam path used in the reported run is in rnn-lstm.h. Its non-square loops still use the former orientation.

A direct [destination][source] version is:

for (int dest = 0; dest < idim; ++dest) {
    for (int source = 0; source < hidd; ++source) {
        nno[dest][source] -=
            learning_rate * dnno[dest][source];
    }

    obias[dest] -= learning_rate * dobias[dest];
}

for (int dest = 0; dest < hidd; ++dest) {
    for (int source = 0; source < idim; ++source) {
        nnh0if[dest][source] -=
            learning_rate * dnnh0if[dest][source];

        nnh0ii[dest][source] -=
            learning_rate * dnnh0ii[dest][source];

        nnh0im[dest][source] -=
            learning_rate * dnnh0im[dest][source];

        nnh0io[dest][source] -=
            learning_rate * dnnh0io[dest][source];
    }
}

The same pattern applies to the first and second Adam moments:

weight[dest][source]
gradient[dest][source]
first_moment[dest][source]
second_moment[dest][source]

The current elementwise-clipping branch and Adam branch can be swept using the same rule. Their relevant sections are:

Making future drift harder

Renaming loop variables from i and j to dest and source helps, but the compiler can also carry the extents into a small helper:

template <size_t Dest, size_t Source>
void sgd_matrix(
    float (&weight)[Dest][Source],
    const float (&gradient)[Dest][Source],
    float learning_rate)
{
    for (size_t dest = 0; dest < Dest; ++dest) {
        for (size_t source = 0; source < Source; ++source) {
            weight[dest][source] -=
                learning_rate * gradient[dest][source];
        }
    }
}

This preserves the existing fixed-size native representation. It does not require introducing a tensor framework or redesigning the program.

A similarly small for_each_matrix_element() helper could be reused for initialization, scaling, clipping and optimizer-state reset.

Validation branches and what each one establishes

Different checks answer different questions. A useful order is to apply the least expensive discriminating check first.

Default branch: one AddressSanitizer update

MSVC supports AddressSanitizer through:

/fsanitize=address

A minimal instrumented path is enough:

start a new process
-> create a new model
-> process one complete BPTT window
-> calculate the gradients
-> run norm clipping
-> perform one optimizer update
-> exit

This passes through the main initialization and training-update paths without requiring a long training session.

An ASan-clean run should be interpreted narrowly:

No covered memory violation was observed on this execution.

It does not establish that:

  • every valid element was visited;
  • each element was visited exactly once;
  • destination and source were not transposed;
  • the analytical gradient matches the loss;
  • the recurrent state boundary is correct.

ASan is a memory-safety instrument, not a numerical oracle. In particular, an incorrect access can remain within one larger static allocation and escape detection while still violating the intended matrix contract.

Coverage branch: verify the traversal itself

For a non-square matrix, an inexpensive diagnostic is to count visits:

unsigned int input_visits[hidd][idim] = { 0 };
unsigned int output_visits[idim][hidd] = { 0 };

for (int dest = 0; dest < hidd; ++dest) {
    for (int source = 0; source < idim; ++source) {
        ++input_visits[dest][source];
    }
}

for (int dest = 0; dest < idim; ++dest) {
    for (int source = 0; source < hidd; ++source) {
        ++output_visits[dest][source];
    }
}

The acceptance condition is simple:

every expected element was visited exactly once
no unexpected element was touched

Another diagnostic is to initialize each location with a value derived from both axes:

weight[dest][source] =
    1000.0f * (float)dest + (float)source;

A transpose, repeated region or omitted region then becomes visible in a small dump.

This is especially useful for square recurrent matrices, where swapped loop bounds remain numerically equal and do not expose the mistake by range alone.

Numerical branch: finite differences

The strongest small check for BPTT is a tiny deterministic unequal-shape fixture, for example:

vocabulary size = 3
hidden size = 2
layers = 2
sequence length = 3
floating-point type = double
fixed parameters and inputs
no clipping
no Adam
one scalar mean NLL

For a selected parameter:

double original = parameter[k];

parameter[k] = original + epsilon;
double loss_plus = forward_loss();

parameter[k] = original - epsilon;
double loss_minus = forward_loss();

parameter[k] = original;

double numerical_gradient =
    (loss_plus - loss_minus) / (2.0 * epsilon);

Compare this value with the analytical BPTT gradient for the same parameter.

PyTorch’s gradcheck uses the same general principle: compare finite-difference estimates with analytical gradients. A PyTorch implementation can serve as an independent executable reference, but a small NumPy or hand-written scalar reference is also sufficient.

The most informative compact result is:

hand-written C/C++ BPTT
        approximately equals
central finite difference
        approximately equals
independent reference implementation

The comparison should include at least one value from each parameter family:

output weight
output bias
layer-2 recurrent weight
layer-2 inter-layer weight
layer-1 recurrent weight
layer-1 character-input weight
each gate bias family

This distinguishes “training loss decreases” from the narrower claim “the tested derivatives correspond to the tested scalar loss.”

Stateful-forward branch

The recurrent state contract can be checked without training.

With updates disabled, compare:

Path A:
    process 2*T characters continuously

Path B:
    process T characters
    preserve final h and c
    process the remaining T characters

The logits and final recurrent states should match within a chosen floating-point tolerance.

This separates two concepts:

  • forward recurrent state may continue across chunks;
  • backward history may intentionally stop at the truncated-BPTT boundary.

Generalisation branch

The reported 0.18 is calculated on the training document. A minimal held-out check does not need a sophisticated dataset:

training stream:
    the current document, excluding one contiguous section

validation stream:
    the excluded section, never used for updates

Record both mean NLL values after each pass or fixed number of updates.

Possible interpretations then become clearer:

Training NLL Validation NLL Likely interpretation
decreases decreases useful sequence structure is being learned
decreases stays high training stream is being memorized
unstable unstable implementation, learning rate or numerical issue remains
stable but flat stable but flat optimization or capacity may now be the limiting factor

For a 7,750-character source, the validation estimate will be noisy, but it still distinguishes training fit from held-out prediction.

Generation and Adam can be treated as separate side paths

These items do not need to block a fresh plain-SGD baseline, but separating them prevents their state from being confused with the core LSTM calculation.

Prompt-conditioned generation

In the current sampleoutput(), the prompt first advances the recurrent ring. The code then resets:

tests = 1;

The generation loop calculates its recurrent indices from this reset value:

twin  = tests & winm;
tprev = (twin + winm) & winm;

The final prompt state is not necessarily stored in ring slot 0. Resetting the same counter used for recurrent indexing can therefore make generation continue from a different slot than the prompt’s final slot.

A clearer state split is:

uint64_t recurrent_step;
uint64_t displayed_output_step;

The recurrent counter advances for every prompt or generated character:

int twin  = recurrent_step & winm;
int tprev = (twin + winm) & winm;

run_one_recurrent_step(...);

++recurrent_step;

The display counter may be reset after the prompt without changing the recurrent-state position.

An alternative simple branch is to make these two modes explicit:

Mode A: generate from zero state
Mode B: condition on a prompt and continue from its final state

Argmax initialization

The generation path currently initializes its maximum with:

float r = 0.f;

Raw logits may all be negative. In that case no class exceeds zero, so the selected character can retain an earlier value.

Argmax can start from the first class:

int best = 0;
float best_logit = netout[twin][0];

for (int i = 1; i < idim; ++i) {
    if (netout[twin][i] > best_logit) {
        best_logit = netout[twin][i];
        best = i;
    }
}

Softmax is unnecessary for argmax because softmax preserves the ordering of logits.

If stochastic generation is added later, then temperature and sampling can be applied to a stable softmax calculation as a separate policy:

temperature -> softmax -> sample

That policy should not be mixed into the correctness test for greedy generation.

Adam state lifetime

At the start of the current train() function, the step counter is reset:

adamt = 1;

The moment serialization functions store the first and second moments, but the current format does not appear to store the Adam step counter. The source comments also note that Adam state must be retained for the entire training process.

There are two clean branches.

Fresh-only Adam

For a new model:

initialize model parameters
-> zero every first moment
-> zero every second moment
-> set Adam step to zero
-> begin training

This is sufficient when resuming Adam training is not required.

The reset should be explicit in newmodel(). Static-duration arrays begin at zero when the process starts, but creating a second new model in the same process should not inherit moments from an earlier model.

Resumable Adam

A resumable checkpoint should contain one versioned training state:

format identifier and version
model dimensions
parameters and biases
first moments
second moments
Adam step counter
optimizer hyperparameters or their policy
recurrent/training cursor state, if continuation requires it

A useful checkpoint acceptance test is:

run to a defined optimizer boundary
-> save
-> perform one next update and retain the result

construct a fresh process/state
-> load
-> perform the same next update
-> compare parameters, moments and step counter

This tests the state transition rather than only whether the file can be read.

How the guide relates to this implementation

The guide linked above is not intended to replace the hand-written LSTM with a framework. Its main purpose is to connect low-level numerical work to the validation and integration practices that have developed around modern AI systems.

The central route is:

state the numerical and memory contract
-> create a tiny transparent reference
-> compare with an independent executable oracle
-> verify gradients and state updates
-> integrate through an explicit boundary when useful
-> profile before selecting a faster backend

This source/destination issue is a compact example of why a modern numerical contract includes more than the forward equation:

declaration
-> initialization
-> forward
-> backward
-> gradient processing
-> optimizer
-> checkpoint

The arithmetic can be correct in one stage while a different stage still interprets the axes differently.

Several older low-level skills transfer directly:

C/DSP practice Modern numerical-system counterpart
Array and explicit length Tensor shape, stride and layout contract
Test waveform Golden tensor fixture
FIR/IIR delay state Recurrent or cache state
Fixed-point range analysis dtype, accumulation and quantization policy
Scalar reference loop Independent correctness oracle
Assembly inspection Compiler reports, profiling and backend inspection
Saved coefficients Versioned model and optimizer state
Scope or trace display Intermediate tensor and state diagnostics

The larger changes are mostly around the arithmetic:

  • automatic differentiation provides an independent derivative path;
  • finite differences make gradient contracts testable;
  • sanitizers detect many memory-safety failures;
  • Python can be used as an orchestration and reference layer without becoming the deployment implementation;
  • custom-operator systems provide explicit framework/native boundaries;
  • CPU libraries, compiler vectorizers, GPUs and accelerators offer several optimization layers;
  • build, test and artifact provenance have become part of numerical correctness.

For example, MSVC now provides both AddressSanitizer and auto-vectorization reporting. A practical optimization route can therefore be:

correct scalar implementation
-> retained numerical fixture
-> optimized compiler build
-> vectorization report
-> profile
-> intrinsics or inline assembly only for a measured remaining gap

Low-level knowledge remains valuable: memory layout, aliasing, alignment, accumulator precision, saturation, locality and state lifetime still determine real behaviour.

The modern addition is that those properties can be stated as contracts and checked from more than one direction.

A small Python/NumPy/PyTorch script can, for example:

load fixed C/C++ parameters
-> run the same tiny sequence
-> export named intermediate tensors
-> compare the first divergent value

The native implementation remains the program under test. Python is only the control plane and independent reference.

If the native component later needs to participate in a larger system, there are documented routes such as:

Neither is required for this project. They are examples of the integration ecosystem now available around a low-level kernel.

Using the hidden-state display as a diagnostic instrument

The hidden-state display is potentially useful beyond visual interest, provided individual units are not assigned fixed semantic meanings too quickly.

Useful per-step or per-window summaries include:

mean(h)
RMS(h)
minimum and maximum h
fraction of |h| above a chosen threshold
mean and RMS of c
gate histograms
gradient RMS by parameter family
update RMS divided by weight RMS

For example:

double sum = 0.0;
double sumsq = 0.0;
int near_edge = 0;

for (int i = 0; i < hidd; ++i) {
    double value = h[t][i];

    sum += value;
    sumsq += value * value;

    if (fabs(value) > 0.95) {
        ++near_edge;
    }
}

double mean = sum / hidd;
double rms = sqrt(sumsq / hidd);
double near_edge_fraction =
    (double)near_edge / hidd;

The hidden output h shows only part of the LSTM state. Recording the cell state and gates provides more context:

Observation Possible mechanism to inspect
h remains near zero output gate, cell magnitude or cancellation
h remains near ±1 large `
cell state persists forget gate near 1
cell state is repeatedly cleared forget gate near 0
little new content enters input gate near 0
gradients disappear through time recurrent Jacobian and gate saturation
gradients spike recurrent products, loss scale or update scale

These are diagnostic hypotheses rather than one-to-one semantic interpretations.

Hidden units form a learned coordinate system. Two functionally similar models can distribute information differently across units, and retraining can rotate or permute the internal representation.

The strongest use of the display is therefore comparative:

before versus after one source change
training forward versus generation forward
one continuous stream versus chunked state carry
plain SGD versus Adam
healthy training versus the first unstable window
layer 1 versus layer 2

A DSP-style spectrum or autocorrelation of selected traces may reveal periodic structure in the character stream, but it should be treated as a description of the chosen internal coordinates rather than proof that a unit implements a specific physical filter.

A practical default route from the present state is therefore:

1. align every non-square lifecycle loop with [destination][source];
2. reset all new-model optimizer state explicitly;
3. run one complete ASan-instrumented BPTT update;
4. create a genuinely fresh model;
5. repeat the plain-SGD, global-norm-clipped training run;
6. retain that result as the new baseline;
7. add a tiny unequal-shape gradient check when stronger numerical evidence is useful.

The current loss reduction is already meaningful integration evidence. The remaining observations are limited, testable and mostly mechanical. Once those paths share one orientation and one state-lifecycle policy, further work can move from implementation recovery to normal modelling choices: held-out evaluation, generation policy, hidden size, BPTT depth, optimizer comparison and the procedural applications for which the network was built.

my observation is that, noting my CHAR based I/O, gradient clipping produces ‘real words’ even at initial error rates whereas the gradient normalisation i use looked “english shaped” but rarely produced any recognisable words. to me, this isn’t wrong, it’s like lfo contours :stuck_out_tongue: w.s. burroughs’ “cut-up method” is likely more appreciable as practice than review. not everyone’s thing, just everything’s one.

your assistance is a pretty bright dot on the map. i feel more or less obligated to continue programming as few share my vantage, and some things are unlikely to be done otherwise. :slight_smile: the lstm and rnn are keen objects to add to decades of proceduralism. i’m moving into audio builds but text is faster to learn from. :slight_smile:

observing the cell states graphically is seeing it :stuck_out_tongue: the last few days have been good, i’ve been able to remain awake and cogent, which is nice. i do think i’ve absorbed the method as well as attained it, which is also nice. nearly embarrasingly simple in retrospect :stuck_out_tongue:

thank you john and whatever occasioned your being here!