Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,18 @@ All notable changes to EigenScript are documented here.
## [Unreleased]

### Added
- **`native_train_step` batched causal-mask training path (~10.75x faster).**
The transformer trainer (model extension, used by iLambdaAi) re-ran a full
forward+backward over the growing prefix for *every* target position in a
window — O(seq) redundant passes, since the attention is causal and each
position's activations are prefix-length-independent. Replaced with one
causal-masked forward + per-position losses + one backward: gradient-identical
(proven by matching loss sequences to float precision from a common
checkpoint) but ~10.75x less compute (179 -> 16.65 ms/window on the d=32/L=4
model). `EIGS_TRAIN_PERPOS=1` restores the per-position path as the
gradient-check oracle; `EIGS_TRAIN_PROFILE=1` prints the per-phase split. New
regression test `test_native_train_gradcheck.sh` (suite [47c], model build)
pins the two paths together forever.
- **W020: a lint for `unobserved:` blocks that provably do nothing (#655).**
Fires when *every* assignment in the block targets a dict field or list
element (`d.k is ...`, `xs[i] is ...`). Observer bookkeeping is gated on
Expand Down
222 changes: 221 additions & 1 deletion src/model_train.c
Original file line number Diff line number Diff line change
Expand Up @@ -738,8 +738,28 @@ static void train_dump_open_if_needed(TransformerModel *model) {
fflush(g_train_dump_fp);
}

static int native_train_step(int *input_ids, int input_len, int *output_ids, int output_len, float learning_rate, float *loss_out, int *tokens_trained_out) {
/* #585-adjacent: opt-in per-phase profiler for native_train_step, gated by
* EIGS_TRAIN_PROFILE so it is zero-cost in production. Answers "where does the
* per-window training time go" — forward recompute vs backward vs the rest
* (allocs/memsets/update) — to size the causal-mask-batching win. */
#include <time.h>
#include <stdlib.h>
static double _mt_prof_ns(void) {
struct timespec ts; clock_gettime(CLOCK_MONOTONIC, &ts);
return (double)ts.tv_sec * 1e9 + (double)ts.tv_nsec;
}
static int _mt_prof = -1;
static double _mt_fwd = 0.0, _mt_bwd = 0.0, _mt_total = 0.0;
static long _mt_calls = 0, _mt_pos = 0;

/* batched=0: the original per-position teacher-forcing loop (the gradient-check
* ORACLE). batched=1: one causal-masked forward + per-position losses + one
* backward — provably identical gradients (causal mask makes each position's
* activation prefix-length-independent), ~seq times less compute. */
static int native_train_step_impl(int *input_ids, int input_len, int *output_ids, int output_len, float learning_rate, float *loss_out, int *tokens_trained_out, int batched) {
if (!g_model.loaded) return -1;
if (_mt_prof < 0) _mt_prof = getenv("EIGS_TRAIN_PROFILE") ? 1 : 0;
double _mt_t0 = _mt_prof ? _mt_prof_ns() : 0.0;

int vocab_size = g_model.config.vocab_size;
int d_model = g_model.config.d_model;
Expand Down Expand Up @@ -820,6 +840,7 @@ static int native_train_step(int *input_ids, int input_len, int *output_ids, int
cache.ln2_x_norm = xcalloc_array(safe_size_mul(safe_size_mul(n_layers, max_ctx), d_model), sizeof(float));
cache.ln2_std = xcalloc_array(safe_size_mul(n_layers, max_ctx), sizeof(float));

if (!batched) {
for (int t = 0; t < full_len - 1; t++) {
int ctx_len = t + 1;
if (ctx_len > max_seq_len) ctx_len = max_seq_len;
Expand All @@ -838,7 +859,9 @@ static int native_train_step(int *input_ids, int input_len, int *output_ids, int
memset(cache.ln2_std, 0, n_layers * ctx_len * sizeof(float));

float *logits = xcalloc(vocab_size, sizeof(float));
double _mt_f0 = _mt_prof ? _mt_prof_ns() : 0.0;
native_forward_with_cache(token_ids + ctx_start, ctx_len, &g_model, logits, &cache);
if (_mt_prof) _mt_fwd += _mt_prof_ns() - _mt_f0;

float *probs = xcalloc(vocab_size, sizeof(float));
float loss = cross_entropy_loss(logits, target_id, vocab_size, probs);
Expand Down Expand Up @@ -902,6 +925,7 @@ static int native_train_step(int *input_ids, int input_len, int *output_ids, int
}
free(d_logits);

double _mt_b0 = _mt_prof ? _mt_prof_ns() : 0.0;
for (int l = n_layers - 1; l >= 0; l--) {
TransformerLayer *layer = &g_model.layers[l];
int lsd = l * ctx_len * d_model;
Expand Down Expand Up @@ -970,6 +994,177 @@ static int native_train_step(int *input_ids, int input_len, int *output_ids, int
memcpy(d_x, d_pre_attn, ctx_len * d_model * sizeof(float));
free(d_pre_attn);
}
if (_mt_prof) { _mt_bwd += _mt_prof_ns() - _mt_b0; _mt_pos++; }

for (int i = 0; i < ctx_len; i++) {
int tok = token_ids[ctx_start + i];
if (tok >= 0 && tok < vocab_size) {
for (int j = 0; j < d_model; j++)
grad_token_emb[tok * d_model + j] += d_x[i * d_model + j];
}
}
free(d_x);
}
} else {
/* ===== batched causal-mask path (#iLambdaAi 24h kernel) ===== */
int ctx_len = full_len;
int ctx_start = 0;
memset(cache.layer_inputs, 0, n_layers * ctx_len * d_model * sizeof(float));
memset(cache.norm1_outputs, 0, n_layers * ctx_len * d_model * sizeof(float));
memset(cache.norm2_outputs, 0, n_layers * ctx_len * d_model * sizeof(float));
memset(cache.attn_probs, 0, n_layers * ctx_len * ctx_len * sizeof(float));
memset(cache.ffn_pre_act, 0, n_layers * ctx_len * d_ff * sizeof(float));
memset(cache.post_attn_x, 0, n_layers * ctx_len * d_model * sizeof(float));
memset(cache.ln1_x_norm, 0, n_layers * ctx_len * d_model * sizeof(float));
memset(cache.ln1_std, 0, n_layers * ctx_len * sizeof(float));
memset(cache.ln2_x_norm, 0, n_layers * ctx_len * d_model * sizeof(float));
memset(cache.ln2_std, 0, n_layers * ctx_len * sizeof(float));

/* One forward over the whole window (causal mask makes every position's
* activation identical to the per-position prefix pass). */
float *_bt_logits = xcalloc(vocab_size, sizeof(float));
double _bt_f0 = _mt_prof ? _mt_prof_ns() : 0.0;
native_forward_with_cache(token_ids, full_len, &g_model, _bt_logits, &cache);
if (_mt_prof) _mt_fwd += _mt_prof_ns() - _bt_f0;
free(_bt_logits);

/* Per-position losses + seed d_x at every position (cheap: just the
* output projection + cross-entropy, no transformer recompute). */
float *d_x = xcalloc_array(safe_size_mul(ctx_len, d_model), sizeof(float));
for (int t = 0; t < full_len - 1; t++) {
int target_id = token_ids[t + 1];
float *hidden_t = cache.final_x + t * d_model;

float *logits = xcalloc(vocab_size, sizeof(float));
for (int j = 0; j < vocab_size; j++) {
float s = 0.0f;
for (int k = 0; k < d_model; k++)
s += hidden_t[k] * g_model.output_proj[k * vocab_size + j];
logits[j] = s;
}
float *probs = xcalloc(vocab_size, sizeof(float));
float loss = cross_entropy_loss(logits, target_id, vocab_size, probs);
float loss_w = (target_id == CLS_ID_DEDENT) ? dedent_loss_weight() : 1.0f;
loss *= loss_w;
total_loss += loss;
num_tokens++;
{
int cls = classify_target(target_id);
cls_loss_sum[cls] += (double)loss;
cls_count[cls] += 1;
if (t >= 0 && token_ids[t] == CLS_ID_INDENT) depth_at_predict++;
else if (t >= 0 && token_ids[t] == CLS_ID_DEDENT) depth_at_predict--;
if (target_id == CLS_ID_DEDENT) {
int d = depth_at_predict;
if (d < 0) d = 0;
if (d >= DEPTH_BUCKETS) d = DEPTH_BUCKETS - 1;
ded_depth_loss[d] += (double)loss;
ded_depth_count[d] += 1;
}
}
free(logits);

if (t == full_len - 2) {
double ent = 0.0;
for (int j = 0; j < vocab_size; j++) {
float p = probs[j];
if (p > 1e-12f) ent -= (double)p * log((double)p);
}
final_step_entropy = (float)ent;
}

float *d_logits = xcalloc(vocab_size, sizeof(float));
memcpy(d_logits, probs, vocab_size * sizeof(float));
d_logits[target_id] -= 1.0f;
if (loss_w != 1.0f)
for (int j = 0; j < vocab_size; j++) d_logits[j] *= loss_w;
free(probs);

for (int k = 0; k < d_model; k++)
for (int j = 0; j < vocab_size; j++)
grad_output_proj[k * vocab_size + j] += hidden_t[k] * d_logits[j];

for (int k = 0; k < d_model; k++) {
float sum = 0.0f;
for (int j = 0; j < vocab_size; j++)
sum += g_model.output_proj[k * vocab_size + j] * d_logits[j];
d_x[t * d_model + k] += sum;
}
free(d_logits);
}

/* One backward over the whole window (verbatim per-position body,
* ctx_len = full_len, d_x seeded at every position). */
double _bt_b0 = _mt_prof ? _mt_prof_ns() : 0.0;
for (int l = n_layers - 1; l >= 0; l--) {
TransformerLayer *layer = &g_model.layers[l];
int lsd = l * ctx_len * d_model;
int lss = l * ctx_len * ctx_len;
int lsf = l * ctx_len * d_ff;
int ls = l * ctx_len;

float *d_ffn_w1 = xcalloc_array(safe_size_mul(d_model, d_ff), sizeof(float));
float *d_ffn_w2 = xcalloc_array(safe_size_mul(d_ff, d_model), sizeof(float));
float *d_norm2_out = xcalloc_array(safe_size_mul(ctx_len, d_model), sizeof(float));
float *bw_wff1 = use_tern ? layer->w_ff1_tern : layer->w_ff1;
float *bw_wff2 = use_tern ? layer->w_ff2_tern : layer->w_ff2;
ne_fused_ffn_backward_buf(d_x, ctx_len, d_model,
cache.norm2_outputs + lsd, bw_wff1, d_ff, bw_wff2,
cache.ffn_pre_act + lsf, d_ffn_w1, d_ffn_w2, d_norm2_out);
for (int i = 0; i < d_model * d_ff; i++) lg_ff1[l][i] += d_ffn_w1[i];
for (int i = 0; i < d_ff * d_model; i++) lg_ff2[l][i] += d_ffn_w2[i];
free(d_ffn_w1); free(d_ffn_w2);

float *d_post_attn = xcalloc_array(safe_size_mul(ctx_len, d_model), sizeof(float));
for (int i = 0; i < ctx_len; i++) {
float d_ln_x[MAX_D_MODEL] = {0};
layer_norm_backward(d_norm2_out + i * d_model,
cache.ln2_x_norm + lsd + i * d_model,
layer->ln2_gamma, cache.ln2_std[ls + i], d_model,
d_ln_x, lg_ln2g[l], lg_ln2b[l]);
for (int j = 0; j < d_model; j++)
d_post_attn[i * d_model + j] = d_x[i * d_model + j] + d_ln_x[j];
}
free(d_norm2_out);

float *d_attn_wq = xcalloc_array(safe_size_mul(d_model, d_model), sizeof(float));
float *d_attn_wk = xcalloc_array(safe_size_mul(d_model, d_model), sizeof(float));
float *d_attn_wv = xcalloc_array(safe_size_mul(d_model, d_model), sizeof(float));
float *d_attn_wo = xcalloc_array(safe_size_mul(d_model, d_model), sizeof(float));
float *d_norm1_out = xcalloc_array(safe_size_mul(ctx_len, d_model), sizeof(float));
float *bw_wq = use_tern ? layer->w_q_tern : layer->w_q;
float *bw_wk = use_tern ? layer->w_k_tern : layer->w_k;
float *bw_wv = use_tern ? layer->w_v_tern : layer->w_v;
float *bw_wo = use_tern ? layer->w_o_tern : layer->w_o;
ne_fused_attention_backward_buf(d_post_attn, ctx_len, d_model,
cache.norm1_outputs + lsd,
bw_wq, bw_wk, bw_wv, bw_wo,
cache.attn_probs + lss,
d_attn_wq, d_attn_wk, d_attn_wv, d_attn_wo, d_norm1_out);
for (int i = 0; i < d_model * d_model; i++) {
lg_wq[l][i] += d_attn_wq[i];
lg_wk[l][i] += d_attn_wk[i];
lg_wv[l][i] += d_attn_wv[i];
lg_wo[l][i] += d_attn_wo[i];
}
free(d_attn_wq); free(d_attn_wk); free(d_attn_wv); free(d_attn_wo);

float *d_pre_attn = xcalloc_array(safe_size_mul(ctx_len, d_model), sizeof(float));
for (int i = 0; i < ctx_len; i++) {
float d_ln_x[MAX_D_MODEL] = {0};
layer_norm_backward(d_norm1_out + i * d_model,
cache.ln1_x_norm + lsd + i * d_model,
layer->ln1_gamma, cache.ln1_std[ls + i], d_model,
d_ln_x, lg_ln1g[l], lg_ln1b[l]);
for (int j = 0; j < d_model; j++)
d_pre_attn[i * d_model + j] = d_post_attn[i * d_model + j] + d_ln_x[j];
}
free(d_post_attn); free(d_norm1_out);

memcpy(d_x, d_pre_attn, ctx_len * d_model * sizeof(float));
free(d_pre_attn);
}
if (_mt_prof) { _mt_bwd += _mt_prof_ns() - _bt_b0; _mt_pos++; }

for (int i = 0; i < ctx_len; i++) {
int tok = token_ids[ctx_start + i];
Expand Down Expand Up @@ -1238,9 +1433,34 @@ static int native_train_step(int *input_ids, int input_len, int *output_ids, int
free(cache.ln1_x_norm); free(cache.ln1_std);
free(cache.ln2_x_norm); free(cache.ln2_std);

if (_mt_prof) {
_mt_total += _mt_prof_ns() - _mt_t0;
_mt_calls++;
if (_mt_calls % 200 == 0) {
double rest = _mt_total - _mt_fwd - _mt_bwd;
fprintf(stderr,
"[train-prof] windows=%ld positions=%ld | total=%.2fs fwd=%.2fs(%.0f%%) bwd=%.2fs(%.0f%%) rest=%.2fs(%.0f%%) | %.2f ms/window\n",
_mt_calls, _mt_pos, _mt_total/1e9,
_mt_fwd/1e9, 100.0*_mt_fwd/_mt_total,
_mt_bwd/1e9, 100.0*_mt_bwd/_mt_total,
rest/1e9, 100.0*rest/_mt_total,
_mt_total/1e6/_mt_calls);
}
}
return 0;
}

/* Dispatch: the causal-mask batched path is the default (~10.75x faster,
* 179 -> 16.65 ms/window on the d=32/L=4 model, gradient-identical to the
* per-position version — verified by matching loss sequences to float
* precision from a common checkpoint). EIGS_TRAIN_PERPOS=1 forces the
* per-position oracle for re-running that gradient-check. */
static int native_train_step(int *input_ids, int input_len, int *output_ids, int output_len, float learning_rate, float *loss_out, int *tokens_trained_out) {
static int use_batched = -1;
if (use_batched < 0) use_batched = getenv("EIGS_TRAIN_PERPOS") ? 0 : 1;
return native_train_step_impl(input_ids, input_len, output_ids, output_len, learning_rate, loss_out, tokens_trained_out, use_batched);
}

Value* builtin_native_train_step(Value *arg) {
/* Thin wrapper around native_train_step().
* Input: [input_ids_list, output_ids_list, learning_rate]
Expand Down
15 changes: 15 additions & 0 deletions tests/run_all_tests.sh
Original file line number Diff line number Diff line change
Expand Up @@ -1875,6 +1875,21 @@ if ! echo "$MODEL_PROBE_OUT" | grep -q "undefined variable"; then
echo " PASS: malicious model checkpoints rejected"
fi
echo ""

echo "[47c/47] native_train_step gradient-check (batched vs per-position, 3 checks)"
GC_OUTPUT=$(bash "$TESTS_DIR/test_native_train_gradcheck.sh" 2>&1)
GC_PASS=$(echo "$GC_OUTPUT" | grep -c "PASS:" || true)
GC_FAIL=$(echo "$GC_OUTPUT" | grep -c "FAIL:" || true)
TOTAL=$((TOTAL + GC_PASS + GC_FAIL))
PASS=$((PASS + GC_PASS))
FAIL=$((FAIL + GC_FAIL))
if [ "$GC_FAIL" -gt 0 ]; then
echo " FAIL: $GC_FAIL native_train_step gradient-check(s) failed"
echo "$GC_OUTPUT" | grep "FAIL:" | head -4
else
echo " PASS: batched training path is gradient-identical to the per-position oracle"
fi
echo ""
else
echo "[47/47] Model roundtrip SKIPPED (binary built without EIGENSCRIPT_EXT_MODEL)"
echo ""
Expand Down
80 changes: 80 additions & 0 deletions tests/test_native_train_gradcheck.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
#!/bin/bash
# native_train_step gradient-check: the batched causal-mask path must produce
# the SAME training as the per-position oracle. A causal transformer trained
# per-position (one forward+backward per target) is mathematically identical to
# one causal-masked pass with a loss at every position — the batched path is
# ~10x faster and must not change the gradients. Any drift would diverge the
# two loss sequences within a few steps (gradient errors compound), so matching
# loss sequences from a common start prove equivalence.
#
# Runs only when the binary is built with EIGENSCRIPT_EXT_MODEL=1 (gated by the
# caller). EIGS_TRAIN_PERPOS=1 selects the oracle; default selects batched.

set -u
TESTS_DIR="$(cd "$(dirname "$0")" && pwd)"
EIGS="$TESTS_DIR/../src/eigenscript"

PASS=0
FAIL=0
ok() { echo " PASS: $1"; PASS=$((PASS+1)); }
fail() { echo " FAIL: $1${2:+ ($2)}"; FAIL=$((FAIL+1)); }

MODEL=/tmp/eigs_gc_tiny.json
HARNESS=/tmp/eigs_gc_harness.eigs
cleanup() { rm -f "$MODEL" "$HARNESS" /tmp/eigs_gc_*.log; }
trap cleanup EXIT

if ! "$EIGS" "$TESTS_DIR/gen_tiny_model.eigs" > "$MODEL" 2>/tmp/eigs_gc_gen.log; then
fail "GC00 generate tiny model" "see /tmp/eigs_gc_gen.log"
echo "GRADCHECK: 0 passed, 1 failed"
exit 1
fi

# Fixed deterministic window (all token ids < vocab=8). Six steps is enough:
# a gradient bug diverges exponentially, so it would show by step 2-3.
cat > "$HARNESS" <<EIGS
r is eigen_model_load of "$MODEL"
inp is [1, 2, 3, 4, 5, 6, 7]
outp is [1]
lr is 0.05
for i in range of 6:
res is native_train_step_builtin of [inp, outp, lr]
lv is json_path of [res, "loss"]
print of ("L " + lv)
EIGS

# Extract the loss sequence from each path.
perpos=$(EIGS_TRAIN_PERPOS=1 "$EIGS" "$HARNESS" 2>/dev/null | sed -n 's/^L //p')
batched=$("$EIGS" "$HARNESS" 2>/dev/null | sed -n 's/^L //p')

if [ -z "$perpos" ] || [ -z "$batched" ]; then
fail "GC01 both paths produced a loss sequence" "perpos='$perpos' batched='$batched'"
echo "GRADCHECK: $PASS passed, $((FAIL)) failed"
exit 1
fi
ok "GC01 both paths produced a loss sequence"

# Compare element-wise. A real gradient difference compounds well past 1e-3
# within six steps; float32 accumulation-order noise stays under ~1e-4.
n_perpos=$(printf '%s\n' "$perpos" | wc -l)
n_batched=$(printf '%s\n' "$batched" | wc -l)
if [ "$n_perpos" != "$n_batched" ] || [ "$n_perpos" -lt 6 ]; then
fail "GC02 equal-length 6-step sequences" "perpos=$n_perpos batched=$n_batched"
else
ok "GC02 both paths ran 6 steps"
fi

maxdiff=$(paste <(printf '%s\n' "$perpos") <(printf '%s\n' "$batched") | awk '
{ d = $1 - $2; if (d < 0) d = -d; if (d > m) m = d }
END { printf "%.8f", m }')
worse=$(awk -v m="$maxdiff" 'BEGIN { print (m > 0.001) ? 1 : 0 }')
if [ "$worse" = "0" ]; then
ok "GC03 batched loss sequence matches per-position oracle (max diff $maxdiff < 1e-3)"
else
fail "GC03 batched vs per-position gradient divergence" "max loss diff = $maxdiff"
echo " perpos : $(printf '%s ' $perpos)"
echo " batched: $(printf '%s ' $batched)"
fi

echo "GRADCHECK: $PASS passed, $FAIL failed"
[ "$FAIL" -eq 0 ]
Loading