Where the library goes after 2.0.2. This file is the index: it names every piece of work worth doing, sizes it, orders it, and points at the document that holds the detail when one already exists. It does not repeat those documents.
| Detail lives in | For |
|---|---|
featuresPlan.md |
Feature candidates with costs, and the "not on this list" rationale |
docs/improvement-plan.md |
MoE training throughput, work items W0-W9 |
docs/optimize-plan.md |
Dense training throughput, Tasks 0-9 |
docs/baseline.md |
Measured numbers and the current per-kernel profile |
2Dto3DPlan.md |
Image-to-3D stages A-H and the deferred texture work |
docs/interoceptive_spec.md |
The interoceptive trading agent and its acceptance criteria |
Effort is for one person: S is days, M is one to two weeks, L is a month or more.
Priority:
- P0: blocks a release, or fixes something wrong today.
- P1: the next things to build. Most users will notice them.
- P2: worth doing once P1 is done, or when someone asks for it.
- P3: parked. Written down so nobody has to argue it again.
Ground rules that apply to every item:
- Profile first. No performance item starts without a fresh profile
(
nsysorexamples/profile_step.rs) that shows the target is where the time goes. The estimates below come from earlier profiles. Re-measure before trusting them. - Training runs on CUDA. Every training-throughput number and every end-to-end training check runs on the GPU path. The CPU path is the reference for parity tests, not a training target.
- Parity before speed. A new kernel lands only with a test that compares it against the CPU reference or the kernel it replaces.
- Read the reject lists first.
docs/improvement-plan.mdhas two lists, "Already landed — do not redo" and "Already measured and rejected — do not redo". Reopen an item from them only with a new measurement.
- Dense transformer training on one RTX 3060: 21.8k tokens/s at 4 x 1024 for
a 101.7M-parameter model. That ties or beats PyTorch with
torch.compileat every measured shape. GEMMs run at 78-98% of the card's BF16 peak. - Kernel time per step: GEMMs 60%, the three flash-attention kernels 19%, everything else 20%. The flash kernels run at about half of peak, so they are the largest target left.
- MoE costs 1.37x a dense step for 1.14x the arithmetic. The routed path runs in BF16 under mixed precision (A7 W1).
- The library has MoE, LoRA (CPU and CUDA), quantization-aware training, int8 inference, ViT, a masked-LM (bidirectional) mode, Lion and Adam, streaming datasets, the text-to-image pipeline (MMDiT, UNet, VAE, CLIP, T5, LLaMA-family prompt encoders), image-to-3D, ONNX import and export, and the interoceptive RL agent.
- Single GPU only. No multi-GPU, no distributed training, no CPU offload.
- Structural tax: no autograd and no N-D tensor type. CPU and CUDA paths are written twice. Every new layer is two forward passes, two hand-written backward passes and a finite-difference test.
Release the work that is already in the tree, and close what is half done.
| # | Item | Effort | Notes |
|---|---|---|---|
| 2.1.1 | S | Landed as the topic commits from 1e0de06 to 92c732a; the tree is clean. Original note: |
|
| 2.1.2 | S | Clippy is clean with and without cuda. The stress-reflex test already existed (agent.rs:246). tests/interoceptive_no_alloc.rs counts allocations over 1000 steps, resets and replay samples, and a mutation check confirmed that it fails when one allocation is added. Original scope: docs/interoceptive_spec.md §5: zero warnings, the stress-reflex logit test (margin_stress 0.05 against 0.95, EmergencyFlatten logit rises above the threshold), and no heap allocation per step in the replay loop. Prove the last one with a counting allocator in a test, not by reading the code. |
|
| 2.1.3 | Matrix::random |
S | Network already seeds its own He init, and no production code calls Matrix::random. It is now documented as unseeded test filler. Original note: src/matrix.rs:98 draws from thread_rng over 0..1, ignores the seed, and makes every weight positive. Make it take an RNG and use a zero-centred range, or deprecate it in favour of Param::he_uniform. The legacy Network path uses it. |
| 2.1.4 | S | write_f32s in src/transformer.rs, covered by the existing round-trip tests. Original note: save_optimizer_state and save_bin (src/transformer.rs) build a full Vec<u8> copy of each tensor before they write it. Write little-endian chunks into the BufWriter instead. This matters on long runs that save every 200 steps. |
|
| 2.1.5 | S | The convolution claim and the model list are fixed. "Not planned" for encoder-decoder and multi-GPU is still decision 1. Original note: The section says convolutions have no backward pass and encoder-decoder is "not planned". Check both claims against the tree and against the decisions in this file. | |
| 2.1.6 | Changelog and release | S | Prepared as 3.0.0, not 2.1.0: TransformerConfig, RopeSpec and ShapeVaeConfig gained public fields. The old "Unreleased" section had already shipped in 2.0.1 and 2.0.2 (including the ImagePipeline::new change), so it now has that heading. Publishing is left to the owner. |
Added 2026-10-09 from the consumers' missingFeatures.md files
(/home/vasylt/Code/rusting/<project>/missingFeatures.md) and the owner's
detection track for the CS2 player detector. These come before the other
tracks. P0 blocks a consumer (no stopgap possible, or a plan item waits on
it); P1 has a working stopgap in the consumer. A stopgap with a gradient
check is the reference implementation. When an item lands, the consumer
entry's - Status: line becomes landed in RustingBrain <commit> with the API
under it.
RustingEngine's list asks for game-engine tooling (CLI, scene editing, capture), none of it machine learning, so it adds nothing here.
Order: items wanted by the most projects first, smallest first within that, except K8 after the CPU layers it is tested against: K1, K5, K7, K6, K3, K4, K2, K8, K10, K11, K13c, K13b, K13a, K13d, K12, K9, K13e, K13f.
| # | Item | Priority | Effort | Asked by | Acceptance |
|---|---|---|---|---|---|
| K1 | ffn::silu_derivative made pubffn::tests::silu_derivative_matches_finite_differences (ten points from -8 to 9, within 1e-3) and a doc test calling it from outside the crate |
P1 | S | RustingChop "Public SiLU derivative", RustingCS2 (copied stopgap), detection track (SiLU everywhere in YOLO11) | A doc test calls it from outside the crate; finite-difference test at ten points. |
| K2 | conv::TrainableGroupNorm::new(groups, channels, eps) on ImageBatch, gamma/beta: Param, forward -> (ImageBatch, GroupNormCache), backward, params_mut() (CPU; device kernels come with K8)new and forward return a Result. Test conv::tests::trainable_group_norm_matches_the_inference_one_and_finite_differences: forward bit-equal to conv::GroupNorm, input/gamma/beta gradients within 1e-3 (relative to the largest) of central differences |
P1 | S | RustingChop "Trainable GroupNorm", RustingCS2 | Input, gamma and beta gradients within 1e-3 of finite differences; matches conv::GroupNorm forward exactly. |
| K3 | conv::spatial_softmax / spatial_softmax_backward (coordinates linspace(-1, 1), x then y) and conv::global_avg_pool / global_avg_pool_backwardconv::tests::spatial_softmax_matches_finite_differences_and_finds_a_peak and conv::tests::global_avg_pool_matches_finite_differences (every input gradient within 1% of the largest) |
P1 | S | RustingChop "Spatial softmax", "Global average pool", RustingCS2 | Finite-difference checks of both backwards; output layout as the stopgap src/stopgap/spatial.rs. |
| K4 | param::Dropout::new(rate), forward(&Matrix, &mut StdRng, training) -> (Matrix, DropoutMask), backward(&mask, &Matrix), inverted scalingnew returns a Result (rates outside [0, 1) refused). Test param::tests::dropout_keeps_the_mean_and_routes_the_gradient_through_its_mask (1e5 ones at 0.15: mean within 1%, dropped share within 0.5%) |
P1 | S | RustingChop "Dropout", RustingCS2 | Identity when not training; mean kept within 1% over 1e5 entries; backward masks the same entries. |
| K5 | losses::bce_with_logits(logits, targets, smoothing, weights), losses::soft_cross_entropy(logits, targets, weights), losses::huber(pred, target, delta, weights), each the batch-mean loss and dL/dlogitslosses::logit_tests::* (each gradient within 1% of the largest against central differences, with and without weights; BCE at logits +-100 gives loss 100 and gradients +-0.5, soft cross-entropy 200 and finite) |
P1 | S | RustingChop "Logit losses with per-sample weights", RustingCS2, detection track (BCE-with-logits for the class head) | Finite-difference checks with and without weights and smoothing; stable at logits of +-100. |
| K6 | TrainableConv2d without a bias (without_bias() or bias: Option<Param>)TrainableConv2d::without_bias() and has_bias(); the pub bias: Param field stays (zero and unused) so existing code still compiles. Test conv::tests::a_convolution_without_bias_adds_and_trains_none (output exactly the biased one minus its bias, same weight gradients, no bias in params_mut) |
P1 | S | RustingChop "Conv without bias", RustingCS2 | A bias-free conv's gradients match finite differences; params_mut has no bias. |
| K7 | step == 0 is an error or a debug_assert!, and the docs of apply_step, step_clipped and Param::step say steps count from 1Param::step asserts step > 0 in every build (every optimizer path goes through it), docs say steps count from 1; test param::tests::step_zero_panics_instead_of_poisoning_the_parameters |
P1 | S | RustingChop "Document (or guard) the 1-based optimizer step", RustingCS2 | A test that step 0 panics in debug (or returns an error) instead of turning parameters NaN. |
| K8 | Device-resident image training: DeviceImageBatch, TrainableConv2d::forward_device/backward with bias on the device, device GroupNorm, SiLU, spatial softmax and global average pool, public upload and download; BF16 activations optional. Split: conv::DeviceImageBatch, CudaDevice::upload/download, TrainableConv2d::forward_device/backward_device, bias and its gradient on the device; test conv::tests::a_device_chain_stays_on_the_card_and_matches_the_host (two biased convs, one with rebuilt columns: zero transfers inside the chain, outputs and all gradients within 1e-4 abs + 1e-4 rel of the host); TrainableGroupNorm::to_cuda_on/forward_device/backward_device with DeviceGroupNormCache, conv::silu_device/silu_backward_device; test conv::tests::device_group_norm_and_silu_match_the_host (2x16x17x12, 4 groups: zero transfers inside the chain, output, input, gamma and beta gradients within 1e-4 abs + 1e-4 rel of the host); conv::spatial_softmax_device/spatial_softmax_backward_device with DeviceSpatialSoftmaxCache, conv::global_avg_pool_device/global_avg_pool_backward_device; test conv::tests::device_spatial_softmax_and_pooling_match_the_host (3x17x19x5: zero transfers, outputs and input gradients within 1e-5 abs + 1e-4 rel of the host); docs/baseline.mdexamples/chop_block_step.rs, per-layer 1002.3 ms, device-resident 98.9 ms per forward and backward step at batch 128 (10.1x, load average 1.5) |
P0 | L | RustingChop "Device-resident image training", RustingCS2 (same root cause), detection track (graph with device-resident activations) | Every device op within FP tolerance of its CPU function; no host round trip inside the chain (checked by a transfer counter); measured step time. |
| K9 | recurrent::Gru::new(input, hidden, rng), forward_seq, backward_seq (through time, with dh0), step for batch 1, params_mut, CUDA gate GEMMsto_cpu and hidden; PyTorch's nn.GRU equations and initialization, rows batch-major. Tests recurrent::tests::every_gradient_matches_finite_differences_over_sixteen_steps (x, h0 and all four parameters, worst gap 1.7e-4), stepping_matches_the_sequence (1e-6), device_gate_gemms_match_the_host_or_skip_without_a_device (256-wide cell, batch 4, 16 steps: outputs and every gradient within 2.9e-6; load average 3.8) |
P1 | M | RustingCS2 "Recurrent cell with backward through time" | Every gradient within 1e-3 of finite differences over 16 steps; CUDA within FP tolerance of CPU. |
| K10 | Glb::base_color_image(&self, primitive) -> Option<(&[u8], &str)> (bytes and MIME type), plus Glb::json() and Glb::binary()(mesh, primitive) indices. Test gltf::tests::a_textured_primitive_hands_back_its_embedded_image (embedded PNG bytes and image/png back; none for a flat-colour or material-less primitive) |
P0 | S | RustingMedia 2 "Base-colour texture bytes out of a GLB" (blocks its PLAN 4) | A GLB with an embedded PNG returns the same bytes and image/png; none for an untextured primitive. |
| K11 | marching_tetrahedra and _sparsemesh::tests::marching_tetrahedra_winds_every_face_outward (sphere radius 0.6, resolution 48, dense and sparse: every face normal agrees with the radial direction, winding number at an inside point within 0.05 of 1) |
P1 | S | RustingMedia 1 "Consistent winding from marching_tetrahedra" |
On a sphere field (radius 0.6, resolution 48) every face normal agrees with the field gradient at its centroid; winding number about 1 inside. |
| K12 | vit_encoder, patch tokens outVitEncoder::load_dinov2(path, image_size, precision) reads a facebook/dinov2-* checkpoint (no pre-norm, exact GELU, LayerScale folded into the out-projection and second FFN layer, position table resampled bicubically to the image size); IMAGENET_MEAN/IMAGENET_STD; row 0 of encode is the class token, rows 1.. the patch tokens. Test vit_encoder::tests::dinov2_matches_transformers (ignored, RB_DINOV2_REF from scripts/dinov2_reference.py): small 4.4e-4 at scale 27.9, base 1.0e-3 at scale 24.3, worst 4.2e-5 relative; device tower cuda_image::tests::a_whole_vit_tower_matches_the_host_or_skips_without_a_device now also runs the DINOv2 layout (load average 4.0) |
P0 | M | RustingMedia 3 "DINOv2 weights in vit_encoder" (blocks its PLAN 5) |
Patch tokens within 1e-3 relative of the HF transformers reference on two images. |
| K13a | Done 2026-10-09 as K13a-i to K13a-iii. Detection layers with CUDA backward: trainable BatchNorm2d (train and eval, running stats) and conv+BN fusion for inference, SiLU, nearest 2x upsample, channel concat, 5x5 stride-1 max pool (SPPF), the C2PSA attention, composed into one model with backward through the whole graph (shares K8's device graph) | P0 | M | Detection track | Each layer's gradients within 1e-3 of finite differences on the CPU and CUDA within FP tolerance of CPU. |
| K13a-i | conv::TrainableBatchNorm2d (new, yolo = eps 1e-3 momentum 0.03, forward(&mut, input, training), backward, fuse_into(&conv), forward_device/backward_device, running statistics as a [2, C] Param kept on the device), upsample_nearest_batch/_backward, concat_channels/split_channels, max_pool_same/_backward, and *_device versions plus add_device. Tests conv::tests::batch_norm_matches_finite_differences_in_both_modes_and_tracks_running_statistics (input gradient within 1e-3 of central differences training and evaluating; running mean and unbiased variance within 1e-6 of PyTorch's update), a_fused_convolution_equals_the_convolution_then_the_evaluating_norm (1e-5), upsample_concat_split_and_max_pool_backwards_are_their_adjoints (max pool within 1e-3 of central differences), and device_detection_layers_match_the_host (every output, gradient and running statistic within 1e-4 + 1e-4 relative; load average 4.9) |
P0 | S | Detection track | As K13a. |
| K13a-ii | Depthwise 3x3 convolution with backward (C2PSA's positional encoding, the Detect head's class branch) and the pixel attention inside C2PSA, host and device | P0 | S | Detection track | Done. DepthwiseConv2d, pixel_attention/_backward and their _device forms. conv::tests::depthwise_convolution_matches_finite_differences (strides 1 and 2, input and weight gaps < 1e-3); conv::tests::pixel_attention_matches_ultralytics (attended map, values and qkv gradient within 1e-5 of torch autograd, fixture /home/vasylt/Code/games/cs2/fixtures/k13a_attention.json); conv::tests::device_depthwise_convolution_matches_the_host and device_pixel_attention_matches_the_host (within 1e-4 relative; load average 1.7). |
| K13a-iii | yolo::Yolo11 (new(Yolo11Scale, classes, rng), load_ultralytics, forward/backward on the host, to_cuda_on, forward_device/outputs_device/backward_device, state_mut, params_mut), yolo::decode (DFL expectation, sigmoid scores, ready for NMS) and yolo::detection_loss (ultralytics v8DetectionLoss: assigner, BCE, CIoU and DFL, gradient with respect to the three maps). Tests yolo::tests::yolo11_matches_ultralytics (a 1/16-width YOLO11 against ultralytics 8.4.174: eval and train outputs, input gradient and running statistics within 1e-4 relative, parameter gradients within 2e-3, the worst a BatchNorm gamma summing thousands of terms at 1.2e-3), detection_loss_matches_ultralytics (loss terms within 1e-5, map gradients within 1e-4 relative, fixture /home/vasylt/Code/games/cs2/fixtures/export_k13e_loss.py), and device_yolo11_matches_the_host (train-mode outputs and input gradient within 1e-3 relative, measured 2e-4 and 3e-4 from batch-statistic summation order; running statistics 1e-4; parameter gradients 2e-3; load average 3.9) |
P0 | M | Detection track | As K13a. |
| K13b | detection::box_iou(pred, target, Overlap) with its gradient, distribution_focal_loss, make_anchors, box_to_ltrb/ltrb_to_box, TaskAlignedAssigner::yolo(strides).assign, non_max_suppression, mean_average_precision. Tests detection::tests::overlap_gradients_match_finite_differences (IoU and GIoU within 2e-4) and detection::tests::detection_ops_match_ultralytics against ultralytics 8.4.174 (/home/vasylt/Code/games/cs2/fixtures/export_k13b_ops.py): 64 box pairs' IoU/GIoU/CIoU values and gradients within 1e-5, DFL loss within 1e-5 and gradient within 1e-6, assignment of 84 anchors to 3 boxes exact (23 foreground, scores within 1e-5), NMS of 300 boxes keeps the same 70 rows bit-equal, per-threshold matches exact on 6 images and mAP50 0.4752 / mAP50-95 0.2885 within 1e-6. CIoU's aspect weight is held constant in the gradient as ultralytics does. CUDA NMS moves to K13f, where its cost is measured |
P0 | M | Detection track | Loss gradients against finite differences; assigner, NMS and mAP against ultralytics outputs exported as fixtures to /home/vasylt/Code/games/cs2/fixtures/. |
| K13c | images featuredetection::read_yolo_split, parse_yolo_labels, rect_shape, DetectionImage::open/resized/letterbox/flip_horizontal, detection::mosaic; test detection::tests::the_pipeline_matches_ultralytics_on_the_player_dataset (8 labelled val images through ultralytics 8.4.174's rect validation pipeline: shape 736x1312 and padding equal, boxes within 1e-5 normalised, 1536 sampled pixels worst 1 level and mean 0.098 apart; one mosaic of four train images at a fixed centre: 5 boxes within 0.01 px), fixtures from /home/vasylt/Code/games/cs2/fixtures/export_k13c_data.py. The random affine after mosaic and HSV jitter are not in it |
P0 | S | Detection track | Reads /home/vasylt/Code/games/cs2/dataset/ (1107 train, 278 val); letterboxed boxes match ultralytics' on fixtures. |
| K13d | best.pt and yolo11s.pt exported to safetensors once by a script in /home/vasylt/Code/games/cs2/fixtures//home/vasylt/Code/games/cs2/fixtures/export_k13d_yolo11s.py writes k13d_player_v1.safetensors (the trained EMA weights), k13d_yolo11s.safetensors and the first val batch with its raw maps. yolo::tests::player_model_matches_ultralytics_on_sixteen_val_images: worst raw-map gap 1.8e-6 of the map's largest value over 16 images. examples/yolo_val.rs on all 278 val images: mAP50 0.7394 (ultralytics 0.739), mAP50-95 0.5803 (0.576), 40.0 ms/image of which the model is 36.7 ms, unfused and with backward caches kept (load average 3.8) |
P0 | M | Detection track | On 16 val images raw head outputs within 1e-3 relative of PyTorch; val mAP50 0.739 within 0.005. |
| K13e | examples/yolo_detect.rs fine-tunes from yolo11s.pt on the datasetoptimizer=auto (mosaic/affine/HSV/flip, close_mosaic 10, AdamW 0.002 with decay on conv weights only, 3 warmup epochs, linear decay, accumulation to 64, clip 10, EMA validated). 25 epochs at batch 4 (batch 8 at 1280 runs out of memory in fp32 even with Yolo11::set_recompute_columns): EMA val mAP50 0.7336, mAP50-95 0.5467 in 1.572 h; mAP50 0.5975/0.6070/0.6744/0.7085 at epochs 5/10/15/20 (ultralytics 0.583/0.652 at 10/20). 4.9 samples/s against ultralytics' 27.5 (AMP, 40 s/epoch): 100 epochs would take ~6.5 h, so the full schedule needs the owner. Load average 1-4 during the run. Augmentation test detection::tests::an_identity_affine_and_unit_hsv_keep_the_image |
P0 | L | Detection track | Val mAP50 >= 0.72; samples/s and wall-clock logged against ultralytics' 1.118 h for 100 epochs. Runs over 2 hours need the owner. |
| K13f | Yolo11::fuse, TrainableConv2d::infer_device, bf16 im2col and GEMMs for the fused 3x3 convolutions under CudaDevice::with_mixed_precision, Yolo11::save_ultralytics, Validation::stages_ms. examples/yolo_val.rs ... 1 tf32 on the 278 val images at batch 1, rect letterboxed (e.g. 1280x736): upload 0.56, forward 13.56, download 0.47 ms = 14.6 ms for the model (unfused fp32 was 37.7 ms); host decode 3.34 ms and NMS 0.02 ms more, 17.9 ms in all; mAP50 0.7390, mAP50-95 0.5800 (fp32 0.7394/0.5803). Load average 2.6 at the start and 15 at the end (other projects). Tests yolo::tests::device_yolo11_matches_the_host (fused host and device within 1e-4 of unfused eval, mixed within 5e-2) and yolo::tests::a_saved_model_loads_back_exactly (round trip exact, fused too; the player checkpoint saved back equals ultralytics' file tensor for tensor). API in CHANGELOG.md. Next levers if it matters: decode on the device (3.3 ms), copy_channels for Concat (1.5 ms), an implicit GEMM instead of im2col |
P0 | M | Detection track | Under 15 ms with time per stage; then the load, train and run API goes in CHANGELOG.md. |
| K13g | YOLO training throughput, so the full 100-epoch Gate 2 schedule fits the 2-hour pre-approval (about 15 samples/s at batch 4; ultralytics does 27.5 with AMP) | P1 | M | Detection track | Added 2026-10-09. yolo_detect --steps 40 --batch 4 at 1280 before: 5.3 samples/s (per batch data 67 ms, forward 221, loss 66, backward and step 401; load 5-6). nsys over 30 batches: dw_bwd_weight 11.7 ms a call (15% of kernel time), im2col_train 11%, col2im_train 9%, memsets 62 ms a batch (column-sized buffers zeroed before a beta-0 GEMM), FP32 SIMT GEMMs about 30%. Step 1 done: the depthwise weight gradient reads channels coalesced (one thread per 64 pixels, tap and channel, atomics into the zeroed sum); matmul_device and col2im allocate without a memset; 1x1 stride-1 convolutions skip im2col and col2im. 6.5 samples/s (forward 197, backward and step 275 ms), losses unchanged to 4 digits. Tests: conv::tests::device_depthwise_convolution_matches_the_host, a_device_chain_stays_on_the_card_and_matches_the_host (its second convolution is now 1x1), the yolo:: and detection:: device tests. Step 2 done: yolo_detect trains on a mixed-precision device (every FP32 GEMM runs as BF16 on the tensor cores with an FP32 accumulator, which is what ultralytics' default AMP does); detection_loss decodes, assigns and differentiates each image on its own rayon task; the input batch is built on the prefetch thread; the batch norm and depthwise backward buffers that a kernel overwrites whole are allocated without a memset. 9.0 samples/s at load 4-5 (forward 156 ms, loss 25, backward and step 225), losses within 0.5% of FP32 (box 0.967 vs 0.966, cls 10.61 vs 10.62, dfl 0.929 vs 0.931). Tests: yolo::tests::detection_loss_matches_ultralytics, the yolo::, detection:: and conv::tests::device device tests. nsys after step 2: kernels 379 ms a batch; im2col_train 43 ms, col2im_train 32, the batch norm and SiLU elementwise kernels about 100, bias and norm column sums as GEMV about 33. Step 3 done: SiLU runs inside the device batch norm kernels (TrainableBatchNorm2d::forward_device_with, a new bn_bwd_prep kernel), 9.3 samples/s. Step 4 done: on a mixed-precision context a training convolution that needs im2col builds bf16 columns, runs all three GEMMs on bf16 operands, writes its column gradients as bf16 and sums them with a new col2im_bf16. 10.1 samples/s at load 6-7 (forward 136 ms, loss 22, backward and step 203), losses within 0.3% of FP32. Test: conv::tests::a_mixed_precision_convolution_tracks_the_host (columns kept and rebuilt, channels in fours and not; output, input, weight and bias gradients within 1% in norm). Step 5 done: col2im_bf16 sums four channels a thread when the channel count allows, 10.6 samples/s. Step 6 done: the device batch norm takes its means, variance and gamma and beta gradients from a col_sums kernel that reads channels coalesced, instead of GEMVs against ones over squares and products buffers; the backward pass takes both of its sums from one read. 11.3 samples/s at load 4-6 (data 30 ms, forward 124, loss 27, backward and step 171), losses within 0.2% of step 4. Tests: the yolo::, detection:: and conv::tests device tests; a sign mutation in the fused sum fails yolo::tests::device_yolo11_matches_the_host. nsys after step 6: kernels 283 ms a batch; im2col_bf16 36.5, col_sums 34, bn_apply 21, copy_channels 19, bn_bwd_prep 18, bn_bwd_apply 18, col2im_bf16 13, add_f32 11; device-to-device copies 19.5 ms. The 40-batch timing runs inside the warmup, where the optimizer steps almost every batch; with steps every 16 batches (the rest of a run) step 6 does 12.1 samples/s. Step 7 done: the batch norm backward no longer writes the gradient before its SiLU: the column sums and bn_bwd_apply take it through the SiLU as they read it (bn_gy), and bn_bwd_prep is gone. 11.5 samples/s at load 8-10 (backward and step 155 ms). Tests: as step 6; dropping the SiLU in bn_bwd_apply fails yolo::tests::device_yolo11_matches_the_host. Step 8 done: a convolution inside Yolo11::forward_device no longer copies its input into its cache (TrainableConv2d::forward_device_keeping(input, false), backward_device_from); the tape already holds the input, so the backward pass reads it there. 12.8 samples/s at load 6-7 (forward 104 ms, loss 29, backward and step 151). Tests: the yolo::, detection:: and conv::tests device tests. Step 9 done: the device batch norm sums its mean and variance in one pass, shifted by each channel's first value so the variance does not cancel away (the running mean as the shift was 4.2e-3 off on a fresh norm over values near 30, no shift 2.4e-3, the first value 6e-5). col_sums 27.2 to 20.5 ms a batch in nsys; 12.6-13.0 samples/s at load 4-8, inside the run-to-run spread. Test: conv::tests::a_device_batch_norm_over_offset_values_matches_the_host (within 5e-4 of the host). An attempt at im2col_bf16 with one thread per pixel and channel over all taps measured 34.7 ms against 35.5 and was dropped. Step 10 done: nsys showed the device idle 12% of a batch, a 20 ms gap of it the host loss; timed inside, the loss spent 9.5 ms gathering every anchor's row into a fresh 35 MB buffer and 2.5 ms scattering the gradients back one row at a time. detection_loss now reads each anchor's row from its map in place and scatters an image's gradients in one parallel copy a map: loss 30 to 19 ms a batch at load 6. Test: yolo::tests::detection_loss_matches_ultralytics. Step 11 done: yolo_detect --warmup N times the steady state, where the optimizer steps once every 16 batches of four: 14.3 samples/s at load 3.5 before this step. A CPU timing of detection_loss on random maps (four images, 20 boxes each) put decode at 9.7 ms, the assigner at 9.6 and the gradient copy-out at 7. Each image now decodes its anchors in parallel without a heap allocation per anchor, the assigner picks each box's top candidates with an exact select (metric down, anchor up) instead of a full sort and runs the boxes in parallel, and the gradients are written into the three map buffers directly: 28 to 9 ms on the CPU, and 20 to 7 ms a batch on the bench. Steady state 15.1 and 15.4 samples/s at load 6.5, which puts 100 epochs of 1107 images at about 2.0 h plus validation. Tests: yolo::tests::detection_loss_matches_ultralytics, the detection:: assigner tests. Step 12 done: the steady-state profile put every elementwise kernel near the card's bandwidth (bn_apply ~300 GB/s), so the cut is in bytes: the device batch norm keeps its input instead of writing the normalized values, and its backward kernels recompute them from the input, mean and inverse deviation (bit for bit the forward's formula). Forward 96 to 90-93 ms a batch, 15.15 to 15.3 samples/s averaged over two A/B pairs at load 6. Tests: the 98 device tests, among them conv::tests::a_device_batch_norm_over_offset_values_matches_the_host and yolo::tests::device_yolo11_matches_the_host. Step 13 done: YOLO's device backward pass took a slice's gradient back by concatenating it between two zeroed blocks and then adding whole tensors, and copied an Add's gradient for its second input. copy_channels gained an add flag, and gradients now accumulate in place: a slice's into its channel range of the input's gradient, an Add's into whichever input already has one (a copy only when neither does), and every other sum in place instead of into a new buffer. 15.3 to 15.75 samples/s averaged over two A/B pairs at load 5-6. Tests: the 98 device tests, yolo::tests::device_yolo11_matches_the_host among them. Tried after it: uploading the input as bytes and dividing on the device (20 MB a batch instead of 78) measured 15.75 against 15.7 samples/s, inside the noise, and was dropped. A real epoch with validation at batch 4 (yolo_detect --epochs 1 --val-every 1 --close-mosaic 0, load 5-7) took 71 s of training at 15.7 samples/s plus about 5 s of validation, so 100 epochs project to 1.99 h: inside the limit with no margin, and batch 8 runs out of memory. Also tried: not copying out a slice that only feeds concatenations (the concatenation and its backward read the source's channels in place) cut copy_channels from 24.2 to 21.4 ms a batch and kernel time from 233.6 to 232.2 ms, but measured 15.3-15.4 against 15.6-15.8 samples/s in two A/B pairs, and was dropped. Open, needs the owner: the rest of the kernel time is bandwidth-bound elementwise work (batch norm, channel copies) and im2col_bf16 plus col2im_bf16 (47.6 ms a batch, 18%); 12 ms more is fp32 pointwise GEMMs for which cuBLAS picks SIMT or align-1 kernels. What remains is an implicit-GEMM convolution, which cuDNN 9 (installed in /usr/lib) provides through cudarc's cudnn feature, a new system dependency that needs approval; writing one by hand is a large job of uncertain return. Otherwise the 100-epoch run (projected 1.99 h) needs the owner's go-ahead past the 2-hour limit. Rejected 2026-10-10, the fp32 pointwise GEMMs: a fresh nsys over 60 steady-state batches (kernels 233.4 ms a batch, GEMMs 77.4) put 7.3 ms a batch in simt_sgemm ... align1 kernels. A shape log showed them on the backbone's 1x1 convolutions (e.g. m64 n409600 k64), whose pointers and leading dimensions are all 16-byte aligned: cuBLAS simply picks SIMT for them under COMPUTE_32F_FAST_16BF. COMPUTE_32F_FAST_TF32 for the fp32-operand GEMMs measured 15.15 against 15.75 samples/s in two A/B pairs at load 1-4, and was dropped. A bf16 cast of the input before each pointwise GEMM does not pay either: with k of 64 to 96 these GEMMs are bandwidth-bound (773 us against about 600 us for the bytes alone at m64 n409600 k64), and the cast pass reads and writes more than the kernel change saves. The stem convolution (k 27, wmma align1, 0.97 ms forward) writes 210 MB of output, so padding its k to 32 saves under 0.4 ms. |
Every item here is gated on ground rule 1.
Done. The fragment-packing guess was wrong: loading the second loop's
(and the first loop's) B operands with ldmatrix.x4 instead of four
LDS.32 cut registers from 198 to 168 but left the probe at 0.973 against
0.990 ms. What paid was dropping the dimension-major copies of the query and
output-gradient tiles: the second loop now reads the query-major tiles with
ldmatrix.x4.trans, the load phase loses its four 16-bit transposed stores
per pair, and shared memory falls from 37 KB to 19 KB, so with 168 registers
three blocks fit on an SM instead of two. Probe 0.990-0.992 to 0.800 ms
(-18%); in the model (nsys, bench_train_step 4 1024 cuda) 847 to 637 us
a launch (-25%), whole step 52149-52208 to
52536-53452 tok/s under load 9-24. Tests: both
fused_attention_*_three_kernel_path_* (64 and 128, causal and
bidirectional); reading every transposed matrix from the first column
block fails the backward one. The 5 ms target was past the card: at
0.995 ms the kernel already ran at about 54% of the 25.5 TFLOPS BF16 peak.
Evidence in docs/baseline.md ("dkv without transposed tiles").
This is the largest single kernel left, at 15.45 ms a step. The ablations in
docs/baseline.md ("What is left") show that the kernel is bound by mma work
and not by bandwidth. The second mma loop costs 0.44 ms against 0.17 ms for the
first, with identical mma and LDS.32 counts. Start with the fragment packing
in that second loop. Use examples/flash_probe.rs against its 0.995 ms
baseline. Target: the same mma throughput as the first loop, about 5 ms a step.
These cost 10.45 ms and 7.24 ms. Apply whatever A1 learns about the second loop. Do not start before A1 lands.
Rejected after measuring, 2026-10-08. A1's lesson was to drop the
transposed shared-memory copies and read with ldmatrix.trans. The
query-gradient kernel already does (A3). In the forward kernel, values
key-major with ldmatrix.x4.trans moved the probe from 0.446 to 0.441 ms,
about 0.04 ms of a 77 ms step. Paired tile loads made it slower (0.49-0.57
ms), and so did ldmatrix for the score keys (0.51 ms). Nothing was kept.
Evidence in docs/baseline.md ("Forward and query-gradient kernels after
A1").
cuda_flash::eligible requires head_dim == 64 and mixed precision. Every
other width falls back to the three-kernel path that used to be 21% of a step.
Add head_dim 128, which most published LLaMA-family checkpoints use and which
B5 needs. Add 32 only if a model needs it.
Done for 128. The four training kernels are templates over the head width
and are built for 64 and 128 (flash_attention_*_64, flash_attention_*_128);
the image kernel stays at 64. The 128-wide backward kernels opt in to more than
48 KiB of shared memory (dq 52 KiB, dkv 70.5 KiB). Parity: both
fused_attention_*_three_kernel_path_* tests run at 64 and 128, causal and
bidirectional, against the FP32 host. Idle GPU, batch 4 x 1024, the default
model cut into 4 heads of 128: 48,456 tok/s fused against 39,374 tok/s on the
three-kernel path (+23%). The 64-wide kernels compile to the same register
counts as before (126/163/198) and the default model runs 53,315 tok/s.
Left: flash_attention_dq_128 uses 254 registers and dkv_128 spills
128 bytes; at 128 they take 7.2 and 8.7 ms a step against 3.6 and 6.7 ms at 64.
dq at 128 done 2026-10-08. Its limit was shared memory, not registers:
a second, transposed copy of the key tile made 52 KiB a block, so one block
fit per SM. It now reads the key tile transposed with ldmatrix.trans, as
dkv already does: 34 KiB, two blocks per SM (254 registers still fit two).
nsys, bench_train_step 4 1024 cuda 128, load 5: dq_128 median 1075 to
620 us a call; four interleaved rounds 48517-48770 to 49954-50304 tokens/s
(+2.6%), head 64 flat (53257-53636 against 52856-53643). Test:
fused_attention_backward_matches_the_three_kernel_path_or_skips_without_device;
feeding the wrong ldmatrix half to the first output tile fails it.
dkv at 128, 2026-10-08: its key, value and two gradient fragments are 192
registers, and the whole query tile's scores 64 more, so it spilled 180
bytes. At width 128 it now takes the query tile in two halves of 32: 48
bytes spilled, dkv_128 median 891 to 783 us, step 49921-50451 to
50471-50589 tokens/s. At 64 one pass stays (the split cost 4% there).
Test: the same backward test; dropping the half from the gradient
products' row offset fails it. A3 done.
Done in 52bbf36 ("train a bidirectional model on the device"): every
fused kernel takes a causal flag, and a bidirectional() TransformerLm
(masked LM, ViT-shaped encoder) trains on the fused BF16 path. Tests:
gpu_transformer::tests::fused_attention_matches_the_three_kernel_path_or_skips_without_device
and ..._backward_..., both run causal and bidirectional at head widths 64
and 128. What remains is the flow transformer and the shape decoder, which
run FP32 throughout and so cannot reach a BF16 kernel; that is A5.
The FP16 non-causal variant exists for inference in the image path. Training a
bidirectional model (masked LM, ViT, the flow transformer) has no fused path.
gpu_cross.rs and gpu_flow.rs both say "FP32 throughout". This item
unblocks A5.
The 3D training path runs in FP32, so it gets half the tensor-core rate or
less. A4 is done; profile examples/train_shape.rs first. Its self-attention
can then call the bidirectional flash kernels directly.
Profile, 2026-10-08 (examples/profile_shape.rs 4 2048 4096, mixed,
packed, nsys): 0.129 s a step against 0.206 in FP32, about 80 ms of it
kernels. Every step had a 44-47 ms gap with the GPU idle: one
cudaStreamSynchronize after the gradient norm's first readback, preceded by
a burst of cuMemFreeAsync. The default memory pool's release threshold is
zero, so each sync handed the step's freed buffers back to the driver and the
next step mapped them in again. device_context now sets the threshold to
u64::MAX (keep_freed_memory): the shape step went 0.130-0.131 to 0.085 s
(-35%), bench_train_step 4 1024 cuda stayed 53451-53562 tokens/s. Load
0.8-1.0. No test: the call fails the context if the driver refuses it.
Done 2026-10-08. A second nsys profile (same command, load 2.6) put the
three-kernel softmax at about 17 ms a step and the per-head FP32-operand
attention GEMMs at about 20 ms more: about 40% of the step. The flash kernels
now take separate query and key/value buffers, widths and lengths (forward
and query gradient over query tiles, key/value gradient over key tiles), and
self-attention passes one buffer as both. gpu_cross runs every activation
and weight in BF16 and attention through those kernels when the context is
mixed precision and the head is 64 or 128 wide; otherwise it keeps the FP32
three-kernel path. Shape VAE step 0.084-0.087 to 0.062-0.065 s (-25%); LM
step unchanged once the width-64 key/value gradient kernel was held to three
blocks per SM (see docs/baseline.md). Test:
gpu_cross::tests::fused_cross_attention_matches_the_host_or_skips_without_device,
two shapes with more key tiles than query tiles and the reverse, worst error
0.47% of the largest value; a forward kernel that stops one key tile short
fails it (0.93%), as does a key/value kernel that walks the wrong number of
query tiles.
Measured on the idle GPU at 96ffcc5 (bench_train_step 4 1024 cuda, nsys,
four steps): the LM head is three cuBLAS GEMMs of 4096 x 512 x 32000, 134 GFLOP
each, taking 5.83, 5.21 and 5.06 ms a step: 23.0, 25.8 and 26.5 TFLOP/s, at the
3060's BF16 tensor-core rate with an FP32 accumulator. ce_loss_grad_bf16 is
2.73 ms: it reads the 262 MB BF16 logits twice and writes them once, about
288 GB/s of 360. A fused kernel can only remove that 2.73 ms (3.5% of 77.5 ms),
and only if its own GEMM keeps up with cuBLAS, which no hand GEMM in this crate
does (the routed experts reach 13.6 to 15.8 TFLOP/s). The logits buffer is
262 MB in BF16 at this shape, not the 512 MB below, which is 2% of the card.
Re-open for memory, not speed, if a run is ever bound by that buffer.
Original note:
The LM head GEMMs are 22.72 ms a step. src/transformer.rs:1367 materializes
the full [rows, vocab] logits on the device. A chunked fused
GEMM-plus-softmax-plus-loss that never writes the logits saves both time and
the largest activation buffer. At vocab 32k and 4 x 1024 tokens, that buffer
is 512 MB in FP32. This also helps if W8 (halve the vocabulary) is rejected.
These are already scoped in docs/improvement-plan.md:
- W1:
run the routed path in BF16done 2026-10-07. Under mixed precision the gathered rows, packed expert weights, SwiGLU hidden and the backward upstreams are BF16; the expert outputs, the scatters and the router stay FP32.gather_scale_rowswrites either precision. Measured on the default model at 4 x 1024: kernel time 82.0 to 80.1 ms a step, the FP32-operand expert GEMMs 18.4 to 6.1 ms, best of five 49,802 to 51,631 tok/s (+3.7%, under the estimate, which was for a 768-wide model). Loss over 500 steps on the repository's markdown tracks the FP32-routed run (3.184 against 3.183 at step 499, held-out 4.924 against 4.944). Evidence indocs/baseline.md("MoE routed path in BF16"). - W2:
fuse the permutation passesparts 1 and 2 rejected 2026-10-07 after re-measuring. On the idle GPU at6f48659, the five permutation kernels take 4.2 ms of a 78.1 ms step (gather 1.29,scatter_add_scaled0.93,scatter_add_rows0.92,row_dot_scatter0.63,moe_stats0.36), down from 17.8 ms when the item was written. Both planned fusions are stale:scatter_add_scaledruns in the forward pass androw_dot_scatterin the backward, so they cannot share a read, and the backward gather reads the output gradient, not the input the forward cache already keeps. The one redundant read left isrow_dot_scatterand the backward gather both reading the gathered output-gradient rows: about 0.5 ms (0.6%), under the run-to-run spread. Part 3 (no forward gather) stays behind A8. Evidence indocs/baseline.md("MoE permutation passes re-measured"). - W3:
folddeferred to the next major version, 2026-10-07. Re-measured in the idle-GPU trace atpack_gate_upinto the parameter layout6f48659: the per-step packs (pack_gate_upandpack_down, thecast_actlaunches inside each MoE forward bracket) are 144 launches and 0.69 ms of a 78.1 ms step (0.9%), not 2.5 ms, since W1 halved the bytes. The transient buffer is 5.8 MB of BF16 per routed layer at the default 8 experts x 352 x 512. The fix replacesSwiGlu's publicgateandupfields (moe::ExpertisSwiGlu), which 3.x cannot do. Take it in 4.0, or earlier if a many-expert model makes the transient buffer matter. - W4: measure the expert GEMM split, and only then optimize it. Step 1
done 2026-10-07: read off the
nsyskernel trace of the idle-GPU run at6f48659, taking the GEMMs between each gather and its scatter as the routed experts (no NVTX needed; the order is fixed). Routed expert GEMMs are 10.67 ms of a 78.1 ms step: 3.92 ms forward in 96 launches (about 13.6 TFLOPS) and 6.75 ms backward in 192 (about 15.8 TFLOPS), against roughly 22 TFLOPS for the dense GEMMs. Each expert GEMM is about 1024 rows by 352 or 704 columns, and cuBLAS launches 24 to 48 blocks for it on a 28-SM card, so the split costs occupancy, not tile choice. At dense efficiency the whole routed set would take about 7.3 ms: about 3.4 ms (4.4%) recoverable, under the plan's 10 ms bar for step 2. A grouped GEMM over all eight experts is the fix if it is ever taken, and it needs A8. Evidence indocs/baseline.md("Routed expert GEMMs attributed"). Step 3 done 2026-10-07:TransformerCache::expert_loads()returns each MoE layer's per-expert share of routed slots, from the host or the device cache. Tests:transformer::tests::expert_loads_report_each_moe_layer_and_sum_to_one,gpu_transformer::tests::expert_loads_on_the_device_match_the_host_or_skip_without_device(fails with the device shares mis-normalised). On the repository's markdown (4096-entry BPE, 500 steps) the busiest expert takes up to 0.41 at initialisation, but 0.145-0.169 per layer from step 200 on against an even 0.125, so step 1's estimate holds for trained routing. Evidence indocs/baseline.md("Expert loads on real text").
Also move the router GEMM onto the tensor cores. Done 2026-10-07. Under
mixed precision the MoE block now takes the BF16 normed input like the dense
feed-forward: the router multiplies BF16 operands into FP32 logits, the
gather reads the BF16 rows directly and the shared expert runs in BF16.
Kernel time 80.1 to 76.7 ms a step; the FP32-operand s1688bf16 GEMMs are
gone. Best of five 51,631 to 52,914 tok/s (+2.5%). Loss over 500 steps ends
at 2.945 (held-out 4.632) against 3.184 (4.924) for the W1 run. Evidence in
docs/baseline.md ("MoE router and shared expert in BF16").
Blocked until 4.0 (checked 2026-10-08): MoeConfig and MoeLayer are
public structs with all fields public, so the capacity factor cannot become a
field in 3.x. A setting kept outside the config would not survive a save. Its
payoff is also small now: the grouped expert GEMM it enables was measured at
about 3.4 ms of 78 ms (W4 step 1).
This is the TODO at src/moe.rs:200. A fixed-capacity variant gives
statically shaped expert buffers. It is the precondition for the third part of
W2, for a batched-pointer expert GEMM, and for C1 (CUDA graphs). Keep dropless
as the default.
Measured on the idle GPU at 6f48659 (bench_train_step 4 1024 cuda):
adam is 207 launches and 5.11 ms a step over 55.18M parameters. At 28 to 32
bytes of traffic per parameter that is 302 to 345 GB/s, 84% to 96% of the
3060's 360 GB/s. The median launch takes 17 µs, so launch cost is not what
bounds it, and the GPU queue never drains during a step. One kernel over a
pointer table could save at most the gap to the roofline, about 0.2 to 0.8
ms (0.3% to 1%), under the run-to-run spread. Lion moves fewer bytes and is
bound the same way. Evidence in docs/baseline.md ("Adam re-measured").
Original note:
Adam is 113 launches and 8.96 ms a step. One kernel over a pointer table of all parameters removes 112 launches. Measure first: Adam is bandwidth-bound, so the gain may only be the launch overhead. Do the same for Lion.
Recompute each block's forward pass during the backward pass instead of storing it. This trades about 30% more compute for most of the activation memory. It is the cheapest way to train longer sequences or a larger model on a 12 GB card. Make it per-layer and opt-in on the builder.
Done 2026-10-07. TransformerBuilder::checkpoint(blocks) (and
TransformerLm::set_checkpoint) marks blocks whose device cache keeps only
the block input; backward_stack reruns forward_block from it before that
block's backward. Expert loads are taken in the forward pass so a
checkpointed routed layer still reports them. Device only: the host path and
the vision stack keep everything. Test
gpu_transformer::tests::checkpointed_blocks_train_like_kept_ones_or_skip_without_device
(FP32 and BF16, a dense and a routed block; fails when the recompute reads
zeros). Default model, every block checkpointed: peak 2843 to 1353 MiB at
16 x 512, step 131.8 to 161.5 ms (+23%). Evidence in docs/baseline.md
("Activation checkpointing").
Block-wise quantized Adam moments cut optimizer memory by 75%. Gate it on the
loss-curve check from docs/optimize-plan.md Task 4 Step 7. Note W6 in
docs/improvement-plan.md: optimizer-state traffic "can hurt".
Done 2026-10-08. TransformerLm::set_8bit_optimizer_state(true), after
to_cuda, holds every trainable weight's Adam moments as one byte each:
blocks of 256 values with an FP32 absmax and bitsandbytes' dynamic map
(signed for the first moment, unsigned for the second). The adam8 kernel
dequantizes, steps, reduces the block's new absmax and requantizes in one
pass. Opt-in and device only, so Optimizer stays as it is; a Lion step on
8-bit state fails. Saving and loading optimizer state dequantize and
requantize. Tests:
gpu_transformer::tests::an_8bit_adam_step_matches_the_host_or_skips_without_device
(five steps against the same scheme on the host, gradients over six
decades, a partial block, and the round trip back to FP32) and
a_model_with_8bit_optimizer_state_trains_like_fp32_or_skips_without_device;
a 1% wrong rescale in the kernel fails both. Default model: peak 2037 to
1723 MiB at 4 x 1024 and 16 x 256 (314 MiB, the 6 bytes a weight predicts
for 55.2M weights), step time unchanged (75.2-75.6 ms). Loss over 2000
steps (examples/adam8_loss_check.rs, repository markdown, BPE 4096,
16 x 256, Adam 3e-4) tracks FP32 state: 0.1824 against 0.1723 at the end,
held-out 6.618 against 6.554. The corpus is small enough that both runs
overfit it; a larger-corpus check is still worth doing before a long run.
Evidence in docs/baseline.md ("8-bit Adam state").
Seen once while verifying, in one of six full device runs:
checkpointed_blocks_train_like_kept_ones_or_skip_without_device failed its
1e-5 loss match (it does not use 8-bit state); it passed seven reruns. The
MoE scatters add with float atomics, whose order varies; the tolerance may be
too tight for that.
W5 measured the ceiling at 5%. Leave it parked until everything above lands.
This is the item the README apologizes for. Plan:
- One process with one context per device, and one model replica each.
- All-reduce the gradients with NCCL through cudarc. Bucket the gradients and overlap the all-reduce with the backward pass.
- Each rank draws from
TokenFilewith its rank folded into the window offsets, so the "resume at step N, same data" property still holds. - Acceptance: on two cards, the loss curve matches a single card at the same effective batch, and throughput is 1.8x or better.
Do not build tensor parallelism, pipeline parallelism or multi-node until two
cards on one host works. See featuresPlan.md "Multi-GPU".
Shard the optimizer state across the data-parallel ranks. Do this after B1.
AdamW with decoupled weight decayalready decoupled:Paramsubtractslearning_rate * weight_decay * valueapart from the Adam update (src/param.rs), the CUDA kernel matches it (cuda_adam_weight_decay_one_batch_update_matches_cpu_or_skips_without_device), andOptimizer::Adamdocuments it as AdamW-style. Checked 2026-10-07.- Muon for the 2D weight matrices, with AdamW for the rest. Measure it against Adam on the 101.7M benchmark model.
- Schedule-free AdamW. It removes the schedule argument from long runs.
- Blocked until 4.0 (checked 2026-10-08):
Optimizeris an exhaustive public enum, so aMuonor schedule-free variant is a breaking change. ThesemverCI job (J3) would flag it. - Adafactor. It has factored second moments, and is cheap in memory.
Ship each one only if it beats Adam or Lion on loss per token in a real run.
Closed 2026-10-08: every bullet below is done, measured out or already existed.
LoRA works on CPU and CUDA. What is left:
src/param.rs:810: a device-resident adapter accumulates its gradient on the host. Keep it on the device.Measured and rejected 2026-10-07. The frozen int8 and NF4 bases already landed with the fold (QLoRA slices below). Skippingsrc/gpu_model.rs:1390: the adapter is folded into the weight once per forward pass. Use two thin GEMMs instead, which also lets the base weight stay frozen in int8.fold_loraentirely, default model, rank 16, 4 x 512, takes the step from 42.6-43.4 to 40.5-41.0 ms, so the fold is at most 2.2 ms (5%). Two thin GEMMs per reader would write a[rows, units]term in the forward pass and a[rows, inner]term in the input gradient, where the fold writes one[units, inner]weight; with 2048 rows against 512 to 1536 wide weights that is more traffic, not less. What the fold pays is mostly launches (two narrowing casts and a rank-16 GEMM a projection); a cast kernel that addsscale * up . downwhile it writes the slot would save at most that 5%, for LoRA runs only. Evidence indocs/baseline.md("LoRA fold cost").nothing to do (checked 2026-10-07): the comment itself says no device path reaches that host method; device training runs insrc/param.rs:810gpu_model.- QLoRA: an int8 or NF4 frozen base with BF16 adapters. Split:
Slice 1, int8 basedone 2026-10-07.TransformerLm::quantize_lora_basestores every adapted projection's frozen base as int8 (thePrecision::Q8scheme) on the host and the device;pack_freshdequantizes it into the weight pack and folds the adapter in as before. The model trains, generates andsave_loras, and refusesmerge_lora,save_binand ONNX export. Test:gpu_transformer::tests::a_gpu_lora_step_on_an_int8_base_matches_the_host_or_skips_without_device(dense and MoE, FP32 and BF16, against the host on the same bytes; dequantizing half the base fails it). Default model, rank 16, 4 x 512: peak 889 to 778 MiB, step unchanged (46.1-46.5 to 46.3-46.6 ms). Evidence indocs/baseline.md("LoRA on an int8 base").Slice 2, NF4 basedone 2026-10-07 (D3(c)).TransformerLm::quantize_lora_base_4bit(Codebook)stores the adapted bases at 4 bits with a scale per block of 64; the device uploads the packed bytes and adequantize_q4kernel widens them into the weight pack through the codebook.has_int8_basebecamehas_quantized_base. Test:gpu_transformer::tests::a_gpu_lora_step_on_an_nf4_base_matches_the_host_or_skips_without_device(FP32 and BF16 against the host on the same nibbles; swapping the kernel's nibble halves fails it). Default model, rank 16, 4 x 512: peak 889 (FP32) / 778 (int8) / 762 MiB (NF4), step unchanged at 42.5-43.3 ms. Evidence indocs/baseline.md("LoRA on an NF4 base").
Save and load an adapter on its own, without the base model.Already existed:TransformerLm::save_loraandload_lora.
Closed 2026-10-07. All three prerequisites and the loader are done (below),
and B4 trains adapters on the loaded model, including on an int8 or NF4 base.
One gap is recorded, not open work: no test compares logits against the
reference implementation on real published weights. The only LLaMA-family
checkpoint on this machine is a 3B model (maya1, LLaMA 3 layout, llama3
RoPE scaling) whose FP32 copy does not fit beside the 12 GB of free RAM, and
there is no Python transformers to produce the reference. The loader's
tests check it against the crate's own TextEncoder reader instead
(hf::tests::the_reference_llama_reader_agrees_with_what_was_saved).
TextEncoder already reads Qwen2 and LLaMA safetensors for inference. Map the
same tensors into a trainable TransformerLm, so a user can fine-tune a
published model with B4. This needs A3 (head_dim 128 is the common case),
RoPE scaling (B6) and a tokenizer round trip through Bpe.
Status: weights done. TransformerLm::load_hf (src/hf.rs) reads LLaMA
and Mistral checkpoints, single-file or sharded. It refuses biases (so Qwen2),
per-head q/k norms, YaRN or dynamic rope_scaling and a sliding window
shorter than max_seq_len, instead of loading them wrong. Linear and llama3
scaling load. head_dim 128 runs on the fused attention path (A3, 2026-10-07).
Tokenizer: sentencepiece BPE done. Bpe reads the LLaMA 2 / Mistral /
TinyLlama flavour (▁ for spaces, <0xNN> byte fallback, the Metaspace
pre-tokenizer or the older Prepend normalizer). Against the Hugging Face
tokenizers library on a real 32k vocabulary, 310 lines of the README encode
to the same ids and decode to the same text in both file formats. Test:
tokenizer::tests::sentencepiece_bpe_matches_the_reference_tokenizer.
LLaMA 3 tokenizer done. Bpe recognises the LLaMA 3 split regex
(digits in runs of up to three, case-insensitive contractions) and its Qwen2
variant (single digits), and honours ignore_merges. Against the Hugging Face
tokenizers library on the real LLaMA 3 (128k) and Qwen-Image (151k) files,
474 lines (this README, src/tokenizer.rs, mixed-script text) encode to the
same ids and decode to the same text. This also fixed Qwen files, whose empty
end_of_word_suffix was read as a CLIP suffix. Test:
tokenizer::tests::the_llama3_split_matches_the_reference_tokenizer.
head_dim 128 on the fast path: done with A3.
- RoPE scaling: linear, NTK-aware and YaRN. B5 needs these to read published
configurations correctly. Linear and llama3 done (
RopeScaling,TransformerConfig::rope_scaling, read and written bysrc/hf.rs). YaRN and dynamic NTK are still open: YaRN also scales attention logits, and dynamic NTK rebuilds the tables per sequence length. Blocked until 4.0:RopeScalingandTransformerConfigare exhaustive, so a YaRN variant or a sliding-window field is a breaking change (checked 2026-10-07). - Sliding-window attention in the flash kernel (a mask on the tile loop).
- Sequence packing with document masks in
TokenBatch, so packed documents do not attend to each other. - Both blocked until 4.0 (checked 2026-10-08): a window is model
configuration, so it belongs in the exhaustive
TransformerConfig, and attention reads its row map fromLayout, a public struct with all fields public, so a document map is a new field there.TokenBatch's fields are private, but it reaches attention only throughLayout.
Track the gradient norm and the per-layer update-to-weight ratiodone:grad_normandstep_clippedalready return the norm.TransformerLm::step_with_update_ratiossteps and returns|dw| / |w|per parameter, inparams_mutorder, on host and device. Tests:transformer::tests::update_ratios_measure_the_step_taken(exact under SGD),gpu_transformer::tests::update_ratios_on_the_device_match_the_host_or_skip_without_device(fails without the post-step sync).TransformerLmhas nofit_with; the caller logs the vector. 2026-10-07.Skip a step when the loss is NaNdone:optimizers::step_clipped(and soTransformerLm::step_clipped,FlowTransformer::step_clippedand the interoceptive agent) skips the update when the gradient norm is NaN or infinite, clears the gradients, and returns the norm so the caller sees it. Before, a NaN norm compared false againstmax_normand the NaN step went through, poisoning the weights and Adam moments. The model step counter does not advance on a skip. Test:transformer::tests::a_non_finite_gradient_skips_the_step(fails without the guard).Skipping a loss spike and rolling back to the last good statedone:SpikeGuard::new(ratio, beta, warmup)keeps a bias-corrected moving mean of accepted losses and flags a non-finite loss or one aboveratiotimes the mean; a spike leaves the mean alone andconsecutive()counts runs. The caller skips withaccumulate_steppluszero_grad, and rolls back with the existingload_binandload_optimizer_state(pattern in the doc example). Tests:optimizers::tests::a_spike_is_skipped_without_moving_the_mean, doctest. 2026-10-07.z-loss on the LM headdone:TransformerBuilder::z_loss(weight)andTransformerLm::set_z_lossaddweight * logsumexp(logits)^2per predicted position totrain_stepandaccumulate_step, reported inTotalLoss::auxiliary_loss. Default 0 leaves the loss unchanged. On CUDA the term folds into the fusedce_loss_gradkernels (FP32 and BF16). Tests:causal_lm_loss::tests::z_loss_gradient_matches_finite_differences,gpu_transformer::tests::a_z_loss_step_on_the_device_matches_the_host_or_skips_without_device(fails with the device term zeroed). No throughput row: the GPU was shared (2026-10-07).
featuresPlan.md excludes RLHF and DPO. The interoceptive module now ships a
DQN loop inside the crate, which weakens the "different library" argument.
DPO needs no reward model and no sampling loop: it is a loss over pairs of
sequences. Reopen this if B5 lands and someone wants to align a fine-tuned
model.
A decode step is many small kernels, so launch overhead dominates. Capture one
decode step per shape and replay it. This needs a device-resident KV cache
(C2) and static buffers. cuda_image.rs says "no graph capture" too. The
UNet loop is also a candidate.
Profile 2026-10-07 (nsys, 129 decode calls): host time is dominated by
cudarc's per-buffer event tracking (about 1770 cuStreamWaitEvent and 970
cuEventRecord calls a token, near 0.9 ms of API time), more than the 190
kernel launches. Turning tracking off (CudaContext::disable_event_tracking)
gave 655 vs 656 tokens/s at batch 1 and 5280 vs 5455 at batch 16 on a clean
GPU, because that host work overlaps the kernels. Rejected: within noise at
batch 1, and it is unsafe for every buffer shared across streams. Graph
capture still needs static buffers to remove the overlap ceiling.
Part 1 done 2026-10-07: routed experts without the host round trip. The
profile at 79f9aac showed 19 device-to-host copies a token at batch 1:
every MoE layer downloaded its routing (and its auxiliary-loss sums) so
cuBLAS could shape one GEMM per expert, which drained the stream six times
a token and would also block graph capture. A decode step of up to 16 rows
now runs decode_moe: a gemv_routed kernel reads each slot's expert on
the device for the gate/up product, and again for the down product summed
over a token's slots with its gates; the auxiliary losses are skipped.
Running every expert densely with zero gates instead (no new GEMV) removed
the copies too but read four times the expert weights and left batch 1
flat. Int8 decode and prompts keep the old path. Default model, 512-token
prompt, 256 new tokens, two interleaved rounds, load 4 to 10: 1 sequence
784-924 to 1357-1363 tokens/s, 4 sequences 2725-2730 to 3793-3795, 16
sequences 6929-6933 to 7918-7930. Tests: the decoder parity tests
(a_device_decoder_matches_the_host_decoder_or_skips_without_device,
a_device_batch_decoder_matches_host_decoders_or_skips_without_device,
whose tiny model has two MoE layers and runs both the vector and scalar
paths); a mutant that ignores the routing gates fails both tests. Evidence in docs/baseline.md ("Routed experts on the
device in decode").
Part 2 (next): capture a decode step as a CUDA graph. Still needed: the remaining per-step host-to-device uploads (RMSNorm weights, token ids, positions) and per-call allocations must become static buffers, and the sequence length must come from device memory so one graph serves every step. Split 2026-10-08:
2a, resident norm weights.Done 2026-10-08.Gpu::decode_normkeeps each RMSNorm weight in the decoder'sPackMemo, so a token no longer uploads 17 of them. nsys, int8, one sequence, 129 decode calls:cuMemcpyHtoDAsync3092 to 916 calls (24 to 7 a token). Throughput is unchanged within the load noise (one sequence 1208-1358 against 1237-1242 tokens/s BF16, 16 sequences 8904-8967 against 8700-8911): the host work overlapped the kernels. Tests: the device decoder parity tests.2a', one stream.Done 2026-10-08. A fresh nsys profile (int8, one sequence) had the GPU busy 25% of a decode window: cudarc waited on and recorded an event for every kernel argument, about 1000cuStreamWaitEventand 500cuEventRecordcalls a token, becauseGpuContextmade its own stream and that put the context in multi-stream mode.GpuContextnow runs on the legacy default stream, so there is one ordered stream and no tracking. Under nsys the GPU is busy 62% and decode runs 637 to 971 tokens/s. Unprofiled, five interleaved rounds at load 7: one sequence BF16 1200-1406 to 1406-1428, int8 1676-1712 to 1711-1747, 16 sequences 8746-8840 to 8772-8899;bench_train_step 4 1024flat (53392-53428 against 53279-53630 tokens/s). Small unprofiled because the host work overlapped the kernels; with it gone, 2c has less to win at batch 1. Tests: the full suite withRUSTING_BRAIN_REQUIRE_CUDA=1.2b, static buffers and 2c, capture and replayrejected for speed 2026-10-08. After 2a',nvidia-smisampled every 50 ms during an unprofiled 3000-token decode (one sequence, 64-token prompt) reads the GPU busy 95-97% (median 95 int8 at 1574 tokens/s, 97 BF16 at 1351): decode is GPU-bound, so removing launches can win about 5% at most. Redo only if the kernels get much faster or a profile shows the GPU idle again.
This is W9 in docs/improvement-plan.md, still not measured. Keep the KV
cache on the device across Decoder calls, and decode several sequences in
one batch. Measure tokens/s with examples/bench_decode.rs before and after.
Step 1 done: bench_decode <prompt> <new> cuda decodes the default model on
the device. Before the cache work it runs 197 tokens/s (5.1 ms/token) with
about 1.1 ms of kernels and 0.8 ms of copies a token; the rest is host
round-trips, about 174 device-to-host copies a token (docs/baseline.md).
Step 2 done: a Decoder on a device model keeps every layer's projected
rows on the device (gpu_model::DeviceKv) and runs the whole block there;
generate goes through Decoder. 384 tokens/s, 1.95x. Parity test
a_device_decoder_matches_the_host_decoder_or_skips_without_device covers a
prompt, single tokens and a second multi-token turn in FP32 and BF16.
Step 3 done: DeviceKv keeps every weight pack for the decoder's life
(PackMemo, lent to the context per call). 624 tokens/s, 3.2x the step-1
number; kernels are 1.01 ms of a 1.6 ms token, the rest is launch overhead
(C1).
Step 4 done: TransformerLm::batch_decoder(n) returns a BatchDecoder that
decodes n equal-length sequences in step. On a device the cache rows
interleave position-major, so one step's rows stay contiguous and the
projection, RoPE (new divisor argument) and logits run once for all of them;
attention runs as strided batched GEMMs. Default model, 512-token prompt:
618 tokens/s at 1 sequence, 1994 at 4, 5194 at 16, 9093 at 64. Parity tests
a_batch_decoder_matches_one_decoder_per_sequence (host) and
a_device_batch_decoder_matches_host_decoders_or_skips_without_device (FP32
and BF16); breaking the RoPE divisor fails the device test. Ragged batches
need C3.
Step 5 done (2026-10-07): under mixed precision the device decode keeps a
BF16 copy of the LM head (DeviceKv::head) and narrows the final norm, so
the head GEMV reads half the bytes. nsys put the FP32 head at a fifth of a
step's kernel time (median 200 us); BF16 runs 120 us. Same-session A/B on
an idle GPU: 626 to 666 tokens/s at one sequence. Breaking the last-rows
offset fails the three device decoder parity tests.
Allocate the KV cache in fixed blocks so sequences of different lengths share a pool. Do this after C2, and only if batched decode with uneven lengths is a real use case.
2026-10-08: C14 made uneven prompts a supported use; they are padded, so this item would now win back the padding's cache capacity.
Split 2026-10-08. The device cache was allocated at max_seq_len for every
sequence when a decoder was made, whatever the session's length, and C16's
fork and C17's beam search copied all of it.
- Part 1, done 2026-10-08: grow the device cache on demand.
DeviceKvstarts empty andreservegrows every layer's buffer and the padding slots to the positions a call needs, at least doubling and at mostmax_seq_len, copying the cached prefix. The layout is unchanged, so no kernel changed. Default model (8 layers, 8 + 2 x 2 heads of 64, FP32 rows): 24,576 bytes a position a sequence, so 50.3 MB a sequence before any token; now a 20-token prompt holds 0.5 MB and a 512 + 256 session 25.2 MB. Tests: the existing device decoder, batch decoder, long decode and fork tests cross several growths; skipping the prefix copy fails three of them. Decode throughput within 0.3% (docs/baseline.md, "KV cache grown on demand (C3 part 1)"). - Part 2: blocks shared across sequences. A block table per sequence,
so uneven sequences in one batch take only their own length. Every
attention path (
decode_attention, the score GEMMs over a strided history) reads a contiguous position-major history today, so this changes the kernels and is L, not M. Only worth it for continuous batching, which the "Not doing" list keeps out (serving). Parked.
Use a small draft model and verify with the large one. This needs C2. It is pure library code once batched verification exists.
Greedy done: TransformerLm::generate_speculative(draft, prompt, n, lookahead) drafts up to lookahead ids, scores them in one target pass
(the device decode now returns the logits of the last positions rows),
keeps the matching prefix plus the target's own id, and rewinds both
decoders (KvCache::truncate, DeviceKv::truncate). Tests
speculative_decoding_matches_greedy_decoding (host) and
device_speculative_decoding_matches_greedy_or_skips_without_device: equal
to generate with a greedy sampler for lookahead 0, 1, 3 and 8, with the
target as its own draft and with an unrelated one, run to max_seq_len.
Accepting every proposal, or rewinding one position too few, fails them.
Sampled done 2026-10-08: TransformerLm::generate_speculative_with(draft, prompt, n, lookahead, sampler) keeps a proposal x with probability
min(1, p(x)/q(x)) and replaces the first refusal with a draw from
max(0, p - q), so the output is distributed as sampling the target alone.
generate_speculative is now this with a greedy sampler. Sampler gained
crate-private probabilities, draw and uniform; pick keeps its
behaviour. Test sampled_speculative_decoding_draws_from_the_target: 4000
runs of 3 ids at lookahead 2 with a draft at total-variation distance 0.82
from the target, empirical marginals of the first two ids within 0.05 of
the exact ones. Keeping every proposal fails it (distance 0.82), and
resampling a refusal from p instead of p - q fails it (0.13). The
greedy tests pass unchanged on host and device.
Left: a tokens/s measurement with a real draft/target pair on a quiet GPU;
there is no trained pair in this repository to measure with.
Closed 2026-10-08: int8 decode covers every block linear and the head (slices 1-4). Int4 was measured first (D3(b), 2026-10-07) and is not worth it for speed on the default model; take it as D3(b) when a model too big for int8 needs the memory.
Precision::Q8 is CPU only for the language model. A dequantize-in-register
GEMV kernel makes decode memory-bound on 1 byte (or half a byte) per weight
instead of 2. Decode on a 3060 is bandwidth-bound, so this is close to a 2x or
4x gain for batch 1.
Slice 1 done (2026-10-07): TransformerLm::set_int8_decode makes device
decoders pack the attention projections, the dense and shared-expert
feed-forwards and the LM head as int8 with a scale per row, the
Precision::Q8 scheme, quantized on the device from the FP32 weights.
gemv_q8 (one warp per output row, 16 weights a lane, up to four input rows
per pass) serves up to 16 rows; a prompt dequantizes into a scratch copy and
takes cuBLAS. Test:
an_int8_device_decoder_matches_a_quantized_host_or_skips_without_device
against a host model with the same weights quantized (dense, untied, FP32
within 1e-3, BF16 within 3e-2); reading the BF16 input out of order or
scaling the dequantized weights by 1.01 fails it. Default model: one sequence
905 to 1000 tokens/s (+10%); 16 sequences 7050 to 6090 (-13%), the int8 GEMV
at 21.8 us a call against cuBLAS's BF16 kernels. Opt-in, so off by default.
Evidence in docs/baseline.md ("Int8 device decode").
Next slices: the routed experts (6 of the default model's 8 layers are MoE,
and their BF16 GEMVs are 19% of the int8 step's kernel time); a multi-row
kernel that beats cuBLAS at 16 rows (x in shared memory, or int8 tensor
cores); int4. Rejected 2026-10-07: staging the input rows in shared memory
for more than four rows (6050 to 4880 tokens/s at 16 sequences; the re-reads
it removed hit L1). The parity test now has a six-token turn, which covers the
GEMV's loop over row groups. Evidence in docs/baseline.md ("Rejected:
shared-memory input rows").
Slice 2 done (2026-10-07): the routed experts and the router go int8 too,
so int8 decode now follows the host Precision::Q8 scheme for every block
linear. Gpu::project_at reads one expert out of the packed set. The parity
test runs on the MoE tiny() model; reading expert 0's scales or weights for
every expert fails it. Default model, same-session A/B: one sequence 895 to
975 tokens/s (+9%; int8 off reads 820 in that session, so int8 is now +19%),
16 sequences 5600 to 5680. Evidence in docs/baseline.md ("Int8 routed
experts").
Slice 3 done (2026-10-07): the multi-row kernel. gemv_q8_mma runs a
decode step of three or more sequences on the tensor cores: a block of eight
warps owns 16 weight rows as the mma's A operand, takes up to 16 BF16 input
rows as B and splits the width over its warps. Test:
gpu_model::tests::an_int8_projection_matches_the_host_for_every_batch_or_skips_without_device
(1 to 16 rows, a partial unit tile, against the host product on the
device's int8 bytes); feeding the second sequence tile the wrong inputs or
dropping one warp's partial sum fails it. Default model: 16 sequences 5900
to 7600 tokens/s (BF16 reads 6780 in that session), 8 sequences 4510 to
5140, one sequence unchanged. Evidence in docs/baseline.md ("Int8 decode
GEMV on the tensor cores").
Slice 4 done (2026-10-08): int8 decode takes the routed GEMV too. Under
int8 the MoE layers still ran forward_moe, which downloads the routing
every layer; gemv_routed_q8 now reads each slot's int8 expert row and its
scale on the device, as gemv_routed does at BF16, when both widths are a
multiple of 16. Test: the int8 decoder parity test now also runs an expert
width of 16 (12 keeps the old path); reading expert row 0's scale for every
row fails it. Default model, bench_decode 512 128 cuda <n> int8, three
interleaved rounds, load 15-40: one sequence 1014-1049 to 1535-1613
tokens/s (+53%), 16 sequences 7732-7895 to 7675-7804 (flat). Evidence in
docs/baseline.md ("Int8 routed GEMV in decode").
Left: int4, closed above.
Split 2026-10-07 into four parts.
Part 1, the T5 encoder on the device:Done 2026-10-07. It runs on the BF16 language-model kernels, not the FP16 image path, because T5 1.1 activations pass 65504;cuda_t5::DeviceT5.swiglu_fwdgained a tanh-GELU gate for it. Test:cuda_t5::tests::a_device_t5_matches_the_host_or_skips_without_device(tiny gated config, F32 and Q8, within 2% of the largest state); a mutant that drops the position bias fails it (F32 worst 0.037 of 0.42). The ignoredtime_a_t5_base_sized_encoder(d_model 768, 12 layers, 512 tokens): host 528 ms, device 28.6-33.8 ms, worst 0.0073 of 0.39. Evidence indocs/baseline.md("T5 encoder on the device").Part 2: the transformer denoiser (Done 2026-10-07.Dit) on the device.cuda_mmdit::DeviceDit, BF16 weights, FP32 residual streams, fused BF16 attention (heads of 64 or 128), and three small FP32 kernels: the modulated layer norm, the head norm with the rotation, and a bias/GELU column slice. Every block's modulation is one stacked GEMV. Tests:cuda_mmdit::tests::the_mmdit_kernels_match_the_host(each kernel against the host to 1e-4; a mutant that drops the rotation fails it, worst 0.43) andcuda_mmdit::tests::a_device_dit_matches_the_host_or_skips_without_device(whole model at F32 and Q8, relative L2 0.020 and 0.008 against a 0.03 bound). The ignoredtime_a_dit_over_a_512_image(d_model 768, 2 double and 4 single blocks, 1024 patches, 256 text tokens): host 1.27 s, device 15.7-17.1 ms. Evidence indocs/baseline.md("MMDiT on the device").Part 3: wire both into the pipeline and measure BF16 weights against the byte path, perDone 2026-10-07.featuresPlan.mdStage 6.ImagePipeline::to_cudanow attaches a transformer denoiser and a gated T5 encoder on one BF16GpuContext; the original ungated T5 stays on the host. Test:pipeline::tests::a_transformer_pipeline_on_the_device_matches_the_host_or_skips_without_device(a tiny Stable Diffusion 3 with heads of 64: prompt states and a two-step image against the host, relative L2 within 2% and 5%, and not bit-equal, so the host path did not run). BF16 against int8 weights on the device,time_a_dit_over_a_512_image: 15.7-16.2 ms against 15.8-16.8 ms a pass, so the byte path costs no time and halves the weights; both stay.Part 4:Rejected 2026-10-07.src/cuda_image.rs:438: replace the naive transpose with a tiled shared-memory transpose, if the profile shows it.nsysoncuda_image_bench(SDXL, 1024x1024, one UNet pass plus the VAE decode):transposeis 70 launches, 17.7 ms, 0.96% of 1847 ms of kernel time, so a perfect transpose saves under 1%. The same profile putsim2colat 18.7%; that is C10.FP16 overflow guard on the SDXL decoder (Done 2026-10-07.cuda_image.rs:25): make sure that it fails loudly and does not produce a black image.DeviceVae::decodereturns an error when any output value is not finite, and names the host path as the fallback. Test:cuda_image::tests::a_vae_decode_that_overflows_fp16_fails_instead_of_returning_nan(a latent past 65504 decodes finite on the host and errors on the device).
- More solvers:
DPM++ 2M Karras sigmasdone (DPM++ 2M was already there;Scheduler::karras_sigmasadds the spacing andsample_with_sigmasruns any caller-chosen levels. Test:diffusion::tests::karras_sigmas_span_the_stable_diffusion_rangechecks diffusers' 14.6146 / 0.0292 range for SD betas and a 10-step DPM++ run. 2026-10-07),UniPCdone 2026-10-07:sample_unipcruns diffusers' default UniPC (bh2, second order, predicting the image, first order on the first and last step) for both schedules. Test:diffusion::tests::unipc_matches_the_diffusers_schedulermatchesUniPCMultistepSchedulerfrom diffusers 0.40 within 1e-5 at 2, 8 and 20 steps on a nonlinear toy model; dropping the first-order corrector or fixing the second-order weights at 0.5 fails it.LCMdone 2026-10-07 (timesteps confirmed against diffusers 0.40LCMScheduler):Scheduler::lcm_sigmas(steps)picks the timesteps diffusers'LCMSchedulerpicks from its 50-step teacher grid (4 steps: 999, 759, 499, 259), andsample_consistencyruns multistep consistency sampling (take the answer, renoise it to the next level).Solveris an exhaustive public enum, so this is a function, not a variant. Thec_skipboundary blend is left out: below 1e-5 at every grid timestep. Tests:diffusion::tests::lcm_sigmas_sit_on_the_timesteps_diffusers_picksandconsistency_sampling_renoises_each_answer_to_the_next_level(fails when the renoise uses the current level). Not run on a real LCM checkpoint. CFG batching: run the guided and unguided passes as one forward pass of batch 2.Rejected 2026-10-08. Quiet GPU,cuda_image_benchpony-v6-xl (SDXL),QUANTIZE=1: one UNet pass 0.071 s at 512x512 and 0.254 s at 1024x1024, so four times the pixels costs 3.6 times the time. A batch of two at 512 is at best a 1024-sized pass, about 10% under two passes, and at 1024 the kernels are already that big, so nearly nothing. The device UNet is single-image throughout (convolutions, group norm, attention), so batching it is an L item for at most 10% at the small size. Redo if a small-image model becomes the main use.Tiled VAE decode, so large images fit in 12 GBdone:VaeDecoder::decode_tiled(latent, tile, overlap)decodes overlapping latent squares throughdecode(so on the device too) and blends the overlaps with linear ramps. Tests:vae::tests::tiles_blend_back_into_the_whole_image(a local decode must stitch back exactly; fails with unweighted normalisation) anda_tiled_decode_matches_the_whole_decode_when_one_tile_covers_it. Not measured on a real SD VAE: no checkpoint run, the GPU was shared. 2026-10-07. Seen once while verifying, unrelated:mesh::simplify_tests::an_error_bound_removes_the_flat_interior_and_keeps_the_shapefailed in one full CPU run and passed in six reruns; it may be flaky.
Found by the C1 profile: a single-row decode step ran attention as one cuBLAS
GEMV per head for the scores, a softmax, and one per head for the values,
FP32 throughout. On the default model that is 17 launches a layer and about
70 us, a third of the token's kernel time. decode_attention now splits the
history into 64-position chunks, one block per (head, sequence, chunk),
reading keys and values in place from the device cache, and
decode_attention_merge rescales and adds the chunks. Several new rows (a
prompt, a speculative check) keep the three-kernel path. Tests: the existing
device decoder, batch decoder and speculative tests, plus
a_long_device_decode_matches_the_host_or_skips_without_device (130-token
prompt, three chunks, two sequences); a wrong score scale fails the first
two, a merge without the rescale fails the last. Attention 70 to 19 us a
layer; 663 to 864 tokens/s for one sequence (+30%) and about 5350 to 6870
for 16 (+28%). Evidence in docs/baseline.md ("Fused decode attention").
Follow-up 2026-10-08: the kernel scored one position per warp with a
shuffle reduction and summed values one position at a time, so its loads
waited on each other. Four threads per position for the scores and an
unrolled value loop put every load in flight at once. Warm-clock microbench
(8 heads, 2 KV heads, width 64): 7.64 to 5.05 us at 64 positions, 8.46 to
6.59 at 640, 20.46 to 14.00 at 1664. bench_decode, BF16, three
interleaved rounds, load 18-33: 16 sequences 7780-7980 to 8731-8835 tokens/s
(+11%) after a 512-token prompt, 4335-4380 to 4548-5294 after 1536; one
sequence within the load noise. Tests: the device decoder parity tests,
including a_long_device_decode_matches_the_host_or_skips_without_device;
halving the score reduction fails three of them.
Evidence in docs/baseline.md ("Decode attention loads in flight").
Found by the profile after C8: under mixed precision the attention output
projection was the only decode GEMV still in FP32 (1024 calls, 6.0 us each).
decode_attention_merge now writes its output BF16 when the context is in
mixed precision, so the projection reads the BF16 weight copy. The cuBLAS
path for several new rows still writes FP32. Test: the mixed-precision case
of a_device_decoder_matches_the_host_decoder_or_skips_without_device, whose
tolerance went from 5e-2 to 5e-3 (measured error at most 1.3e-3); a merge
that scales its BF16 output by 1.5 now fails it, and passed the old bound.
893-898 to 919-931 tokens/s for one sequence (+3.5%); 16 sequences unchanged
within noise. Evidence in docs/baseline.md ("BF16 decode attention output").
Added 2026-10-07 from the C6 part 4 profile. im2col is 18.7% of the SDXL
1024x1024 bench's kernel time (2804 launches), behind only the GEMMs it
feeds. The kernel is already coalesced and writes nine taps a thread, so
what is left is not writing the column matrix at all: a convolution kernel
that gathers its B tiles from the input map straight into shared memory.
cuDNN would do it but is a new dependency, which needs the owner.
Acceptance: a parity test against Conv2d::forward for 3x3 stride 1 and 2
with padding, at FP16 and BF16; cuda_image_bench 1024x1024 UNet pass and
VAE decode faster than 0.251 s and 0.53 s under the same load.
Split 2026-10-08. The image path has no BF16 module (its activations are FP16 throughout), so parity is checked at FP16 only.
- Part 1 — DONE 2026-10-08: row-wise lowering for stride-1 convolutions.
im2rowexpands only the horizontal taps, so its buffer holdskcopies of the input instead ofk * k; each vertical tap is then a whole-row offset into that buffer, and the convolution iskaccumulating cuBLAS GEMMs over weights repacked at upload into[ky][out][in * k + kx]. Stride-2 convolutions keepim2col. Test:a_device_convolution_matches_the_host_or_skips_without_a_devicenow also covers 3x3 unpadded and 5x5 padding 2, beside the chunked test; making every tap overwrite instead of accumulate fails it.cuda_image_bench1024x1024 (load 3.1-4.5): VAE decode 0.52 to 0.49 s in all three rounds; UNet pass 0.244-0.247 to 0.241-0.247 s, unchanged within noise. The gather drops from 346 ms (im2col, 2804 launches) to 176 ms (im2row, 1038 launches); about half of that comes back as GEMM time. Evidence indocs/baseline.md("Row-wise convolution lowering"). - Part 2: a true implicit-GEMM kernel that gathers B tiles from the map into
shared memory, for the UNet's 0.251 s target. Open; the risks are that the
CHW layout leaves shifted taps unaligned for 16-byte loads and that no hand
GEMM in this crate matches cuBLAS yet (13.6-15.8 against about 22 TFLOP/s).
Parked 2026-10-08 on the part 1 profile. Split by launch shape, the
UNet's
im2rowlaunches (320 to 1280 channels) total about 22 ms over the whole bench run, 1-2% of a 0.245 s pass, so the UNet target cannot come from the gather. The VAE's (128 to 512 channels) total about 150 ms, at about 250 GB/s, against roughly 300 ms of GEMM in the same decode. A hand implicit GEMM at this crate's 60-70% of cuBLAS would lose about as much GEMM time as it saves in gather. Worth taking only through cuDNN, which is a new dependency. Owner: may the image path depend on cuDNN (through cudarc'scudnnfeature) for its 3x3 convolutions?
Found while writing the J7 image tutorial: every SDXL text-to-image result
carried a cross-hatched texture, and image-to-image at strength 0.3 with the
same prompt moved the picture by rel 0.37 and painted streaks over it. A
reference check against ComfyUI (already installed, no new dependency) on
Pony Diffusion V6 XL cleared the UNet (eps rel 0.0010 to 0.0060 at sigma
14.6, 1 and 0.1, device Q8 against CPU FP32), the VAE encoder (rel 0.012
against FP32) and CLIP-L (rel 7.5e-5), and found CLIP-G off by rel 1.07 on
every position after the prompt. ClipTextEncoder::tokenize padded with the
end token; SDXL's tokenizer_2 pads with !, and the UNet cross-attends to
the padded positions. ImagePipeline::load now reads pad_token from each
CLIP tokenizer's tokenizer_config.json and calls the new
ClipTextEncoder::set_pad_token. After: CLIP-G rel 0.021 in FP32, the
texture is gone, and the same image-to-image run moves by rel 0.147 with no
streaks. Tests: a_named_pad_token_fills_after_the_end_token, and
two_clip_towers_are_read_side_by_side_the_way_an_xl_model_wants now
checks both towers' padding (it fails without the fix). Stable Diffusion 2.x
and SD3 name ! too and are fixed by the same change. The CLIP-G residual of
0.021 comes from the tanh GELU approximation where the checkpoint asks for
exact gelu; that is P3 until a measurement shows it in an image. Evidence in
docs/baseline.md ("Image pipeline reference check").
Added 2026-10-08 from the J7 tutorial run. VaeEncoder has no
attach_device, so image-to-image encodes on the CPU: 1024x1024 takes about
117-140 s against 13 s for the whole 25-step text-to-image run on the RTX
3060, and 512x512 takes 10-18 s. The decoder's device blocks (resnet, mid
attention, group norm, convolution) cover everything but the stride-2
downsample. Acceptance: a parity test against VaeEncoder::encode at FP16;
1024x1024 encode under 2 s under the same load.
Done: VaeEncoder::attach_device uploads a DeviceVaeEncoder that reuses the
decoder's resnet and attention blocks, and ImagePipeline::to_cuda attaches
it. The downsample's right-and-bottom-only padding is a pad_end on the
device convolution: im2col already reads zeros past the edge, so only the
output size changes. Test
a_whole_vae_encode_matches_the_host_or_skips_without_a_device (fails with
pad_end 0). On pony-v6-xl, RTX 3060 shared with another job, load 2.3-2.7:
1024x1024 encode 117-140 s to 0.25 s, 512x512 10-18 s to 0.06 s. Against
ComfyUI's FP32 encoder the device latent reads rel 0.066 (host F32 0.012,
ComfyUI's own BF16 0.108). The tutorial's repaint at strength 0.5 went from
117.2 s to 6.9 s.
Added 2026-10-08. Sampler has temperature, top-k, top-p and a
multiplicative repetition penalty. Min-p (keep tokens whose probability is
at least p times the top one) is the truncation most local-inference users
now reach for first, and the additive presence and frequency penalties are
the OpenAI-style knobs that chat front ends pass through. All are a few
lines in Sampler::kept, so pick, probabilities and sampled speculative
decoding get them at once. Acceptance: unit tests that min-p keeps exactly
the tokens at or above the threshold and always the argmax, and that the
two penalties subtract presence once and frequency per occurrence.
Done: Sampler::min_p, Sampler::presence_penalty,
Sampler::frequency_penalty. Min-p applies after the temperature and top-k,
before top-p, as in Hugging Face's warper order. Tests
min_p_keeps_the_tokens_at_or_above_its_share_of_the_top and
presence_counts_once_and_frequency_per_occurrence (counting the presence
penalty per occurrence fails the second).
Added 2026-10-08. BatchDecoder made every call feed every sequence the
same number of tokens, so batched serving of real prompts, which never have
equal lengths, needed the caller to pad and mask by hand, and the library
had no mask to do it with. Acceptance: a batch decoder fed prompts of
different lengths gives each sequence the logits of a single-sequence host
decoder fed that prompt alone, on the host and on a device, including a
prompt padded by more than one 64-position attention chunk.
Done: on a device, shorter prompts are padded on the left with id 0, and a
per-position slot flag in DeviceKv masks padding keys out of attention:
in decode_attention for the fused path, and in a new mask_padding kernel
before the softmax of the score path. Rotary positions are relative, so a
left-padded sequence scores as it would alone. Only the prompts may differ:
padding later in the history would change the distance to earlier tokens,
and feeding uneven lengths after the prompt is an error. Padding still takes
max_seq_len capacity, which C3 would remove. Tests
a_device_batch_decoder_matches_host_decoders_or_skips_without_device
(prompts of 4, 2 and 6 tokens) and
a_long_device_decode_matches_the_host_or_skips_without_device (130 and 40
tokens, 90 padding positions). Dropping either mask fails both. Decode
throughput: see docs/baseline.md, "Padded batch prompts (C14)".
Added 2026-10-08. The decoders kept their logits private, so every choice
Sampler does not make (constrained or grammar-guided output, a logit
bias, log-probabilities for ranking or classification, beam search) had to
rerun the whole forward pass through TransformerLm::forward. Acceptance: a
public accessor for the next-token logits and a way to append a
caller-chosen id with the forward pass deferred as next defers it, with a
test that pushing the argmax reproduces greedy decoding.
Done: Decoder::logits, Decoder::push, BatchDecoder::logits(sequence),
BatchDecoder::push(ids). Test
pushing_the_argmax_of_the_logits_decodes_greedily (fails if push does
not queue the id for the next pass).
Added 2026-10-08. Every request that starts with the same system prompt or few-shot preamble prefilled it again, and beam search, which branches a session, could not be built on the decoders at all. Acceptance: a fork that continues independently of its original, each matching a fresh session fed the same history, on the host and on a device.
Done: Decoder::fork. On a device the fork copies the cached rows and
shares the packed weights and the narrowed LM head through Arc. Tests
a_forked_decoder_continues_independently and
a_forked_device_decoder_continues_independently_or_skips_without_device
(a fork that drops the device caches fails it). BatchDecoder has no fork
yet; add one when batched beam search wants it.
Added 2026-10-08. The crate had sampling and speculative decoding but no beam search, the usual decoder for translation, summarisation and other tasks where the single most probable output matters more than variety. Acceptance: one beam is greedy decoding, and with a beam per vocabulary id two steps return the most probable pair found by exhaustive search.
Done: TransformerLm::generate_beam(prompt, max_new_tokens, beams, end) on
Decoder::fork, logits and push (C15, C16). A continuation that samples
end finishes and gives up its beam; the result is the one with the best
mean log-probability per token. Test
beam_search_finds_the_most_probable_continuation (keeping one candidate a
step, or scoring each step without its prefix, fails it). Each step forks
every kept beam, a full cache copy; reusing a parent for its last child is
the first saving if beam search turns out hot.
Added 2026-10-08. Scoring a given text (ranking candidate answers,
classifying by the most probable label, a per-document perplexity) needed
the caller to run forward_train and take the log-softmax by hand.
Acceptance: log_probabilities(ids) whose negated mean equals evaluate's
loss on that sequence and whose entries match a decoder's logits, on the
host and on a device.
Done: TransformerLm::log_probabilities. Tests
log_probabilities_match_the_loss_and_the_decoder and
device_log_probabilities_match_the_loss_or_skip_without_device (scoring
each row's own input id instead of the next fails them). It runs the
training forward and downloads [len, vocab] logits, as evaluate does.
Added 2026-10-08. Profiles of bench_decode showed the fused decode
attention at 23% of the kernel time for one int8 sequence after 4000 tokens
and 31% for 16 sequences after 2000. Each block served one query head, so
with 8 heads over 2 key-value heads every cached key and value was read by
four blocks. Acceptance: one block per (key-value head, sequence, chunk)
scoring every query head of its group, passing the existing device decoder
tests, a mutation that breaks the grouping failing them, and tokens/s no
lower at 64 positions and higher at 2000 and 4000.
Done: decode_attention in src/cuda_training.rs serves up to
DECODE_GROUP (8) query heads a block; larger groups take the cuBLAS path.
The device decoder tests (a_long_device_decode_matches_the_host_or_skips_without_device
and the other 33 matching decod, beam, fork, log_prob) pass; scoring
every query head of a group with the group's first query fails 6 of them.
bench_decode tokens/s, 2f02760 against the change: 4000 + 256 int8 one
sequence 1197-1212 to 1425-1437, 2000 + 128 x 16 5016-5024 to 6906-6915,
512 + 128 x 16 9163-9182 to 10348-10452, 64 + 256 int8 one sequence
1774 to 1783-1810. Numbers in docs/baseline.md.
Added 2026-10-08. After C19, nsys put the prompt path's
attention_softmax_lse at 21% of kernel time for 16 sequences after a
2000-token prompt, plus the two GEMMs around a [history, history] score
matrix a head (2 GB at that size). The time to the first token is that
prefill. Acceptance: an even first prompt under mixed precision at a head
width the training flash kernel takes runs that kernel, matching the host
decoder; bench_decode reports the prefill time and it drops at 2000
positions.
Done: decode_block launches the training flash forward once per sequence
on a BF16 copy of the prompt's cache rows, taking the interleaved layout as
rows sequences times as wide. Later feeds, padded prompts, FP32 and other
head widths keep the cuBLAS path. Test
a_device_prompt_at_head_width_64_matches_the_host_or_skips_without_device
(dropping the causal flag, the per-sequence offset or the widened output
stride each fails it). Prefill, 65355e8 against the change: 2000 x 16
363-365 to 149 ms, 1000 x 16 110-111 to 67-70, 512 x 16 44-45 to 35-38,
2000 x 1 27-30 to 12.5, 4000 x 1 int8 97-98 to 32-38. Decode steps are
unchanged.
Follow-up 2026-10-08: left-padded prompts of uneven length take the flash
kernel too, each sequence launched over its real positions only, with the
padding rows of the output zeroed. The test now also pads one sequence by
90 positions; launching over the padding or skipping the zeroing fails it.
Prefill with every other prompt half as long, 6566c19 against the change:
2000 x 16 438-440 to 125-126 ms, 512 x 16 47-48 to 32, 2000 x 2 59-92 to 19.
Follow-up 2026-10-08: a later turn of 128 rows or more (a chat turn, a
long speculative check) takes the flash kernel too. The kernel's causal
flag now carries the key position of the first query plus one, so query
row r sees keys up to r + shift; training passes 1 and is unchanged.
bench_decode 2000 8 cuda 16 "" 512 turn 140-160 to 60-70 ms; below 128
rows the BF16 copy of the history costs more than it saves (16 x 8: 10 ms
against 25), so those keep cuBLAS. Test: the same prompt test now feeds a
130-row turn after the cache (error under 4e-3, bound 1e-2); masking only
each query tile's last key tile fails it (0.038 against 0.026).
Added 2026-10-08 from an nsys profile of bench_decode 2000 128 cuda 16
after C20: decode_attention is 23% of kernel time, 128 us a layer a step,
reading about 34 MB of FP32 cache rows a layer (about 264 GB/s of 360), so
it is bound by the bytes. The cache also keeps every row's query, which no
later step reads: 3072 bytes a position a layer on the default model where
keys and values in BF16 take 512. Under mixed precision with the fused
kernels available, keep only keys and values, in BF16; queries go to a
scratch buffer for the call. Multi-row turns then read the cache in place
through the flash kernel, with no BF16 copy of the history.
Acceptance: the device decoder parity tests pass under mixed precision
with the new cache (and a mutation in the new store fails them);
bench_decode 2000 128 cuda 16 tokens/s higher and one-sequence decode no
lower; KV bytes a position a layer 3072 to 512 on the default model; no
small-turn regression (bench_decode 2000 8 cuda 16 "" 8).
Done: the mixed-precision cache holds BF16 keys and values only, 512 bytes a
position a layer on the default model against 3072. Tests:
transformer::tests::a_device_decoder_matches_the_host_decoder_or_skips_without_device,
a_device_batch_decoder_matches_host_decoders_or_skips_without_device,
a_device_prompt_at_head_width_64_matches_the_host_or_skips_without_device,
an_int8_device_decoder_matches_a_quantized_host_or_skips_without_device,
device_log_probabilities_match_the_loss_or_skip_without_device and
device_speculative_decoding_matches_greedy_or_skips_without_device pass;
storing each new row one position late fails the head-width-64 prompt test.
bench_decode 2000 128 cuda 16: 7040 to 7360 tokens/s; 8-step turns 2.89 to
2.30 ms a step; one sequence unchanged at 0.71 ms a step (load 5.4-6.5,
docs/baseline.md).
The CPU path is the parity reference and the no-GPU fallback. Keep it correct and reasonably fast, and do not chase peak throughput on it.
| # | Item | Effort | Notes |
|---|---|---|---|
| D1 | S | Measured: at 2048 tokens, d_model 512, 8 experts, auxiliary_losses takes 11 µs against a 35 ms forward and 67 ms backward pass (0.01%). The column walk covers only [tokens, 8]. See docs/baseline.md ("CPU MoE pieces"). |
|
| D2 | exp per element in softmax_rows |
S | Measured at the same shape: softmax_rows takes 91 µs of a 102 ms forward-plus-backward (0.09%), so halving its exp calls saves under 0.05%. Below D4, which was rejected at 0.4%. |
| D3 | int4 / NF4 weight storage | M | Shared by B4 (QLoRA) and C5. Block-wise scales, same layout on CPU and CUDA. Split 2026-10-07: (a) host storage: a 4-bit index per weight, two to a byte, into a 16-entry codebook (symmetric int4 or NF4), with one f32 absmax per block of 64 along a row; matmul, dequantize, and a test that the product matches the dequantized float product and that NF4 has the lower error on normal weights. (b) quantize a model's linears to it and decode on the device with a 4-bit GEMV (C5's int4). (c) NF4 frozen base for LoRA (B4 slice 2). (a) done 2026-10-07: quantized::{Codebook, Quantized::from_matrix_4bit} and TransformerLm::quantize_4bit (block linears at 4 bits, embedding and untied head at int8; training, BIN Q8 save and device decode refuse). Tests a_4bit_product_matches_the_float_product_of_its_own_weights, nf4_loses_less_than_the_even_grid_on_normal_weights (RMS int4 0.1074, NF4 0.0940), a_4bit_block_of_zeros_stays_zero_and_its_neighbour_keeps_its_extremes, a_4bit_weight_takes_under_six_tenths_of_a_byte, a_4bit_model_matches_a_float_model_on_the_same_grid_and_refuses_to_train. Host decode at a 100M shape: NF4 292-331 tok/s against int8 255-271 (docs/baseline.md). (c) done 2026-10-07: see B4 slice 2. (b) measured 2026-10-07, not started: at one sequence gemv_q8 is 43% of device decode kernel time on an RTX 3060, but the 512-unit projections run at about 87 GB/s of 360, latency-bound, so halving their bytes buys little; only the LM head (60 us, about 270 GB/s, 6% of a token) is bandwidth-bound. Worth it for memory on a model too big for int8, not for speed on the default model. |
| D4 | Drop the cloned inputs in caches | S | Measured at 0.4% and rejected. It only becomes worth it with many experts. P3. |
| D5 | — | Already exists: TrainableConv2d has a backward pass and a device path. The uncommitted diff fixes its bias, which never trained on the device. |
BatchSource, DatasetStream, JsonlStream, TokenStream and TokenFile
exist already. What is missing:
| # | Item | Priority | Effort | Notes |
|---|---|---|---|---|
| E1 | Weighted dataset mixing | P1 | S | Done: TokenMix in src/token_file.rs. Original plan: draw from N TokenFiles by weight, determined by the step number so that a resume is exact. Every real pretraining mix needs this. |
| E2 | Parquet reader | P2 | M | Most public corpora on the Hub ship as Parquet. Put it behind a feature flag so the default build stays small. Read one column of text, a row group at a time. |
| E3 | P2 | S | rusting_brain::webdataset::WebDataset streams Samples (key plus (extension, bytes) members) from a tar shard, by hand with no new dependency: ustar, old GNU, GNU long names and pax path. Tests: members_group_into_samples_by_key, a_truncated_or_corrupt_shard_is_an_error, fuzz::webdataset_survives_corruption; also read correctly from shards written by GNU tar and by Python tarfile in pax and GNU format, with 150-character names. Decoding the images is left to vit_encoder::read_image or the caller. Original note: Image and text pairs for C6 training and for ViT. tar is a small crate, or about 100 lines by hand. |
|
| E4 | Shuffled chunk windows |
P3 | S | src/token_file.rs:288 returns windows in file order. Evaluation wants that, so add it only if something else needs a shuffled full pass. |
| E5 | Multi-line CSV fields | P3 | S | src/dataset.rs:924 reads single-line rows only. |
| E6 | Memory-mapped shards | P3 | S | src/shards.rs:32 seeks instead of mapping. Change it only if a profile shows that the reads cost time. |
| E7 | Tokenizer training | P2 | M | Done 2026-10-07 (byte-level BPE). Bpe::train_json(texts, vocab_size, specials) trains a GPT-2 style byte-level BPE (heap of pair counts, lazy stale entries, deterministic ties) and writes a tokenizer.json that Bpe::from_json loads. Evidence: tokenizer::tests::a_trained_tokenizer_loads_and_round_trips (round trip on unseen Unicode, frequent word becomes one token, specials keep ids 0..n and no merge crosses them, same corpus gives the same file) and training_refuses_a_vocabulary_too_small_or_a_repeated_special. Also fixed an O(n²) in pre_tokenize (617 ms to 1.4 ms on 78k chars). Open: WordPiece/Unigram training; loading the output in HF tokenizers not checked (package not installed here). |
| E8 | P1 | S | Bpe::merge keeps the pairs in a heap over a linked list, n log n. the_heap_merge_matches_the_pair_by_pair_loop checks it against the old loop on random merges and text up to 1,000 symbols; on a real sentencepiece file the ids of 1 KB, 5 KB and 50 KB texts hash the same before and after, and 50 KB went from 43.1 s to 12.6 ms (docs/baseline.md). Original note: Bpe::merge (src/tokenizer.rs) rescans every adjacent pair after each merge, and a Metaspace or Prepend file (LLaMA 2, Mistral, TinyLlama) hands it the whole text as one piece. Measured in docs/baseline.md ("Tokenizer merge"): 5,376 characters take 432 ms, four times the text takes sixteen times as long, so one 100 KB document in TokenStream would take minutes. Replace the loop with a linked list and a rank-ordered heap. Acceptance: ids identical to the current loop on the existing tokenizer tests and on a 50 KB text, and time per character flat from 1 KB to 50 KB. |
| # | Item | Priority | Effort | Notes |
|---|---|---|---|---|
| F1 | Write safetensors | P1 | S | Already existed: safetensors::write and write_as (F32, BF16). Writing lets a trained TransformerLm or LoRA adapter load in PyTorch, llama.cpp converters and the Hub. The format is a JSON header plus raw bytes. |
| F2 | Hugging Face config export | P1 | S | Done: TransformerLm::save_hf writes config.json and model.safetensors; a test reads them back through the separate TextEncoder LLaMA reader. Original plan: write a LLaMA-compatible config.json next to F1's output, so that transformers loads the result directly. This is the reverse of B5. |
| F3 | P2 | M | Run trained models in llama.cpp and Ollama. TransformerLm::save_gguf(path, q8_0) writes a llama-architecture GGUF v3 file, F32 or Q8_0 (norms stay F32), with Q and K rows permuted for llama.cpp's adjacent-pair RoPE, and linear or llama3 rope scaling (rope_freqs). Checked against the gguf 0.19 Python reader: every tensor present with the right shape, Q8_0 bytes identical to gguf.quants.quantize, F32 tensors equal to save_hf after the converter's permute, rope_freqs equal to the converter's formula. Tests in gguf::tests; the RoPE-convention test fails without the permutation. Left: tokenizer metadata (written as no_vocab), and a load in llama.cpp itself, which is not installed here. |
|
| F4 | ONNX export for MoE | P3 | M | save_onnx covers dense networks and the dense LM. MoE needs dynamic routing in the graph, which ONNX handles poorly. Park it until someone asks. |
| F5 | Checkpoint format version | P1 | S | Already existed: every binary file starts with a versioned magic (RBWTS001, RBOPT001, RBLOR001, RBEMA001), save_bin stores the config as a JSON header, and load_bin checks the parameter count and every shape. Original plan: put a version field and a model-shape header in binary checkpoints, so that an old checkpoint fails with a clear error and not with garbage weights. Check first whether one already exists. |
| # | Item | Priority | Effort | Notes |
|---|---|---|---|---|
| G1 | P2 | M | The tower already existed as vit_encoder::VitEncoder. Added VitEncoder::embed (class token through visual_projection), clip::logit_scale and clip::zero_shot (softmax over scaled cosines; retrieval ranks by the same cosines). Against transformers 5.16.1 CLIPModel on a small random checkpoint (eos 3): image and text embeddings within 2.2e-7, zero-shot probabilities within 1.2e-7. That check found a bug: older configurations (OpenAI CLIP, Stable Diffusion 1.x, FLUX.1's CLIP-L) say bos 0, eos 2, which transformers treats as legacy and pools at the highest id; ClipTextConfig::from_file now maps that case to the vocabulary's last two ids (49406, 49407). Tests: vit_encoder::tests::the_embedding_is_the_projected_class_token, clip::tests::zero_shot_is_a_softmax_over_scaled_cosines, clip::tests::a_published_config_states_the_shape (legacy ids). Not run on a published checkpoint: none with both towers is on disk. Original note: src/clip.rs:13 has no image tower. With it, the library can do zero-shot classification and image-text retrieval, and it reuses VisionTransformer. |
|
| G2 | ControlNet and inpainting — done 2026-10-08 | P2 | L | Left out in featuresPlan.md Stage 7. Both condition on a second image. Part 1 (S): diffusion::Inpaint wraps any Denoiser with a picture latent and one keep weight a latent row; where it keeps, the prediction is the one whose clean image is the picture, so kept rows walk their own noise path to it under every solver and schedule, and the model paints the rest to match. ImagePipeline::sample_latent_inpaint(.., keep, strength, ..). Tests: diffusion::tests::inpainting_keeps_the_picture_and_repaints_the_rest (flow Euler, shifted flow DPM++ 2M, DDIM ancestral: kept rows land on the picture within 1e-3, repainted rows on the model's image, a 0.5 row halfway; a wrong-length mask errors) and the inpainting step in pipeline::tests::a_picture_can_start_the_run_instead_of_noise. Part 2 (M): inpainting UNets (in_channels 9 against out_channels 4) load and read the mask and the blanked picture's latent after the noisy latent through Unet::set_inpaint, with only the noisy latent scaled, as diffusers' scheduler does. ImagePipeline::generate_inpaint(prompt, image, keep, strength, ..) takes a pixel mask: a trained UNet gets the binarized mask sampled at each latent cell's corner and the latent of the picture with the repainted part zeroed, the way diffusers builds them; any other model goes through Inpaint with the mask averaged over each cell. Tests: unet::tests::an_inpainting_model_reads_the_mask_after_the_latent, pipeline::tests::inpainting_runs_through_a_trained_unet_and_any_other_model (both paths, wrong mask size errors, the mask does not leak into the next run), and the ignored unet::tests::an_inpainting_model_matches_diffusers against scripts/unet_inpaint_reference.py (diffusers 0.40, worst error 5.5e-7 on a mean output of 0.075; scaling the mask channels too fails it at 0.072). Part 3 (M): ControlNet loads a diffusers ControlNet (a trained copy of the UNet's way down, the controlnet_cond_embedding stack that brings an RGB picture in [0, 1] down to the latent, and one zero convolution a rung) and Unet::set_controls(vec![Control { net, image, scale }]) adds its scaled corrections to every skip and to the middle; several ControlNets add up. The UNet's way down moved into a shared Encoder. On a CUDA device the ControlNets run there too (DeviceControlNet, uploaded by set_controls; ponytail: note on Unet::upload_controls). Tests: unet::tests::a_controlnet_steers_the_unet_only_as_hard_as_its_scale (scale 0 gives the plain output bit for bit, scale 1 changes it, a picture of the wrong size errors), cuda_image::tests::a_controlnet_on_the_device_matches_the_host_or_skips_without_a_device (two ControlNets, within 0.05 of the host's largest value, and well closer to the host than the uncontrolled output is) and the ignored unet::tests::a_controlnet_matches_diffusers against scripts/controlnet_reference.py (diffusers 0.40, scale 0.7: worst error 8.2e-7 on a mean output of 0.108; leaving the scale out fails it at 0.040, leaving the middle correction out at 0.033). |
| G3 | P2 | L | Training for the image path. It needs D5, A4 and B4 on the image blocks. Split 2026-10-08. Part 1 (M): load published LoRA files into an image model. Most people meet image LoRAs as files to apply, not to train. ShardedSafeTensors::add_lora(path, prefixes, scale) reads PEFT/diffusers (lora_A/lora_B) and kohya (lora_down/lora_up/alpha) pairs, matches them to the checkpoint's weights by name (dots or underscores), and adds scale * alpha / rank * up @ down to each weight as it is read; ImagePipeline::load_with_loras applies them to the denoiser. Acceptance: a test against PEFT's merge on a tiny diffusers UNet within 1e-5; keys for another component are skipped, unknown adapter kinds and unmatched names are errors. Done: tests safetensors::tests::a_lora_adds_up_times_down_to_the_weight_it_names (kohya and PEFT spellings give the exact merged weight; a stray name, a LoHa key, or asking for the text encoder's adapter is an error; another part's file changes nothing), pipeline::tests::a_lora_file_changes_the_models_it_is_loaded_into (strength 0 gives the plain image bit for bit; a file's text encoder half changes the picture further, through lora_te_ names on the CLIP towers; a text-encoder-only file is an error), and the ignored unet::tests::a_lora_file_merges_as_peft_does against scripts/unet_lora_reference.py (PEFT 0.19 fuse_lora at strength 0.6 over 72 adapters on attention, proj_in, the feed-forward and 3x3 resnet convolutions: worst error 1.1e-6 on a mean output of 0.19, the same for the kohya copy; ignoring alpha fails it at 0.008). kohya's SDXL files name the original UNet's blocks (input_blocks_4_1, middle_block_0_in_layers_2); add_lora aliases them to the diffusers names from the blocks the checkpoint holds, and a_lora_file_merges_as_peft_does loads such a file (kohya_sdxl_lora.safetensors, whose names diffusers' own converter maps back to the same modules) to the same output; starting the up blocks at index 1 fails it. kohya's FLUX files name the reference DiT's modules, whose fused img_attn_qkv and linear1 diffusers splits into to_q/to_k/to_v (and proj_mlp); one adapter may now feed several weights, which take its output rows in turn, and the pipeline takes lora_unet_ for a DiT too. Test mmdit::tests::a_kohya_lora_for_the_reference_names_adapts_both_spellings_alike (one kohya file on the reference and diffusers checkpoints gives the same prediction within 1e-5, and a different one from strength 0; swapping to_q and to_k in the split fails it). The final modulation (final_layer.adaLN_modulation, written in the other half order) is not aliased and stays an error. Part 2 (L): training, split 2026-10-08. The image models are inference-only (no backward anywhere in mmdit.rs or unet.rs); the LM's param::Lora already has the adapter math. The DiT goes first: it has fewer layer kinds than the UNet (linear, affine-free layer norm, adaLN modulation, per-head RMS norm, RoPE, attention, tanh GELU). 2a (M): a host reference for one LoRA step on Dit: a forward that keeps its activations and a backward to adapters on every block projection (qkv, projection, mlp_in, mlp_out, fused_in, fused_out), base frozen, conditioning frozen. It is the oracle for 2b, not a training path (training runs on the GPU). Acceptance: on a tiny diffusers FluxTransformer2DModel with PEFT adapters on the same modules, the adapter gradients of a fixed loss match PyTorch autograd within 1e-4 relative. 2a done 2026-10-08: dit_lora::DitLora reads a diffusers-FLUX PEFT file onto the fused projections (to_q/to_k/to_v are row ranges of qkv or fused_in, proj_mlp the rest of fused_in), forward keeps a Tape, and backward adds adapter gradients through every block, the RoPE (by its inverse rotation), both norms and attention. Test: the ignored dit_lora::tests::dit_lora_gradients_match_pytorch against scripts/dit_lora_reference.py (diffusers 0.40, PEFT, two double and two single blocks, all 68 adapter tensors; worst prediction error 3.4e-6, worst gradient error 5.4e-5 relative; dropping the layer norm's projection term fails it at 0.89, skipping the inverse RoPE on single-block queries at 1.4e-3). 2b (L): the same step on CUDA, matched to 2a, with step time measured. Split 2026-10-08: the device attention is the fused kernel, which takes heads of 64 or 128, so 2b is matched to 2a's host code on a tiny config with 64-wide heads rather than to the PyTorch file directly. 2b-i (M): DeviceDit runs the adapters; acceptance: the prediction matches DitLora::forward within the BF16 tolerance DeviceDit already meets. 2b-ii (L), split 2026-10-08. 2b-ii-a (S): the backward kernels the forward's own kernels need: the modulated layer norm, the head norm with RoPE, and the tanh GELU; acceptance: each matches the host reference's backward (dit_lora::layer_norm_backward, head_norm_backward after the inverse rotation, gelu_derivative) within 1e-4. 2b-ii-b (L): a device tape of what the backward reads and the device backward to adapter gradients, with those kernels, the existing flash backward, gate_residual_backward and linear_backward_input; acceptance: every adapter gradient matches DitLora::backward within 2e-2 relative, and one step at 1024 image tokens on the default FLUX shape scaled to fit 12 GB is timed in docs/baseline.md. 2b done 2026-10-08: DeviceDit::forward_with runs the adapters, backward keeps a device tape and returns every adapter gradient, with new kernels for the modulated layer norm, head norm with RoPE and tanh GELU backward. Tests: cuda_mmdit::tests::device_adapters_match_the_host_or_skip_without_device and device_adapter_gradients_match_the_host_or_skip_without_device (relative gradient error 0.011 overall, worst tensor 0.047 at double.0.text.projection; the bound is 2e-2 overall and 1e-1 a tensor, BF16 noise on single small tensors runs past 2e-2). Dropping the key norm from the double blocks' head norm backward fails it at 0.031 overall, 1.06 on single.1.fused_in. One step (forward, backward, adapter gradients) at 1024 image and 256 text tokens on d_model 768, 6 heads, 2 double and 4 single blocks: 45-56 ms after a 130 ms first step, 1612 MiB in use (time_a_dit_adapter_step, load 4.2). The full FLUX shape was not timed: its BF16 weights do not fit 12 GB. 2c (M), split 2026-10-08: FLUX.1's BF16 weights (24 GB) do not fit 12 GB and the device backward refuses int8 bases, so the training path is checked on small DiTs here. 2c-i (S): fresh adapters on every module a diffusers FLUX LoRA covers (DitLora::new), one rectified-flow step on the device (DeviceDit::flow_step: noise to sigma, regress noise - latent, clipped Adam on the adapters), and DitLora::write_peft; acceptance: a written file reads back to the same prediction within 1e-5, and 40 steps on one latent halve its loss. 2c-ii (M): a train_dit_lora binary over cached latents and prompt states that writes a PEFT file part 1 loads and diffusers loads too, run on the tiny diffusers DiT. The UNet follows as its own row once 2a-2c land. 2c done 2026-10-08: DitLora::new puts fresh adapters on the 34 modules diffusers' FLUX LoRAs cover (A random, B zero), DeviceDit::flow_step runs one rectified-flow step with clipped Adam, and DitLora::write_peft writes the diffusers PEFT names. examples/train_dit_lora.rs encodes captioned images and captions once, drops the encoders and trains on the device. Tests: dit_lora::tests::written_adapters_read_back_to_the_same_prediction (writing lora_A for lora_B fails it), cuda_mmdit::tests::flow_steps_fit_one_latent_or_skip_without_device (40 steps at 1e-2: loss 1.52 to 0.0020), and the ignored dit_lora::tests::a_trained_lora_loads_as_diffusers_loads_it with scripts/dit_lora_trained_check.py: 200 steps at 64x64 on three captioned pictures over the tiny diffusers DiT (loss 1.14 over steps 1-50 to 0.33 over 151-200), diffusers 0.40 load_lora_adapter loads all 34 adapters, the LoRA moves its prediction by 1.7 relative, and both read_peft and load_with_loras match it within 1.1e-6 relative. |
|
| G10 | UNet LoRA training (Stable Diffusion 1.5 and SDXL) — a (host reference), b-i (device backward kernels), b-ii (adapted device forward) and b-iii-a (device attention backward) and b-iii-b (device backward to adapter gradients) done 2026-10-08; b-iii-c and c (the train_unet_lora example) done 2026-10-09 — G10 done |
P2 | L | Added 2026-10-08 after G3 part 2 trained the DiT. Most published image LoRAs are trained on SD 1.5 and SDXL UNets, and unet.rs, cuda_image.rs and DeviceUnet are inference-only. diffusers' own train_text_to_image_lora targets the attention projections (to_q, to_k, to_v, to_out.0), so the backward needs weight gradients only there, and input gradients through the resnet blocks (convolution, group norm, SiLU), the cross-attention and the up and down samplers. a (M): a host reference: a UNet forward that keeps its activations and a backward to adapters on the attention projections, base and conditioning frozen. Acceptance: on a tiny diffusers UNet2DConditionModel with PEFT adapters on those modules, every adapter gradient of a fixed loss matches PyTorch autograd within 1e-4 relative. b (L): the same on DeviceUnet in FP16; acceptance: adapter gradients within 2e-2 relative of a, and one step on SD 1.5 at 512x512 timed in docs/baseline.md. Split: b-i (M) FP16 device backward kernels (the reverse of im2col, group norm, affine layer norm, GEGLU, SiLU and nearest upsampling), each matched to its host function in unet_lora within an FP16 tolerance; b-ii (M) DeviceUnet forward with adapters, keeping a device tape, matched to UnetLora::forward; b-iii (L) the device backward to adapter gradients and the timed SD 1.5 step, split 2026-10-08: SD 1.5's heads are 40, 80 and 160 wide and the fused attention takes 64 or 128, so b-iii-a (M) a device attention backward for any head width, a chunk of query rows at a time, with key and value gradients summed in FP32; acceptance: within 2% of dit_lora::attention_backward for self- and cross-attention over several chunks. b-iii-b (L) the device backward to adapter gradients over the tape; acceptance: within 2e-2 relative of UnetLora::backward. docs/baseline.mdcuda_image::lora::tests::an_sd15_step_at_512_is_timed (ignored). train_unet_lora example (noise-prediction loss on the scheduler's noise levels, captioned images encoded once, as train_dit_lora); acceptance: on the tiny UNet the loss falls and diffusers' load_lora_weights loads the written file to the crate's own prediction within 1e-5.UnetLora::new (he-uniform down, zero up, scale alpha/rank) and write_peft (diffusers PEFT keys, the scale folded into lora_B), DeviceUnet::noise_step (noisy latent, adapted pass, eps MSE, device backward, Adam with the gradient norm clipped to 1), and examples/train_unet_lora.rs. Tests: unet_lora::tests::written_adapters_read_back_to_the_same_prediction; cuda_image::lora::tests::noise_steps_lower_the_loss_or_skip_without_a_device (fixed noise at sigma 1.5: 1.227 to 1.139 in 30 steps, 1.097 in 200; rank-4 adapters on the tiny UNet's one attention level fit slowly); the ignored pipeline::tests::publish_a_tiny_sd and unet_lora::tests::a_trained_unet_lora_loads_as_diffusers_loads_it against scripts/unet_lora_trained_check.py (300 steps of the example on a diffusers random UNet, 48 adapters loaded by load_lora_adapter; read_peft within 8.5e-7 relative of diffusers, the LoRA moving the prediction 0.74). a done 2026-10-08: unet_lora::UnetLora reads a diffusers PEFT file onto those projections, forward keeps a Tape, and backward adds adapter gradients back through every transformer (attention, cross-attention with its frozen prompt, affine layer norms, GEGLU, the group norm and projections in and out), every resnet (convolutions by the reverse of im2col, group norms, SiLU), the down and up samplers and the skips. Tests: unet_lora::tests::adapter_gradients_match_finite_differences (all 88 adapters of a random-weight tiny UNet, each factor's slope along its own gradient within 1% plus 1e-6 of f32 noise), and the ignored unet_lora_gradients_match_pytorch against scripts/unet_lora_gradients.py (diffusers 0.40 and PEFT on the two-level UNet of unet_lora_reference.py, 96 adapter tensors: prediction within 3.0e-6, worst gradient 4.9e-5 relative). Dropping the group norm backward's projection term fails both (1.31 relative on the first to_k); skipping the skip gradients on the way down fails the first. The script runs diffusers' GEGLU through the tanh GELU, as unet.rs does; diffusers itself uses the exact one, which is why the inference test against diffusers allows 1e-3. b-i done 2026-10-08: ImageGpu gained conv_backward (weight^T . grad lowered to FP16 columns a chunk of output rows at a time, then the reverse of im2col or of one im2row tap as FP32 atomics, with a 1x1 convolution one GEMM), group_norm_backward, layer_norm_backward, gelu_gate_backward, silu_backward and upsample_backward; act_lhs_transposed takes out_narrow, because an FP16-accumulating context writes only FP16. Test: cuda_image::tests::backward_kernels_match_the_host_or_skip_without_a_device (every kernel against its host function in unet_lora, within 1% of the largest reference value; the five convolution shapes of the forward test, each in one chunk and one row per chunk). Dropping the group norm backward's projection term fails it at 0.996 relative. b-ii done 2026-10-08: cuda_image::DeviceAdapters::upload puts a UnetLora on the device, and DeviceUnet::forward_adapted adds each adapter after its projection (`[q |
| G4 | 3D texture | P2 | L | Deferred in 2Dto3DPlan.md §6. The rasterizer route is the open decision. |
| G5 | Eikonal regularization for the shape VAE | P3 | M | src/shape_vae.rs:34 needs dL/dquery_point. |
| G6 | T5 attention mask | P3 | S | src/t5.rs:13 has no mask. Padded prompts in a batch need one. |
| G7 | Encoder-decoder (seq2seq) training | P3 | L | The README says "not planned". T5 inference exists, and cross-attention exists in gpu_cross. Revisit if translation or summarization becomes a goal. |
| G8 | P2 | M | The MarketTrunk is an MLP. A small causal transformer over a window of ticks reuses TransformerLm blocks. Done: interoceptive::CausalTrunk embeds each candle of the window as a token, runs TransformerBlocks (RoPE, SwiGLU, causal attention, one sequence per row through Layout), reads the last candle's state after an RMS norm, joins the position and trend features there and ends in one ReLU layer to hidden. AgentConfig::causal: Option<CausalConfig> picks it (None, the MLP, by default); QNetwork::trunk is now the Trunk enum. Test: interoceptive::agent::tests::a_causal_trunk_backward_matches_finite_differences (every parameter of a two-layer, three-candle trunk under the Q network; skipping the RMS norm's backward fails it at 0.166 against 0.196). Not done: a training comparison against the MLP, which needs the agent trained (owner decision 6). |
|
| G9 | Textual inversion embeddings — done 2026-10-08 | P2 | M | Stable Diffusion users load learned prompt tokens (<cat-toy>) as often as LoRA files, and the CLIP path has no way to add a token. Read diffusers ({"<token>": [d]}), A1111 (emb_params [n, d]) and SDXL (clip_l/clip_g) safetensors files, add the token to each CLIP tokenizer and its rows to the tower's embedding table, and expand a multi-vector token as diffusers does. Acceptance: an ignored test matches diffusers load_textual_inversion + maybe_convert_prompt on a tiny CLIP tower with the SDXL tokenizer, hidden states and pooled vector within 1e-5. Done: Bpe::add_token, ClipTextEncoder::add_token, clip::read_embedding, ImagePipeline::add_embedding. A legacy config (eos_token_id = 2) pools at the highest id, as Transformers does, which differs from the first end token once an added id passes it. Tests: clip::tests::textual_inversion_matches_diffusers (ignored, RB_TI_REF from scripts/textual_inversion_reference.py; ids exact, worst error under 1e-5 on both towers; it fails when the argmax pool or the expansion is removed) and tokenizer::tests::an_added_token_stands_for_all_its_ids_and_cannot_be_added_twice. |
After 2.1.2:
| # | Item | Priority | Effort | Notes |
|---|---|---|---|---|
| H1 | Real-data backtest harness — part 1 done | P1 | M | Part 1 (evaluation): interoceptive::backtest runs the greedy policy once over a span of candles with fees and slippage and no shocks, and reports EquityStats (total return, per-step Sharpe, maximum drawdown) and the liquidation flag beside a buy-and-hold baseline; walk_forward makes the train/test windows. Tests: interoceptive::backtest::tests::*. Part 2 (open): train on each window and report across windows. The agent trains on the CPU only (H6 is P3), so this part needs an owner decision on a CPU training run, or H6. Original note: ticks_from_csv exists. Add a walk-forward split (train on one window, evaluate on the next), costs and slippage, and a report (Sharpe, maximum drawdown, liquidation count) against a buy-and-hold baseline. Without this, a training curve says nothing about the agent. |
| H2 | P1 | S | The target network already existed; the target was plain max_a Q_target. InteroceptiveAgent::train_step now uses double_bellman_targets: the online network picks the next action, the target network scores it. Test: interoceptive::loss::tests::td_loss_touches_only_the_chosen_action_and_done_cuts_the_bootstrap (a row where online and target disagree gets 1.5, not the max-based 2.5). Cost: one more online forward per update. |
|
| H3 | P2 | S | ReplayBuffer::prioritized(capacity, obs_dim, alpha, beta) keeps a sum tree; sample_prioritized_into draws stratified and reports importance weights, update_priorities takes InteroceptiveAgent::last_td_errors, and Trainer uses it when the buffer is prioritized. Tests: prioritized_sampling_follows_the_td_errors (frequencies within 0.01 of p/Σp over 100k draws, weights (N P)^-β normalized to 1, new pushes at the max priority), weights_scale_each_row_of_the_td_loss, and the prioritized pass of a_short_run_collects_trains_and_finishes_episodes. Beta annealing is left to the caller (set_beta). Original note: A change to ReplayBuffer. |
|
| H4 | Actor-critic (PPO) | P2 | M | Continuous position sizing instead of discrete actions. |
| H5 | Ablation: interoception on against off | P1 | S | The spec's whole claim is that steering the trunk from outside beats concatenating the state onto the input. Train both with the same seeds and the same shocks, and report the difference. |
| H6 | CUDA trunk | P3 | M | Only if training time becomes the bottleneck. The networks are small, so the CPU and rayon may win. |
metal_training covers dense networks only.
| # | Item | Priority | Effort |
|---|---|---|---|
| I1 | Transformer forward and inference on Metal | P3 | L |
| I2 | Transformer training on Metal | P3 | L |
Park both unless a Mac user asks. Every CUDA kernel would need a Metal twin, and that is the structural tax at its worst.
| # | Item | Priority | Effort | Notes |
|---|---|---|---|---|
| J1 | P1 | S | scripts/bench_check.sh builds both benches with --features cuda, takes the best tokens/s of five runs, and fails below 97% of the rows in docs/baseline.md ("Regression check"); --record prints new rows. It warns when another process holds the GPU or the load average is 2 or more. Baseline at cd8b514: train step 50329 tok/s (CUDA, single runs 48353-50001), decode 2434 tok/s (CPU, single runs 2348-2434). Checked: train passed at 50472; a 60000 baseline row failed it; decode failed at 2261 with a parallel rustc at load 5.7, which is the warning's case. Original note: Write a script that runs bench_train_step and bench_decode and compares the result against the numbers in docs/baseline.md. Fail if a result is more than 3% slower. Run it before every release. CI has no GPU, so run it locally. |
|
| J2 | P1 | S | ci.yml already ran cargo test, clippy -D warnings and the MSRV check. Added cargo doc --no-deps --features cuda,onnx,images with RUSTDOCFLAGS=-D warnings, and fixed the 12 broken or private intra-doc links it found. Every CUDA test skip now goes through cuda_training::skip_without_cuda; RUSTING_BRAIN_REQUIRE_CUDA=1 turns a skip into a failure. Checked with CUDA_VISIBLE_DEVICES=: param:: tests pass 8/8 without the variable and fail 2 with it. Original note: Check that ci.yml runs cargo test, clippy -D warnings, the MSRV build (1.85) and cargo doc. The CUDA tests skip without a device. Make sure they skip and do not pass silently. |
|
| J3 | cargo semver-checks in CI |
P2 | S | A semver job in ci.yml runs obi1kenobi/cargo-semver-checks-action@v2 against the latest tag (git describe --tags --abbrev=0) with default features. Locally, cargo semver-checks --baseline-rev v3.0.0 --default-features (0.51.0): 229 checks pass, 31 skip. With --features cuda,onnx,images it fails to build here: the tool resolves dependencies without Cargo.lock, and kstring 2.0.5 wants rustc 1.96 against the local 1.95; add those features when CI's stable is past that. Original note: 2.0 broke the API once. Make the next break a choice. |
| J4 | P2 | S | src/fuzz.rs feeds 2,000 seeded corruptions (bit flips, overwritten runs, cuts, splices, huge numbers in text headers) of one valid file to each reader: safetensors, GLB, BPE and unigram tokenizer.json, .npy, IDX, load_bin and the EMA file. Runs in cargo test (fuzz::*, 0.2 s). It found: a 58 GB allocation from a corrupt BPE token id (ids now refused at 2^24), an index panic on a truncated v2 .npy header, shape overflows in .npy and IDX, load_bin, load_lora and Ema::load allocating whatever length or shape the file claimed (now checked against the file size, and load_bin checks the configuration's weight count, RoPE table and LoRA rank before building; the rank check came from a later review and has its own test). Seeded mutation, not cargo fuzz: that is a new tool and dependency; ask if coverage-guided runs are wanted. Original note: safetensors, gltf, npy, idx, tokenizer.json and the binary checkpoints all read untrusted bytes. Use cargo fuzz on each header parser. A malformed file must return an error and must not panic or allocate without a limit. |
|
| J5 | P1 | S | build.rs records git rev-parse --short HEAD and reruns when HEAD or a branch ref moves; rusting_brain::BUILD is "<version> (<hash>)". Test: build_names_the_checkout (BUILD equals the current HEAD after a commit). Open, outside this repo: RustingLLM train and bench print rusting_brain::BUILD next to the parameter counts (src/bin/train.rs:234), and its README names cargo build --release --features cuda as the one build command. Original note: W0 in improvement-plan.md. A trainer that was nine hours stale cost 1.63x. Check whether the guard landed. |
|
| J6 | Kernel source of truth | P3 | L | featuresPlan.md asks whether a shared kernel description pays for itself. Decide when the fourth new layer type needs two hand-written backward passes. Until then, accept the tax. |
| J7 | Tutorials (TokenFile, LoRA and image slices done) |
P2 | S each | One chapter each for TokenFile pretraining end to endtrain.bin/validation.bin and trains from TokenFile::batch; chapters 14 and 16 and the README load tokenizers with Bpe instead of the tokenizers crate. Run on CUDA: the resumed step-200 checkpoint gives held-out 3.5091, the live value. The run found the save_bin stale-weights bug (fixed in 014106e). LoRA slice, 2026-10-07: chapter 17 (tutorials/17_lora_fine_tuning.md) trains a base, adapts it, saves, reloads and merges the adapter. On CUDA: domain loss 4.86 to 0.15, reloaded 0.1528, merged 0.1528, general loss 0.11 to 1.73 (the forgetting section). The run found that LoRA failed on CUDA under mixed precision (fixed in cc85271). Image slice, 2026-10-08: chapter 18 (tutorials/18_text_to_image.md) loads a diffusers directory at Q8, moves it to the GPU, samples with DPM++ 2M and a negative prompt, and repaints the result. On an RTX 3060 with Pony Diffusion V6 XL: 1024x1024 in 13.2 s, repaint at strength 0.5 in 117.2 s (CPU VAE encoder; 6.9 s after C12). The run found the SDXL padding bug (C11, fixed in fdf9f0d). |
| J8 | ponytail: debt review |
P2 | S | 44 comments reviewed on 2026-10-07. Already tracked: CLIP image tower (G1), UNet conditioning (G2), YaRN, Conv2d backward (TrainableConv2d exists), TokenStream order (E4), CSV newlines (E5), shard mmap (E6). New: E8, the quadratic sentencepiece merge, measured before filing. The rest name limits with a working way around them (evaluate in training-size batches, skip_batches reads the prefix, materialized LoRA weight, FP16 decoder range, naive transpose kernel) or are inference-only scope notes, and stay as comments. Original note: The tree has about 40 ponytail: comments. Each one names a known limit. Review them once per release, and move the ones that now matter into this file. |
| J9 | Flaky gpu_transformer::tests::checkpointed_blocks_train_like_kept_ones_or_skip_without_device |
P2 | S | Seen 2026-10-09: in about one full cargo test --features cuda --lib run in three, the mixed-precision case differs at step 2 by 1.7e-4 and 3.9e-5 (bound 1e-5); alone it passes. Find which backward kernel is order-dependent (FP32 atomics feeding BF16 weights are the first suspect) and either make it deterministic or bound the BF16 case by its noise. 2026-10-09 retry: not reproduced in 30 runs alone, 15 runs of gpu_transformer:: and 12 full CUDA suite runs (load average 3 to 21). The tiny model's heads are 4 wide, so J11's flash switch does not explain it. Left open until it shows again. |
| J10 | mesh::simplify_tests::an_error_bound_removes_the_flat_interior_and_keeps_the_shape |
P2 | S | The flat-grid assertion (faces == 2) failed in 3 of 200 runs of the test alone. Cause: HashMap order decided which of the zero-cost edges came out of the heap first, and an edge refused for folding was dropped for good, so some orders got stuck above two faces. collapse_edges now keeps refused edges and weighs them again once the queue has nothing cheap left, for as long as a round still collapses something. 0 failures in 400 runs; mesh:: 29 pass. |
| J11 | P2 | S | Found while retrying J9: transformer::tests::a_device_prompt_at_head_width_64_matches_the_host_or_skips_without_device and pipeline::tests::a_transformer_pipeline_on_the_device_matches_the_host_or_skips_without_device failed in 5 of 6 full runs with "not a BF16 key-value cache without flash attention" and "needs the fused attention kernel". Cause: the two gpu_transformer flash parity tests flipped the process-wide cuda_flash::DISABLED. It is now cuda_flash::OFF_ON_THIS_THREAD, a test-only thread-local; the environment variable stays process-wide. 0 failures in 6 full runs after. |
Found by reading the consumers' plans for work that will need this crate,
not from their missingFeatures.md files. Order: P1 first, smallest first.
| # | Item | Priority | Effort | Notes |
|---|---|---|---|---|
| R1 | P1 | S | Schedule::warmup_stable_decay. Test warmup_stable_decay_holds_the_peak_then_falls_linearly_to_the_floor: steps 9-80 are exactly the peak, step 90 is 0.5, step 100 is the floor, and a 200-step schedule gives the same rates through step 80. Original note: RustingLLM PLAN.md:210 wants WSD: hold the peak, then decay over the last fraction of the run. Schedule only has warmup plus cosine. Add Schedule::warmup_stable_decay(peak, warmup, steps, decay_fraction), linear decay to the floor, and keep floor. Acceptance: a unit test that the rate is flat at the peak through the stable phase, falls linearly to the floor over the last fraction, and that rates before the decay start do not change when steps grows (the property that lets a run be extended). |
|
| R2 | P1 | S | Param::step drops the decay for one-row parameters before the CPU or device update. Tests weight_decay_shrinks_matrices_and_leaves_one_row_parameters_alone (Adam and Lion: a 2x2 matrix becomes 0.95x with lr 0.1, wd 0.5; a 1x3 gain is unchanged) and weight_decay_on_the_device_matches_the_host_or_skips_without_device (within 1e-6). Full CUDA suite 656 passed. Original note: RustingLLM PLAN.md:214 asks for decay 0.1 "on matrices only" and to check what Adam decays. Network already skips biases, but Param::step (CPU, param.rs:632) and the device Adam and Lion kernels (gpu_transformer.rs:1528) decay every parameter, norm gains included. Skip decay for one-row parameters (norm gains, biases), the nanoGPT rule. Acceptance: with a zero gradient, one Adam step leaves a norm gain unchanged and shrinks a matrix by lr * wd, on CPU and CUDA. |
|
| R3 | P1 | S | Bpe::train_json_split_digits trains on Qwen2's split (split_llama3(text, 1), already parity-tested against the Qwen2 rule) and writes it as a Split plus ByteLevel sequence, the shape of Qwen2's own file. Test a_digit_split_tokenizer_never_learns_a_token_spanning_two_digits: the GPT-2 trainer learns multi-digit tokens on the same corpus and this one learns none, " 2024" is 5 tokens, the file loads with the split and round-trips text. Not checked against Hugging Face tokenizers itself: it is not installed here. Original note: RustingLLM PLAN.md:184 retrains a 32k BPE with digits split one by one. Bpe::train_json uses the GPT-2 pre-tokenizer only. Add the Hugging Face Digits { individual_digits: true } pre-tokenizer as a training option, written into the JSON so Bpe::from_json encodes the same way. Acceptance: a trained tokenizer never merges two digits, and its JSON round-trips to the same ids. |
|
| R4 | Dataset::shuffle without cloning rows |
P2 | S | Rows are moved with mem::take. Measured on 200,000 rows x 64 features, 20 seeded shuffles, release build, load average 13-17 (uptime 1 day 10:53): 30.9-35.9 ms per shuffle before, 4.9-7.3 ms after. Test a_seeded_shuffle_permutes_the_rows_in_pairs_and_repeats (there was no shuffle test). fit_with still clones the dataset once per fit, not per epoch. Original note: RustingTrade improvementPlan.md P08. shuffle clones every row (dataset.rs:451); DatasetStream::reorder already moves them with mem::take. Do the same. Acceptance: same order as before for the same seed (existing tests), and a measured shuffle time before and after on a large dataset. |
Checked and not filed:
- RustingMedia
PLAN.md:104, logit-normal timesteps:flow_transformer::sample_timestepsis already logit-normal with a shift. - RustingTrade P09, device-side validation:
CudaTrainingSession::prepare_validationalready keeps the validation set on the device. Checkpoint cadence lives in RustingTrade's own trainer. - RustingCS2
PLAN.md:263, audio input: no capture exists yet. Add STFT and log-mel features when it does. - RustingLLM DPO (
PLAN.md:255) is owner decision 2.
consumers K1-K13 (Consumer requests), before everything below
2.1 (now) 2.1.1-2.1.6, H5, J1, J5
2.2 (fine-tune) F1, F2, F5, A3, B5, B4, B6 (RoPE scaling), E1
2.3 (speed) A1, A6, A7, A10, C2, C5, C1
2.4 (scale) B1, B2, A8, A11, E2, E7
2.5 (images) A4, A5, C6, G1, G3, G10, D5
later everything at P2/P3, re-ranked by what users ask for
Why this order:
- 2.1 ships what already exists and fixes correctness problems.
- 2.2 is the largest gain for a one-card user: take a published model,
fine-tune it with LoRA, and export it to the Hub. This uses code that
already exists (
TextEncoder, LoRA,SafeTensors). - 2.3 goes after the measured remainder of the profile: the flash kernels are 19% of a step and the LM head is 13%. It also makes inference worth using.
- 2.4 is the multi-GPU work. It is large, and it pays off only for users with two cards.
- 2.5 gives the image path the same treatment the text path got.
- Encoder-decoder (G7): keep "not planned" in the README, or reopen it?
- DPO and RL fine-tuning (B8): the interoceptive module changes the
argument in
featuresPlan.md. Decide whether alignment belongs in the crate. - Vocabulary size (W8): a 15% gain, but it is a model decision and not a kernel decision.
- N-D tensor type: a 3.0 breaking change, or never? It is the precondition for J6 and for a cleaner convolution path.
- Parquet (E2): add the
arrow/parquetdependency tree behind a feature flag, or write a minimal reader? - CPU training for the interoceptive agent: H1 part 2, H5 and the J7 agent chapter all need the agent trained, and it trains on the CPU only. Allow a CPU run for these, or promote H6 (CUDA trunk, now P3) so they can run on the device? Asked 2026-10-08.
- cuDNN for image convolutions (C10 part 2): may the image path depend
on cuDNN, through cudarc's
cudnnfeature, for its 3x3 convolutions? A hand implicit GEMM would only about break even. Asked 2026-10-08. - YOLO Gate 2 at the full schedule (K13g): the 100-epoch fine-tune projects to 1.99 h at 15.7 samples/s, inside the 2-hour pre-approval with no margin. Approve a run that may go a few minutes past 2 h, or allow cuDNN (decision 7) for the convolutions first. Asked 2026-10-10.
These are the same as featuresPlan.md "Not on this list", and they still
hold:
- Serving, batching servers and HTTP APIs. ONNX, GGUF and safetensors export are the seam.
- A Python binding.
- An autograd engine. This is a rewrite and not a feature (see J6).
- Tensor parallelism, pipeline parallelism and multi-node, until B1 works on two cards.