Skip to content
All projects

EnAi

Cut AI training compute at runtime.

A training run keeps computing gradients for layers that stopped learning hours ago. EnAi watches gradient energy per layer while the run is in flight, freezes each layer the moment its contribution collapses, and raises the batch size into the headroom that frees up. All of it happens mid-run, with no restart and no change to the model code.

Role
Solo. I build the engine, the study and the measurement.
Period
2026, ongoing
Status
Reference implementation running
Built with
PyTorch · Python · Autograd hooks · Metal Performance Shaders · ResNet-18 / CIFAR-10
6.91%
training compute removedAnalytic ledger, confirmed by autograd
+20.9%
throughput once engagedMachine normalised, median of 12 runs
1,563 to 0
backward passes into frozen layersCounted per layer by hooks left attached
92%
of the theoretical ceiling reached+7.42% predicted, +6.85% measured

The measurements

Throughput per epoch

  • Baseline
  • EnAi
Epoch 1
429434
Epoch 2
462544
Epoch 3
463547
images / second

Images per second. Epoch 1 is identical across every arm by design, which is what makes it usable as a control for the machine's own thermal drift.

The engine engages at the start of epoch 2. Across the twelve-run study, normalising each run against its own epoch 1 puts the engaged epochs at +20.85%.

Throughput per epoch
epochBaseline (images / second)EnAi (images / second)
Epoch 1429434
Epoch 2462544
Epoch 3463547

Compute per image, per step

  • GMAC
Baseline
1.6645
EnAi
1.5495
GMAC / image / step

GMAC per image per step. The forward pass is unchanged; the whole saving comes out of the two backward terms.

A difference of 0.115 GMAC, which is the 6.91% quoted above. Measured by an analytic ledger and confirmed against autograd.

Compute per image, per step
armGMAC (GMAC / image / step)
Baseline1.6645
EnAi1.5495

Passes per frozen layer, after the freeze

  • Passes
Forward
1,644
Backward
0
passes

Backward hooks stay attached for the whole run. After the freeze they stop firing entirely while the forward hooks keep going, which is autograd confirming the sub-graph was pruned.

1,563 backward passes before the freeze, exactly 0 after. Counted per layer by hooks deliberately left in place.

Passes per frozen layer, after the freeze
directionPasses (passes)
Forward1644
Backward0
Terminal log from enai-lab.com showing the EnAi governor engaging at the start of epoch two: freezing conv1, bn1, layer1.0.conv1 and layer1.0.bn1, raising batch size from 32 to 64, scaling the learning rate, and cutting compute from 1.6645 to 1.5495 GMAC per image per step.
Swipe to see the full logWhat the governor actually did, logged at the moment it engaged. Three levers fire together: freeze, batch size, learning rate.

Charts drawn from the published figures at enai-lab.com. The governor log is a screenshot of the run itself.

The problem

What was actually wrong

Convergence is uneven. The first convolutional layers settle into edge and texture detectors in a fraction of the time the deeper layers need, but a standard training loop treats every layer as equally unfinished from the first step to the last.

Backward is the expensive half. A layer costs F going forward and up to 2F coming back: once for its weight gradient, once for the gradient it passes upstream. Freeze a layer at the head of the graph and both terms disappear, along with the input gradient for the layer just behind it.

And nothing is allowed to react. Batch size, learning rate and the set of trainable parameters are all chosen before step one and never revisited. A run that could safely speed up halfway through has no mechanism to notice, let alone act.

Approach

What I built

Observe. Forward and full backward hooks sample each watched layer every N steps: activation RMS, gradient RMS at the output and at the weights, and that layer's share of the network's total gradient energy. On the steps in between, both hooks return on their first line, so the steady state cost is a branch rather than a synchronisation.

Decide. A governor reads that telemetry at each epoch boundary. When a layer's share of gradient energy collapses it has stopped contributing to learning and becomes a candidate. In the reference run the two watched layers fell from 35.7% of total gradient energy to effectively nothing.

Adapt. Freeze the layer and its BatchNorm, raise the batch size into the headroom the freeze just released, and rescale the learning rate linearly so the optimisation trajectory stays comparable. The run continues from exactly where it was, with no checkpoint and no restart.

Freezing four modules made 38,848 parameters non trainable, which is 0.35% of the model. It removed 6.91% of the compute. Early convolutional layers are tiny in parameters and expensive in operations because they run at full spatial resolution, so quoting parameter count here would understate the result by roughly twenty times.

Where it got hard

3 traps, all caught by instrumentation

Three things that looked like they worked

Each one produces a system that reports a saving while quietly doing something else. All three were found by measurement rather than by reasoning, and each now has a test that fails if it comes back.

  1. 01

    Freezing BatchNorm does not freeze BatchNorm

    Setting requires_grad to False stops the weight and bias updates. It does nothing about the running mean and variance, which are updated as a side effect of the forward pass, and model.train() silently puts the module back into training mode at the start of every epoch. Frozen BatchNorms are now held in eval mode and re-asserted after every model.train() call, with a test that fails if that re-assertion is ever deleted.

  2. 02

    Stale gradients keep the optimizer moving

    SGD skips a parameter only when its gradient is None. A gradient tensor left over from the previous step keeps updating a parameter that has supposedly been frozen, silently, with no error anywhere. Freezing now clears the gradient to None explicitly, and the test compares real weight tensors before and after optimizer steps instead of trusting the flag.

  3. 03

    Changing batch size can cost more than it saves

    Rebuilding the DataLoader to change batch size tears down and respawns the worker pool. On macOS those workers are spawned rather than forked, so each one re-imports the framework: about 20 seconds of dead time on a run whose baseline was 32 seconds. Batch size is now changed by mutating a sampler in place, so the loader and its workers are never touched and the transition costs milliseconds.

Not established

What these numbers do not show

This section exists because an efficiency claim is only worth what its methodology survives. The ablation arms were designed so they could have shown the freeze contributing nothing.

  • Energy reduction is not established. The meter was CodeCarbon, which on Apple Silicon without root falls back to a constant TDP estimate. It attributed zero watts to the GPU doing nearly all the work, so the resulting energy delta is the time delta in different units.
  • Accuracy parity is not established either. Across three seeds, final accuracy sits 0.33pp below baseline (t = -2.0, df = 2; significance at 5% would need |t| above 4.30). Not resolvable at this sample size, though all three seeds moved the same direction, which reads as a small real cost rather than parity.
  • Wall clock does not transfer. On this hardware the same baseline configuration varies by 20% between seeds from thermal throttling alone, so every timing figure is normalised against a within run control rather than compared as raw seconds.
  • One model, one dataset, one accelerator, three epochs. Twelve runs make the comparison internally sound. They say nothing yet about larger scale, other architectures, or multi device training.

Next

callm

Production safeguards for LLM calls, in one decorator.