icarus112 commited on
Commit
635c2d0
Β·
verified Β·
1 Parent(s): bea623e

Update Feather a10g-large-gt80k training runtime image

Browse files
This view is limited to 50 files because it contains too many changes. Β  See raw diff
Files changed (50) hide show
  1. .dockerignore +16 -16
  2. Dockerfile +124 -124
  3. entrypoint.py +278 -260
  4. mamba_ssm_init.py +69 -69
  5. overlay/.dockerignore +20 -20
  6. overlay/configs/__init__.py +5 -5
  7. overlay/configs/hardware_config.py +104 -104
  8. overlay/configs/harness_config.py +78 -78
  9. overlay/configs/model_config.py +80 -80
  10. overlay/htm_rust/Cargo.lock +383 -383
  11. overlay/htm_rust/Cargo.toml +37 -37
  12. overlay/htm_rust/bench_gpu.py +81 -81
  13. overlay/htm_rust/build.rs +162 -162
  14. overlay/htm_rust/docs/GPU_HTM.md +302 -302
  15. overlay/htm_rust/pyproject.toml +17 -17
  16. overlay/htm_rust/src/gpu/fused.rs +719 -719
  17. overlay/htm_rust/src/gpu/kernels/htm_fused_step.cu +677 -677
  18. overlay/htm_rust/src/gpu/kernels/sp_boost_fused.cu +59 -59
  19. overlay/htm_rust/src/gpu/kernels/sp_duty.cu +45 -45
  20. overlay/htm_rust/src/gpu/kernels/sp_learn.cu +45 -45
  21. overlay/htm_rust/src/gpu/kernels/sp_overlap.cu +78 -78
  22. overlay/htm_rust/src/gpu/kernels/sp_topk.cu +117 -117
  23. overlay/htm_rust/src/gpu/kernels/tm_activate.cu +66 -66
  24. overlay/htm_rust/src/gpu/kernels/tm_anomaly.cu +43 -43
  25. overlay/htm_rust/src/gpu/kernels/tm_grow.cu +155 -155
  26. overlay/htm_rust/src/gpu/kernels/tm_learn.cu +75 -75
  27. overlay/htm_rust/src/gpu/kernels/tm_predict.cu +102 -102
  28. overlay/htm_rust/src/gpu/kernels/tm_punish.cu +64 -64
  29. overlay/htm_rust/src/gpu/kernels/tm_reset.cu +36 -36
  30. overlay/htm_rust/src/gpu/mod.rs +549 -549
  31. overlay/htm_rust/src/gpu/sp_gpu.rs +796 -796
  32. overlay/htm_rust/src/gpu/tests.rs +663 -663
  33. overlay/htm_rust/src/gpu/tm_gpu.rs +460 -460
  34. overlay/htm_rust/src/lib.rs +198 -198
  35. overlay/htm_rust/src/region.rs +94 -94
  36. overlay/htm_rust/src/sp.rs +302 -302
  37. overlay/htm_rust/src/tm.rs +545 -545
  38. overlay/htm_rust/uv.lock +8 -8
  39. overlay/hydra/__init__.py +31 -31
  40. overlay/hydra/config.py +225 -225
  41. overlay/hydra/data_module.py +288 -288
  42. overlay/hydra/diffusion_loss.py +236 -236
  43. overlay/hydra/engram.py +177 -177
  44. overlay/hydra/eval.py +210 -210
  45. overlay/hydra/gdn_block.py +126 -126
  46. overlay/hydra/hyena_block.py +68 -68
  47. overlay/hydra/lightning_module.py +326 -326
  48. overlay/hydra/model.py +894 -894
  49. overlay/hydra/optimizer.py +252 -252
  50. overlay/hydra/training.py +961 -961
.dockerignore CHANGED
@@ -1,16 +1,16 @@
1
- # Keep HF runtime image context deterministic and small.
2
- **/__pycache__/
3
- **/*.py[cod]
4
- **/.pytest_cache/
5
- **/.mypy_cache/
6
- **/.ruff_cache/
7
- **/.venv/
8
- **/target/
9
- **/logs/
10
- **/*.log
11
- **/*.out
12
- **/*.pt
13
- **/*.safetensors
14
- **/*.parquet
15
- **/*.npz
16
- **/.git/
 
1
+ # Keep HF runtime image context deterministic and small.
2
+ **/__pycache__/
3
+ **/*.py[cod]
4
+ **/.pytest_cache/
5
+ **/.mypy_cache/
6
+ **/.ruff_cache/
7
+ **/.venv/
8
+ **/target/
9
+ **/logs/
10
+ **/*.log
11
+ **/*.out
12
+ **/*.pt
13
+ **/*.safetensors
14
+ **/*.parquet
15
+ **/*.npz
16
+ **/.git/
Dockerfile CHANGED
@@ -1,124 +1,124 @@
1
- FROM pytorch/pytorch:2.5.1-cuda12.1-cudnn9-devel
2
-
3
- # Default target is HF Jobs a10g-large (NVIDIA A10G, Ampere GA102, sm_86).
4
- # Override at build time for other cards, e.g. --build-arg FEATHER_GPU_ARCH=sm_90a.
5
- ARG FEATHER_GPU_ARCH=sm_86
6
- ARG FEATHER_TORCH_CUDA_ARCH_LIST=8.6
7
-
8
- ENV DEBIAN_FRONTEND=noninteractive \
9
- PIP_NO_CACHE_DIR=1 \
10
- PYTHONUNBUFFERED=1 \
11
- CARGO_HOME=/root/.cargo \
12
- RUSTUP_HOME=/root/.rustup \
13
- HTM_CUDA_ARCH=${FEATHER_GPU_ARCH} \
14
- TORCH_CUDA_ARCH_LIST=${FEATHER_TORCH_CUDA_ARCH_LIST} \
15
- PATH=/root/.cargo/bin:${PATH}
16
-
17
- RUN apt-get update && apt-get install -y --no-install-recommends \
18
- git curl ca-certificates build-essential pkg-config libssl-dev && \
19
- rm -rf /var/lib/apt/lists/*
20
-
21
- RUN curl https://sh.rustup.rs -sSf | bash -s -- -y --profile minimal --default-toolchain stable
22
-
23
- RUN pip install --upgrade pip setuptools wheel && \
24
- pip install \
25
- maturin \
26
- huggingface_hub \
27
- datasets \
28
- requests \
29
- pyarrow \
30
- rustbpe \
31
- pandas \
32
- tiktoken \
33
- pydantic \
34
- ninja \
35
- packaging \
36
- einops
37
-
38
- # Mamba-3 fused CUDA kernel stack (mandatory β€” NO fallback allowed).
39
- #
40
- # We install PRE-BUILT manylinux wheels from the official state-spaces/mamba
41
- # and Dao-AILab/causal-conv1d GitHub releases. Compiling mamba_ssm from source
42
- # on HF Spaces' cpu-basic builder (~16GB RAM) OOMKills even with MAX_JOBS=1 β€”
43
- # nvcc on the templated selective-scan/chunk-scan kernels needs 8–12GB per TU.
44
- #
45
- # Wheel selection for base image pytorch/pytorch:2.5.1-cuda12.1-cudnn9-devel:
46
- # - Python 3.11 (cp311) β€” matches PyTorch 2.5.1 image
47
- # - CUDA 12.x wheels (cu12) β€” compatible with CUDA 12.1 base
48
- # - PyTorch 2.5 ABI (torch2.5) β€” exact torch match
49
- # - cxx11abiFALSE β€” standard PyTorch pip build
50
- #
51
- # Versions: mamba_ssm 2.3.0 + causal_conv1d 1.6.0 (matching torch2.5 ABI).
52
- # Both are CUDA-compiled, no build toolchain needed
53
- # on the Space builder.
54
- #
55
- # Step A: install the published v2.3.0 prebuilt wheel (compiled CUDA ops
56
- # for selective_scan, layernorm_gated, ssd_*, causal_conv1d, etc).
57
- RUN pip install \
58
- 'https://github.com/Dao-AILab/causal-conv1d/releases/download/v1.6.0/causal_conv1d-1.6.0+cu12torch2.5cxx11abiFALSE-cp311-cp311-linux_x86_64.whl' \
59
- 'https://github.com/state-spaces/mamba/releases/download/v2.3.0/mamba_ssm-2.3.0+cu12torch2.5cxx11abiFALSE-cp311-cp311-linux_x86_64.whl' && \
60
- python -c "import importlib.metadata as m; print('installed mamba_ssm=' + m.version('mamba_ssm') + ' causal_conv1d=' + m.version('causal_conv1d'))"
61
-
62
- #
63
- # Step B: graft the Mamba3 class + its pure-Triton ops subtree from mamba-ssm
64
- # main. v2.3.1 is the latest release but Mamba3 landed post-release; the new
65
- # files under ops/triton/mamba3/ are ALL pure Python @triton.jit kernels with
66
- # zero compiled-CUDA dependencies (verified: every import in that subtree is
67
- # triton/torch/python β€” no .so files, no nvcc). So we install the v2.3.1 wheel
68
- # (for its compiled ops) and overlay the main-branch Mamba3 sources on top.
69
- #
70
- # This avoids the source-build OOM on the cpu-basic HF Space builder and the
71
- # missing-file error the smoke hit on the last attempt.
72
- # Download grafted mamba3 module + triton ops subtree
73
- RUN SITE=/opt/conda/lib/python3.11/site-packages/mamba_ssm && \
74
- BASE=https://raw.githubusercontent.com/state-spaces/mamba/main && \
75
- curl -fsSL "$BASE/mamba_ssm/modules/mamba3.py" -o "$SITE/modules/mamba3.py" && \
76
- mkdir -p "$SITE/ops/triton/mamba3" && \
77
- for f in __init__.py angle_dt.py mamba3_mimo_rotary_step.py mamba3_mimo_utils.py mamba3_siso_bwd.py mamba3_siso_combined.py mamba3_siso_fwd.py mamba3_siso_step.py utils.py; do \
78
- curl -fsSL "$BASE/mamba_ssm/ops/triton/mamba3/$f" -o "$SITE/ops/triton/mamba3/$f"; \
79
- done
80
-
81
- # Replace mamba_ssm/__init__.py with a minimal one that only imports Mamba3
82
- # (pure-Triton, works). The shipped __init__.py eagerly imports
83
- # selective_scan_cuda.so which has a libtorch C++ ABI mismatch on this base
84
- # image ("undefined symbol: _ZN3c107WarningC1E..."). Since training only needs
85
- # Mamba3 (grafted from main), we skip all compiled-CUDA imports.
86
- COPY mamba_ssm_init.py /opt/conda/lib/python3.11/site-packages/mamba_ssm/__init__.py
87
-
88
- # Structural check (no triton init β€” triton has no GPU on the builder)
89
- RUN SITE=/opt/conda/lib/python3.11/site-packages/mamba_ssm && \
90
- test -f "$SITE/modules/mamba3.py" && \
91
- test -f "$SITE/ops/triton/mamba3/mamba3_siso_combined.py" && \
92
- test -s "$SITE/__init__.py" && \
93
- echo "mamba3 graft + __init__ override verified"
94
-
95
- # Optional tilelang for MIMO path β€” pure-python, cheap; SISO Mamba3 works without.
96
- RUN pip install tilelang || echo "[dockerfile] tilelang optional install failed β€” continuing"
97
-
98
- # Triton version decision: FORCE 3.4.0 β€” first line with both mamba3
99
- # APIs (set_allocator + tl.make_tensor_descriptor) while avoiding the 3.5.x
100
- # driver-discovery regression seen on HF A10G (`0 active drivers` despite
101
- # torch.cuda being available). torch 2.5's _inductor expects older Triton
102
- # internals, but mamba_ssm/__init__.py shims AttrsDescriptor as a stub
103
- # before any torch._inductor import path runs, so the incompatibility is
104
- # neutralized. Build-time assert verifies mamba3's two required APIs.
105
- RUN pip install --force-reinstall --no-deps 'triton==3.4.0' && \
106
- python -c "import triton; from triton import language as tl; \
107
- assert hasattr(triton, 'set_allocator'), 'missing triton.set_allocator'; \
108
- assert hasattr(tl, 'make_tensor_descriptor'), 'missing tl.make_tensor_descriptor'; \
109
- print(f'triton={triton.__version__} set_allocator+make_tensor_descriptor OK, AttrsDescriptor shimmed in mamba_ssm/__init__.py')"
110
-
111
- WORKDIR /workspace
112
- COPY overlay /workspace/feather
113
- COPY entrypoint.py /app/entrypoint.py
114
- WORKDIR /workspace/feather
115
-
116
- RUN python -m py_compile hydra/training.py prepare.py train.py && \
117
- bash -n scripts/run_domain_expanded_pretrain.sh
118
-
119
- RUN export LD_LIBRARY_PATH=/usr/local/cuda/lib64:${LD_LIBRARY_PATH} && \
120
- echo "building htm_rust GPU kernels for HTM_CUDA_ARCH=${HTM_CUDA_ARCH} TORCH_CUDA_ARCH_LIST=${TORCH_CUDA_ARCH_LIST}" && \
121
- maturin build --release --features gpu --manifest-path htm_rust/Cargo.toml && \
122
- pip install htm_rust/target/wheels/htm_rust-*.whl
123
-
124
- CMD ["python", "/app/entrypoint.py"]
 
1
+ FROM pytorch/pytorch:2.5.1-cuda12.1-cudnn9-devel
2
+
3
+ # Default target is HF Jobs a10g-large (NVIDIA A10G, Ampere GA102, sm_86).
4
+ # Override at build time for other cards, e.g. --build-arg FEATHER_GPU_ARCH=sm_90a.
5
+ ARG FEATHER_GPU_ARCH=sm_86
6
+ ARG FEATHER_TORCH_CUDA_ARCH_LIST=8.6
7
+
8
+ ENV DEBIAN_FRONTEND=noninteractive \
9
+ PIP_NO_CACHE_DIR=1 \
10
+ PYTHONUNBUFFERED=1 \
11
+ CARGO_HOME=/root/.cargo \
12
+ RUSTUP_HOME=/root/.rustup \
13
+ HTM_CUDA_ARCH=${FEATHER_GPU_ARCH} \
14
+ TORCH_CUDA_ARCH_LIST=${FEATHER_TORCH_CUDA_ARCH_LIST} \
15
+ PATH=/root/.cargo/bin:${PATH}
16
+
17
+ RUN apt-get update && apt-get install -y --no-install-recommends \
18
+ git curl ca-certificates build-essential pkg-config libssl-dev && \
19
+ rm -rf /var/lib/apt/lists/*
20
+
21
+ RUN curl https://sh.rustup.rs -sSf | bash -s -- -y --profile minimal --default-toolchain stable
22
+
23
+ RUN pip install --upgrade pip setuptools wheel && \
24
+ pip install \
25
+ maturin \
26
+ huggingface_hub \
27
+ datasets \
28
+ requests \
29
+ pyarrow \
30
+ rustbpe \
31
+ pandas \
32
+ tiktoken \
33
+ pydantic \
34
+ ninja \
35
+ packaging \
36
+ einops
37
+
38
+ # Mamba-3 fused CUDA kernel stack (mandatory β€” NO fallback allowed).
39
+ #
40
+ # We install PRE-BUILT manylinux wheels from the official state-spaces/mamba
41
+ # and Dao-AILab/causal-conv1d GitHub releases. Compiling mamba_ssm from source
42
+ # on HF Spaces' cpu-basic builder (~16GB RAM) OOMKills even with MAX_JOBS=1 β€”
43
+ # nvcc on the templated selective-scan/chunk-scan kernels needs 8–12GB per TU.
44
+ #
45
+ # Wheel selection for base image pytorch/pytorch:2.5.1-cuda12.1-cudnn9-devel:
46
+ # - Python 3.11 (cp311) β€” matches PyTorch 2.5.1 image
47
+ # - CUDA 12.x wheels (cu12) β€” compatible with CUDA 12.1 base
48
+ # - PyTorch 2.5 ABI (torch2.5) β€” exact torch match
49
+ # - cxx11abiFALSE β€” standard PyTorch pip build
50
+ #
51
+ # Versions: mamba_ssm 2.3.0 + causal_conv1d 1.6.0 (matching torch2.5 ABI).
52
+ # Both are CUDA-compiled, no build toolchain needed
53
+ # on the Space builder.
54
+ #
55
+ # Step A: install the published v2.3.0 prebuilt wheel (compiled CUDA ops
56
+ # for selective_scan, layernorm_gated, ssd_*, causal_conv1d, etc).
57
+ RUN pip install \
58
+ 'https://github.com/Dao-AILab/causal-conv1d/releases/download/v1.6.0/causal_conv1d-1.6.0+cu12torch2.5cxx11abiFALSE-cp311-cp311-linux_x86_64.whl' \
59
+ 'https://github.com/state-spaces/mamba/releases/download/v2.3.0/mamba_ssm-2.3.0+cu12torch2.5cxx11abiFALSE-cp311-cp311-linux_x86_64.whl' && \
60
+ python -c "import importlib.metadata as m; print('installed mamba_ssm=' + m.version('mamba_ssm') + ' causal_conv1d=' + m.version('causal_conv1d'))"
61
+
62
+ #
63
+ # Step B: graft the Mamba3 class + its pure-Triton ops subtree from mamba-ssm
64
+ # main. v2.3.1 is the latest release but Mamba3 landed post-release; the new
65
+ # files under ops/triton/mamba3/ are ALL pure Python @triton.jit kernels with
66
+ # zero compiled-CUDA dependencies (verified: every import in that subtree is
67
+ # triton/torch/python β€” no .so files, no nvcc). So we install the v2.3.1 wheel
68
+ # (for its compiled ops) and overlay the main-branch Mamba3 sources on top.
69
+ #
70
+ # This avoids the source-build OOM on the cpu-basic HF Space builder and the
71
+ # missing-file error the smoke hit on the last attempt.
72
+ # Download grafted mamba3 module + triton ops subtree
73
+ RUN SITE=/opt/conda/lib/python3.11/site-packages/mamba_ssm && \
74
+ BASE=https://raw.githubusercontent.com/state-spaces/mamba/main && \
75
+ curl -fsSL "$BASE/mamba_ssm/modules/mamba3.py" -o "$SITE/modules/mamba3.py" && \
76
+ mkdir -p "$SITE/ops/triton/mamba3" && \
77
+ for f in __init__.py angle_dt.py mamba3_mimo_rotary_step.py mamba3_mimo_utils.py mamba3_siso_bwd.py mamba3_siso_combined.py mamba3_siso_fwd.py mamba3_siso_step.py utils.py; do \
78
+ curl -fsSL "$BASE/mamba_ssm/ops/triton/mamba3/$f" -o "$SITE/ops/triton/mamba3/$f"; \
79
+ done
80
+
81
+ # Replace mamba_ssm/__init__.py with a minimal one that only imports Mamba3
82
+ # (pure-Triton, works). The shipped __init__.py eagerly imports
83
+ # selective_scan_cuda.so which has a libtorch C++ ABI mismatch on this base
84
+ # image ("undefined symbol: _ZN3c107WarningC1E..."). Since training only needs
85
+ # Mamba3 (grafted from main), we skip all compiled-CUDA imports.
86
+ COPY mamba_ssm_init.py /opt/conda/lib/python3.11/site-packages/mamba_ssm/__init__.py
87
+
88
+ # Structural check (no triton init β€” triton has no GPU on the builder)
89
+ RUN SITE=/opt/conda/lib/python3.11/site-packages/mamba_ssm && \
90
+ test -f "$SITE/modules/mamba3.py" && \
91
+ test -f "$SITE/ops/triton/mamba3/mamba3_siso_combined.py" && \
92
+ test -s "$SITE/__init__.py" && \
93
+ echo "mamba3 graft + __init__ override verified"
94
+
95
+ # Optional tilelang for MIMO path β€” pure-python, cheap; SISO Mamba3 works without.
96
+ RUN pip install tilelang || echo "[dockerfile] tilelang optional install failed β€” continuing"
97
+
98
+ # Triton version decision: FORCE 3.4.0 β€” first line with both mamba3
99
+ # APIs (set_allocator + tl.make_tensor_descriptor) while avoiding the 3.5.x
100
+ # driver-discovery regression seen on HF A10G (`0 active drivers` despite
101
+ # torch.cuda being available). torch 2.5's _inductor expects older Triton
102
+ # internals, but mamba_ssm/__init__.py shims AttrsDescriptor as a stub
103
+ # before any torch._inductor import path runs, so the incompatibility is
104
+ # neutralized. Build-time assert verifies mamba3's two required APIs.
105
+ RUN pip install --force-reinstall --no-deps 'triton==3.4.0' && \
106
+ python -c "import triton; from triton import language as tl; \
107
+ assert hasattr(triton, 'set_allocator'), 'missing triton.set_allocator'; \
108
+ assert hasattr(tl, 'make_tensor_descriptor'), 'missing tl.make_tensor_descriptor'; \
109
+ print(f'triton={triton.__version__} set_allocator+make_tensor_descriptor OK, AttrsDescriptor shimmed in mamba_ssm/__init__.py')"
110
+
111
+ WORKDIR /workspace
112
+ COPY overlay /workspace/feather
113
+ COPY entrypoint.py /app/entrypoint.py
114
+ WORKDIR /workspace/feather
115
+
116
+ RUN python -m py_compile hydra/training.py prepare.py train.py && \
117
+ bash -n scripts/run_domain_expanded_pretrain.sh
118
+
119
+ RUN export LD_LIBRARY_PATH=/usr/local/cuda/lib64:${LD_LIBRARY_PATH} && \
120
+ echo "building htm_rust GPU kernels for HTM_CUDA_ARCH=${HTM_CUDA_ARCH} TORCH_CUDA_ARCH_LIST=${TORCH_CUDA_ARCH_LIST}" && \
121
+ maturin build --release --features gpu --manifest-path htm_rust/Cargo.toml && \
122
+ pip install htm_rust/target/wheels/htm_rust-*.whl
123
+
124
+ CMD ["python", "/app/entrypoint.py"]
entrypoint.py CHANGED
@@ -1,260 +1,278 @@
1
- #!/usr/bin/env python3
2
- from __future__ import annotations
3
-
4
- import json
5
- import os
6
- import subprocess
7
- import sys
8
- import time
9
- from http.server import BaseHTTPRequestHandler, HTTPServer
10
- from pathlib import Path
11
- from threading import Thread
12
-
13
-
14
- def _prepend_library_path(*paths: str) -> None:
15
- """Expose injected NVIDIA driver libraries before torch/triton imports."""
16
- existing = [p for p in os.environ.get('LD_LIBRARY_PATH', '').split(':') if p]
17
- merged = []
18
- for p in paths:
19
- if p and p not in merged:
20
- merged.append(p)
21
- for p in existing:
22
- if p not in merged:
23
- merged.append(p)
24
- os.environ['LD_LIBRARY_PATH'] = ':'.join(merged)
25
-
26
-
27
- _prepend_library_path(
28
- # HF Jobs injects the host driver under /usr/local/nvidia. Prefer that
29
- # over CUDA toolkit/compat libcuda stubs; using /usr/local/cuda/compat here
30
- # made A10G PyTorch report Error 803 despite nvidia-smi working.
31
- '/usr/local/nvidia/lib64',
32
- '/usr/local/nvidia/lib',
33
- '/usr/lib/x86_64-linux-gnu',
34
- )
35
-
36
-
37
- # =============================================================================
38
- # EARLY CUDA FABRIC MANAGER KICK (before ANY CUDA-touching imports)
39
- # =============================================================================
40
- # On HF GPU hosts, cudaGetDeviceCount can transiently return not-ready errors
41
- # on first use. H200 fabric-manager is the worst case; A10G is usually ready
42
- # immediately, but the same early kick keeps the runtime deterministic.
43
- # synchronizes with the container's first driver call. Once any NVML/CUDA
44
- # call succeeds once (even just nvidia-smi), the fabric is up for the rest
45
- # of the container lifetime.
46
- #
47
- # Our previous approach (wait in a subprocess before training) didn't work
48
- # because the "initialization failed" state persisted across calls in the
49
- # same container. The real fix: kick the driver exactly once with
50
- # nvidia-smi, which is what successfully-working baseline containers do
51
- # implicitly via their first torch.cuda call.
52
- #
53
- # Must happen BEFORE `import torch` (because any import that eagerly calls
54
- # cudaGetDeviceCount will cache the Error 802 state).
55
- def _early_cuda_kick() -> None:
56
- deadline = time.time() + 120.0
57
- attempt = 0
58
- while time.time() < deadline:
59
- attempt += 1
60
- r = subprocess.run(['nvidia-smi'], capture_output=True, text=True, timeout=30)
61
- if r.returncode == 0:
62
- gpu_line = next((ln.strip() for ln in (r.stdout or '').splitlines() if any(g in ln for g in ('A10', 'A100', 'H100', 'H200', 'RTX'))), 'gpu=unknown')
63
- print(f'[boot] nvidia-smi OK on attempt {attempt}: {gpu_line}', flush=True)
64
- break
65
- print(f'[boot] nvidia-smi attempt {attempt} rc={r.returncode} stderr={(r.stderr or "")[:120]}',
66
- flush=True)
67
- time.sleep(2)
68
- # After nvidia-smi, probe torch in a subprocess so any latent error state
69
- # doesn't leak into the main process's CUDA context.
70
- probe = 'import torch; import sys; sys.exit(0 if torch.cuda.is_available() else 1)'
71
- torch_deadline = time.time() + 120.0
72
- t_attempt = 0
73
- while time.time() < torch_deadline:
74
- t_attempt += 1
75
- r = subprocess.run([sys.executable, '-c', probe], capture_output=True, text=True, timeout=60)
76
- if r.returncode == 0:
77
- print(f'[boot] torch.cuda.is_available() = True after {t_attempt} probe(s)', flush=True)
78
- return
79
- if t_attempt == 1:
80
- print(f'[boot] torch cuda probe {t_attempt}: {(r.stderr or "")[:200]}', flush=True)
81
- time.sleep(2)
82
- print('[boot] WARNING: torch.cuda never became ready β€” training will likely fail', flush=True)
83
-
84
-
85
- _early_cuda_kick()
86
-
87
- # Hydrate triton compilation cache from HF Hub before any triton/mamba_ssm import.
88
- # triton_cache_setup.py is copied next to this file by the job bash command.
89
- try:
90
- import triton_cache_setup as _tcs
91
- _tcs.setup()
92
- except ImportError:
93
- print('[boot] triton_cache_setup not found; skipping cache hydrate', flush=True)
94
-
95
- from huggingface_hub import HfApi # noqa: E402 (import after cuda kick)
96
-
97
- REPO_ROOT = Path('/workspace/feather')
98
- CACHE_ROOT = Path.home() / '.cache' / 'autoresearch'
99
- LOG_FILE = REPO_ROOT / 'run_domain_expanded.log'
100
- JOB_ID = os.environ.get('JOB_ID', 'local-job')
101
- OUTPUT_REPO = os.environ.get('HF_REPO_ID', 'icarus112/feather-pretrain-checkpoints')
102
- TOKEN = os.environ.get('HF_TOKEN')
103
- RUNTIME_MODE = os.environ.get('FEATHER_RUNTIME_MODE', 'space')
104
- APP_PORT = int(os.environ.get('PORT', '7860'))
105
-
106
-
107
- class _HealthHandler(BaseHTTPRequestHandler):
108
- def do_GET(self):
109
- if self.path in ('/', '/health', '/healthz', '/ready'):
110
- payload = {
111
- 'status': 'ok',
112
- 'mode': RUNTIME_MODE,
113
- 'job_id': JOB_ID,
114
- }
115
- body = json.dumps(payload).encode('utf-8')
116
- self.send_response(200)
117
- self.send_header('Content-Type', 'application/json')
118
- self.send_header('Content-Length', str(len(body)))
119
- self.end_headers()
120
- self.wfile.write(body)
121
- return
122
- self.send_response(404)
123
- self.end_headers()
124
-
125
- def log_message(self, format, *args):
126
- return
127
-
128
-
129
- def _start_health_server() -> HTTPServer:
130
- server = HTTPServer(('0.0.0.0', APP_PORT), _HealthHandler)
131
- thread = Thread(target=server.serve_forever, daemon=True)
132
- thread.start()
133
- print(f'[space] health server listening on 0.0.0.0:{APP_PORT}', flush=True)
134
- return server
135
-
136
-
137
- def upload_artifact(api: HfApi, path: Path, dest: str) -> None:
138
- if not path.exists():
139
- print(f'[upload] skip missing {path}', flush=True)
140
- return
141
- api.upload_file(
142
- path_or_fileobj=str(path),
143
- path_in_repo=dest,
144
- repo_id=OUTPUT_REPO,
145
- repo_type='model',
146
- )
147
- print(f'[upload] uploaded {path} -> {OUTPUT_REPO}/{dest}', flush=True)
148
-
149
-
150
- def _wait_for_cuda_ready(timeout_s: int = 120) -> None:
151
- """Block until CUDA is fully initialized or timeout.
152
-
153
- On H200 hosts with NVSwitch/fabric manager, nvidia driver setup can race
154
- with container start. cudaGetDeviceCount can return CUDA_ERROR_SYSTEM_NOT_READY
155
- (error 802) for the first few seconds, and any import that triggers
156
- @triton.autotune (e.g. mamba_ssm, torch amp utilities) blows up with
157
- "0 active drivers" if it happens during that window.
158
-
159
- We pre-init CUDA in a throwaway Python subprocess (so any error state does
160
- not leak into the main training process) and retry until torch.cuda
161
- reports ready.
162
- """
163
- import time as _t
164
- probe = (
165
- "import torch; "
166
- "import sys; "
167
- "avail = torch.cuda.is_available(); "
168
- "count = torch.cuda.device_count() if avail else 0; "
169
- "torch.empty(1, device='cuda') if (avail and count > 0) else None; "
170
- "from triton.runtime import driver; "
171
- "driver.active.get_current_device(); "
172
- "sys.exit(0 if (avail and count > 0) else 1)"
173
- )
174
- deadline = _t.time() + timeout_s
175
- attempt = 0
176
- while _t.time() < deadline:
177
- attempt += 1
178
- r = subprocess.run(['python', '-c', probe], capture_output=True, text=True)
179
- if r.returncode == 0:
180
- print(f'[job] CUDA/Triton ready after {attempt} probe(s)', flush=True)
181
- return
182
- if attempt == 1:
183
- print(f'[job] CUDA not ready yet (will retry up to {timeout_s}s): {r.stderr.strip()[:200]}', flush=True)
184
- _t.sleep(2)
185
- print(f'[job] CUDA still not ready after {timeout_s}s β€” continuing anyway (training will likely fail)', flush=True)
186
-
187
-
188
- def run_job_mode() -> int:
189
- os.chdir(REPO_ROOT)
190
- os.environ.setdefault('HYDRA_TIME_BUDGET', '43200')
191
- os.environ.setdefault('HYDRA_TARGET_SHARDS', '2048')
192
- os.environ.setdefault('HYDRA_DOWNLOAD_WORKERS', '16')
193
- os.environ.setdefault('HYDRA_CKPT_INTERVAL', '1000')
194
- os.environ.setdefault('HYDRA_RESUME_CKPT', str(CACHE_ROOT / 'latest.pt'))
195
- os.environ.setdefault('FEATHER_GPU_PROFILE', 'a10g-large')
196
- os.environ.setdefault('HTM_CUDA_ARCH', 'sm_86')
197
- os.environ.setdefault('TORCH_CUDA_ARCH_LIST', '8.6')
198
- os.environ.setdefault('TRITON_CACHE_DIR', f"/workspace/triton_cache/{os.environ['FEATHER_GPU_PROFILE']}")
199
- os.environ.setdefault('TRITON_CACHE_REPO', f"icarus112/feather-triton-cache-{os.environ['FEATHER_GPU_PROFILE']}")
200
- print(f"[job] gpu_profile={os.environ['FEATHER_GPU_PROFILE']} htm_cuda_arch={os.environ['HTM_CUDA_ARCH']} torch_cuda_arch={os.environ['TORCH_CUDA_ARCH_LIST']}", flush=True)
201
-
202
- # CUDA readiness was kicked at module import via _early_cuda_kick. Keep
203
- # the wait as a second safety net β€” no-op if CUDA already ready.
204
- _wait_for_cuda_ready()
205
-
206
- cmd = [
207
- 'bash',
208
- './scripts/run_domain_expanded_pretrain.sh',
209
- '--target-shards', os.environ['HYDRA_TARGET_SHARDS'],
210
- '--download-workers', os.environ['HYDRA_DOWNLOAD_WORKERS'],
211
- ]
212
- print('[job] starting Feather domain-expanded pretrain', flush=True)
213
- print(f'[job] command={cmd}', flush=True)
214
- proc = subprocess.run(cmd, check=False)
215
-
216
- # Push triton compilation cache back to HF Hub for next run.
217
- try:
218
- import triton_cache_setup as _tcs
219
- _tcs.teardown()
220
- except Exception as _tcs_err:
221
- print(f'[triton_cache] teardown error (non-fatal): {_tcs_err}', flush=True)
222
-
223
- if TOKEN:
224
- api = HfApi(token=TOKEN)
225
- try:
226
- api.create_repo(repo_id=OUTPUT_REPO, repo_type='model', private=True, exist_ok=True)
227
- except Exception as e:
228
- print(f'[upload] create_repo warning: {type(e).__name__}: {e}', flush=True)
229
- prefix = f'jobs/{JOB_ID}'
230
- try:
231
- upload_artifact(api, LOG_FILE, f'{prefix}/run_domain_expanded.log')
232
- upload_artifact(api, CACHE_ROOT / 'latest.pt', f'{prefix}/latest.pt')
233
- upload_artifact(api, CACHE_ROOT / 'pretrain_final.pt', f'{prefix}/pretrain_final.pt')
234
- except Exception as e:
235
- print(f'[upload] upload warning: {type(e).__name__}: {e}', flush=True)
236
- else:
237
- print('[upload] HF_TOKEN not set; skipping artifact upload', flush=True)
238
-
239
- return proc.returncode
240
-
241
-
242
- def run_space_mode() -> int:
243
- server = _start_health_server()
244
- print('[space] Feather runtime image ready', flush=True)
245
- try:
246
- while True:
247
- time.sleep(3600)
248
- finally:
249
- server.shutdown()
250
- server.server_close()
251
-
252
-
253
- def main() -> int:
254
- if RUNTIME_MODE == 'job':
255
- return run_job_mode()
256
- return run_space_mode()
257
-
258
-
259
- if __name__ == '__main__':
260
- raise SystemExit(main())
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import os
6
+ import subprocess
7
+ import sys
8
+ import time
9
+ from http.server import BaseHTTPRequestHandler, HTTPServer
10
+ from pathlib import Path
11
+ from threading import Thread
12
+
13
+
14
+ def _prepend_library_path(*paths: str) -> None:
15
+ """Expose injected NVIDIA driver libraries before torch/triton imports."""
16
+ existing = [p for p in os.environ.get('LD_LIBRARY_PATH', '').split(':') if p]
17
+ merged = []
18
+ for p in paths:
19
+ if p and p not in merged:
20
+ merged.append(p)
21
+ for p in existing:
22
+ if p not in merged:
23
+ merged.append(p)
24
+ os.environ['LD_LIBRARY_PATH'] = ':'.join(merged)
25
+
26
+
27
+ _prepend_library_path(
28
+ # HF Jobs injects the host driver under /usr/local/nvidia. Prefer that
29
+ # over CUDA toolkit/compat libcuda stubs; using /usr/local/cuda/compat here
30
+ # made A10G PyTorch report Error 803 despite nvidia-smi working.
31
+ '/usr/local/nvidia/lib64',
32
+ '/usr/local/nvidia/lib',
33
+ '/usr/lib/x86_64-linux-gnu',
34
+ )
35
+
36
+
37
+ # =============================================================================
38
+ # EARLY CUDA FABRIC MANAGER KICK (before ANY CUDA-touching imports)
39
+ # =============================================================================
40
+ # On HF GPU hosts, cudaGetDeviceCount can transiently return not-ready errors
41
+ # on first use. H200 fabric-manager is the worst case; A10G is usually ready
42
+ # immediately, but the same early kick keeps the runtime deterministic.
43
+ # synchronizes with the container's first driver call. Once any NVML/CUDA
44
+ # call succeeds once (even just nvidia-smi), the fabric is up for the rest
45
+ # of the container lifetime.
46
+ #
47
+ # Our previous approach (wait in a subprocess before training) didn't work
48
+ # because the "initialization failed" state persisted across calls in the
49
+ # same container. The real fix: kick the driver exactly once with
50
+ # nvidia-smi, which is what successfully-working baseline containers do
51
+ # implicitly via their first torch.cuda call.
52
+ #
53
+ # Must happen BEFORE `import torch` (because any import that eagerly calls
54
+ # cudaGetDeviceCount will cache the Error 802 state).
55
+ def _early_cuda_kick() -> None:
56
+ deadline = time.time() + 120.0
57
+ attempt = 0
58
+ while time.time() < deadline:
59
+ attempt += 1
60
+ r = subprocess.run(['nvidia-smi'], capture_output=True, text=True, timeout=30)
61
+ if r.returncode == 0:
62
+ gpu_line = next((ln.strip() for ln in (r.stdout or '').splitlines() if any(g in ln for g in ('A10', 'A100', 'H100', 'H200', 'RTX'))), 'gpu=unknown')
63
+ print(f'[boot] nvidia-smi OK on attempt {attempt}: {gpu_line}', flush=True)
64
+ break
65
+ print(f'[boot] nvidia-smi attempt {attempt} rc={r.returncode} stderr={(r.stderr or "")[:120]}',
66
+ flush=True)
67
+ time.sleep(2)
68
+ # After nvidia-smi, probe torch in a subprocess so any latent error state
69
+ # doesn't leak into the main process's CUDA context.
70
+ probe = 'import torch; import sys; sys.exit(0 if torch.cuda.is_available() else 1)'
71
+ torch_deadline = time.time() + 120.0
72
+ t_attempt = 0
73
+ while time.time() < torch_deadline:
74
+ t_attempt += 1
75
+ r = subprocess.run([sys.executable, '-c', probe], capture_output=True, text=True, timeout=60)
76
+ if r.returncode == 0:
77
+ print(f'[boot] torch.cuda.is_available() = True after {t_attempt} probe(s)', flush=True)
78
+ return
79
+ if t_attempt == 1:
80
+ print(f'[boot] torch cuda probe {t_attempt}: {(r.stderr or "")[:200]}', flush=True)
81
+ time.sleep(2)
82
+ print('[boot] WARNING: torch.cuda never became ready β€” training will likely fail', flush=True)
83
+
84
+
85
+ _early_cuda_kick()
86
+
87
+ # Hydrate triton compilation cache from HF Hub before any triton/mamba_ssm import.
88
+ # triton_cache_setup.py is copied next to this file by the job bash command.
89
+ try:
90
+ import triton_cache_setup as _tcs
91
+ _tcs.setup()
92
+ except ImportError:
93
+ print('[boot] triton_cache_setup not found; skipping cache hydrate', flush=True)
94
+
95
+ from huggingface_hub import HfApi # noqa: E402 (import after cuda kick)
96
+
97
+ REPO_ROOT = Path('/workspace/feather')
98
+ CACHE_ROOT = Path.home() / '.cache' / 'autoresearch'
99
+ LOG_FILE = REPO_ROOT / 'run_domain_expanded.log'
100
+ JOB_ID = os.environ.get('JOB_ID', 'local-job')
101
+ OUTPUT_REPO = os.environ.get('HF_REPO_ID', 'icarus112/feather-pretrain-checkpoints')
102
+ TOKEN = os.environ.get('HF_TOKEN')
103
+ RUNTIME_MODE = os.environ.get('FEATHER_RUNTIME_MODE', 'space')
104
+ APP_PORT = int(os.environ.get('PORT', '7860'))
105
+
106
+
107
+ class _HealthHandler(BaseHTTPRequestHandler):
108
+ def do_GET(self):
109
+ if self.path in ('/', '/health', '/healthz', '/ready'):
110
+ payload = {
111
+ 'status': 'ok',
112
+ 'mode': RUNTIME_MODE,
113
+ 'job_id': JOB_ID,
114
+ }
115
+ body = json.dumps(payload).encode('utf-8')
116
+ self.send_response(200)
117
+ self.send_header('Content-Type', 'application/json')
118
+ self.send_header('Content-Length', str(len(body)))
119
+ self.end_headers()
120
+ self.wfile.write(body)
121
+ return
122
+ self.send_response(404)
123
+ self.end_headers()
124
+
125
+ def log_message(self, format, *args):
126
+ return
127
+
128
+
129
+ def _start_health_server() -> HTTPServer:
130
+ server = HTTPServer(('0.0.0.0', APP_PORT), _HealthHandler)
131
+ thread = Thread(target=server.serve_forever, daemon=True)
132
+ thread.start()
133
+ print(f'[space] health server listening on 0.0.0.0:{APP_PORT}', flush=True)
134
+ return server
135
+
136
+
137
+ def upload_artifact(api: HfApi, path: Path, dest: str) -> None:
138
+ if not path.exists():
139
+ print(f'[upload] skip missing {path}', flush=True)
140
+ return
141
+ api.upload_file(
142
+ path_or_fileobj=str(path),
143
+ path_in_repo=dest,
144
+ repo_id=OUTPUT_REPO,
145
+ repo_type='model',
146
+ )
147
+ print(f'[upload] uploaded {path} -> {OUTPUT_REPO}/{dest}', flush=True)
148
+
149
+
150
+ def _wait_for_cuda_ready(timeout_s: int = 120) -> None:
151
+ """Block until CUDA is fully initialized or timeout.
152
+
153
+ On H200 hosts with NVSwitch/fabric manager, nvidia driver setup can race
154
+ with container start. cudaGetDeviceCount can return CUDA_ERROR_SYSTEM_NOT_READY
155
+ (error 802) for the first few seconds, and any import that triggers
156
+ @triton.autotune (e.g. mamba_ssm, torch amp utilities) blows up with
157
+ "0 active drivers" if it happens during that window.
158
+
159
+ We pre-init CUDA in a throwaway Python subprocess (so any error state does
160
+ not leak into the main training process) and retry until torch.cuda
161
+ reports ready.
162
+ """
163
+ import time as _t
164
+ probe = (
165
+ "import torch; "
166
+ "import sys; "
167
+ "avail = torch.cuda.is_available(); "
168
+ "count = torch.cuda.device_count() if avail else 0; "
169
+ "torch.empty(1, device='cuda') if (avail and count > 0) else None; "
170
+ "from triton.runtime import driver; "
171
+ "driver.active.get_current_device(); "
172
+ "sys.exit(0 if (avail and count > 0) else 1)"
173
+ )
174
+ deadline = _t.time() + timeout_s
175
+ attempt = 0
176
+ while _t.time() < deadline:
177
+ attempt += 1
178
+ r = subprocess.run(['python', '-c', probe], capture_output=True, text=True)
179
+ if r.returncode == 0:
180
+ print(f'[job] CUDA/Triton ready after {attempt} probe(s)', flush=True)
181
+ return
182
+ if attempt == 1:
183
+ print(f'[job] CUDA not ready yet (will retry up to {timeout_s}s): {r.stderr.strip()[:200]}', flush=True)
184
+ _t.sleep(2)
185
+ print(f'[job] CUDA still not ready after {timeout_s}s β€” continuing anyway (training will likely fail)', flush=True)
186
+
187
+
188
+ def run_job_mode() -> int:
189
+ os.chdir(REPO_ROOT)
190
+ os.environ.setdefault('HYDRA_TIME_BUDGET', '43200')
191
+ os.environ.setdefault('HYDRA_TARGET_SHARDS', '2048')
192
+ os.environ.setdefault('HYDRA_DOWNLOAD_WORKERS', '16')
193
+ os.environ.setdefault('HYDRA_CKPT_INTERVAL', '1000')
194
+ os.environ.setdefault('HYDRA_RESUME_CKPT', str(CACHE_ROOT / 'latest.pt'))
195
+ os.environ.setdefault('FEATHER_GPU_PROFILE', 'a10g-large')
196
+ os.environ.setdefault('HTM_CUDA_ARCH', 'sm_86')
197
+ os.environ.setdefault('TORCH_CUDA_ARCH_LIST', '8.6')
198
+ os.environ.setdefault('TRITON_CACHE_DIR', f"/workspace/triton_cache/{os.environ['FEATHER_GPU_PROFILE']}")
199
+ os.environ.setdefault('TRITON_CACHE_REPO', f"icarus112/feather-triton-cache-{os.environ['FEATHER_GPU_PROFILE']}")
200
+ print(f"[job] gpu_profile={os.environ['FEATHER_GPU_PROFILE']} htm_cuda_arch={os.environ['HTM_CUDA_ARCH']} torch_cuda_arch={os.environ['TORCH_CUDA_ARCH_LIST']}", flush=True)
201
+
202
+ # CUDA readiness was kicked at module import via _early_cuda_kick. Keep
203
+ # the wait as a second safety net β€” no-op if CUDA already ready.
204
+ _wait_for_cuda_ready()
205
+
206
+ # ---------- FIX 1: retina.npz auto-generation ----------
207
+ # SemanticFoldingSDR requires retina.npz at model init. Generate it from
208
+ # the shard data (if not already cached) before importing the model.
209
+ retina_path = CACHE_ROOT / 'retina.npz'
210
+ if not retina_path.exists():
211
+ print(f'[job] retina.npz missing at {retina_path} β€” generating from shards...', flush=True)
212
+ r = subprocess.run(
213
+ [sys.executable, '-u', 'subsystems/sdr_retina.py'],
214
+ check=False, timeout=1800,
215
+ )
216
+ if r.returncode != 0 or not retina_path.exists():
217
+ print(f'[job] FATAL: retina generation failed (rc={r.returncode})', flush=True)
218
+ return 1
219
+ print(f'[job] retina.npz generated ({retina_path.stat().st_size} bytes)', flush=True)
220
+ else:
221
+ print(f'[job] retina.npz already cached', flush=True)
222
+ # --------------------------------------------------------
223
+
224
+ # ---------- FIX 2: direct train.py (no bash wrapper) ----------
225
+ # run_domain_expanded_pretrain.sh drops HYDRA_* env vars at the bash→py
226
+ # boundary. Bypass it: stream data at train-time via Nemotron path, call
227
+ # train.py directly with all env vars intact.
228
+ print('[job] starting Feather training (direct train.py, streaming data)...', flush=True)
229
+ train_cmd = [sys.executable, '-u', 'train.py']
230
+ print(f'[job] command={train_cmd}', flush=True)
231
+ proc = subprocess.run(train_cmd, check=False)
232
+ # ------------------------------------------------------------
233
+
234
+ # Push triton compilation cache back to HF Hub for next run.
235
+ try:
236
+ import triton_cache_setup as _tcs
237
+ _tcs.teardown()
238
+ except Exception as _tcs_err:
239
+ print(f'[triton_cache] teardown error (non-fatal): {_tcs_err}', flush=True)
240
+
241
+ if TOKEN:
242
+ api = HfApi(token=TOKEN)
243
+ try:
244
+ api.create_repo(repo_id=OUTPUT_REPO, repo_type='model', private=True, exist_ok=True)
245
+ except Exception as e:
246
+ print(f'[upload] create_repo warning: {type(e).__name__}: {e}', flush=True)
247
+ prefix = f'jobs/{JOB_ID}'
248
+ try:
249
+ upload_artifact(api, LOG_FILE, f'{prefix}/run_domain_expanded.log')
250
+ upload_artifact(api, CACHE_ROOT / 'latest.pt', f'{prefix}/latest.pt')
251
+ upload_artifact(api, CACHE_ROOT / 'pretrain_final.pt', f'{prefix}/pretrain_final.pt')
252
+ except Exception as e:
253
+ print(f'[upload] upload warning: {type(e).__name__}: {e}', flush=True)
254
+ else:
255
+ print('[upload] HF_TOKEN not set; skipping artifact upload', flush=True)
256
+
257
+ return proc.returncode
258
+
259
+
260
+ def run_space_mode() -> int:
261
+ server = _start_health_server()
262
+ print('[space] Feather runtime image ready', flush=True)
263
+ try:
264
+ while True:
265
+ time.sleep(3600)
266
+ finally:
267
+ server.shutdown()
268
+ server.server_close()
269
+
270
+
271
+ def main() -> int:
272
+ if RUNTIME_MODE == 'job':
273
+ return run_job_mode()
274
+ return run_space_mode()
275
+
276
+
277
+ if __name__ == '__main__':
278
+ raise SystemExit(main())
mamba_ssm_init.py CHANGED
@@ -1,69 +1,69 @@
1
- # mamba_ssm package init β€” minimal override to avoid broken selective_scan_cuda.so
2
- # ABI mismatch with the base image's libtorch.
3
- #
4
- # The upstream __init__.py eagerly imports selective_scan_cuda which fails on
5
- # pytorch/pytorch:2.6.0-cuda12.4-cudnn9-devel (undefined c10::Warning ctor
6
- # symbol). We only need Mamba3 (grafted from main, pure-Triton), so we skip
7
- # all compiled-CUDA imports here and let Mamba3 load directly.
8
-
9
- __version__ = "2.3.1+feather-graft"
10
-
11
- # selective_scan_fn / mamba_inner_fn are shimmed to None β€” they are NOT used
12
- # by the Feather training path (which is Mamba3-only). If any import path
13
- # hits this, it will get a clear AttributeError instead of an obscure ImportError.
14
- selective_scan_fn = None
15
- mamba_inner_fn = None
16
-
17
- # --- triton API compatibility shims -----------------------------------------
18
- # Version matrix is hostile: torch 2.6 pins triton==3.2.0 because torch._inductor
19
- # imports AttrsDescriptor from triton.compiler.compiler β€” removed in triton 3.4+.
20
- # Grafted Mamba3 (from mamba-ssm main) needs triton.set_allocator and
21
- # tl.make_tensor_descriptor, both added in triton 3.3+. No single triton version
22
- # satisfies both simultaneously. We run on triton 3.5.1 (latest, has both mamba3
23
- # APIs) and shim AttrsDescriptor as a stub dataclass for torch._inductor. The
24
- # stub is never actually invoked at runtime because the codebase does not use
25
- # torch.compile β€” but importing torch._inductor.* still requires the symbol to
26
- # exist at module load time.
27
- import triton as _triton # noqa: E402
28
- if not hasattr(_triton, "set_allocator"):
29
- def _noop_set_allocator(_fn): # pragma: no cover
30
- return None
31
- _triton.set_allocator = _noop_set_allocator
32
-
33
- import triton.compiler.compiler as _tcc # noqa: E402
34
- if not hasattr(_tcc, "AttrsDescriptor"):
35
- class _AttrsDescriptorShim:
36
- """Stub for torch._inductor compatibility on triton >= 3.4.
37
- torch._inductor.runtime.hints imports this at module load but the
38
- constructor is only called inside torch.compile paths. Accept any
39
- args/kwargs so the import itself succeeds."""
40
- def __init__(self, *args, **kwargs):
41
- self.args = args
42
- self.kwargs = kwargs
43
-
44
- @classmethod
45
- def from_hints(cls, *args, **kwargs):
46
- return cls(*args, **kwargs)
47
-
48
- _tcc.AttrsDescriptor = _AttrsDescriptorShim
49
-
50
- # triton_key: removed in triton 3.5, used by torch._inductor.codecache for
51
- # FxGraphCache key derivation. Return a stable string so caching still works.
52
- if not hasattr(_tcc, "triton_key"):
53
- def _triton_key_shim():
54
- import triton as _t
55
- return f"triton-{_t.__version__}-shim"
56
- _tcc.triton_key = _triton_key_shim
57
-
58
- # Suppress torch.compile/_dynamo errors globally β€” we don't rely on torch.compile
59
- # for performance in this codebase (Muon + mamba3 CUDA kernels already fused),
60
- # so fall back to eager on any dynamo failure rather than crashing. This is
61
- # defense-in-depth against further triton API drift.
62
- try:
63
- import torch._dynamo # noqa: F401 β€” triggers dynamo module init
64
- torch._dynamo.config.suppress_errors = True
65
- except Exception: # pragma: no cover
66
- pass
67
-
68
- # Expose Mamba3 at top level to match `from mamba_ssm import Mamba3`.
69
- from mamba_ssm.modules.mamba3 import Mamba3 # noqa: E402
 
1
+ # mamba_ssm package init β€” minimal override to avoid broken selective_scan_cuda.so
2
+ # ABI mismatch with the base image's libtorch.
3
+ #
4
+ # The upstream __init__.py eagerly imports selective_scan_cuda which fails on
5
+ # pytorch/pytorch:2.6.0-cuda12.4-cudnn9-devel (undefined c10::Warning ctor
6
+ # symbol). We only need Mamba3 (grafted from main, pure-Triton), so we skip
7
+ # all compiled-CUDA imports here and let Mamba3 load directly.
8
+
9
+ __version__ = "2.3.1+feather-graft"
10
+
11
+ # selective_scan_fn / mamba_inner_fn are shimmed to None β€” they are NOT used
12
+ # by the Feather training path (which is Mamba3-only). If any import path
13
+ # hits this, it will get a clear AttributeError instead of an obscure ImportError.
14
+ selective_scan_fn = None
15
+ mamba_inner_fn = None
16
+
17
+ # --- triton API compatibility shims -----------------------------------------
18
+ # Version matrix is hostile: torch 2.6 pins triton==3.2.0 because torch._inductor
19
+ # imports AttrsDescriptor from triton.compiler.compiler β€” removed in triton 3.4+.
20
+ # Grafted Mamba3 (from mamba-ssm main) needs triton.set_allocator and
21
+ # tl.make_tensor_descriptor, both added in triton 3.3+. No single triton version
22
+ # satisfies both simultaneously. We run on triton 3.5.1 (latest, has both mamba3
23
+ # APIs) and shim AttrsDescriptor as a stub dataclass for torch._inductor. The
24
+ # stub is never actually invoked at runtime because the codebase does not use
25
+ # torch.compile β€” but importing torch._inductor.* still requires the symbol to
26
+ # exist at module load time.
27
+ import triton as _triton # noqa: E402
28
+ if not hasattr(_triton, "set_allocator"):
29
+ def _noop_set_allocator(_fn): # pragma: no cover
30
+ return None
31
+ _triton.set_allocator = _noop_set_allocator
32
+
33
+ import triton.compiler.compiler as _tcc # noqa: E402
34
+ if not hasattr(_tcc, "AttrsDescriptor"):
35
+ class _AttrsDescriptorShim:
36
+ """Stub for torch._inductor compatibility on triton >= 3.4.
37
+ torch._inductor.runtime.hints imports this at module load but the
38
+ constructor is only called inside torch.compile paths. Accept any
39
+ args/kwargs so the import itself succeeds."""
40
+ def __init__(self, *args, **kwargs):
41
+ self.args = args
42
+ self.kwargs = kwargs
43
+
44
+ @classmethod
45
+ def from_hints(cls, *args, **kwargs):
46
+ return cls(*args, **kwargs)
47
+
48
+ _tcc.AttrsDescriptor = _AttrsDescriptorShim
49
+
50
+ # triton_key: removed in triton 3.5, used by torch._inductor.codecache for
51
+ # FxGraphCache key derivation. Return a stable string so caching still works.
52
+ if not hasattr(_tcc, "triton_key"):
53
+ def _triton_key_shim():
54
+ import triton as _t
55
+ return f"triton-{_t.__version__}-shim"
56
+ _tcc.triton_key = _triton_key_shim
57
+
58
+ # Suppress torch.compile/_dynamo errors globally β€” we don't rely on torch.compile
59
+ # for performance in this codebase (Muon + mamba3 CUDA kernels already fused),
60
+ # so fall back to eager on any dynamo failure rather than crashing. This is
61
+ # defense-in-depth against further triton API drift.
62
+ try:
63
+ import torch._dynamo # noqa: F401 β€” triggers dynamo module init
64
+ torch._dynamo.config.suppress_errors = True
65
+ except Exception: # pragma: no cover
66
+ pass
67
+
68
+ # Expose Mamba3 at top level to match `from mamba_ssm import Mamba3`.
69
+ from mamba_ssm.modules.mamba3 import Mamba3 # noqa: E402
overlay/.dockerignore CHANGED
@@ -1,20 +1,20 @@
1
- .git
2
- .github
3
- .venv
4
- .remember
5
- .letta
6
- .claude
7
- __pycache__
8
- *.pyc
9
- *.pyo
10
- *.pyd
11
- *.log
12
- run_*.log
13
- run*.log
14
- *.txt
15
- WORKER_COMPLETE
16
- autoresearch_loop.log
17
- data/
18
- state_store/
19
- htm_rust/target/
20
- hydra-core/target/
 
1
+ .git
2
+ .github
3
+ .venv
4
+ .remember
5
+ .letta
6
+ .claude
7
+ __pycache__
8
+ *.pyc
9
+ *.pyo
10
+ *.pyd
11
+ *.log
12
+ run_*.log
13
+ run*.log
14
+ *.txt
15
+ WORKER_COMPLETE
16
+ autoresearch_loop.log
17
+ data/
18
+ state_store/
19
+ htm_rust/target/
20
+ hydra-core/target/
overlay/configs/__init__.py CHANGED
@@ -1,5 +1,5 @@
1
- from configs.hardware_config import HardwareConfig
2
- from configs.harness_config import HarnessConfig
3
- from configs.model_config import PostSemClawConfig
4
-
5
- __all__ = ["PostSemClawConfig", "HarnessConfig", "HardwareConfig"]
 
1
+ from configs.hardware_config import HardwareConfig
2
+ from configs.harness_config import HarnessConfig
3
+ from configs.model_config import PostSemClawConfig
4
+
5
+ __all__ = ["PostSemClawConfig", "HarnessConfig", "HardwareConfig"]
overlay/configs/hardware_config.py CHANGED
@@ -1,104 +1,104 @@
1
- """Hardware detection and memory budget configuration."""
2
- from __future__ import annotations
3
-
4
- import torch
5
- from pydantic import BaseModel, Field
6
-
7
-
8
- class HardwareConfig(BaseModel):
9
- """Auto-detected hardware configuration with memory budgets."""
10
-
11
- gpu_name: str = Field(default="unknown", description="GPU device name")
12
- gpu_memory_mb: int = Field(default=0, description="Total GPU memory in MB")
13
- gpu_vram_mb: int = Field(default=0, description="Alias for gpu_memory_mb (legacy compat)")
14
- compute_capability: tuple[int, int] = Field(
15
- default=(0, 0), description="CUDA compute capability"
16
- )
17
- peak_flops: float = Field(
18
- default=12.74e12, description="Peak FP32 FLOPS for MFU calculation"
19
- )
20
- bf16_peak_flops: float = Field(
21
- default=38.1e12, description="Peak BF16 FLOPS (RTX 3060 default)"
22
- )
23
-
24
- # Memory budget
25
- model_budget_mb: int = Field(
26
- default=1500, description="Max MB for model params + optimizer"
27
- )
28
- activation_budget_mb: int = Field(
29
- default=3000, description="Max MB for activations"
30
- )
31
- overhead_mb: int = Field(
32
- default=500, description="Reserved for CUDA context + PyTorch overhead"
33
- )
34
- max_vram_usage_pct: float = Field(
35
- default=90.0, description="Max VRAM usage as % of total"
36
- )
37
- gradient_checkpointing: bool = Field(
38
- default=False, description="Enable gradient checkpointing to save VRAM"
39
- )
40
-
41
- @classmethod
42
- def detect(cls) -> HardwareConfig:
43
- """Auto-detect hardware from current CUDA device."""
44
- if not torch.cuda.is_available():
45
- return cls()
46
-
47
- device = torch.cuda.current_device()
48
- props = torch.cuda.get_device_properties(device)
49
- cap = (props.major, props.minor)
50
- mem_mb = props.total_memory // (1024 * 1024)
51
- gpu_name = props.name
52
-
53
- # Peak FP32 FLOPS lookup by compute capability (approximate)
54
- fp32_flops_table: dict[tuple[int, int], float] = {
55
- (8, 6): 12.74e12, # RTX 3060
56
- (8, 9): 40.09e12, # RTX 4090
57
- (9, 0): 989.5e12, # H100 (BF16)
58
- }
59
- peak = fp32_flops_table.get(cap, 12.74e12)
60
-
61
- # BF16 peak FLOPS lookup by GPU name substring
62
- bf16_flops_table: dict[str, float] = {
63
- "3060": 38.1e12,
64
- "3090": 71.0e12,
65
- "4090": 165.2e12,
66
- "A100": 312e12,
67
- "H100": 989.5e12,
68
- "A10G": 70.0e12,
69
- }
70
- bf16_peak = 38.1e12 # default to RTX 3060
71
- for key, val in bf16_flops_table.items():
72
- if key in gpu_name:
73
- bf16_peak = val
74
- break
75
-
76
- # Memory budget: leave overhead_mb for CUDA context
77
- overhead = 500
78
- available = mem_mb - overhead
79
- model_budget = int(available * 0.3) # 30% for params + optimizer
80
- activation_budget = int(available * 0.7) # 70% for activations
81
-
82
- return cls(
83
- gpu_name=gpu_name,
84
- gpu_memory_mb=mem_mb,
85
- gpu_vram_mb=mem_mb,
86
- compute_capability=cap,
87
- peak_flops=peak,
88
- bf16_peak_flops=bf16_peak,
89
- model_budget_mb=model_budget,
90
- activation_budget_mb=activation_budget,
91
- )
92
-
93
- def suggest_batch_size(self, d_model: int, seq_len: int, n_layer: int) -> int:
94
- """Suggest batch size based on activation budget.
95
-
96
- Uses rough estimate: per-sample activation ~= n_layer * seq_len * d_model
97
- * 4 bytes * 2 (fwd + bwd).
98
- """
99
- per_sample_mb = n_layer * seq_len * d_model * 4 * 2 / (1024 * 1024)
100
- if per_sample_mb <= 0:
101
- return 1
102
- batch = max(1, int(self.activation_budget_mb / per_sample_mb))
103
- # Round down to power of 2
104
- return 2 ** (batch.bit_length() - 1) if batch > 1 else 1
 
1
+ """Hardware detection and memory budget configuration."""
2
+ from __future__ import annotations
3
+
4
+ import torch
5
+ from pydantic import BaseModel, Field
6
+
7
+
8
+ class HardwareConfig(BaseModel):
9
+ """Auto-detected hardware configuration with memory budgets."""
10
+
11
+ gpu_name: str = Field(default="unknown", description="GPU device name")
12
+ gpu_memory_mb: int = Field(default=0, description="Total GPU memory in MB")
13
+ gpu_vram_mb: int = Field(default=0, description="Alias for gpu_memory_mb (legacy compat)")
14
+ compute_capability: tuple[int, int] = Field(
15
+ default=(0, 0), description="CUDA compute capability"
16
+ )
17
+ peak_flops: float = Field(
18
+ default=12.74e12, description="Peak FP32 FLOPS for MFU calculation"
19
+ )
20
+ bf16_peak_flops: float = Field(
21
+ default=38.1e12, description="Peak BF16 FLOPS (RTX 3060 default)"
22
+ )
23
+
24
+ # Memory budget
25
+ model_budget_mb: int = Field(
26
+ default=1500, description="Max MB for model params + optimizer"
27
+ )
28
+ activation_budget_mb: int = Field(
29
+ default=3000, description="Max MB for activations"
30
+ )
31
+ overhead_mb: int = Field(
32
+ default=500, description="Reserved for CUDA context + PyTorch overhead"
33
+ )
34
+ max_vram_usage_pct: float = Field(
35
+ default=90.0, description="Max VRAM usage as % of total"
36
+ )
37
+ gradient_checkpointing: bool = Field(
38
+ default=False, description="Enable gradient checkpointing to save VRAM"
39
+ )
40
+
41
+ @classmethod
42
+ def detect(cls) -> HardwareConfig:
43
+ """Auto-detect hardware from current CUDA device."""
44
+ if not torch.cuda.is_available():
45
+ return cls()
46
+
47
+ device = torch.cuda.current_device()
48
+ props = torch.cuda.get_device_properties(device)
49
+ cap = (props.major, props.minor)
50
+ mem_mb = props.total_memory // (1024 * 1024)
51
+ gpu_name = props.name
52
+
53
+ # Peak FP32 FLOPS lookup by compute capability (approximate)
54
+ fp32_flops_table: dict[tuple[int, int], float] = {
55
+ (8, 6): 12.74e12, # RTX 3060
56
+ (8, 9): 40.09e12, # RTX 4090
57
+ (9, 0): 989.5e12, # H100 (BF16)
58
+ }
59
+ peak = fp32_flops_table.get(cap, 12.74e12)
60
+
61
+ # BF16 peak FLOPS lookup by GPU name substring
62
+ bf16_flops_table: dict[str, float] = {
63
+ "3060": 38.1e12,
64
+ "3090": 71.0e12,
65
+ "4090": 165.2e12,
66
+ "A100": 312e12,
67
+ "H100": 989.5e12,
68
+ "A10G": 70.0e12,
69
+ }
70
+ bf16_peak = 38.1e12 # default to RTX 3060
71
+ for key, val in bf16_flops_table.items():
72
+ if key in gpu_name:
73
+ bf16_peak = val
74
+ break
75
+
76
+ # Memory budget: leave overhead_mb for CUDA context
77
+ overhead = 500
78
+ available = mem_mb - overhead
79
+ model_budget = int(available * 0.3) # 30% for params + optimizer
80
+ activation_budget = int(available * 0.7) # 70% for activations
81
+
82
+ return cls(
83
+ gpu_name=gpu_name,
84
+ gpu_memory_mb=mem_mb,
85
+ gpu_vram_mb=mem_mb,
86
+ compute_capability=cap,
87
+ peak_flops=peak,
88
+ bf16_peak_flops=bf16_peak,
89
+ model_budget_mb=model_budget,
90
+ activation_budget_mb=activation_budget,
91
+ )
92
+
93
+ def suggest_batch_size(self, d_model: int, seq_len: int, n_layer: int) -> int:
94
+ """Suggest batch size based on activation budget.
95
+
96
+ Uses rough estimate: per-sample activation ~= n_layer * seq_len * d_model
97
+ * 4 bytes * 2 (fwd + bwd).
98
+ """
99
+ per_sample_mb = n_layer * seq_len * d_model * 4 * 2 / (1024 * 1024)
100
+ if per_sample_mb <= 0:
101
+ return 1
102
+ batch = max(1, int(self.activation_budget_mb / per_sample_mb))
103
+ # Round down to power of 2
104
+ return 2 ** (batch.bit_length() - 1) if batch > 1 else 1
overlay/configs/harness_config.py CHANGED
@@ -1,78 +1,78 @@
1
- """Harness configuration for HYDRA's self-evolving outer loop."""
2
- from typing import Literal
3
-
4
- from pydantic import BaseModel, Field
5
-
6
-
7
- class HarnessConfig(BaseModel):
8
- """Configuration for the HYDRA harness behavior."""
9
-
10
- # Inner loop
11
- time_budget_seconds: int = Field(
12
- default=300, ge=60, description="Training time budget per experiment in seconds"
13
- )
14
- max_experiments: int = Field(
15
- default=1000, ge=0, description="Max experiments before stopping (0=infinite)"
16
- )
17
-
18
- # Meta-agent
19
- meta_interval: int = Field(
20
- default=20, ge=5, description="Run meta-agent every N experiments"
21
- )
22
- max_meta_changes: int = Field(
23
- default=3, ge=1, le=10, description="Max changes per meta-iteration"
24
- )
25
-
26
- # Search strategy
27
- exploration_mode: Literal["conservative", "balanced", "bold"] = "balanced"
28
- exploration_budget: int = Field(
29
- default=5, ge=1, description="Consecutive bold experiments when stuck"
30
- )
31
- stuck_threshold: int = Field(
32
- default=10, ge=3, description="No improvement for N experiments = stuck"
33
- )
34
- crash_threshold: float = Field(
35
- default=0.5,
36
- ge=0.1,
37
- le=1.0,
38
- description="Crash rate threshold for BROKEN state",
39
- )
40
- regression_tolerance: float = Field(
41
- default=0.05,
42
- ge=0,
43
- le=0.2,
44
- description="Max val_bpb regression from best (fraction)",
45
- )
46
- max_regression_pct: float = Field(
47
- default=5.0, description="Max % regression from best known val_bpb"
48
- )
49
-
50
- # Keep/discard criteria
51
- primary_metric: str = "val_bpb"
52
- secondary_metrics: dict = Field(
53
- default_factory=lambda: {
54
- "mhc_spectral_norm": {"max": 2.0},
55
- "engram_hit_rate": {"min": 0.1},
56
- "hestia_quant_error": {"max": 0.05},
57
- }
58
- )
59
-
60
- # Experiment execution
61
- experiment_timeout: int = Field(
62
- default=600, ge=300, description="Kill experiment after N seconds"
63
- )
64
- warmup_steps: int = Field(
65
- default=10, ge=0, description="Steps to exclude from timing"
66
- )
67
-
68
- # Git
69
- branch_prefix: str = Field(default="autoresearch", description="Branch naming prefix")
70
- results_file: str = Field(default="results.tsv", description="Experiment log file")
71
-
72
- # Secondary metric gates (optional keep/discard criteria)
73
- gate_mhc_spectral_norm: float | None = Field(
74
- default=None, description="Max mhc_spectral_norm for keep (None=disabled)"
75
- )
76
- gate_engram_hit_rate: float | None = Field(
77
- default=None, description="Min engram_hit_rate for keep (None=disabled)"
78
- )
 
1
+ """Harness configuration for HYDRA's self-evolving outer loop."""
2
+ from typing import Literal
3
+
4
+ from pydantic import BaseModel, Field
5
+
6
+
7
+ class HarnessConfig(BaseModel):
8
+ """Configuration for the HYDRA harness behavior."""
9
+
10
+ # Inner loop
11
+ time_budget_seconds: int = Field(
12
+ default=300, ge=60, description="Training time budget per experiment in seconds"
13
+ )
14
+ max_experiments: int = Field(
15
+ default=1000, ge=0, description="Max experiments before stopping (0=infinite)"
16
+ )
17
+
18
+ # Meta-agent
19
+ meta_interval: int = Field(
20
+ default=20, ge=5, description="Run meta-agent every N experiments"
21
+ )
22
+ max_meta_changes: int = Field(
23
+ default=3, ge=1, le=10, description="Max changes per meta-iteration"
24
+ )
25
+
26
+ # Search strategy
27
+ exploration_mode: Literal["conservative", "balanced", "bold"] = "balanced"
28
+ exploration_budget: int = Field(
29
+ default=5, ge=1, description="Consecutive bold experiments when stuck"
30
+ )
31
+ stuck_threshold: int = Field(
32
+ default=10, ge=3, description="No improvement for N experiments = stuck"
33
+ )
34
+ crash_threshold: float = Field(
35
+ default=0.5,
36
+ ge=0.1,
37
+ le=1.0,
38
+ description="Crash rate threshold for BROKEN state",
39
+ )
40
+ regression_tolerance: float = Field(
41
+ default=0.05,
42
+ ge=0,
43
+ le=0.2,
44
+ description="Max val_bpb regression from best (fraction)",
45
+ )
46
+ max_regression_pct: float = Field(
47
+ default=5.0, description="Max % regression from best known val_bpb"
48
+ )
49
+
50
+ # Keep/discard criteria
51
+ primary_metric: str = "val_bpb"
52
+ secondary_metrics: dict = Field(
53
+ default_factory=lambda: {
54
+ "mhc_spectral_norm": {"max": 2.0},
55
+ "engram_hit_rate": {"min": 0.1},
56
+ "hestia_quant_error": {"max": 0.05},
57
+ }
58
+ )
59
+
60
+ # Experiment execution
61
+ experiment_timeout: int = Field(
62
+ default=600, ge=300, description="Kill experiment after N seconds"
63
+ )
64
+ warmup_steps: int = Field(
65
+ default=10, ge=0, description="Steps to exclude from timing"
66
+ )
67
+
68
+ # Git
69
+ branch_prefix: str = Field(default="autoresearch", description="Branch naming prefix")
70
+ results_file: str = Field(default="results.tsv", description="Experiment log file")
71
+
72
+ # Secondary metric gates (optional keep/discard criteria)
73
+ gate_mhc_spectral_norm: float | None = Field(
74
+ default=None, description="Max mhc_spectral_norm for keep (None=disabled)"
75
+ )
76
+ gate_engram_hit_rate: float | None = Field(
77
+ default=None, description="Min engram_hit_rate for keep (None=disabled)"
78
+ )
overlay/configs/model_config.py CHANGED
@@ -1,80 +1,80 @@
1
- """Post-SEM-Claw model configuration with Pydantic validation."""
2
- from pydantic import BaseModel, Field, field_validator
3
-
4
-
5
- class PostSemClawConfig(BaseModel):
6
- """Configuration for the Post-SEM-Claw architecture.
7
-
8
- Default values mirror the @dataclass in train.py exactly.
9
- train.py is the source of truth β€” this file must stay in sync with it.
10
- """
11
-
12
- # Sequence
13
- sequence_len: int = Field(default=2048, description="Context length (from prepare.py MAX_SEQ_LEN)")
14
- vocab_size: int = Field(default=8192, description="Vocabulary size (from prepare.py VOCAB_SIZE)")
15
-
16
- # Mamba-3 SSM
17
- n_layer: int = Field(default=4, ge=1, le=48, description="Number of Mamba-3 blocks")
18
- d_model: int = Field(default=256, ge=64, description="Model embedding dimension")
19
- d_state: int = Field(default=64, ge=16, description="SSM state dimension")
20
- headdim: int = Field(default=32, ge=16, description="SSM head dimension")
21
- n_heads: int = Field(default=8, ge=1, description="Number of SSM heads (d_model // headdim)")
22
- expand: int = Field(default=2, ge=1, le=4, description="Inner dim multiplier (inner_dim = expand * d_model)")
23
-
24
- # mHC (Manifold Hyper-Connection)
25
- mhc_n_streams: int = Field(default=4, ge=2, le=8, description="Number of residual streams")
26
- mhc_sinkhorn_iters: int = Field(default=5, ge=1, le=100, description="Sinkhorn-Knopp iterations")
27
-
28
- # Engram (conditional memory)
29
- engram_n_columns: int = Field(default=4096, ge=256, description="Hash table columns")
30
- engram_key_dim: int = Field(default=64, ge=16, description="Engram key dimension")
31
- engram_layer_idx: int = Field(default=1, ge=0, description="Which layer gets engram (0-indexed)")
32
-
33
- # Hestia QAT (disabled Phase 1, skeleton only)
34
- hestia_enabled: bool = Field(default=False, description="Enable Hestia quantization")
35
- hestia_bits: float = Field(default=1.58, gt=0, description="Target quantization bits (1.58 = 1.58-bit ternary)")
36
-
37
- # SDR (bypass-only in Phase 1)
38
- sdr_enabled: bool = Field(default=False, description="Enable stochastic resonance")
39
- sdr_k: int = Field(default=64, ge=1, description="Top-K sparsification")
40
- sdr_noise_std: float = Field(default=0.1, ge=0.0, description="SR noise standard deviation")
41
-
42
- @field_validator("n_heads")
43
- @classmethod
44
- def validate_heads(cls, v: int, info: "FieldValidationInfo") -> int:
45
- """Ensure n_heads equals d_model // headdim."""
46
- d_model = info.data.get("d_model", 256)
47
- headdim = info.data.get("headdim", 32)
48
- expected = d_model // headdim
49
- if v != expected:
50
- raise ValueError(
51
- f"n_heads ({v}) must equal d_model // headdim ({expected})"
52
- )
53
- return v
54
-
55
- def estimate_params(self) -> int:
56
- """Rough parameter count estimate based on train.py architecture."""
57
- inner = self.expand * self.d_model
58
- # in_proj: d_model -> inner + inner + d_state + d_state + n_heads
59
- in_proj = self.d_model * (inner + inner + self.d_state + self.d_state + self.n_heads)
60
- out_proj = inner * self.d_model
61
- # conv1d (kernel=4, groups=inner_dim)
62
- conv = inner * 4
63
- # A_log, lambda_theta, D: n_heads each (3 vectors)
64
- ssm_params = self.n_heads * 3
65
- # bc_norm: d_state * 2 (weight + bias)
66
- bc_norm = self.d_state * 2
67
- per_block = in_proj + out_proj + conv + ssm_params + bc_norm
68
- blocks = per_block * self.n_layer
69
-
70
- # Embedding + lm_head (tied or untied)
71
- embed = self.vocab_size * self.d_model * 2
72
-
73
- # Engram: one instance at engram_layer_idx
74
- # columns * d_model keys + d_model * engram_key_dim projection
75
- engram = self.engram_n_columns * self.d_model + self.d_model * self.engram_key_dim
76
-
77
- # mHC mixing matrices: n_layer * mhc_n_streams^2
78
- mhc = self.n_layer * self.mhc_n_streams ** 2
79
-
80
- return embed + blocks + engram + mhc
 
1
+ """Post-SEM-Claw model configuration with Pydantic validation."""
2
+ from pydantic import BaseModel, Field, field_validator
3
+
4
+
5
+ class PostSemClawConfig(BaseModel):
6
+ """Configuration for the Post-SEM-Claw architecture.
7
+
8
+ Default values mirror the @dataclass in train.py exactly.
9
+ train.py is the source of truth β€” this file must stay in sync with it.
10
+ """
11
+
12
+ # Sequence
13
+ sequence_len: int = Field(default=2048, description="Context length (from prepare.py MAX_SEQ_LEN)")
14
+ vocab_size: int = Field(default=8192, description="Vocabulary size (from prepare.py VOCAB_SIZE)")
15
+
16
+ # Mamba-3 SSM
17
+ n_layer: int = Field(default=4, ge=1, le=48, description="Number of Mamba-3 blocks")
18
+ d_model: int = Field(default=256, ge=64, description="Model embedding dimension")
19
+ d_state: int = Field(default=64, ge=16, description="SSM state dimension")
20
+ headdim: int = Field(default=32, ge=16, description="SSM head dimension")
21
+ n_heads: int = Field(default=8, ge=1, description="Number of SSM heads (d_model // headdim)")
22
+ expand: int = Field(default=2, ge=1, le=4, description="Inner dim multiplier (inner_dim = expand * d_model)")
23
+
24
+ # mHC (Manifold Hyper-Connection)
25
+ mhc_n_streams: int = Field(default=4, ge=2, le=8, description="Number of residual streams")
26
+ mhc_sinkhorn_iters: int = Field(default=5, ge=1, le=100, description="Sinkhorn-Knopp iterations")
27
+
28
+ # Engram (conditional memory)
29
+ engram_n_columns: int = Field(default=4096, ge=256, description="Hash table columns")
30
+ engram_key_dim: int = Field(default=64, ge=16, description="Engram key dimension")
31
+ engram_layer_idx: int = Field(default=1, ge=0, description="Which layer gets engram (0-indexed)")
32
+
33
+ # Hestia QAT (disabled Phase 1, skeleton only)
34
+ hestia_enabled: bool = Field(default=False, description="Enable Hestia quantization")
35
+ hestia_bits: float = Field(default=1.58, gt=0, description="Target quantization bits (1.58 = 1.58-bit ternary)")
36
+
37
+ # SDR (bypass-only in Phase 1)
38
+ sdr_enabled: bool = Field(default=False, description="Enable stochastic resonance")
39
+ sdr_k: int = Field(default=64, ge=1, description="Top-K sparsification")
40
+ sdr_noise_std: float = Field(default=0.1, ge=0.0, description="SR noise standard deviation")
41
+
42
+ @field_validator("n_heads")
43
+ @classmethod
44
+ def validate_heads(cls, v: int, info: "FieldValidationInfo") -> int:
45
+ """Ensure n_heads equals d_model // headdim."""
46
+ d_model = info.data.get("d_model", 256)
47
+ headdim = info.data.get("headdim", 32)
48
+ expected = d_model // headdim
49
+ if v != expected:
50
+ raise ValueError(
51
+ f"n_heads ({v}) must equal d_model // headdim ({expected})"
52
+ )
53
+ return v
54
+
55
+ def estimate_params(self) -> int:
56
+ """Rough parameter count estimate based on train.py architecture."""
57
+ inner = self.expand * self.d_model
58
+ # in_proj: d_model -> inner + inner + d_state + d_state + n_heads
59
+ in_proj = self.d_model * (inner + inner + self.d_state + self.d_state + self.n_heads)
60
+ out_proj = inner * self.d_model
61
+ # conv1d (kernel=4, groups=inner_dim)
62
+ conv = inner * 4
63
+ # A_log, lambda_theta, D: n_heads each (3 vectors)
64
+ ssm_params = self.n_heads * 3
65
+ # bc_norm: d_state * 2 (weight + bias)
66
+ bc_norm = self.d_state * 2
67
+ per_block = in_proj + out_proj + conv + ssm_params + bc_norm
68
+ blocks = per_block * self.n_layer
69
+
70
+ # Embedding + lm_head (tied or untied)
71
+ embed = self.vocab_size * self.d_model * 2
72
+
73
+ # Engram: one instance at engram_layer_idx
74
+ # columns * d_model keys + d_model * engram_key_dim projection
75
+ engram = self.engram_n_columns * self.d_model + self.d_model * self.engram_key_dim
76
+
77
+ # mHC mixing matrices: n_layer * mhc_n_streams^2
78
+ mhc = self.n_layer * self.mhc_n_streams ** 2
79
+
80
+ return embed + blocks + engram + mhc
overlay/htm_rust/Cargo.lock CHANGED
@@ -1,383 +1,383 @@
1
- # This file is automatically @generated by Cargo.
2
- # It is not intended for manual editing.
3
- version = 4
4
-
5
- [[package]]
6
- name = "autocfg"
7
- version = "1.5.0"
8
- source = "registry+https://github.com/rust-lang/crates.io-index"
9
- checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
10
-
11
- [[package]]
12
- name = "cfg-if"
13
- version = "1.0.4"
14
- source = "registry+https://github.com/rust-lang/crates.io-index"
15
- checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
16
-
17
- [[package]]
18
- name = "cudarc"
19
- version = "0.12.1"
20
- source = "registry+https://github.com/rust-lang/crates.io-index"
21
- checksum = "38cd60a9a42ec83a2ed7effb0b1f073270264ea99da7acfc44f7e8d74dee0384"
22
- dependencies = [
23
- "libloading",
24
- ]
25
-
26
- [[package]]
27
- name = "getrandom"
28
- version = "0.2.17"
29
- source = "registry+https://github.com/rust-lang/crates.io-index"
30
- checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
31
- dependencies = [
32
- "cfg-if",
33
- "libc",
34
- "wasi",
35
- ]
36
-
37
- [[package]]
38
- name = "heck"
39
- version = "0.5.0"
40
- source = "registry+https://github.com/rust-lang/crates.io-index"
41
- checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
42
-
43
- [[package]]
44
- name = "htm_rust"
45
- version = "0.1.0"
46
- dependencies = [
47
- "cudarc",
48
- "ndarray",
49
- "numpy",
50
- "pyo3",
51
- "rand",
52
- "rand_xoshiro",
53
- ]
54
-
55
- [[package]]
56
- name = "indoc"
57
- version = "2.0.7"
58
- source = "registry+https://github.com/rust-lang/crates.io-index"
59
- checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706"
60
- dependencies = [
61
- "rustversion",
62
- ]
63
-
64
- [[package]]
65
- name = "libc"
66
- version = "0.2.185"
67
- source = "registry+https://github.com/rust-lang/crates.io-index"
68
- checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f"
69
-
70
- [[package]]
71
- name = "libloading"
72
- version = "0.8.9"
73
- source = "registry+https://github.com/rust-lang/crates.io-index"
74
- checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55"
75
- dependencies = [
76
- "cfg-if",
77
- "windows-link",
78
- ]
79
-
80
- [[package]]
81
- name = "matrixmultiply"
82
- version = "0.3.10"
83
- source = "registry+https://github.com/rust-lang/crates.io-index"
84
- checksum = "a06de3016e9fae57a36fd14dba131fccf49f74b40b7fbdb472f96e361ec71a08"
85
- dependencies = [
86
- "autocfg",
87
- "rawpointer",
88
- ]
89
-
90
- [[package]]
91
- name = "memoffset"
92
- version = "0.9.1"
93
- source = "registry+https://github.com/rust-lang/crates.io-index"
94
- checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a"
95
- dependencies = [
96
- "autocfg",
97
- ]
98
-
99
- [[package]]
100
- name = "ndarray"
101
- version = "0.16.1"
102
- source = "registry+https://github.com/rust-lang/crates.io-index"
103
- checksum = "882ed72dce9365842bf196bdeedf5055305f11fc8c03dee7bb0194a6cad34841"
104
- dependencies = [
105
- "matrixmultiply",
106
- "num-complex",
107
- "num-integer",
108
- "num-traits",
109
- "portable-atomic",
110
- "portable-atomic-util",
111
- "rawpointer",
112
- ]
113
-
114
- [[package]]
115
- name = "num-complex"
116
- version = "0.4.6"
117
- source = "registry+https://github.com/rust-lang/crates.io-index"
118
- checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495"
119
- dependencies = [
120
- "num-traits",
121
- ]
122
-
123
- [[package]]
124
- name = "num-integer"
125
- version = "0.1.46"
126
- source = "registry+https://github.com/rust-lang/crates.io-index"
127
- checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f"
128
- dependencies = [
129
- "num-traits",
130
- ]
131
-
132
- [[package]]
133
- name = "num-traits"
134
- version = "0.2.19"
135
- source = "registry+https://github.com/rust-lang/crates.io-index"
136
- checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
137
- dependencies = [
138
- "autocfg",
139
- ]
140
-
141
- [[package]]
142
- name = "numpy"
143
- version = "0.22.1"
144
- source = "registry+https://github.com/rust-lang/crates.io-index"
145
- checksum = "edb929bc0da91a4d85ed6c0a84deaa53d411abfb387fc271124f91bf6b89f14e"
146
- dependencies = [
147
- "libc",
148
- "ndarray",
149
- "num-complex",
150
- "num-integer",
151
- "num-traits",
152
- "pyo3",
153
- "rustc-hash",
154
- ]
155
-
156
- [[package]]
157
- name = "once_cell"
158
- version = "1.21.4"
159
- source = "registry+https://github.com/rust-lang/crates.io-index"
160
- checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
161
-
162
- [[package]]
163
- name = "portable-atomic"
164
- version = "1.13.1"
165
- source = "registry+https://github.com/rust-lang/crates.io-index"
166
- checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
167
-
168
- [[package]]
169
- name = "portable-atomic-util"
170
- version = "0.2.6"
171
- source = "registry+https://github.com/rust-lang/crates.io-index"
172
- checksum = "091397be61a01d4be58e7841595bd4bfedb15f1cd54977d79b8271e94ed799a3"
173
- dependencies = [
174
- "portable-atomic",
175
- ]
176
-
177
- [[package]]
178
- name = "ppv-lite86"
179
- version = "0.2.21"
180
- source = "registry+https://github.com/rust-lang/crates.io-index"
181
- checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
182
- dependencies = [
183
- "zerocopy",
184
- ]
185
-
186
- [[package]]
187
- name = "proc-macro2"
188
- version = "1.0.106"
189
- source = "registry+https://github.com/rust-lang/crates.io-index"
190
- checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
191
- dependencies = [
192
- "unicode-ident",
193
- ]
194
-
195
- [[package]]
196
- name = "pyo3"
197
- version = "0.22.6"
198
- source = "registry+https://github.com/rust-lang/crates.io-index"
199
- checksum = "f402062616ab18202ae8319da13fa4279883a2b8a9d9f83f20dbade813ce1884"
200
- dependencies = [
201
- "cfg-if",
202
- "indoc",
203
- "libc",
204
- "memoffset",
205
- "once_cell",
206
- "portable-atomic",
207
- "pyo3-build-config",
208
- "pyo3-ffi",
209
- "pyo3-macros",
210
- "unindent",
211
- ]
212
-
213
- [[package]]
214
- name = "pyo3-build-config"
215
- version = "0.22.6"
216
- source = "registry+https://github.com/rust-lang/crates.io-index"
217
- checksum = "b14b5775b5ff446dd1056212d778012cbe8a0fbffd368029fd9e25b514479c38"
218
- dependencies = [
219
- "once_cell",
220
- "target-lexicon",
221
- ]
222
-
223
- [[package]]
224
- name = "pyo3-ffi"
225
- version = "0.22.6"
226
- source = "registry+https://github.com/rust-lang/crates.io-index"
227
- checksum = "9ab5bcf04a2cdcbb50c7d6105de943f543f9ed92af55818fd17b660390fc8636"
228
- dependencies = [
229
- "libc",
230
- "pyo3-build-config",
231
- ]
232
-
233
- [[package]]
234
- name = "pyo3-macros"
235
- version = "0.22.6"
236
- source = "registry+https://github.com/rust-lang/crates.io-index"
237
- checksum = "0fd24d897903a9e6d80b968368a34e1525aeb719d568dba8b3d4bfa5dc67d453"
238
- dependencies = [
239
- "proc-macro2",
240
- "pyo3-macros-backend",
241
- "quote",
242
- "syn",
243
- ]
244
-
245
- [[package]]
246
- name = "pyo3-macros-backend"
247
- version = "0.22.6"
248
- source = "registry+https://github.com/rust-lang/crates.io-index"
249
- checksum = "36c011a03ba1e50152b4b394b479826cad97e7a21eb52df179cd91ac411cbfbe"
250
- dependencies = [
251
- "heck",
252
- "proc-macro2",
253
- "pyo3-build-config",
254
- "quote",
255
- "syn",
256
- ]
257
-
258
- [[package]]
259
- name = "quote"
260
- version = "1.0.45"
261
- source = "registry+https://github.com/rust-lang/crates.io-index"
262
- checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
263
- dependencies = [
264
- "proc-macro2",
265
- ]
266
-
267
- [[package]]
268
- name = "rand"
269
- version = "0.8.5"
270
- source = "registry+https://github.com/rust-lang/crates.io-index"
271
- checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
272
- dependencies = [
273
- "libc",
274
- "rand_chacha",
275
- "rand_core",
276
- ]
277
-
278
- [[package]]
279
- name = "rand_chacha"
280
- version = "0.3.1"
281
- source = "registry+https://github.com/rust-lang/crates.io-index"
282
- checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
283
- dependencies = [
284
- "ppv-lite86",
285
- "rand_core",
286
- ]
287
-
288
- [[package]]
289
- name = "rand_core"
290
- version = "0.6.4"
291
- source = "registry+https://github.com/rust-lang/crates.io-index"
292
- checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
293
- dependencies = [
294
- "getrandom",
295
- ]
296
-
297
- [[package]]
298
- name = "rand_xoshiro"
299
- version = "0.6.0"
300
- source = "registry+https://github.com/rust-lang/crates.io-index"
301
- checksum = "6f97cdb2a36ed4183de61b2f824cc45c9f1037f28afe0a322e9fff4c108b5aaa"
302
- dependencies = [
303
- "rand_core",
304
- ]
305
-
306
- [[package]]
307
- name = "rawpointer"
308
- version = "0.2.1"
309
- source = "registry+https://github.com/rust-lang/crates.io-index"
310
- checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3"
311
-
312
- [[package]]
313
- name = "rustc-hash"
314
- version = "1.1.0"
315
- source = "registry+https://github.com/rust-lang/crates.io-index"
316
- checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2"
317
-
318
- [[package]]
319
- name = "rustversion"
320
- version = "1.0.22"
321
- source = "registry+https://github.com/rust-lang/crates.io-index"
322
- checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
323
-
324
- [[package]]
325
- name = "syn"
326
- version = "2.0.117"
327
- source = "registry+https://github.com/rust-lang/crates.io-index"
328
- checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
329
- dependencies = [
330
- "proc-macro2",
331
- "quote",
332
- "unicode-ident",
333
- ]
334
-
335
- [[package]]
336
- name = "target-lexicon"
337
- version = "0.12.16"
338
- source = "registry+https://github.com/rust-lang/crates.io-index"
339
- checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1"
340
-
341
- [[package]]
342
- name = "unicode-ident"
343
- version = "1.0.24"
344
- source = "registry+https://github.com/rust-lang/crates.io-index"
345
- checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
346
-
347
- [[package]]
348
- name = "unindent"
349
- version = "0.2.4"
350
- source = "registry+https://github.com/rust-lang/crates.io-index"
351
- checksum = "7264e107f553ccae879d21fbea1d6724ac785e8c3bfc762137959b5802826ef3"
352
-
353
- [[package]]
354
- name = "wasi"
355
- version = "0.11.1+wasi-snapshot-preview1"
356
- source = "registry+https://github.com/rust-lang/crates.io-index"
357
- checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
358
-
359
- [[package]]
360
- name = "windows-link"
361
- version = "0.2.1"
362
- source = "registry+https://github.com/rust-lang/crates.io-index"
363
- checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
364
-
365
- [[package]]
366
- name = "zerocopy"
367
- version = "0.8.48"
368
- source = "registry+https://github.com/rust-lang/crates.io-index"
369
- checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9"
370
- dependencies = [
371
- "zerocopy-derive",
372
- ]
373
-
374
- [[package]]
375
- name = "zerocopy-derive"
376
- version = "0.8.48"
377
- source = "registry+https://github.com/rust-lang/crates.io-index"
378
- checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4"
379
- dependencies = [
380
- "proc-macro2",
381
- "quote",
382
- "syn",
383
- ]
 
1
+ # This file is automatically @generated by Cargo.
2
+ # It is not intended for manual editing.
3
+ version = 4
4
+
5
+ [[package]]
6
+ name = "autocfg"
7
+ version = "1.5.0"
8
+ source = "registry+https://github.com/rust-lang/crates.io-index"
9
+ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
10
+
11
+ [[package]]
12
+ name = "cfg-if"
13
+ version = "1.0.4"
14
+ source = "registry+https://github.com/rust-lang/crates.io-index"
15
+ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
16
+
17
+ [[package]]
18
+ name = "cudarc"
19
+ version = "0.12.1"
20
+ source = "registry+https://github.com/rust-lang/crates.io-index"
21
+ checksum = "38cd60a9a42ec83a2ed7effb0b1f073270264ea99da7acfc44f7e8d74dee0384"
22
+ dependencies = [
23
+ "libloading",
24
+ ]
25
+
26
+ [[package]]
27
+ name = "getrandom"
28
+ version = "0.2.17"
29
+ source = "registry+https://github.com/rust-lang/crates.io-index"
30
+ checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
31
+ dependencies = [
32
+ "cfg-if",
33
+ "libc",
34
+ "wasi",
35
+ ]
36
+
37
+ [[package]]
38
+ name = "heck"
39
+ version = "0.5.0"
40
+ source = "registry+https://github.com/rust-lang/crates.io-index"
41
+ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
42
+
43
+ [[package]]
44
+ name = "htm_rust"
45
+ version = "0.1.0"
46
+ dependencies = [
47
+ "cudarc",
48
+ "ndarray",
49
+ "numpy",
50
+ "pyo3",
51
+ "rand",
52
+ "rand_xoshiro",
53
+ ]
54
+
55
+ [[package]]
56
+ name = "indoc"
57
+ version = "2.0.7"
58
+ source = "registry+https://github.com/rust-lang/crates.io-index"
59
+ checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706"
60
+ dependencies = [
61
+ "rustversion",
62
+ ]
63
+
64
+ [[package]]
65
+ name = "libc"
66
+ version = "0.2.185"
67
+ source = "registry+https://github.com/rust-lang/crates.io-index"
68
+ checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f"
69
+
70
+ [[package]]
71
+ name = "libloading"
72
+ version = "0.8.9"
73
+ source = "registry+https://github.com/rust-lang/crates.io-index"
74
+ checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55"
75
+ dependencies = [
76
+ "cfg-if",
77
+ "windows-link",
78
+ ]
79
+
80
+ [[package]]
81
+ name = "matrixmultiply"
82
+ version = "0.3.10"
83
+ source = "registry+https://github.com/rust-lang/crates.io-index"
84
+ checksum = "a06de3016e9fae57a36fd14dba131fccf49f74b40b7fbdb472f96e361ec71a08"
85
+ dependencies = [
86
+ "autocfg",
87
+ "rawpointer",
88
+ ]
89
+
90
+ [[package]]
91
+ name = "memoffset"
92
+ version = "0.9.1"
93
+ source = "registry+https://github.com/rust-lang/crates.io-index"
94
+ checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a"
95
+ dependencies = [
96
+ "autocfg",
97
+ ]
98
+
99
+ [[package]]
100
+ name = "ndarray"
101
+ version = "0.16.1"
102
+ source = "registry+https://github.com/rust-lang/crates.io-index"
103
+ checksum = "882ed72dce9365842bf196bdeedf5055305f11fc8c03dee7bb0194a6cad34841"
104
+ dependencies = [
105
+ "matrixmultiply",
106
+ "num-complex",
107
+ "num-integer",
108
+ "num-traits",
109
+ "portable-atomic",
110
+ "portable-atomic-util",
111
+ "rawpointer",
112
+ ]
113
+
114
+ [[package]]
115
+ name = "num-complex"
116
+ version = "0.4.6"
117
+ source = "registry+https://github.com/rust-lang/crates.io-index"
118
+ checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495"
119
+ dependencies = [
120
+ "num-traits",
121
+ ]
122
+
123
+ [[package]]
124
+ name = "num-integer"
125
+ version = "0.1.46"
126
+ source = "registry+https://github.com/rust-lang/crates.io-index"
127
+ checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f"
128
+ dependencies = [
129
+ "num-traits",
130
+ ]
131
+
132
+ [[package]]
133
+ name = "num-traits"
134
+ version = "0.2.19"
135
+ source = "registry+https://github.com/rust-lang/crates.io-index"
136
+ checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
137
+ dependencies = [
138
+ "autocfg",
139
+ ]
140
+
141
+ [[package]]
142
+ name = "numpy"
143
+ version = "0.22.1"
144
+ source = "registry+https://github.com/rust-lang/crates.io-index"
145
+ checksum = "edb929bc0da91a4d85ed6c0a84deaa53d411abfb387fc271124f91bf6b89f14e"
146
+ dependencies = [
147
+ "libc",
148
+ "ndarray",
149
+ "num-complex",
150
+ "num-integer",
151
+ "num-traits",
152
+ "pyo3",
153
+ "rustc-hash",
154
+ ]
155
+
156
+ [[package]]
157
+ name = "once_cell"
158
+ version = "1.21.4"
159
+ source = "registry+https://github.com/rust-lang/crates.io-index"
160
+ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
161
+
162
+ [[package]]
163
+ name = "portable-atomic"
164
+ version = "1.13.1"
165
+ source = "registry+https://github.com/rust-lang/crates.io-index"
166
+ checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
167
+
168
+ [[package]]
169
+ name = "portable-atomic-util"
170
+ version = "0.2.6"
171
+ source = "registry+https://github.com/rust-lang/crates.io-index"
172
+ checksum = "091397be61a01d4be58e7841595bd4bfedb15f1cd54977d79b8271e94ed799a3"
173
+ dependencies = [
174
+ "portable-atomic",
175
+ ]
176
+
177
+ [[package]]
178
+ name = "ppv-lite86"
179
+ version = "0.2.21"
180
+ source = "registry+https://github.com/rust-lang/crates.io-index"
181
+ checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
182
+ dependencies = [
183
+ "zerocopy",
184
+ ]
185
+
186
+ [[package]]
187
+ name = "proc-macro2"
188
+ version = "1.0.106"
189
+ source = "registry+https://github.com/rust-lang/crates.io-index"
190
+ checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
191
+ dependencies = [
192
+ "unicode-ident",
193
+ ]
194
+
195
+ [[package]]
196
+ name = "pyo3"
197
+ version = "0.22.6"
198
+ source = "registry+https://github.com/rust-lang/crates.io-index"
199
+ checksum = "f402062616ab18202ae8319da13fa4279883a2b8a9d9f83f20dbade813ce1884"
200
+ dependencies = [
201
+ "cfg-if",
202
+ "indoc",
203
+ "libc",
204
+ "memoffset",
205
+ "once_cell",
206
+ "portable-atomic",
207
+ "pyo3-build-config",
208
+ "pyo3-ffi",
209
+ "pyo3-macros",
210
+ "unindent",
211
+ ]
212
+
213
+ [[package]]
214
+ name = "pyo3-build-config"
215
+ version = "0.22.6"
216
+ source = "registry+https://github.com/rust-lang/crates.io-index"
217
+ checksum = "b14b5775b5ff446dd1056212d778012cbe8a0fbffd368029fd9e25b514479c38"
218
+ dependencies = [
219
+ "once_cell",
220
+ "target-lexicon",
221
+ ]
222
+
223
+ [[package]]
224
+ name = "pyo3-ffi"
225
+ version = "0.22.6"
226
+ source = "registry+https://github.com/rust-lang/crates.io-index"
227
+ checksum = "9ab5bcf04a2cdcbb50c7d6105de943f543f9ed92af55818fd17b660390fc8636"
228
+ dependencies = [
229
+ "libc",
230
+ "pyo3-build-config",
231
+ ]
232
+
233
+ [[package]]
234
+ name = "pyo3-macros"
235
+ version = "0.22.6"
236
+ source = "registry+https://github.com/rust-lang/crates.io-index"
237
+ checksum = "0fd24d897903a9e6d80b968368a34e1525aeb719d568dba8b3d4bfa5dc67d453"
238
+ dependencies = [
239
+ "proc-macro2",
240
+ "pyo3-macros-backend",
241
+ "quote",
242
+ "syn",
243
+ ]
244
+
245
+ [[package]]
246
+ name = "pyo3-macros-backend"
247
+ version = "0.22.6"
248
+ source = "registry+https://github.com/rust-lang/crates.io-index"
249
+ checksum = "36c011a03ba1e50152b4b394b479826cad97e7a21eb52df179cd91ac411cbfbe"
250
+ dependencies = [
251
+ "heck",
252
+ "proc-macro2",
253
+ "pyo3-build-config",
254
+ "quote",
255
+ "syn",
256
+ ]
257
+
258
+ [[package]]
259
+ name = "quote"
260
+ version = "1.0.45"
261
+ source = "registry+https://github.com/rust-lang/crates.io-index"
262
+ checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
263
+ dependencies = [
264
+ "proc-macro2",
265
+ ]
266
+
267
+ [[package]]
268
+ name = "rand"
269
+ version = "0.8.5"
270
+ source = "registry+https://github.com/rust-lang/crates.io-index"
271
+ checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
272
+ dependencies = [
273
+ "libc",
274
+ "rand_chacha",
275
+ "rand_core",
276
+ ]
277
+
278
+ [[package]]
279
+ name = "rand_chacha"
280
+ version = "0.3.1"
281
+ source = "registry+https://github.com/rust-lang/crates.io-index"
282
+ checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
283
+ dependencies = [
284
+ "ppv-lite86",
285
+ "rand_core",
286
+ ]
287
+
288
+ [[package]]
289
+ name = "rand_core"
290
+ version = "0.6.4"
291
+ source = "registry+https://github.com/rust-lang/crates.io-index"
292
+ checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
293
+ dependencies = [
294
+ "getrandom",
295
+ ]
296
+
297
+ [[package]]
298
+ name = "rand_xoshiro"
299
+ version = "0.6.0"
300
+ source = "registry+https://github.com/rust-lang/crates.io-index"
301
+ checksum = "6f97cdb2a36ed4183de61b2f824cc45c9f1037f28afe0a322e9fff4c108b5aaa"
302
+ dependencies = [
303
+ "rand_core",
304
+ ]
305
+
306
+ [[package]]
307
+ name = "rawpointer"
308
+ version = "0.2.1"
309
+ source = "registry+https://github.com/rust-lang/crates.io-index"
310
+ checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3"
311
+
312
+ [[package]]
313
+ name = "rustc-hash"
314
+ version = "1.1.0"
315
+ source = "registry+https://github.com/rust-lang/crates.io-index"
316
+ checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2"
317
+
318
+ [[package]]
319
+ name = "rustversion"
320
+ version = "1.0.22"
321
+ source = "registry+https://github.com/rust-lang/crates.io-index"
322
+ checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
323
+
324
+ [[package]]
325
+ name = "syn"
326
+ version = "2.0.117"
327
+ source = "registry+https://github.com/rust-lang/crates.io-index"
328
+ checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
329
+ dependencies = [
330
+ "proc-macro2",
331
+ "quote",
332
+ "unicode-ident",
333
+ ]
334
+
335
+ [[package]]
336
+ name = "target-lexicon"
337
+ version = "0.12.16"
338
+ source = "registry+https://github.com/rust-lang/crates.io-index"
339
+ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1"
340
+
341
+ [[package]]
342
+ name = "unicode-ident"
343
+ version = "1.0.24"
344
+ source = "registry+https://github.com/rust-lang/crates.io-index"
345
+ checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
346
+
347
+ [[package]]
348
+ name = "unindent"
349
+ version = "0.2.4"
350
+ source = "registry+https://github.com/rust-lang/crates.io-index"
351
+ checksum = "7264e107f553ccae879d21fbea1d6724ac785e8c3bfc762137959b5802826ef3"
352
+
353
+ [[package]]
354
+ name = "wasi"
355
+ version = "0.11.1+wasi-snapshot-preview1"
356
+ source = "registry+https://github.com/rust-lang/crates.io-index"
357
+ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
358
+
359
+ [[package]]
360
+ name = "windows-link"
361
+ version = "0.2.1"
362
+ source = "registry+https://github.com/rust-lang/crates.io-index"
363
+ checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
364
+
365
+ [[package]]
366
+ name = "zerocopy"
367
+ version = "0.8.48"
368
+ source = "registry+https://github.com/rust-lang/crates.io-index"
369
+ checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9"
370
+ dependencies = [
371
+ "zerocopy-derive",
372
+ ]
373
+
374
+ [[package]]
375
+ name = "zerocopy-derive"
376
+ version = "0.8.48"
377
+ source = "registry+https://github.com/rust-lang/crates.io-index"
378
+ checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4"
379
+ dependencies = [
380
+ "proc-macro2",
381
+ "quote",
382
+ "syn",
383
+ ]
overlay/htm_rust/Cargo.toml CHANGED
@@ -1,37 +1,37 @@
1
- [package]
2
- name = "htm_rust"
3
- version = "0.1.0"
4
- edition = "2021"
5
- authors = ["Feather/HYDRA"]
6
- description = "Numenta BAMI-spec Hierarchical Temporal Memory (Spatial Pooler + Temporal Memory) with pyo3 bindings"
7
- license = "MIT"
8
-
9
- [lib]
10
- name = "htm_rust"
11
- crate-type = ["cdylib", "rlib"]
12
-
13
- [dependencies]
14
- pyo3 = { version = "0.22", features = ["extension-module"] }
15
- numpy = "0.22"
16
- ndarray = "0.16"
17
- rand = "0.8"
18
- rand_xoshiro = "0.6"
19
- # cudarc: CUDA Rust bindings with dynamic-loading (no link-time dep on libcuda).
20
- # Kernels are embedded as PTX and JIT-compiled at runtime.
21
- cudarc = { version = "0.12", default-features = false, features = ["dynamic-linking", "driver", "cuda-12010"], optional = true }
22
-
23
- [build-dependencies]
24
- # Only required when building with --features gpu. We shell to nvcc directly
25
- # so we don't need cc's cuda support (which drags in extra deps).
26
-
27
- [features]
28
- default = []
29
- # `gpu` adds the HTMRegionGPU class, compiles .cu kernels to PTX at build time,
30
- # and links cudarc. Without this feature the crate is pure-CPU and has no
31
- # CUDA dependency at build or run time.
32
- gpu = ["cudarc"]
33
-
34
- [profile.release]
35
- opt-level = 3
36
- lto = "thin"
37
- codegen-units = 1
 
1
+ [package]
2
+ name = "htm_rust"
3
+ version = "0.1.0"
4
+ edition = "2021"
5
+ authors = ["Feather/HYDRA"]
6
+ description = "Numenta BAMI-spec Hierarchical Temporal Memory (Spatial Pooler + Temporal Memory) with pyo3 bindings"
7
+ license = "MIT"
8
+
9
+ [lib]
10
+ name = "htm_rust"
11
+ crate-type = ["cdylib", "rlib"]
12
+
13
+ [dependencies]
14
+ pyo3 = { version = "0.22", features = ["extension-module"] }
15
+ numpy = "0.22"
16
+ ndarray = "0.16"
17
+ rand = "0.8"
18
+ rand_xoshiro = "0.6"
19
+ # cudarc: CUDA Rust bindings with dynamic-loading (no link-time dep on libcuda).
20
+ # Kernels are embedded as PTX and JIT-compiled at runtime.
21
+ cudarc = { version = "0.12", default-features = false, features = ["dynamic-linking", "driver", "cuda-12010"], optional = true }
22
+
23
+ [build-dependencies]
24
+ # Only required when building with --features gpu. We shell to nvcc directly
25
+ # so we don't need cc's cuda support (which drags in extra deps).
26
+
27
+ [features]
28
+ default = []
29
+ # `gpu` adds the HTMRegionGPU class, compiles .cu kernels to PTX at build time,
30
+ # and links cudarc. Without this feature the crate is pure-CPU and has no
31
+ # CUDA dependency at build or run time.
32
+ gpu = ["cudarc"]
33
+
34
+ [profile.release]
35
+ opt-level = 3
36
+ lto = "thin"
37
+ codegen-units = 1
overlay/htm_rust/bench_gpu.py CHANGED
@@ -1,81 +1,81 @@
1
- """Microbenchmark: CPU vs GPU HTMLayer forward at HYDRA training sizes.
2
-
3
- Usage:
4
- source .venv/bin/activate
5
- export LD_LIBRARY_PATH=/usr/lib/wsl/lib:/usr/local/cuda-12.1/lib64:$LD_LIBRARY_PATH
6
- python htm_rust/bench_gpu.py
7
- """
8
- import os
9
- import sys
10
- import time
11
-
12
- # Ensure /home/mikeb/work/feather is on sys.path so `subsystems` imports.
13
- _FEATHER = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
14
- if _FEATHER not in sys.path:
15
- sys.path.insert(0, _FEATHER)
16
-
17
- import numpy as np
18
- import torch
19
-
20
- from subsystems.htm import HTMLayer
21
-
22
-
23
- def bench(layer: HTMLayer, sdr: torch.Tensor, warmup: int = 1, iters: int = 3) -> float:
24
- """Return mean ms/forward."""
25
- for _ in range(warmup):
26
- _ = layer(sdr)
27
- if torch.cuda.is_available():
28
- torch.cuda.synchronize()
29
- t0 = time.perf_counter()
30
- for _ in range(iters):
31
- _ = layer(sdr)
32
- if torch.cuda.is_available():
33
- torch.cuda.synchronize()
34
- dt = time.perf_counter() - t0
35
- return dt * 1000 / iters
36
-
37
-
38
- def main() -> None:
39
- # HYDRA training config: B=8, T=2048, bits=16384, cols=2048.
40
- B, T, D = int(os.environ.get("B", 8)), int(os.environ.get("T", 2048)), 16384
41
- n_cols = 2048
42
-
43
- print(f"config: B={B} T={T} D={D} n_cols={n_cols}")
44
- print(f"torch: {torch.__version__} cuda={torch.cuda.is_available()}")
45
-
46
- # Build a fixed sparse SDR once.
47
- rng = np.random.default_rng(0)
48
- sdr = np.zeros((B, T, D), dtype=bool)
49
- on = int(D * 0.02)
50
- for b in range(B):
51
- for t in range(T):
52
- idx = rng.choice(D, size=on, replace=False)
53
- sdr[b, t, idx] = True
54
- sdr_t = torch.from_numpy(sdr)
55
-
56
- # CPU baseline.
57
- print("\n--- CPU ---")
58
- cpu_layer = HTMLayer(
59
- input_bits=D, n_columns=n_cols, cells_per_column=32,
60
- batch_size=B, seed=42, use_gpu=False,
61
- )
62
- cpu_layer.train()
63
- cpu_ms = bench(cpu_layer, sdr_t, warmup=1, iters=2)
64
- print(f"CPU: {cpu_ms:.1f} ms/forward ({cpu_ms/T:.2f} ms/step Γ— T={T})")
65
-
66
- # GPU.
67
- print("\n--- GPU ---")
68
- gpu_layer = HTMLayer(
69
- input_bits=D, n_columns=n_cols, cells_per_column=32,
70
- batch_size=B, seed=42, use_gpu=True,
71
- )
72
- gpu_layer.train()
73
- sdr_cuda = sdr_t.cuda()
74
- gpu_ms = bench(gpu_layer, sdr_cuda, warmup=1, iters=2)
75
- print(f"GPU: {gpu_ms:.1f} ms/forward ({gpu_ms/T:.2f} ms/step Γ— T={T})")
76
-
77
- print(f"\nSpeedup: {cpu_ms / gpu_ms:.2f}x")
78
-
79
-
80
- if __name__ == "__main__":
81
- main()
 
1
+ """Microbenchmark: CPU vs GPU HTMLayer forward at HYDRA training sizes.
2
+
3
+ Usage:
4
+ source .venv/bin/activate
5
+ export LD_LIBRARY_PATH=/usr/lib/wsl/lib:/usr/local/cuda-12.1/lib64:$LD_LIBRARY_PATH
6
+ python htm_rust/bench_gpu.py
7
+ """
8
+ import os
9
+ import sys
10
+ import time
11
+
12
+ # Ensure /home/mikeb/work/feather is on sys.path so `subsystems` imports.
13
+ _FEATHER = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
14
+ if _FEATHER not in sys.path:
15
+ sys.path.insert(0, _FEATHER)
16
+
17
+ import numpy as np
18
+ import torch
19
+
20
+ from subsystems.htm import HTMLayer
21
+
22
+
23
+ def bench(layer: HTMLayer, sdr: torch.Tensor, warmup: int = 1, iters: int = 3) -> float:
24
+ """Return mean ms/forward."""
25
+ for _ in range(warmup):
26
+ _ = layer(sdr)
27
+ if torch.cuda.is_available():
28
+ torch.cuda.synchronize()
29
+ t0 = time.perf_counter()
30
+ for _ in range(iters):
31
+ _ = layer(sdr)
32
+ if torch.cuda.is_available():
33
+ torch.cuda.synchronize()
34
+ dt = time.perf_counter() - t0
35
+ return dt * 1000 / iters
36
+
37
+
38
+ def main() -> None:
39
+ # HYDRA training config: B=8, T=2048, bits=16384, cols=2048.
40
+ B, T, D = int(os.environ.get("B", 8)), int(os.environ.get("T", 2048)), 16384
41
+ n_cols = 2048
42
+
43
+ print(f"config: B={B} T={T} D={D} n_cols={n_cols}")
44
+ print(f"torch: {torch.__version__} cuda={torch.cuda.is_available()}")
45
+
46
+ # Build a fixed sparse SDR once.
47
+ rng = np.random.default_rng(0)
48
+ sdr = np.zeros((B, T, D), dtype=bool)
49
+ on = int(D * 0.02)
50
+ for b in range(B):
51
+ for t in range(T):
52
+ idx = rng.choice(D, size=on, replace=False)
53
+ sdr[b, t, idx] = True
54
+ sdr_t = torch.from_numpy(sdr)
55
+
56
+ # CPU baseline.
57
+ print("\n--- CPU ---")
58
+ cpu_layer = HTMLayer(
59
+ input_bits=D, n_columns=n_cols, cells_per_column=32,
60
+ batch_size=B, seed=42, use_gpu=False,
61
+ )
62
+ cpu_layer.train()
63
+ cpu_ms = bench(cpu_layer, sdr_t, warmup=1, iters=2)
64
+ print(f"CPU: {cpu_ms:.1f} ms/forward ({cpu_ms/T:.2f} ms/step Γ— T={T})")
65
+
66
+ # GPU.
67
+ print("\n--- GPU ---")
68
+ gpu_layer = HTMLayer(
69
+ input_bits=D, n_columns=n_cols, cells_per_column=32,
70
+ batch_size=B, seed=42, use_gpu=True,
71
+ )
72
+ gpu_layer.train()
73
+ sdr_cuda = sdr_t.cuda()
74
+ gpu_ms = bench(gpu_layer, sdr_cuda, warmup=1, iters=2)
75
+ print(f"GPU: {gpu_ms:.1f} ms/forward ({gpu_ms/T:.2f} ms/step Γ— T={T})")
76
+
77
+ print(f"\nSpeedup: {cpu_ms / gpu_ms:.2f}x")
78
+
79
+
80
+ if __name__ == "__main__":
81
+ main()
overlay/htm_rust/build.rs CHANGED
@@ -1,162 +1,162 @@
1
- //! Build script: compiles `.cu` kernel files to PTX when the `gpu` feature
2
- //! is enabled. PTX files are embedded into the final Rust binary via
3
- //! `include_str!` / `OUT_DIR` constants and JIT-loaded at runtime by cudarc.
4
- //!
5
- //! No-op when `gpu` feature is off β€” CPU-only builds have zero CUDA
6
- //! toolchain dependency.
7
- //!
8
- //! nvcc lookup order:
9
- //! 1. $NVCC env var
10
- //! 2. `nvcc` on PATH
11
- //! 3. `/usr/local/cuda-12.1/bin/nvcc`
12
- //! 4. `/usr/local/cuda/bin/nvcc`
13
- //!
14
- //! Default target: sm_86 (Ampere A10G / RTX 30xx). Override with $HTM_CUDA_ARCH (e.g. sm_90a for H200).
15
-
16
- use std::env;
17
- use std::path::PathBuf;
18
- use std::process::Command;
19
-
20
- fn main() {
21
- // Re-run whenever we edit the build script or any kernel source.
22
- println!("cargo:rerun-if-changed=build.rs");
23
-
24
- let gpu = env::var_os("CARGO_FEATURE_GPU").is_some();
25
- if !gpu {
26
- return;
27
- }
28
-
29
- // Kernels to compile. Each .cu file β†’ one .ptx file, embedded by name.
30
- let kernels: &[&str] = &[
31
- "sp_overlap",
32
- "sp_topk",
33
- "sp_learn",
34
- "sp_duty",
35
- "sp_boost_fused",
36
- "tm_predict",
37
- "tm_activate",
38
- "tm_learn",
39
- "tm_punish",
40
- "tm_grow",
41
- "tm_anomaly",
42
- "tm_reset",
43
- "htm_fused_step",
44
- ];
45
-
46
- let kernels_dir = PathBuf::from("src/gpu/kernels");
47
- for k in kernels {
48
- let src = kernels_dir.join(format!("{k}.cu"));
49
- println!("cargo:rerun-if-changed={}", src.display());
50
- }
51
-
52
- let out_dir = PathBuf::from(env::var("OUT_DIR").expect("OUT_DIR"));
53
- let arch = env::var("HTM_CUDA_ARCH").unwrap_or_else(|_| "sm_86".into());
54
-
55
- let nvcc = find_nvcc();
56
- println!("cargo:warning=htm_rust: nvcc = {nvcc}");
57
- println!("cargo:warning=htm_rust: target arch = {arch}");
58
-
59
- // Prefer gcc-12 if present (CUDA 12.1 doesn't support gcc-13+ headers).
60
- let host_compiler = env::var("HTM_CUDA_CCBIN")
61
- .ok()
62
- .or_else(|| {
63
- for cand in ["/usr/bin/gcc-12", "/usr/bin/gcc-11"] {
64
- if std::path::Path::new(cand).exists() {
65
- return Some(cand.to_string());
66
- }
67
- }
68
- None
69
- });
70
-
71
- // Optionally patch the emitted PTX `.version` header down to match an
72
- // older driver. Useful when the system driver (e.g. on WSL2) is older
73
- // than the nvcc toolchain. Set HTM_PTX_VERSION to e.g. "7.8" or "8.0".
74
- let ptx_version_override = env::var("HTM_PTX_VERSION").ok();
75
-
76
- for k in kernels {
77
- let src = kernels_dir.join(format!("{k}.cu"));
78
- let ptx = out_dir.join(format!("{k}.ptx"));
79
- if !src.exists() {
80
- panic!("missing kernel source: {}", src.display());
81
- }
82
- let mut cmd = Command::new(&nvcc);
83
- // Note: `--use_fast_math` breaks bit-parity with host `expf`, which
84
- // in turn flips boost tie-breaks in SP learning. We accept the tiny
85
- // perf loss for correctness; the hot overlap kernel has no transcendentals.
86
- cmd.args([
87
- "--ptx",
88
- "-O3",
89
- "-rdc=true",
90
- "-arch",
91
- &arch,
92
- ]);
93
- // `cooperative_groups::this_cluster()` is not declared for Ampere
94
- // device compiles in CUDA 12.x, even if guarded by __CUDA_ARCH__ in
95
- // some nvcc front-end phases. Define an explicit build-time kill
96
- // switch for all non-Hopper targets so sm_86/A10G only sees the
97
- // cooperative-grid path.
98
- if !arch.starts_with("sm_90") {
99
- cmd.arg("-DHTM_DISABLE_CLUSTER=1");
100
- }
101
- if let Some(cc) = &host_compiler {
102
- cmd.args(["-ccbin", cc]);
103
- }
104
- cmd.arg("-o").arg(&ptx).arg(&src);
105
- let status = cmd
106
- .status()
107
- .unwrap_or_else(|e| panic!("failed to spawn nvcc: {e}"));
108
- if !status.success() {
109
- panic!("nvcc failed for {}", src.display());
110
- }
111
-
112
- if let Some(ver) = &ptx_version_override {
113
- // Read, patch, write.
114
- let text = std::fs::read_to_string(&ptx)
115
- .unwrap_or_else(|e| panic!("read {} failed: {e}", ptx.display()));
116
- // Match `.version X.Y` where X and Y are digits. Replace whole line.
117
- let patched: String = text
118
- .lines()
119
- .map(|line| {
120
- let t = line.trim_start();
121
- if t.starts_with(".version ") {
122
- format!(".version {ver}")
123
- } else {
124
- line.to_string()
125
- }
126
- })
127
- .collect::<Vec<_>>()
128
- .join("\n");
129
- std::fs::write(&ptx, patched)
130
- .unwrap_or_else(|e| panic!("write {} failed: {e}", ptx.display()));
131
- }
132
- }
133
-
134
- // Export OUT_DIR for include_str! in Rust.
135
- println!(
136
- "cargo:rustc-env=HTM_GPU_PTX_DIR={}",
137
- out_dir.display()
138
- );
139
- }
140
-
141
- fn find_nvcc() -> String {
142
- if let Ok(n) = env::var("NVCC") {
143
- return n;
144
- }
145
- // Try PATH.
146
- if Command::new("nvcc").arg("--version").output().is_ok() {
147
- return "nvcc".into();
148
- }
149
- for cand in [
150
- "/usr/local/cuda-12.1/bin/nvcc",
151
- "/usr/local/cuda/bin/nvcc",
152
- "/usr/local/cuda-12/bin/nvcc",
153
- ] {
154
- if std::path::Path::new(cand).exists() {
155
- return cand.into();
156
- }
157
- }
158
- panic!(
159
- "nvcc not found. Set $NVCC or install CUDA toolkit. \
160
- Tried PATH, /usr/local/cuda-12.1, /usr/local/cuda."
161
- );
162
- }
 
1
+ //! Build script: compiles `.cu` kernel files to PTX when the `gpu` feature
2
+ //! is enabled. PTX files are embedded into the final Rust binary via
3
+ //! `include_str!` / `OUT_DIR` constants and JIT-loaded at runtime by cudarc.
4
+ //!
5
+ //! No-op when `gpu` feature is off β€” CPU-only builds have zero CUDA
6
+ //! toolchain dependency.
7
+ //!
8
+ //! nvcc lookup order:
9
+ //! 1. $NVCC env var
10
+ //! 2. `nvcc` on PATH
11
+ //! 3. `/usr/local/cuda-12.1/bin/nvcc`
12
+ //! 4. `/usr/local/cuda/bin/nvcc`
13
+ //!
14
+ //! Default target: sm_86 (Ampere A10G / RTX 30xx). Override with $HTM_CUDA_ARCH (e.g. sm_90a for H200).
15
+
16
+ use std::env;
17
+ use std::path::PathBuf;
18
+ use std::process::Command;
19
+
20
+ fn main() {
21
+ // Re-run whenever we edit the build script or any kernel source.
22
+ println!("cargo:rerun-if-changed=build.rs");
23
+
24
+ let gpu = env::var_os("CARGO_FEATURE_GPU").is_some();
25
+ if !gpu {
26
+ return;
27
+ }
28
+
29
+ // Kernels to compile. Each .cu file β†’ one .ptx file, embedded by name.
30
+ let kernels: &[&str] = &[
31
+ "sp_overlap",
32
+ "sp_topk",
33
+ "sp_learn",
34
+ "sp_duty",
35
+ "sp_boost_fused",
36
+ "tm_predict",
37
+ "tm_activate",
38
+ "tm_learn",
39
+ "tm_punish",
40
+ "tm_grow",
41
+ "tm_anomaly",
42
+ "tm_reset",
43
+ "htm_fused_step",
44
+ ];
45
+
46
+ let kernels_dir = PathBuf::from("src/gpu/kernels");
47
+ for k in kernels {
48
+ let src = kernels_dir.join(format!("{k}.cu"));
49
+ println!("cargo:rerun-if-changed={}", src.display());
50
+ }
51
+
52
+ let out_dir = PathBuf::from(env::var("OUT_DIR").expect("OUT_DIR"));
53
+ let arch = env::var("HTM_CUDA_ARCH").unwrap_or_else(|_| "sm_86".into());
54
+
55
+ let nvcc = find_nvcc();
56
+ println!("cargo:warning=htm_rust: nvcc = {nvcc}");
57
+ println!("cargo:warning=htm_rust: target arch = {arch}");
58
+
59
+ // Prefer gcc-12 if present (CUDA 12.1 doesn't support gcc-13+ headers).
60
+ let host_compiler = env::var("HTM_CUDA_CCBIN")
61
+ .ok()
62
+ .or_else(|| {
63
+ for cand in ["/usr/bin/gcc-12", "/usr/bin/gcc-11"] {
64
+ if std::path::Path::new(cand).exists() {
65
+ return Some(cand.to_string());
66
+ }
67
+ }
68
+ None
69
+ });
70
+
71
+ // Optionally patch the emitted PTX `.version` header down to match an
72
+ // older driver. Useful when the system driver (e.g. on WSL2) is older
73
+ // than the nvcc toolchain. Set HTM_PTX_VERSION to e.g. "7.8" or "8.0".
74
+ let ptx_version_override = env::var("HTM_PTX_VERSION").ok();
75
+
76
+ for k in kernels {
77
+ let src = kernels_dir.join(format!("{k}.cu"));
78
+ let ptx = out_dir.join(format!("{k}.ptx"));
79
+ if !src.exists() {
80
+ panic!("missing kernel source: {}", src.display());
81
+ }
82
+ let mut cmd = Command::new(&nvcc);
83
+ // Note: `--use_fast_math` breaks bit-parity with host `expf`, which
84
+ // in turn flips boost tie-breaks in SP learning. We accept the tiny
85
+ // perf loss for correctness; the hot overlap kernel has no transcendentals.
86
+ cmd.args([
87
+ "--ptx",
88
+ "-O3",
89
+ "-rdc=true",
90
+ "-arch",
91
+ &arch,
92
+ ]);
93
+ // `cooperative_groups::this_cluster()` is not declared for Ampere
94
+ // device compiles in CUDA 12.x, even if guarded by __CUDA_ARCH__ in
95
+ // some nvcc front-end phases. Define an explicit build-time kill
96
+ // switch for all non-Hopper targets so sm_86/A10G only sees the
97
+ // cooperative-grid path.
98
+ if !arch.starts_with("sm_90") {
99
+ cmd.arg("-DHTM_DISABLE_CLUSTER=1");
100
+ }
101
+ if let Some(cc) = &host_compiler {
102
+ cmd.args(["-ccbin", cc]);
103
+ }
104
+ cmd.arg("-o").arg(&ptx).arg(&src);
105
+ let status = cmd
106
+ .status()
107
+ .unwrap_or_else(|e| panic!("failed to spawn nvcc: {e}"));
108
+ if !status.success() {
109
+ panic!("nvcc failed for {}", src.display());
110
+ }
111
+
112
+ if let Some(ver) = &ptx_version_override {
113
+ // Read, patch, write.
114
+ let text = std::fs::read_to_string(&ptx)
115
+ .unwrap_or_else(|e| panic!("read {} failed: {e}", ptx.display()));
116
+ // Match `.version X.Y` where X and Y are digits. Replace whole line.
117
+ let patched: String = text
118
+ .lines()
119
+ .map(|line| {
120
+ let t = line.trim_start();
121
+ if t.starts_with(".version ") {
122
+ format!(".version {ver}")
123
+ } else {
124
+ line.to_string()
125
+ }
126
+ })
127
+ .collect::<Vec<_>>()
128
+ .join("\n");
129
+ std::fs::write(&ptx, patched)
130
+ .unwrap_or_else(|e| panic!("write {} failed: {e}", ptx.display()));
131
+ }
132
+ }
133
+
134
+ // Export OUT_DIR for include_str! in Rust.
135
+ println!(
136
+ "cargo:rustc-env=HTM_GPU_PTX_DIR={}",
137
+ out_dir.display()
138
+ );
139
+ }
140
+
141
+ fn find_nvcc() -> String {
142
+ if let Ok(n) = env::var("NVCC") {
143
+ return n;
144
+ }
145
+ // Try PATH.
146
+ if Command::new("nvcc").arg("--version").output().is_ok() {
147
+ return "nvcc".into();
148
+ }
149
+ for cand in [
150
+ "/usr/local/cuda-12.1/bin/nvcc",
151
+ "/usr/local/cuda/bin/nvcc",
152
+ "/usr/local/cuda-12/bin/nvcc",
153
+ ] {
154
+ if std::path::Path::new(cand).exists() {
155
+ return cand.into();
156
+ }
157
+ }
158
+ panic!(
159
+ "nvcc not found. Set $NVCC or install CUDA toolkit. \
160
+ Tried PATH, /usr/local/cuda-12.1, /usr/local/cuda."
161
+ );
162
+ }
overlay/htm_rust/docs/GPU_HTM.md CHANGED
@@ -1,302 +1,302 @@
1
- # GPU HTM Backend
2
-
3
- ## Status
4
-
5
- **FUSED MEGAKERNEL: entire T-timestep SP+TM forward collapsed into a single
6
- CUDA launch per forward pass.**
7
-
8
- * Legacy path: 12 kernels Γ— T=2048 timesteps = 24K launches per forward.
9
- * Fused path: **1 launch per forward** (24000Γ— launch-overhead reduction).
10
- * End-to-end training throughput: **~2.7k β†’ ~60k tok/sec** (~22x speedup).
11
- * Fused path uses per-column threshold inhibition instead of global top-K
12
- (see Β§Fused Kernel below β€” this is a real architectural change).
13
-
14
- ## Fused Kernel
15
-
16
- ### Why
17
-
18
- Global top-K column selection requires cross-block synchronization at every
19
- timestep. On WSL2/sm_86 without `-rdc=true`, `cooperative_groups::grid_sync()`
20
- is unreliable. Without a grid sync, collapsing the T-loop into one kernel is
21
- impossible, so every forward pays 12Γ—T kernel launches and 90%+ of runtime is
22
- CUDA launch overhead + small-kernel tails.
23
-
24
- ### How
25
-
26
- Replace global top-K with **per-column threshold activation**:
27
-
28
- is_active[c] = (overlap[c] * boost[c]) > inhibition_threshold[c]
29
-
30
- `inhibition_threshold[c]` is a per-column scalar, learned via EMA update:
31
-
32
- err = active_duty[c] - sparsity_target
33
- new_thr = clamp(thr + thr_adapt_rate * err * 100, 0.1, 1000)
34
-
35
- This is biologically grounded (GABAergic local lateral inhibition in
36
- neocortical columns) and supported by HTM theory. The duty-cycle-driven
37
- feedback loop was already present; we simply redirect its output to drive
38
- activation threshold instead of multiplicative boost. The global top-K,
39
- which had no biological basis, is removed.
40
-
41
- ### Cross-block coherence
42
-
43
- - **Ping-pong bitsets** for `cell_active_bits` and `cell_winner_bits`: at
44
- even t write to `_a`, read from `_b`; at odd t reversed. This eliminates
45
- the need for an in-place snapshot kernel between timesteps.
46
- - **Primary path: cooperative launch + hardware grid sync**. Host code probes
47
- `CU_DEVICE_ATTRIBUTE_COOPERATIVE_LAUNCH`, computes the cooperative whole-grid
48
- residency limit from occupancy, and launches the fused megakernel with
49
- `cuLaunchCooperativeKernel`. In-kernel barriers use
50
- `cooperative_groups::this_grid().sync()`.
51
- - **Fallback path: software grid barrier** via a 3-slot atomic counter array
52
- (`barrier_counters`). This remains as a compatibility fallback when
53
- cooperative launch is unavailable.
54
- - **Launch invariant**: cooperative launch is capped to the hardware residency
55
- limit for `blockDim.x = 1024`; software fallback remains capped conservatively
56
- (`HTM_FUSED_GRID_CAP`, default 8) to avoid whole-grid spin deadlock.
57
-
58
- ### Kernel structure
59
-
60
- ```
61
- for t in 0..T:
62
- # Phase 0: clear curr_active/curr_winner for my column range
63
- grid_barrier()
64
- # Phase A: SP overlap β†’ boost β†’ threshold β†’ SP learn β†’ duty + threshold EMA
65
- grid_barrier()
66
- # Phase B: TM predict (per cell, per seg) β†’ TM learn (reinforce on match)
67
- # β†’ burst if none predicted β†’ segment grow/reinforce
68
- grid_barrier()
69
- # Phase C: block 0 writes anomaly[t]
70
- ```
71
-
72
- Each warp owns a contiguous slice of columns. At grid=24 blocks Γ— 32 warps =
73
- 768 warps, n_columns=2048 β†’ 2-3 columns per warp.
74
-
75
- ### Parity with legacy GPU path
76
-
77
- **Semantics diverge**. Legacy: exactly `k = round(sparsity * n_cols)` columns
78
- active per step. Fused: variable, converging to `sparsity * n_cols` on
79
- average via the per-column EMA. Anomaly decay on repeating sequences is
80
- preserved (see `gpu_fused_tm_anomaly_decays_on_repeating_sequence` test).
81
-
82
- This is an intentional architectural change committed under
83
- `no-bypass/full-architecture` per program.md rules. The legacy top-K path
84
- (`step_many_cuda`) remains available for reference and can be re-enabled via
85
- `HYDRA_HTM_FUSED=0`.
86
-
87
- ### Tests
88
-
89
- - `gpu_threshold_converges_to_sparsity` (tests.rs): 1000-step warmup on
90
- random SDRs, then measure mean active cols/step on next 200 steps. Must
91
- land within [0.25Γ—, 4Γ—] of `sparsity_target * n_cols`.
92
- - `gpu_fused_tm_anomaly_decays_on_repeating_sequence`: feed A,B,C repeating
93
- for 300 steps. Late anomaly must be < early anomaly AND < 0.5.
94
-
95
- ## Legacy Pipeline (kept for fallback)
96
-
97
- * SP: 5 kernels, bit-identical parity with CPU under strict-parity mode.
98
- * TM: 7 kernels, relaxed-parity with CPU.
99
- * Speedup at training size (B=8, T=2048, bits=16384): **3.83x** vs CPU.
100
-
101
- ## Building
102
-
103
- CPU-only (default, zero CUDA dep):
104
- ```bash
105
- cargo build --release
106
- ```
107
-
108
- GPU-enabled:
109
- ```bash
110
- export PATH=/usr/local/cuda-12.1/bin:$PATH
111
- export LD_LIBRARY_PATH=/usr/lib/wsl/lib:/usr/local/cuda-12.1/lib64:$LD_LIBRARY_PATH
112
- export HTM_PTX_VERSION=7.8 # lower if driver older than nvcc
113
- cargo build --release --features gpu
114
- cargo test --release --features gpu --lib # fused path includes cooperative launch + grid-sync tests
115
-
116
- # Python wheel:
117
- maturin develop --release --features gpu --manifest-path htm_rust/Cargo.toml
118
- ```
119
-
120
- ## Architecture
121
-
122
- ### Module layout
123
- ```
124
- src/gpu/
125
- mod.rs # HTMRegionGpu pyclass + step_many_gpu (full pipeline)
126
- sp_gpu.rs # Persistent SP device buffers + step_batch_with_tm
127
- tm_gpu.rs # Persistent TM device buffers + step (predict→activate→learn)
128
- tests.rs # CPU-vs-GPU SP parity + end-to-end TM anomaly decay
129
- kernels/
130
- sp_overlap.cu # per-column overlap reduction
131
- sp_topk.cu # k-WTA top-K winner selection
132
- sp_learn.cu # Hebbian +inc/-dec on proximal synapses
133
- sp_duty.cu # EMA duty-cycle update
134
- sp_boost_fused.cu # fused mean + exp boost (GPU-side)
135
- tm_reset.cu # per-step: snapshot active→prev, clear buffers
136
- tm_predict.cu # per-cell: score owned segments vs prev_active_bits
137
- tm_activate.cu # per-col: activate predicted cells OR burst
138
- tm_learn.cu # per-cell: reinforce correctly-predicted segments
139
- tm_punish.cu # per-cell: decay matching segs on inactive cols
140
- tm_grow.cu # per-bursting-col: reuse matching seg OR create new,
141
- # grow synapses to prev_winners
142
- tm_anomaly.cu # per-step: unpredicted/active ratio
143
- ```
144
-
145
- ### Persistent SP state (per region, unchanged from Phase 1)
146
- At n_cols=2048, S=40, bits=16384: ~355 KB persistent + ~90 KB transient.
147
-
148
- ### Persistent TM state (per region)
149
-
150
- Capacity knobs (configured in `tm_gpu.rs`):
151
- - `MAX_SEGMENTS_PER_CELL = 4`
152
- - `MAX_SYN_PER_SEGMENT = 20`
153
-
154
- At cells_per_col=32, n_cols=2048:
155
- - `n_cells = 65_536`
156
- - `n_segments_max = 262_144` (~262K)
157
- - `n_synapses_max = 5_242_880` (~5.2M)
158
-
159
- | Buffer | Shape / type | Notes |
160
- |-----------------------|----------------------|----------------------------------------|
161
- | `seg_cell_id` | (n_segs,) u32 | owning cell; U32_MAX = unused |
162
- | `seg_syn_count` | (n_segs,) u32 | #active synapses in slot |
163
- | `syn_presyn` | (n_segs Γ— S,) u32 | presynaptic cell indices |
164
- | `syn_perm` | (n_segs Γ— S,) i16 | permanence scaled 0..32767 (0.0..1.0) |
165
- | `cell_seg_count` | (n_cells,) u32 | segments allocated on each cell |
166
- | `cell_active_bits` | (n_cells/32,) u32 | packed bitset, current step |
167
- | `cell_winner_bits` | (n_cells/32,) u32 | packed bitset, current step |
168
- | `cell_predictive_bits`| (n_cells/32,) u32 | set by predict, read by activate |
169
- | `prev_active_bits` | (n_cells/32,) u32 | snapshot at step start |
170
- | `prev_winner_bits` | (n_cells/32,) u32 | snapshot at step start |
171
- | `col_predicted` | (n_cols,) u8 | set if any cell in col is predictive |
172
- | `col_best_match` | (n_cols,) u32 | packed (pot<<21 | seg_id), atomicMax |
173
- | `seg_num_active_conn` | (n_segs,) u32 | output of predict |
174
- | `seg_num_active_pot` | (n_segs,) u32 | output of predict |
175
- | `unpredicted_count` | (1,) u32 | atomic counter for anomaly |
176
- | `burst_cols_flat` | (n_cols,) u32 | list of bursting cols |
177
- | `burst_cols_count` | (1,) u32 | length of above list |
178
-
179
- **Total per TM region: ~42 MB.** Batch of 8 regions: ~340 MB. Fits 6 GB RTX 3060.
180
-
181
- ### Per-step pipeline (single iteration of `step_batch_with_tm`)
182
-
183
- ```
184
- SP side TM side
185
- --------- ---------
186
- 1. D2D input slice β†’ inp_dev
187
- 2. sp_overlap (n_cols blocks)
188
- 3. sp_topk (1 block)
189
- 4. sp_learn (n_cols blocks)
190
- 5. sp_duty (n_cols/256 blocks)
191
- 6. sp_boost_fused (1 block)
192
- 7. D2D active_mask β†’ cols_dev[ti]
193
- 8. tm_reset_step (ceil(n_cells/32/256))
194
- 9. tm_predict (n_cells blocks Γ— 32 thr)
195
- 10. tm_activate (n_cols/256 blocks)
196
- 11. tm_anomaly (1 block)
197
- if learn:
198
- 12. tm_learn (n_cells blocks)
199
- 13. tm_punish (n_cells blocks)
200
- 14. tm_grow (n_cols blocks β€” early-exits)
201
- ```
202
-
203
- No host sync in the T-step loop. At the end one `dtoh_sync_copy` each for
204
- `cols_dev` (T Γ— n_cols bytes) and `anom_dev` (T Γ— f32).
205
-
206
- ## Parity
207
-
208
- ### SP: strict bit-identical
209
- See Phase 1 docs β€” `gpu_sp_matches_cpu_with_learn` over 50 steps passes exact.
210
-
211
- ### TM: relaxed-parity
212
- The GPU TM has known, deliberate deviations from CPU to admit massive parallelism:
213
-
214
- 1. **Bursting winner cell**: CPU picks the least-used cell (fewest segments) with
215
- random tiebreak. GPU picks cell 0 of the column (deterministic, branch-free).
216
- Learning dynamics are preserved because segment creation/reinforcement is
217
- the dominant effect, not which specific cell in a bursting column wins.
218
-
219
- 2. **Permanence storage**: i16 fixed-point (scale 32767) vs f32. Rounding
220
- differs by <=1 ULP of the scale (~3.0e-5), below any meaningful learning
221
- quantum (inc=0.10, dec=0.10, predicted_segment_dec=0.10).
222
-
223
- 3. **Grown synapse candidate order**: CPU randomly samples from prev_winner_cells.
224
- GPU iterates prev_winner_bits words in a pseudo-random rotated order keyed
225
- by (bursting_col_idx, iter_seed). Output is a different subset but same size.
226
-
227
- 4. **Segment LRU eviction**: CPU tracks `last_used_iteration` per segment.
228
- GPU wraps around (slot = count % max_segments_per_cell). In the autoresearch
229
- loop where TM resets every forward, eviction rarely triggers.
230
-
231
- The GPU parity test (`gpu_tm_anomaly_decays_on_repeating_sequence`) feeds a
232
- repeating A,B,C sequence and asserts anomaly decays: **1.000 early β†’ 0.000 late**.
233
-
234
- ## Bottleneck Analysis
235
-
236
- | Source | Cost/step (B=8 T=2048) |
237
- |----------------------------------|-------------------------:|
238
- | 14 kernel launches | ~70 ΞΌs |
239
- | ~262K predict/learn/punish blocks| ~2.5 ms |
240
- | No D2H until end-of-batch | 0 ΞΌs |
241
- | Final D2H (T Γ— n_cols + T Γ— f32) | ~200 ΞΌs per region |
242
-
243
- Per-step wall time at B=8 T=2048:
244
- - CPU (reference): **~11.4 ms / step**
245
- - GPU (current): **~2.98 ms / step**
246
- - **Speedup: 3.83x**
247
-
248
- ## End-to-End Training Benchmark
249
-
250
- **Config**: B=8, T=2048, vocab=8192, 60-second time budget, full HYDRA stack
251
- (SDR Semantic + HTM + Mamba-3 + Engram + mHC + Hestia QAT).
252
-
253
- **Results**:
254
- - GPU util: **97-98% sustained**
255
- - VRAM: **5.4 GB / 6.0 GB** (90% utilisation)
256
- - Steps completed: 16
257
- - tok/sec: **~2,200-2,500** (stable post-warmup)
258
- - Final val_bpb: **2.249** (from ~3.1 initial)
259
- - Factual eval: 1/9 hits
260
-
261
- Compared to previous CPU-HTM baseline (~100 tok/s), the full-GPU HTM delivers
262
- **~22x end-to-end throughput** β€” far above the 3-10x target.
263
-
264
- ## Bench Commands
265
-
266
- ```bash
267
- source .venv/bin/activate
268
- export LD_LIBRARY_PATH=/usr/lib/wsl/lib:/usr/local/cuda-12.1/lib64:$LD_LIBRARY_PATH
269
-
270
- # Microbench
271
- B=8 T=2048 python htm_rust/bench_gpu.py
272
-
273
- # Full training
274
- HYDRA_TIME_BUDGET=60 HYDRA_BATCH_SIZE=8 HYDRA_TOTAL_BATCH=32768 python -u train.py
275
- ```
276
-
277
- ## Known Limitations / Future Work
278
-
279
- - **Segment-compacted launches**: predict/learn/punish iterate all n_cells
280
- blocks, using `cell_seg_count` to skip empty cells. A compacted live-cell
281
- list would shave another ~40% of launch overhead.
282
- - **Winner selection**: currently cell 0 of bursting col. Proper least-used
283
- selection would help stability of cross-column patterns.
284
- - **Single CUDA stream per region**: with B=8 regions we serialise on stream 0.
285
- Multi-stream would lift the ~20% launch overhead at small batch sizes.
286
- - **Permanence bump on chronically under-stimulated columns**: SP's strict-parity
287
- bump is not mirrored on GPU fast path. Effect on long runs needs measurement.
288
- - **`seg_num_active_conn` output is reused across reinforce + punish**: the two
289
- kernels each launch n_cells blocks. They could be fused into one for one fewer
290
- kernel launch per step.
291
-
292
- ## Files
293
-
294
- - `htm_rust/build.rs` β€” nvcc-driven PTX compilation, 12 kernels.
295
- - `htm_rust/Cargo.toml` β€” `gpu` feature flag, cudarc dep.
296
- - `htm_rust/src/gpu/mod.rs` β€” `HTMRegionGpu` pyclass + `step_many_gpu`.
297
- - `htm_rust/src/gpu/sp_gpu.rs` β€” SP state + `step_batch_with_tm`.
298
- - `htm_rust/src/gpu/tm_gpu.rs` β€” TM state + `step`.
299
- - `htm_rust/src/gpu/tests.rs` β€” parity + correctness tests.
300
- - `htm_rust/src/gpu/kernels/*.cu` β€” 5 SP + 7 TM kernels.
301
- - `htm_rust/bench_gpu.py` β€” CPU-vs-GPU microbench.
302
- - `subsystems/htm.py` β€” transparent GPU/CPU backend selection in `HTMLayer`.
 
1
+ # GPU HTM Backend
2
+
3
+ ## Status
4
+
5
+ **FUSED MEGAKERNEL: entire T-timestep SP+TM forward collapsed into a single
6
+ CUDA launch per forward pass.**
7
+
8
+ * Legacy path: 12 kernels Γ— T=2048 timesteps = 24K launches per forward.
9
+ * Fused path: **1 launch per forward** (24000Γ— launch-overhead reduction).
10
+ * End-to-end training throughput: **~2.7k β†’ ~60k tok/sec** (~22x speedup).
11
+ * Fused path uses per-column threshold inhibition instead of global top-K
12
+ (see Β§Fused Kernel below β€” this is a real architectural change).
13
+
14
+ ## Fused Kernel
15
+
16
+ ### Why
17
+
18
+ Global top-K column selection requires cross-block synchronization at every
19
+ timestep. On WSL2/sm_86 without `-rdc=true`, `cooperative_groups::grid_sync()`
20
+ is unreliable. Without a grid sync, collapsing the T-loop into one kernel is
21
+ impossible, so every forward pays 12Γ—T kernel launches and 90%+ of runtime is
22
+ CUDA launch overhead + small-kernel tails.
23
+
24
+ ### How
25
+
26
+ Replace global top-K with **per-column threshold activation**:
27
+
28
+ is_active[c] = (overlap[c] * boost[c]) > inhibition_threshold[c]
29
+
30
+ `inhibition_threshold[c]` is a per-column scalar, learned via EMA update:
31
+
32
+ err = active_duty[c] - sparsity_target
33
+ new_thr = clamp(thr + thr_adapt_rate * err * 100, 0.1, 1000)
34
+
35
+ This is biologically grounded (GABAergic local lateral inhibition in
36
+ neocortical columns) and supported by HTM theory. The duty-cycle-driven
37
+ feedback loop was already present; we simply redirect its output to drive
38
+ activation threshold instead of multiplicative boost. The global top-K,
39
+ which had no biological basis, is removed.
40
+
41
+ ### Cross-block coherence
42
+
43
+ - **Ping-pong bitsets** for `cell_active_bits` and `cell_winner_bits`: at
44
+ even t write to `_a`, read from `_b`; at odd t reversed. This eliminates
45
+ the need for an in-place snapshot kernel between timesteps.
46
+ - **Primary path: cooperative launch + hardware grid sync**. Host code probes
47
+ `CU_DEVICE_ATTRIBUTE_COOPERATIVE_LAUNCH`, computes the cooperative whole-grid
48
+ residency limit from occupancy, and launches the fused megakernel with
49
+ `cuLaunchCooperativeKernel`. In-kernel barriers use
50
+ `cooperative_groups::this_grid().sync()`.
51
+ - **Fallback path: software grid barrier** via a 3-slot atomic counter array
52
+ (`barrier_counters`). This remains as a compatibility fallback when
53
+ cooperative launch is unavailable.
54
+ - **Launch invariant**: cooperative launch is capped to the hardware residency
55
+ limit for `blockDim.x = 1024`; software fallback remains capped conservatively
56
+ (`HTM_FUSED_GRID_CAP`, default 8) to avoid whole-grid spin deadlock.
57
+
58
+ ### Kernel structure
59
+
60
+ ```
61
+ for t in 0..T:
62
+ # Phase 0: clear curr_active/curr_winner for my column range
63
+ grid_barrier()
64
+ # Phase A: SP overlap β†’ boost β†’ threshold β†’ SP learn β†’ duty + threshold EMA
65
+ grid_barrier()
66
+ # Phase B: TM predict (per cell, per seg) β†’ TM learn (reinforce on match)
67
+ # β†’ burst if none predicted β†’ segment grow/reinforce
68
+ grid_barrier()
69
+ # Phase C: block 0 writes anomaly[t]
70
+ ```
71
+
72
+ Each warp owns a contiguous slice of columns. At grid=24 blocks Γ— 32 warps =
73
+ 768 warps, n_columns=2048 β†’ 2-3 columns per warp.
74
+
75
+ ### Parity with legacy GPU path
76
+
77
+ **Semantics diverge**. Legacy: exactly `k = round(sparsity * n_cols)` columns
78
+ active per step. Fused: variable, converging to `sparsity * n_cols` on
79
+ average via the per-column EMA. Anomaly decay on repeating sequences is
80
+ preserved (see `gpu_fused_tm_anomaly_decays_on_repeating_sequence` test).
81
+
82
+ This is an intentional architectural change committed under
83
+ `no-bypass/full-architecture` per program.md rules. The legacy top-K path
84
+ (`step_many_cuda`) remains available for reference and can be re-enabled via
85
+ `HYDRA_HTM_FUSED=0`.
86
+
87
+ ### Tests
88
+
89
+ - `gpu_threshold_converges_to_sparsity` (tests.rs): 1000-step warmup on
90
+ random SDRs, then measure mean active cols/step on next 200 steps. Must
91
+ land within [0.25Γ—, 4Γ—] of `sparsity_target * n_cols`.
92
+ - `gpu_fused_tm_anomaly_decays_on_repeating_sequence`: feed A,B,C repeating
93
+ for 300 steps. Late anomaly must be < early anomaly AND < 0.5.
94
+
95
+ ## Legacy Pipeline (kept for fallback)
96
+
97
+ * SP: 5 kernels, bit-identical parity with CPU under strict-parity mode.
98
+ * TM: 7 kernels, relaxed-parity with CPU.
99
+ * Speedup at training size (B=8, T=2048, bits=16384): **3.83x** vs CPU.
100
+
101
+ ## Building
102
+
103
+ CPU-only (default, zero CUDA dep):
104
+ ```bash
105
+ cargo build --release
106
+ ```
107
+
108
+ GPU-enabled:
109
+ ```bash
110
+ export PATH=/usr/local/cuda-12.1/bin:$PATH
111
+ export LD_LIBRARY_PATH=/usr/lib/wsl/lib:/usr/local/cuda-12.1/lib64:$LD_LIBRARY_PATH
112
+ export HTM_PTX_VERSION=7.8 # lower if driver older than nvcc
113
+ cargo build --release --features gpu
114
+ cargo test --release --features gpu --lib # fused path includes cooperative launch + grid-sync tests
115
+
116
+ # Python wheel:
117
+ maturin develop --release --features gpu --manifest-path htm_rust/Cargo.toml
118
+ ```
119
+
120
+ ## Architecture
121
+
122
+ ### Module layout
123
+ ```
124
+ src/gpu/
125
+ mod.rs # HTMRegionGpu pyclass + step_many_gpu (full pipeline)
126
+ sp_gpu.rs # Persistent SP device buffers + step_batch_with_tm
127
+ tm_gpu.rs # Persistent TM device buffers + step (predict→activate→learn)
128
+ tests.rs # CPU-vs-GPU SP parity + end-to-end TM anomaly decay
129
+ kernels/
130
+ sp_overlap.cu # per-column overlap reduction
131
+ sp_topk.cu # k-WTA top-K winner selection
132
+ sp_learn.cu # Hebbian +inc/-dec on proximal synapses
133
+ sp_duty.cu # EMA duty-cycle update
134
+ sp_boost_fused.cu # fused mean + exp boost (GPU-side)
135
+ tm_reset.cu # per-step: snapshot active→prev, clear buffers
136
+ tm_predict.cu # per-cell: score owned segments vs prev_active_bits
137
+ tm_activate.cu # per-col: activate predicted cells OR burst
138
+ tm_learn.cu # per-cell: reinforce correctly-predicted segments
139
+ tm_punish.cu # per-cell: decay matching segs on inactive cols
140
+ tm_grow.cu # per-bursting-col: reuse matching seg OR create new,
141
+ # grow synapses to prev_winners
142
+ tm_anomaly.cu # per-step: unpredicted/active ratio
143
+ ```
144
+
145
+ ### Persistent SP state (per region, unchanged from Phase 1)
146
+ At n_cols=2048, S=40, bits=16384: ~355 KB persistent + ~90 KB transient.
147
+
148
+ ### Persistent TM state (per region)
149
+
150
+ Capacity knobs (configured in `tm_gpu.rs`):
151
+ - `MAX_SEGMENTS_PER_CELL = 4`
152
+ - `MAX_SYN_PER_SEGMENT = 20`
153
+
154
+ At cells_per_col=32, n_cols=2048:
155
+ - `n_cells = 65_536`
156
+ - `n_segments_max = 262_144` (~262K)
157
+ - `n_synapses_max = 5_242_880` (~5.2M)
158
+
159
+ | Buffer | Shape / type | Notes |
160
+ |-----------------------|----------------------|----------------------------------------|
161
+ | `seg_cell_id` | (n_segs,) u32 | owning cell; U32_MAX = unused |
162
+ | `seg_syn_count` | (n_segs,) u32 | #active synapses in slot |
163
+ | `syn_presyn` | (n_segs Γ— S,) u32 | presynaptic cell indices |
164
+ | `syn_perm` | (n_segs Γ— S,) i16 | permanence scaled 0..32767 (0.0..1.0) |
165
+ | `cell_seg_count` | (n_cells,) u32 | segments allocated on each cell |
166
+ | `cell_active_bits` | (n_cells/32,) u32 | packed bitset, current step |
167
+ | `cell_winner_bits` | (n_cells/32,) u32 | packed bitset, current step |
168
+ | `cell_predictive_bits`| (n_cells/32,) u32 | set by predict, read by activate |
169
+ | `prev_active_bits` | (n_cells/32,) u32 | snapshot at step start |
170
+ | `prev_winner_bits` | (n_cells/32,) u32 | snapshot at step start |
171
+ | `col_predicted` | (n_cols,) u8 | set if any cell in col is predictive |
172
+ | `col_best_match` | (n_cols,) u32 | packed (pot<<21 | seg_id), atomicMax |
173
+ | `seg_num_active_conn` | (n_segs,) u32 | output of predict |
174
+ | `seg_num_active_pot` | (n_segs,) u32 | output of predict |
175
+ | `unpredicted_count` | (1,) u32 | atomic counter for anomaly |
176
+ | `burst_cols_flat` | (n_cols,) u32 | list of bursting cols |
177
+ | `burst_cols_count` | (1,) u32 | length of above list |
178
+
179
+ **Total per TM region: ~42 MB.** Batch of 8 regions: ~340 MB. Fits 6 GB RTX 3060.
180
+
181
+ ### Per-step pipeline (single iteration of `step_batch_with_tm`)
182
+
183
+ ```
184
+ SP side TM side
185
+ --------- ---------
186
+ 1. D2D input slice β†’ inp_dev
187
+ 2. sp_overlap (n_cols blocks)
188
+ 3. sp_topk (1 block)
189
+ 4. sp_learn (n_cols blocks)
190
+ 5. sp_duty (n_cols/256 blocks)
191
+ 6. sp_boost_fused (1 block)
192
+ 7. D2D active_mask β†’ cols_dev[ti]
193
+ 8. tm_reset_step (ceil(n_cells/32/256))
194
+ 9. tm_predict (n_cells blocks Γ— 32 thr)
195
+ 10. tm_activate (n_cols/256 blocks)
196
+ 11. tm_anomaly (1 block)
197
+ if learn:
198
+ 12. tm_learn (n_cells blocks)
199
+ 13. tm_punish (n_cells blocks)
200
+ 14. tm_grow (n_cols blocks β€” early-exits)
201
+ ```
202
+
203
+ No host sync in the T-step loop. At the end one `dtoh_sync_copy` each for
204
+ `cols_dev` (T Γ— n_cols bytes) and `anom_dev` (T Γ— f32).
205
+
206
+ ## Parity
207
+
208
+ ### SP: strict bit-identical
209
+ See Phase 1 docs β€” `gpu_sp_matches_cpu_with_learn` over 50 steps passes exact.
210
+
211
+ ### TM: relaxed-parity
212
+ The GPU TM has known, deliberate deviations from CPU to admit massive parallelism:
213
+
214
+ 1. **Bursting winner cell**: CPU picks the least-used cell (fewest segments) with
215
+ random tiebreak. GPU picks cell 0 of the column (deterministic, branch-free).
216
+ Learning dynamics are preserved because segment creation/reinforcement is
217
+ the dominant effect, not which specific cell in a bursting column wins.
218
+
219
+ 2. **Permanence storage**: i16 fixed-point (scale 32767) vs f32. Rounding
220
+ differs by <=1 ULP of the scale (~3.0e-5), below any meaningful learning
221
+ quantum (inc=0.10, dec=0.10, predicted_segment_dec=0.10).
222
+
223
+ 3. **Grown synapse candidate order**: CPU randomly samples from prev_winner_cells.
224
+ GPU iterates prev_winner_bits words in a pseudo-random rotated order keyed
225
+ by (bursting_col_idx, iter_seed). Output is a different subset but same size.
226
+
227
+ 4. **Segment LRU eviction**: CPU tracks `last_used_iteration` per segment.
228
+ GPU wraps around (slot = count % max_segments_per_cell). In the autoresearch
229
+ loop where TM resets every forward, eviction rarely triggers.
230
+
231
+ The GPU parity test (`gpu_tm_anomaly_decays_on_repeating_sequence`) feeds a
232
+ repeating A,B,C sequence and asserts anomaly decays: **1.000 early β†’ 0.000 late**.
233
+
234
+ ## Bottleneck Analysis
235
+
236
+ | Source | Cost/step (B=8 T=2048) |
237
+ |----------------------------------|-------------------------:|
238
+ | 14 kernel launches | ~70 ΞΌs |
239
+ | ~262K predict/learn/punish blocks| ~2.5 ms |
240
+ | No D2H until end-of-batch | 0 ΞΌs |
241
+ | Final D2H (T Γ— n_cols + T Γ— f32) | ~200 ΞΌs per region |
242
+
243
+ Per-step wall time at B=8 T=2048:
244
+ - CPU (reference): **~11.4 ms / step**
245
+ - GPU (current): **~2.98 ms / step**
246
+ - **Speedup: 3.83x**
247
+
248
+ ## End-to-End Training Benchmark
249
+
250
+ **Config**: B=8, T=2048, vocab=8192, 60-second time budget, full HYDRA stack
251
+ (SDR Semantic + HTM + Mamba-3 + Engram + mHC + Hestia QAT).
252
+
253
+ **Results**:
254
+ - GPU util: **97-98% sustained**
255
+ - VRAM: **5.4 GB / 6.0 GB** (90% utilisation)
256
+ - Steps completed: 16
257
+ - tok/sec: **~2,200-2,500** (stable post-warmup)
258
+ - Final val_bpb: **2.249** (from ~3.1 initial)
259
+ - Factual eval: 1/9 hits
260
+
261
+ Compared to previous CPU-HTM baseline (~100 tok/s), the full-GPU HTM delivers
262
+ **~22x end-to-end throughput** β€” far above the 3-10x target.
263
+
264
+ ## Bench Commands
265
+
266
+ ```bash
267
+ source .venv/bin/activate
268
+ export LD_LIBRARY_PATH=/usr/lib/wsl/lib:/usr/local/cuda-12.1/lib64:$LD_LIBRARY_PATH
269
+
270
+ # Microbench
271
+ B=8 T=2048 python htm_rust/bench_gpu.py
272
+
273
+ # Full training
274
+ HYDRA_TIME_BUDGET=60 HYDRA_BATCH_SIZE=8 HYDRA_TOTAL_BATCH=32768 python -u train.py
275
+ ```
276
+
277
+ ## Known Limitations / Future Work
278
+
279
+ - **Segment-compacted launches**: predict/learn/punish iterate all n_cells
280
+ blocks, using `cell_seg_count` to skip empty cells. A compacted live-cell
281
+ list would shave another ~40% of launch overhead.
282
+ - **Winner selection**: currently cell 0 of bursting col. Proper least-used
283
+ selection would help stability of cross-column patterns.
284
+ - **Single CUDA stream per region**: with B=8 regions we serialise on stream 0.
285
+ Multi-stream would lift the ~20% launch overhead at small batch sizes.
286
+ - **Permanence bump on chronically under-stimulated columns**: SP's strict-parity
287
+ bump is not mirrored on GPU fast path. Effect on long runs needs measurement.
288
+ - **`seg_num_active_conn` output is reused across reinforce + punish**: the two
289
+ kernels each launch n_cells blocks. They could be fused into one for one fewer
290
+ kernel launch per step.
291
+
292
+ ## Files
293
+
294
+ - `htm_rust/build.rs` β€” nvcc-driven PTX compilation, 12 kernels.
295
+ - `htm_rust/Cargo.toml` β€” `gpu` feature flag, cudarc dep.
296
+ - `htm_rust/src/gpu/mod.rs` β€” `HTMRegionGpu` pyclass + `step_many_gpu`.
297
+ - `htm_rust/src/gpu/sp_gpu.rs` β€” SP state + `step_batch_with_tm`.
298
+ - `htm_rust/src/gpu/tm_gpu.rs` β€” TM state + `step`.
299
+ - `htm_rust/src/gpu/tests.rs` β€” parity + correctness tests.
300
+ - `htm_rust/src/gpu/kernels/*.cu` β€” 5 SP + 7 TM kernels.
301
+ - `htm_rust/bench_gpu.py` β€” CPU-vs-GPU microbench.
302
+ - `subsystems/htm.py` β€” transparent GPU/CPU backend selection in `HTMLayer`.
overlay/htm_rust/pyproject.toml CHANGED
@@ -1,17 +1,17 @@
1
- [build-system]
2
- requires = ["maturin>=1.4,<2.0"]
3
- build-backend = "maturin"
4
-
5
- [project]
6
- name = "htm_rust"
7
- version = "0.1.0"
8
- description = "Numenta BAMI-spec HTM (Spatial Pooler + Temporal Memory) in Rust with pyo3 bindings"
9
- requires-python = ">=3.11"
10
- classifiers = [
11
- "Programming Language :: Rust",
12
- "Programming Language :: Python :: Implementation :: CPython",
13
- ]
14
-
15
- [tool.maturin]
16
- features = ["pyo3/extension-module"]
17
- module-name = "htm_rust"
 
1
+ [build-system]
2
+ requires = ["maturin>=1.4,<2.0"]
3
+ build-backend = "maturin"
4
+
5
+ [project]
6
+ name = "htm_rust"
7
+ version = "0.1.0"
8
+ description = "Numenta BAMI-spec HTM (Spatial Pooler + Temporal Memory) in Rust with pyo3 bindings"
9
+ requires-python = ">=3.11"
10
+ classifiers = [
11
+ "Programming Language :: Rust",
12
+ "Programming Language :: Python :: Implementation :: CPython",
13
+ ]
14
+
15
+ [tool.maturin]
16
+ features = ["pyo3/extension-module"]
17
+ module-name = "htm_rust"
overlay/htm_rust/src/gpu/fused.rs CHANGED
@@ -1,719 +1,719 @@
1
- //! Fused HTM megakernel launcher.
2
- //!
3
- //! Collapses the 12-kernel per-timestep pipeline (and the outer T-loop) into
4
- //! a single kernel launch per forward. See `kernels/htm_fused_step.cu` for
5
- //! the kernel design and the cross-block coherence strategy (grid barrier
6
- //! via device counter with all blocks concurrently resident).
7
- //!
8
- //! Launch invariant: `grid_dim.x <= concurrent-block capacity`. Host code
9
- //! probes the device SM count at construction and caps grid_dim.x
10
- //! accordingly β€” otherwise the grid barrier deadlocks.
11
- //!
12
- //! Semantic change from the top-K pipeline: activation is per-column
13
- //! threshold-based (local lateral inhibition) instead of global top-K.
14
- //! A per-column `inhibition_threshold` is tracked and EMA-steered to hit
15
- //! the sparsity target. This is a real architectural change and is
16
- //! documented in `docs/GPU_HTM.md`.
17
-
18
- #![cfg(feature = "gpu")]
19
-
20
- use std::ffi::CString;
21
- use std::sync::Arc;
22
-
23
- use cudarc::driver::{
24
- result, sys, CudaDevice, CudaSlice, DevicePtr, DeviceRepr, DriverError, LaunchConfig,
25
- };
26
- use cudarc::nvrtc::Ptx;
27
-
28
- use super::sp_gpu::SpatialPoolerGpu;
29
- use super::tm_gpu::{TemporalMemoryGpu, MAX_SEGMENTS_PER_CELL, MAX_SYN_PER_SEGMENT};
30
-
31
- const PTX_HTM_FUSED: &str = include_str!(concat!(env!("HTM_GPU_PTX_DIR"), "/htm_fused_step.ptx"));
32
-
33
- /// Struct-by-value pointer pack β€” matches C-side `FusedPtrs`.
34
- ///
35
- /// NOTE: `barrier_counters` is kept as an ABI-compat dummy (always 0). The
36
- /// C-side `FusedPtrs` still has the field at the same byte offset; removing
37
- /// it here would shift all subsequent fields and break the layout. Worker A
38
- /// will eventually delete the field from both sides once the kernel is
39
- /// updated; until then we zero it.
40
- #[repr(C)]
41
- #[derive(Clone, Copy)]
42
- pub struct FusedPtrs {
43
- pub syn_bit: u64,
44
- pub syn_perm: u64,
45
- pub boost: u64,
46
- pub active_duty: u64,
47
- pub inhibition_threshold: u64,
48
- pub seg_cell_id: u64,
49
- pub seg_syn_count: u64,
50
- pub syn_presyn: u64,
51
- pub tm_syn_perm: u64,
52
- pub cell_seg_count: u64,
53
- pub cell_active_a: u64,
54
- pub cell_active_b: u64,
55
- pub cell_winner_a: u64,
56
- pub cell_winner_b: u64,
57
- pub inputs: u64,
58
- pub cols_out: u64,
59
- pub anom_out: u64,
60
- /// ABI-compat dummy β€” always 0. No device memory is allocated for this
61
- /// field; the cluster barrier replaces the old software DLB barrier.
62
- pub barrier_counters: u64,
63
- pub step_scratch: u64,
64
- }
65
-
66
- unsafe impl DeviceRepr for FusedPtrs {}
67
-
68
- /// Launch-time config β€” matches C-side `FusedConfig` 1:1.
69
- #[repr(C)]
70
- #[derive(Clone, Copy)]
71
- pub struct FusedConfig {
72
- pub input_bits: u32,
73
- pub n_columns: u32,
74
- pub synapses_per_col: u32,
75
- pub conn_thr: f32,
76
- pub sp_inc: f32,
77
- pub sp_dec: f32,
78
- pub sparsity_target: f32,
79
- pub duty_alpha: f32,
80
- pub thr_adapt_rate: f32,
81
- pub cells_per_column: u32,
82
- pub n_cells: u32,
83
- pub bits_words: u32,
84
- pub max_segments_per_cell: u32,
85
- pub synapses_per_segment: u32,
86
- pub activation_threshold: u32,
87
- pub learning_threshold: u32,
88
- pub max_new_synapses: u32,
89
- pub conn_thr_i16: i32,
90
- pub perm_inc_i16: i32,
91
- pub perm_dec_i16: i32,
92
- pub predicted_seg_dec_i16: i32,
93
- pub initial_perm_i16: i32,
94
- pub t: u32,
95
- pub learn: u32,
96
- pub iter_seed: u32,
97
- pub cooperative_grid_sync: u32,
98
- }
99
-
100
- unsafe impl DeviceRepr for FusedConfig {}
101
-
102
- /// Cluster launch parameters probed at construction time.
103
- #[derive(Clone, Copy, Debug, PartialEq, Eq)]
104
- pub(crate) struct ClusterInfo {
105
- /// Maximum cluster size supported by this device (0 = cluster unsupported).
106
- pub max_cluster_size: u32,
107
- }
108
-
109
- // There is only ONE launch mode: non-cooperative launch with Hopper Thread
110
- // Block Cluster attribute (`CU_LAUNCH_ATTRIBUTE_CLUSTER_DIMENSION`). The old
111
- // software DLB barrier and the cooperative-launch path are both removed.
112
- // Cluster barriers replace both.
113
- #[derive(Clone, Copy, Debug, PartialEq, Eq)]
114
- pub(crate) struct FusedLaunchPlan {
115
- pub grid_dim_x: u32,
116
- pub block_dim_x: u32,
117
- pub cooperative_grid_limit: u32,
118
- pub sm_count: u32,
119
- }
120
-
121
- fn fused_grid_cap_override() -> Option<u32> {
122
- std::env::var("HTM_FUSED_GRID_CAP")
123
- .ok()
124
- .and_then(|s| s.parse::<u32>().ok())
125
- .map(|v| v.max(1))
126
- }
127
-
128
- pub(crate) fn plan_fused_launch(
129
- sm_count: u32,
130
- cooperative_supported: bool,
131
- cooperative_grid_limit: u32,
132
- grid_cap_override: Option<u32>,
133
- ) -> Result<FusedLaunchPlan, String> {
134
- let sm_count = sm_count.max(1);
135
- // 1024 threads/block exceeds the register file on Ampere and makes the
136
- // cooperative-grid residency probe lie when the launch uses a different
137
- // block size. Keep the planned block size identical to the occupancy probe.
138
- let block_dim_x = 256u32;
139
-
140
- // Cluster launch path: cooperative launch is not required. Keep the probe
141
- // result for residency estimation only.
142
- if !cooperative_supported {
143
- eprintln!("[htm_rust] INFO: cooperative launch unsupported; cluster path only.");
144
- }
145
-
146
- // Cluster constraint: grid_dim_x must equal the cluster size (16) so that
147
- // each region maps to exactly one cluster. `HTM_FUSED_GRID_CAP` can lower
148
- // this for debugging but should not exceed 16 for cluster correctness.
149
- let default_grid_cap = 16u32;
150
- let grid_cap = grid_cap_override.unwrap_or(default_grid_cap).min(16);
151
- let resident_bound = if cooperative_grid_limit > 0 {
152
- cooperative_grid_limit.max(sm_count * 2)
153
- } else {
154
- sm_count * 2
155
- };
156
- Ok(FusedLaunchPlan {
157
- grid_dim_x: resident_bound.min(grid_cap).max(1),
158
- block_dim_x,
159
- cooperative_grid_limit: resident_bound,
160
- sm_count,
161
- })
162
- }
163
-
164
- pub(crate) fn plan_batched_grid_dim(
165
- grid_dim_x: u32,
166
- cooperative_grid_limit: u32,
167
- batch_regions: usize,
168
- use_cluster: bool,
169
- ) -> Result<u32, String> {
170
- if use_cluster {
171
- return Ok(grid_dim_x.max(1));
172
- }
173
-
174
- let batch_regions = batch_regions.max(1) as u32;
175
- if cooperative_grid_limit == 0 {
176
- return Err("COOPERATIVE_LAUNCH_TOO_LARGE: cooperative launch limit unavailable".into());
177
- }
178
-
179
- let max_grid_x = cooperative_grid_limit / batch_regions;
180
- if max_grid_x == 0 {
181
- return Err(format!(
182
- "COOPERATIVE_LAUNCH_TOO_LARGE: batch_regions={batch_regions} exceeds cooperative_grid_limit={cooperative_grid_limit}"
183
- ));
184
- }
185
-
186
- Ok(grid_dim_x.min(max_grid_x).max(1))
187
- }
188
-
189
- pub(super) struct RawFusedKernel {
190
- module: sys::CUmodule,
191
- pub(super) function: sys::CUfunction,
192
- pub(super) function_batched: sys::CUfunction,
193
- }
194
-
195
- unsafe impl Send for RawFusedKernel {}
196
- unsafe impl Sync for RawFusedKernel {}
197
-
198
- impl Drop for RawFusedKernel {
199
- fn drop(&mut self) {
200
- unsafe {
201
- let _ = result::module::unload(self.module);
202
- }
203
- }
204
- }
205
-
206
- /// Owns fused-path-only device state:
207
- /// - per-column inhibition threshold (replaces global top-K)
208
- /// - ping-pong cell_active/cell_winner bitsets
209
- /// - step_scratch (n_active, n_unpred per timestep)
210
- /// - cluster launch capability info
211
- pub struct FusedState {
212
- dev: Arc<CudaDevice>,
213
- pub(super) raw_kernel: RawFusedKernel,
214
-
215
- pub inhibition_threshold: CudaSlice<f32>,
216
- pub cell_active_bits_a: CudaSlice<u32>,
217
- pub cell_active_bits_b: CudaSlice<u32>,
218
- pub cell_winner_bits_a: CudaSlice<u32>,
219
- pub cell_winner_bits_b: CudaSlice<u32>,
220
- pub step_scratch: CudaSlice<u32>, // length 6
221
-
222
- pub grid_dim_x: u32,
223
- pub block_dim_x: u32,
224
- pub cooperative_grid_limit: u32,
225
- pub iter_counter: u32,
226
-
227
- /// Hopper cluster launch capability (0 = unsupported).
228
- pub cluster_info: ClusterInfo,
229
-
230
- // Config mirror (read-only after init).
231
- #[allow(dead_code)]
232
- pub initial_threshold: f32,
233
- }
234
-
235
- impl FusedState {
236
- pub fn new(
237
- dev: Arc<CudaDevice>,
238
- n_columns: usize,
239
- cells_per_column: usize,
240
- initial_threshold: f32,
241
- ) -> Result<Self, DriverError> {
242
- let n_cells = n_columns * cells_per_column;
243
- assert!(
244
- n_cells % 32 == 0,
245
- "n_cells must be divisible by 32 for bitsets"
246
- );
247
- let bits_words = n_cells / 32;
248
-
249
- let mut inhibition_threshold = dev.alloc_zeros::<f32>(n_columns)?;
250
- let init_vec = vec![initial_threshold; n_columns];
251
- dev.htod_sync_copy_into(&init_vec, &mut inhibition_threshold)?;
252
-
253
- let cell_active_bits_a = dev.alloc_zeros::<u32>(bits_words)?;
254
- let cell_active_bits_b = dev.alloc_zeros::<u32>(bits_words)?;
255
- let cell_winner_bits_a = dev.alloc_zeros::<u32>(bits_words)?;
256
- let cell_winner_bits_b = dev.alloc_zeros::<u32>(bits_words)?;
257
- let step_scratch = dev.alloc_zeros::<u32>(6)?;
258
-
259
- unsafe {
260
- result::ctx::set_current(*dev.cu_primary_ctx())?;
261
- }
262
- if dev.get_func("htm_fused", "htm_fused_step").is_none() {
263
- dev.load_ptx(
264
- Ptx::from_src(PTX_HTM_FUSED),
265
- "htm_fused",
266
- &["htm_fused_step", "htm_fused_step_batched"],
267
- )?;
268
- }
269
- let ptx = CString::new(PTX_HTM_FUSED).expect("PTX contains no interior nul bytes");
270
- let module = unsafe { result::module::load_data(ptx.as_ptr().cast()) }?;
271
- let function = unsafe {
272
- result::module::get_function(module, CString::new("htm_fused_step").unwrap())
273
- }?;
274
- let function_batched = unsafe {
275
- result::module::get_function(module, CString::new("htm_fused_step_batched").unwrap())
276
- }?;
277
-
278
- // Cluster size 16 on Hopper is "non-portable" (> 8 requires opt-in).
279
- // Must set CU_FUNC_ATTRIBUTE_NON_PORTABLE_CLUSTER_SIZE_ALLOWED=1 on
280
- // every launched kernel function, otherwise cuLaunchKernelEx rejects
281
- // the cluster dim with CUDA_ERROR_INVALID_CLUSTER_SIZE.
282
- unsafe {
283
- let attr =
284
- sys::CUfunction_attribute::CU_FUNC_ATTRIBUTE_NON_PORTABLE_CLUSTER_SIZE_ALLOWED;
285
- // Ignore errors: older CUDA may lack the attribute, in which case
286
- // only portable sizes (<= 8) work β€” plan_fused_launch caps at 8.
287
- let _ = sys::lib().cuFuncSetAttribute(function, attr, 1);
288
- let _ = sys::lib().cuFuncSetAttribute(function_batched, attr, 1);
289
- }
290
-
291
- // Probe SM count.
292
- let sm_count = match dev.attribute(
293
- cudarc::driver::sys::CUdevice_attribute::CU_DEVICE_ATTRIBUTE_MULTIPROCESSOR_COUNT,
294
- ) {
295
- Ok(v) => v as u32,
296
- Err(_) => 16u32,
297
- };
298
-
299
- // T1: Probe Hopper cluster launch capability.
300
- let max_cluster_size = match dev
301
- .attribute(cudarc::driver::sys::CUdevice_attribute::CU_DEVICE_ATTRIBUTE_CLUSTER_LAUNCH)
302
- {
303
- Ok(v) if v > 0 => {
304
- // H200/sm_90a supports up to 16 blocks per cluster.
305
- // There is no MAX_CLUSTER_SIZE attribute in CUDA 12.4; hard-code the
306
- // Hopper maximum which is 16 (8 SMs Γ— 2 blocks/SM = 16 blocks/cluster).
307
- 16u32
308
- }
309
- _ => 0u32,
310
- };
311
- eprintln!("[htm_rust] cluster: max_cluster_size={}", max_cluster_size);
312
- let cluster_info = ClusterInfo { max_cluster_size };
313
-
314
- let cooperative_supported = matches!(
315
- dev.attribute(sys::CUdevice_attribute::CU_DEVICE_ATTRIBUTE_COOPERATIVE_LAUNCH),
316
- Ok(v) if v > 0
317
- );
318
- let cooperative_grid_limit = if cooperative_supported {
319
- let blocks_per_sm = unsafe {
320
- // Must match plan_fused_launch(): the A10G/Ampere-safe fused
321
- // kernel launch uses 256 threads/block, not the historical
322
- // 1024-thread Hopper occupancy probe.
323
- result::occupancy::max_active_block_per_multiprocessor(function, 256, 0)
324
- }
325
- .ok()
326
- .map(|v| v.max(0) as u32)
327
- .unwrap_or(0);
328
- sm_count.saturating_mul(blocks_per_sm)
329
- } else {
330
- 0
331
- };
332
- let launch_plan = plan_fused_launch(
333
- sm_count,
334
- cooperative_supported,
335
- cooperative_grid_limit,
336
- fused_grid_cap_override(),
337
- )
338
- .map_err(|msg| {
339
- // Surface as a CUDA-ish error so callers can propagate.
340
- eprintln!("[htm_rust] FATAL: {msg}");
341
- DriverError(cudarc::driver::sys::CUresult::CUDA_ERROR_NOT_SUPPORTED)
342
- })?;
343
-
344
- eprintln!(
345
- "[htm_rust] fused kernel: sm_count={} grid_dim_x={} cooperative_grid_limit={} cluster_max={}",
346
- launch_plan.sm_count, launch_plan.grid_dim_x, launch_plan.cooperative_grid_limit,
347
- cluster_info.max_cluster_size,
348
- );
349
-
350
- Ok(Self {
351
- dev,
352
- raw_kernel: RawFusedKernel {
353
- module,
354
- function,
355
- function_batched,
356
- },
357
- inhibition_threshold,
358
- cell_active_bits_a,
359
- cell_active_bits_b,
360
- cell_winner_bits_a,
361
- cell_winner_bits_b,
362
- step_scratch,
363
- grid_dim_x: launch_plan.grid_dim_x,
364
- block_dim_x: launch_plan.block_dim_x,
365
- cooperative_grid_limit: launch_plan.cooperative_grid_limit,
366
- iter_counter: 0,
367
- cluster_info,
368
- initial_threshold,
369
- })
370
- }
371
-
372
- /// Reset fused state. Called at region.reset().
373
- pub fn reset(&mut self) -> Result<(), DriverError> {
374
- self.dev.memset_zeros(&mut self.cell_active_bits_a)?;
375
- self.dev.memset_zeros(&mut self.cell_active_bits_b)?;
376
- self.dev.memset_zeros(&mut self.cell_winner_bits_a)?;
377
- self.dev.memset_zeros(&mut self.cell_winner_bits_b)?;
378
- self.dev.memset_zeros(&mut self.step_scratch)?;
379
- // Do NOT reset inhibition_threshold β€” it's learned state. A hard
380
- // reset of TM state should NOT forget the sparsity calibration.
381
- Ok(())
382
- }
383
- }
384
-
385
- /// Launch the fused megakernel. Processes all T timesteps in one kernel.
386
- ///
387
- /// Uses `cuLaunchKernelEx` with `CU_LAUNCH_ATTRIBUTE_CLUSTER_DIMENSION=(16,1,1)`
388
- /// when the device supports cluster launch, otherwise falls back to a plain
389
- /// `launch_kernel`. For single-region launches, grid_dim_x <= 16 ensures the
390
- /// entire grid fits in one cluster.
391
- #[allow(clippy::too_many_arguments)]
392
- pub fn launch_fused(
393
- sp: &mut SpatialPoolerGpu,
394
- tm: &mut TemporalMemoryGpu,
395
- fused: &mut FusedState,
396
- inputs_flat: &CudaSlice<u8>,
397
- cols_out: &mut CudaSlice<u8>,
398
- anom_out: &mut CudaSlice<f32>,
399
- t: usize,
400
- input_bits: usize,
401
- learn: bool,
402
- ) -> Result<(), DriverError> {
403
- // Reset step_scratch before each launch (safe re-entry).
404
- sp.dev_ref().memset_zeros(&mut fused.step_scratch)?;
405
-
406
- fused.iter_counter = fused.iter_counter.wrapping_add(1);
407
-
408
- let cfg = FusedConfig {
409
- input_bits: input_bits as u32,
410
- n_columns: sp.n_columns_accessor() as u32,
411
- synapses_per_col: sp.synapses_per_col_accessor() as u32,
412
- conn_thr: sp.conn_thr_accessor(),
413
- sp_inc: sp.inc_accessor(),
414
- sp_dec: sp.dec_accessor(),
415
- sparsity_target: sp.sparsity_accessor(),
416
- duty_alpha: 1.0f32 / sp.duty_period_accessor().max(1.0),
417
- thr_adapt_rate: 0.001f32,
418
- cells_per_column: tm.cells_per_column as u32,
419
- n_cells: tm.n_cells as u32,
420
- bits_words: tm.bits_words as u32,
421
- max_segments_per_cell: MAX_SEGMENTS_PER_CELL as u32,
422
- synapses_per_segment: MAX_SYN_PER_SEGMENT as u32,
423
- activation_threshold: tm.activation_threshold,
424
- learning_threshold: tm.learning_threshold,
425
- max_new_synapses: tm.max_new_synapse_count,
426
- conn_thr_i16: tm.conn_thr_i16 as i32,
427
- perm_inc_i16: tm.perm_inc_i16 as i32,
428
- perm_dec_i16: tm.perm_dec_i16 as i32,
429
- predicted_seg_dec_i16: tm.predicted_seg_dec_i16 as i32,
430
- initial_perm_i16: tm.initial_perm_i16 as i32,
431
- t: t as u32,
432
- learn: if learn { 1 } else { 0 },
433
- iter_seed: fused.iter_counter,
434
- cooperative_grid_sync: 1,
435
- };
436
-
437
- let ptrs = FusedPtrs {
438
- syn_bit: *sp.syn_bit_accessor().device_ptr(),
439
- syn_perm: *sp.syn_perm_accessor().device_ptr(),
440
- boost: *sp.boost_accessor().device_ptr(),
441
- active_duty: *sp.active_duty_accessor().device_ptr(),
442
- inhibition_threshold: *fused.inhibition_threshold.device_ptr(),
443
- seg_cell_id: *tm.seg_cell_id_accessor().device_ptr(),
444
- seg_syn_count: *tm.seg_syn_count_accessor().device_ptr(),
445
- syn_presyn: *tm.syn_presyn_accessor().device_ptr(),
446
- tm_syn_perm: *tm.syn_perm_accessor().device_ptr(),
447
- cell_seg_count: *tm.cell_seg_count_accessor().device_ptr(),
448
- cell_active_a: *fused.cell_active_bits_a.device_ptr(),
449
- cell_active_b: *fused.cell_active_bits_b.device_ptr(),
450
- cell_winner_a: *fused.cell_winner_bits_a.device_ptr(),
451
- cell_winner_b: *fused.cell_winner_bits_b.device_ptr(),
452
- inputs: *inputs_flat.device_ptr(),
453
- cols_out: *cols_out.device_ptr(),
454
- anom_out: *anom_out.device_ptr(),
455
- barrier_counters: 0u64, // ABI-compat dummy; cluster barrier replaces DLB.
456
- step_scratch: *fused.step_scratch.device_ptr(),
457
- };
458
-
459
- let grid_x = fused.grid_dim_x;
460
- let block_x = fused.block_dim_x;
461
- let cu_stream = *sp.dev_ref().cu_stream();
462
- let use_cluster = fused.cluster_info.max_cluster_size > 0;
463
-
464
- unsafe {
465
- result::ctx::set_current(*sp.dev_ref().cu_primary_ctx())?;
466
- let mut kernel_params: [*mut std::ffi::c_void; 2] = [
467
- (&ptrs as *const FusedPtrs).cast_mut().cast(),
468
- (&cfg as *const FusedConfig).cast_mut().cast(),
469
- ];
470
-
471
- if use_cluster {
472
- // T10: Hopper cluster launch with CU_LAUNCH_ATTRIBUTE_CLUSTER_DIMENSION.
473
- // cluster_dim=(16,1,1) maps the entire single-region grid into one cluster.
474
- let mut attr: sys::CUlaunchAttribute = std::mem::zeroed();
475
- attr.id = sys::CUlaunchAttributeID::CU_LAUNCH_ATTRIBUTE_CLUSTER_DIMENSION;
476
- attr.value.clusterDim.x = 16;
477
- attr.value.clusterDim.y = 1;
478
- attr.value.clusterDim.z = 1;
479
-
480
- let mut launch_cfg: sys::CUlaunchConfig = std::mem::zeroed();
481
- launch_cfg.gridDimX = grid_x;
482
- launch_cfg.gridDimY = 1;
483
- launch_cfg.gridDimZ = 1;
484
- launch_cfg.blockDimX = block_x;
485
- launch_cfg.blockDimY = 1;
486
- launch_cfg.blockDimZ = 1;
487
- launch_cfg.sharedMemBytes = 0;
488
- launch_cfg.hStream = cu_stream;
489
- launch_cfg.numAttrs = 1;
490
- launch_cfg.attrs = &mut attr as *mut sys::CUlaunchAttribute;
491
-
492
- let ret = sys::lib().cuLaunchKernelEx(
493
- &launch_cfg as *const sys::CUlaunchConfig,
494
- fused.raw_kernel.function,
495
- kernel_params.as_mut_ptr(),
496
- std::ptr::null_mut(),
497
- );
498
- if ret != sys::CUresult::CUDA_SUCCESS {
499
- return Err(DriverError(ret));
500
- }
501
- } else {
502
- // Pre-Hopper: cooperative kernel launch. The fused kernel uses
503
- // cg::this_grid().sync(); normal launches poison the CUDA context
504
- // with an asynchronous unspecified launch failure.
505
- let ret = sys::lib().cuLaunchCooperativeKernel(
506
- fused.raw_kernel.function,
507
- grid_x,
508
- 1,
509
- 1,
510
- block_x,
511
- 1,
512
- 1,
513
- 0,
514
- cu_stream,
515
- kernel_params.as_mut_ptr(),
516
- );
517
- if ret != sys::CUresult::CUDA_SUCCESS {
518
- return Err(DriverError(ret));
519
- }
520
- }
521
- }
522
-
523
- Ok(())
524
- }
525
-
526
- /// Single batched non-cooperative launch for B regions with DLB sync. Uses the same kernel
527
- /// body; each block reads its region's FusedPtrs from a device-side array
528
- /// indexed by blockIdx.y. All regions share the same config (same
529
- /// input_bits/n_columns/etc.) so we pass one FusedConfig.
530
- ///
531
- /// This breaks through the CUDA cooperative-kernel device-level
532
- /// serialization: multiple cooperative launches are serialized regardless
533
- /// of stream, but one cooperative launch with grid.y=B processes all
534
- /// regions in a single invocation β€” ~BΓ— speedup vs B sequential launches.
535
- #[allow(clippy::too_many_arguments)]
536
- /// Low-level raw-pointer entry, called by PyO3 binding which holds the
537
- /// mutable borrows. Safety: each `*mut HTMRegionGpu` must point to a live,
538
- /// uniquely-borrowed region. All regions must be distinct.
539
- pub(super) fn launch_fused_batched_raw(
540
- region_ptrs: &[*mut super::HTMRegionGpu],
541
- inputs_per_region: &[u64],
542
- cols_per_region: &[u64],
543
- anom_per_region: &[u64],
544
- t: usize,
545
- input_bits: usize,
546
- learn: bool,
547
- ) -> Result<(), DriverError> {
548
- let b = region_ptrs.len();
549
- assert_eq!(inputs_per_region.len(), b);
550
- assert_eq!(cols_per_region.len(), b);
551
- assert_eq!(anom_per_region.len(), b);
552
- assert!(b >= 1, "need at least one region");
553
-
554
- // Reset per-region step_scratch before each launch.
555
- for &rp in region_ptrs.iter() {
556
- let r = unsafe { &mut *rp };
557
- let dev = r.sp_gpu.dev_ref().clone();
558
- dev.memset_zeros(&mut r.fused_state.step_scratch)?;
559
- r.fused_state.iter_counter = r.fused_state.iter_counter.wrapping_add(1);
560
- }
561
-
562
- // Shared config β€” all regions use identical sp/tm parameters.
563
- let (grid_x, block_x, cooperative_grid_limit, function_batched, cu_stream, cu_ctx) = {
564
- let r0 = unsafe { &*region_ptrs[0] };
565
- (
566
- r0.fused_state.grid_dim_x,
567
- r0.fused_state.block_dim_x,
568
- r0.fused_state.cooperative_grid_limit,
569
- r0.fused_state.raw_kernel.function_batched,
570
- *r0.sp_gpu.dev_ref().cu_stream(),
571
- *r0.sp_gpu.dev_ref().cu_primary_ctx(),
572
- )
573
- };
574
-
575
- let cfg = {
576
- let r = unsafe { &*region_ptrs[0] };
577
- FusedConfig {
578
- input_bits: input_bits as u32,
579
- n_columns: r.sp_gpu.n_columns_accessor() as u32,
580
- synapses_per_col: r.sp_gpu.synapses_per_col_accessor() as u32,
581
- conn_thr: r.sp_gpu.conn_thr_accessor(),
582
- sp_inc: r.sp_gpu.inc_accessor(),
583
- sp_dec: r.sp_gpu.dec_accessor(),
584
- sparsity_target: r.sp_gpu.sparsity_accessor(),
585
- duty_alpha: 1.0f32 / r.sp_gpu.duty_period_accessor().max(1.0),
586
- thr_adapt_rate: 0.001f32,
587
- cells_per_column: r.tm_gpu.cells_per_column as u32,
588
- n_cells: r.tm_gpu.n_cells as u32,
589
- bits_words: r.tm_gpu.bits_words as u32,
590
- max_segments_per_cell: MAX_SEGMENTS_PER_CELL as u32,
591
- synapses_per_segment: MAX_SYN_PER_SEGMENT as u32,
592
- activation_threshold: r.tm_gpu.activation_threshold,
593
- learning_threshold: r.tm_gpu.learning_threshold,
594
- max_new_synapses: r.tm_gpu.max_new_synapse_count,
595
- conn_thr_i16: r.tm_gpu.conn_thr_i16 as i32,
596
- perm_inc_i16: r.tm_gpu.perm_inc_i16 as i32,
597
- perm_dec_i16: r.tm_gpu.perm_dec_i16 as i32,
598
- predicted_seg_dec_i16: r.tm_gpu.predicted_seg_dec_i16 as i32,
599
- initial_perm_i16: r.tm_gpu.initial_perm_i16 as i32,
600
- t: t as u32,
601
- learn: if learn { 1 } else { 0 },
602
- iter_seed: r.fused_state.iter_counter,
603
- cooperative_grid_sync: 1,
604
- }
605
- };
606
-
607
- // Build B FusedPtrs per-region.
608
- let ptrs_vec: Vec<FusedPtrs> = (0..b)
609
- .map(|i| {
610
- let r = unsafe { &*region_ptrs[i] };
611
- FusedPtrs {
612
- syn_bit: *r.sp_gpu.syn_bit_accessor().device_ptr(),
613
- syn_perm: *r.sp_gpu.syn_perm_accessor().device_ptr(),
614
- boost: *r.sp_gpu.boost_accessor().device_ptr(),
615
- active_duty: *r.sp_gpu.active_duty_accessor().device_ptr(),
616
- inhibition_threshold: *r.fused_state.inhibition_threshold.device_ptr(),
617
- seg_cell_id: *r.tm_gpu.seg_cell_id_accessor().device_ptr(),
618
- seg_syn_count: *r.tm_gpu.seg_syn_count_accessor().device_ptr(),
619
- syn_presyn: *r.tm_gpu.syn_presyn_accessor().device_ptr(),
620
- tm_syn_perm: *r.tm_gpu.syn_perm_accessor().device_ptr(),
621
- cell_seg_count: *r.tm_gpu.cell_seg_count_accessor().device_ptr(),
622
- cell_active_a: *r.fused_state.cell_active_bits_a.device_ptr(),
623
- cell_active_b: *r.fused_state.cell_active_bits_b.device_ptr(),
624
- cell_winner_a: *r.fused_state.cell_winner_bits_a.device_ptr(),
625
- cell_winner_b: *r.fused_state.cell_winner_bits_b.device_ptr(),
626
- inputs: inputs_per_region[i],
627
- cols_out: cols_per_region[i],
628
- anom_out: anom_per_region[i],
629
- barrier_counters: 0u64, // ABI-compat dummy; cluster barrier replaces DLB.
630
- step_scratch: *r.fused_state.step_scratch.device_ptr(),
631
- }
632
- })
633
- .collect();
634
-
635
- // Upload FusedPtrs array to device (B * sizeof(FusedPtrs) bytes).
636
- // FusedPtrs is repr(C) + DeviceRepr so htod_sync_copy handles it.
637
- let dev = unsafe { &*region_ptrs[0] }.sp_gpu.dev_ref().clone();
638
- let ptrs_dev: CudaSlice<FusedPtrs> = dev.htod_sync_copy(&ptrs_vec)?;
639
- let ptrs_dev_ptr: u64 = *ptrs_dev.device_ptr();
640
-
641
- // T10: Cluster launch for batched regions.
642
- // Grid = (grid_x, B, 1) with cluster_dim=(16,1,1): each region (Y slice)
643
- // occupies exactly one cluster of 16 blocks. All 8 clusters run concurrently
644
- // on the H200's 132 SMs (8 Γ— 16 = 128 blocks ≀ 132 SMs).
645
- let use_cluster = {
646
- let r0 = unsafe { &*region_ptrs[0] };
647
- r0.fused_state.cluster_info.max_cluster_size > 0
648
- };
649
- let grid_x =
650
- plan_batched_grid_dim(grid_x, cooperative_grid_limit, b, use_cluster).map_err(|msg| {
651
- eprintln!("[htm_rust] FATAL: {msg}");
652
- DriverError(cudarc::driver::sys::CUresult::CUDA_ERROR_COOPERATIVE_LAUNCH_TOO_LARGE)
653
- })?;
654
-
655
- unsafe {
656
- result::ctx::set_current(cu_ctx)?;
657
- let mut kernel_params: [*mut std::ffi::c_void; 2] = [
658
- (&ptrs_dev_ptr as *const u64).cast_mut().cast(),
659
- (&cfg as *const FusedConfig).cast_mut().cast(),
660
- ];
661
-
662
- if use_cluster {
663
- let mut attr: sys::CUlaunchAttribute = std::mem::zeroed();
664
- attr.id = sys::CUlaunchAttributeID::CU_LAUNCH_ATTRIBUTE_CLUSTER_DIMENSION;
665
- attr.value.clusterDim.x = 16;
666
- attr.value.clusterDim.y = 1;
667
- attr.value.clusterDim.z = 1;
668
-
669
- let mut launch_cfg: sys::CUlaunchConfig = std::mem::zeroed();
670
- launch_cfg.gridDimX = grid_x;
671
- launch_cfg.gridDimY = b as u32;
672
- launch_cfg.gridDimZ = 1;
673
- launch_cfg.blockDimX = block_x;
674
- launch_cfg.blockDimY = 1;
675
- launch_cfg.blockDimZ = 1;
676
- launch_cfg.sharedMemBytes = 0;
677
- launch_cfg.hStream = cu_stream;
678
- launch_cfg.numAttrs = 1;
679
- launch_cfg.attrs = &mut attr as *mut sys::CUlaunchAttribute;
680
-
681
- let ret = sys::lib().cuLaunchKernelEx(
682
- &launch_cfg as *const sys::CUlaunchConfig,
683
- function_batched,
684
- kernel_params.as_mut_ptr(),
685
- std::ptr::null_mut(),
686
- );
687
- if ret != sys::CUresult::CUDA_SUCCESS {
688
- return Err(DriverError(ret));
689
- }
690
- } else {
691
- // Pre-Hopper: cooperative kernel launch. The fused kernel uses
692
- // cg::this_grid().sync(), which is only valid under cooperative
693
- // launch. A normal launch can run until the first grid.sync() and
694
- // then poison the CUDA context with an unspecified launch failure.
695
- let ret = sys::lib().cuLaunchCooperativeKernel(
696
- function_batched,
697
- grid_x,
698
- b as u32,
699
- 1,
700
- block_x,
701
- 1,
702
- 1,
703
- 0,
704
- cu_stream,
705
- kernel_params.as_mut_ptr(),
706
- );
707
- if ret != sys::CUresult::CUDA_SUCCESS {
708
- return Err(DriverError(ret));
709
- }
710
- }
711
- }
712
-
713
- // `ptrs_dev` is a per-call device array consumed by the async kernel.
714
- // Keep it alive until the kernel has read it; otherwise dropping/freeing
715
- // it immediately after launch can surface as a later unrelated CUDA error.
716
- dev.synchronize()?;
717
-
718
- Ok(())
719
- }
 
1
+ //! Fused HTM megakernel launcher.
2
+ //!
3
+ //! Collapses the 12-kernel per-timestep pipeline (and the outer T-loop) into
4
+ //! a single kernel launch per forward. See `kernels/htm_fused_step.cu` for
5
+ //! the kernel design and the cross-block coherence strategy (grid barrier
6
+ //! via device counter with all blocks concurrently resident).
7
+ //!
8
+ //! Launch invariant: `grid_dim.x <= concurrent-block capacity`. Host code
9
+ //! probes the device SM count at construction and caps grid_dim.x
10
+ //! accordingly β€” otherwise the grid barrier deadlocks.
11
+ //!
12
+ //! Semantic change from the top-K pipeline: activation is per-column
13
+ //! threshold-based (local lateral inhibition) instead of global top-K.
14
+ //! A per-column `inhibition_threshold` is tracked and EMA-steered to hit
15
+ //! the sparsity target. This is a real architectural change and is
16
+ //! documented in `docs/GPU_HTM.md`.
17
+
18
+ #![cfg(feature = "gpu")]
19
+
20
+ use std::ffi::CString;
21
+ use std::sync::Arc;
22
+
23
+ use cudarc::driver::{
24
+ result, sys, CudaDevice, CudaSlice, DevicePtr, DeviceRepr, DriverError, LaunchConfig,
25
+ };
26
+ use cudarc::nvrtc::Ptx;
27
+
28
+ use super::sp_gpu::SpatialPoolerGpu;
29
+ use super::tm_gpu::{TemporalMemoryGpu, MAX_SEGMENTS_PER_CELL, MAX_SYN_PER_SEGMENT};
30
+
31
+ const PTX_HTM_FUSED: &str = include_str!(concat!(env!("HTM_GPU_PTX_DIR"), "/htm_fused_step.ptx"));
32
+
33
+ /// Struct-by-value pointer pack β€” matches C-side `FusedPtrs`.
34
+ ///
35
+ /// NOTE: `barrier_counters` is kept as an ABI-compat dummy (always 0). The
36
+ /// C-side `FusedPtrs` still has the field at the same byte offset; removing
37
+ /// it here would shift all subsequent fields and break the layout. Worker A
38
+ /// will eventually delete the field from both sides once the kernel is
39
+ /// updated; until then we zero it.
40
+ #[repr(C)]
41
+ #[derive(Clone, Copy)]
42
+ pub struct FusedPtrs {
43
+ pub syn_bit: u64,
44
+ pub syn_perm: u64,
45
+ pub boost: u64,
46
+ pub active_duty: u64,
47
+ pub inhibition_threshold: u64,
48
+ pub seg_cell_id: u64,
49
+ pub seg_syn_count: u64,
50
+ pub syn_presyn: u64,
51
+ pub tm_syn_perm: u64,
52
+ pub cell_seg_count: u64,
53
+ pub cell_active_a: u64,
54
+ pub cell_active_b: u64,
55
+ pub cell_winner_a: u64,
56
+ pub cell_winner_b: u64,
57
+ pub inputs: u64,
58
+ pub cols_out: u64,
59
+ pub anom_out: u64,
60
+ /// ABI-compat dummy β€” always 0. No device memory is allocated for this
61
+ /// field; the cluster barrier replaces the old software DLB barrier.
62
+ pub barrier_counters: u64,
63
+ pub step_scratch: u64,
64
+ }
65
+
66
+ unsafe impl DeviceRepr for FusedPtrs {}
67
+
68
+ /// Launch-time config β€” matches C-side `FusedConfig` 1:1.
69
+ #[repr(C)]
70
+ #[derive(Clone, Copy)]
71
+ pub struct FusedConfig {
72
+ pub input_bits: u32,
73
+ pub n_columns: u32,
74
+ pub synapses_per_col: u32,
75
+ pub conn_thr: f32,
76
+ pub sp_inc: f32,
77
+ pub sp_dec: f32,
78
+ pub sparsity_target: f32,
79
+ pub duty_alpha: f32,
80
+ pub thr_adapt_rate: f32,
81
+ pub cells_per_column: u32,
82
+ pub n_cells: u32,
83
+ pub bits_words: u32,
84
+ pub max_segments_per_cell: u32,
85
+ pub synapses_per_segment: u32,
86
+ pub activation_threshold: u32,
87
+ pub learning_threshold: u32,
88
+ pub max_new_synapses: u32,
89
+ pub conn_thr_i16: i32,
90
+ pub perm_inc_i16: i32,
91
+ pub perm_dec_i16: i32,
92
+ pub predicted_seg_dec_i16: i32,
93
+ pub initial_perm_i16: i32,
94
+ pub t: u32,
95
+ pub learn: u32,
96
+ pub iter_seed: u32,
97
+ pub cooperative_grid_sync: u32,
98
+ }
99
+
100
+ unsafe impl DeviceRepr for FusedConfig {}
101
+
102
+ /// Cluster launch parameters probed at construction time.
103
+ #[derive(Clone, Copy, Debug, PartialEq, Eq)]
104
+ pub(crate) struct ClusterInfo {
105
+ /// Maximum cluster size supported by this device (0 = cluster unsupported).
106
+ pub max_cluster_size: u32,
107
+ }
108
+
109
+ // There is only ONE launch mode: non-cooperative launch with Hopper Thread
110
+ // Block Cluster attribute (`CU_LAUNCH_ATTRIBUTE_CLUSTER_DIMENSION`). The old
111
+ // software DLB barrier and the cooperative-launch path are both removed.
112
+ // Cluster barriers replace both.
113
+ #[derive(Clone, Copy, Debug, PartialEq, Eq)]
114
+ pub(crate) struct FusedLaunchPlan {
115
+ pub grid_dim_x: u32,
116
+ pub block_dim_x: u32,
117
+ pub cooperative_grid_limit: u32,
118
+ pub sm_count: u32,
119
+ }
120
+
121
+ fn fused_grid_cap_override() -> Option<u32> {
122
+ std::env::var("HTM_FUSED_GRID_CAP")
123
+ .ok()
124
+ .and_then(|s| s.parse::<u32>().ok())
125
+ .map(|v| v.max(1))
126
+ }
127
+
128
+ pub(crate) fn plan_fused_launch(
129
+ sm_count: u32,
130
+ cooperative_supported: bool,
131
+ cooperative_grid_limit: u32,
132
+ grid_cap_override: Option<u32>,
133
+ ) -> Result<FusedLaunchPlan, String> {
134
+ let sm_count = sm_count.max(1);
135
+ // 1024 threads/block exceeds the register file on Ampere and makes the
136
+ // cooperative-grid residency probe lie when the launch uses a different
137
+ // block size. Keep the planned block size identical to the occupancy probe.
138
+ let block_dim_x = 256u32;
139
+
140
+ // Cluster launch path: cooperative launch is not required. Keep the probe
141
+ // result for residency estimation only.
142
+ if !cooperative_supported {
143
+ eprintln!("[htm_rust] INFO: cooperative launch unsupported; cluster path only.");
144
+ }
145
+
146
+ // Cluster constraint: grid_dim_x must equal the cluster size (16) so that
147
+ // each region maps to exactly one cluster. `HTM_FUSED_GRID_CAP` can lower
148
+ // this for debugging but should not exceed 16 for cluster correctness.
149
+ let default_grid_cap = 16u32;
150
+ let grid_cap = grid_cap_override.unwrap_or(default_grid_cap).min(16);
151
+ let resident_bound = if cooperative_grid_limit > 0 {
152
+ cooperative_grid_limit.max(sm_count * 2)
153
+ } else {
154
+ sm_count * 2
155
+ };
156
+ Ok(FusedLaunchPlan {
157
+ grid_dim_x: resident_bound.min(grid_cap).max(1),
158
+ block_dim_x,
159
+ cooperative_grid_limit: resident_bound,
160
+ sm_count,
161
+ })
162
+ }
163
+
164
+ pub(crate) fn plan_batched_grid_dim(
165
+ grid_dim_x: u32,
166
+ cooperative_grid_limit: u32,
167
+ batch_regions: usize,
168
+ use_cluster: bool,
169
+ ) -> Result<u32, String> {
170
+ if use_cluster {
171
+ return Ok(grid_dim_x.max(1));
172
+ }
173
+
174
+ let batch_regions = batch_regions.max(1) as u32;
175
+ if cooperative_grid_limit == 0 {
176
+ return Err("COOPERATIVE_LAUNCH_TOO_LARGE: cooperative launch limit unavailable".into());
177
+ }
178
+
179
+ let max_grid_x = cooperative_grid_limit / batch_regions;
180
+ if max_grid_x == 0 {
181
+ return Err(format!(
182
+ "COOPERATIVE_LAUNCH_TOO_LARGE: batch_regions={batch_regions} exceeds cooperative_grid_limit={cooperative_grid_limit}"
183
+ ));
184
+ }
185
+
186
+ Ok(grid_dim_x.min(max_grid_x).max(1))
187
+ }
188
+
189
+ pub(super) struct RawFusedKernel {
190
+ module: sys::CUmodule,
191
+ pub(super) function: sys::CUfunction,
192
+ pub(super) function_batched: sys::CUfunction,
193
+ }
194
+
195
+ unsafe impl Send for RawFusedKernel {}
196
+ unsafe impl Sync for RawFusedKernel {}
197
+
198
+ impl Drop for RawFusedKernel {
199
+ fn drop(&mut self) {
200
+ unsafe {
201
+ let _ = result::module::unload(self.module);
202
+ }
203
+ }
204
+ }
205
+
206
+ /// Owns fused-path-only device state:
207
+ /// - per-column inhibition threshold (replaces global top-K)
208
+ /// - ping-pong cell_active/cell_winner bitsets
209
+ /// - step_scratch (n_active, n_unpred per timestep)
210
+ /// - cluster launch capability info
211
+ pub struct FusedState {
212
+ dev: Arc<CudaDevice>,
213
+ pub(super) raw_kernel: RawFusedKernel,
214
+
215
+ pub inhibition_threshold: CudaSlice<f32>,
216
+ pub cell_active_bits_a: CudaSlice<u32>,
217
+ pub cell_active_bits_b: CudaSlice<u32>,
218
+ pub cell_winner_bits_a: CudaSlice<u32>,
219
+ pub cell_winner_bits_b: CudaSlice<u32>,
220
+ pub step_scratch: CudaSlice<u32>, // length 6
221
+
222
+ pub grid_dim_x: u32,
223
+ pub block_dim_x: u32,
224
+ pub cooperative_grid_limit: u32,
225
+ pub iter_counter: u32,
226
+
227
+ /// Hopper cluster launch capability (0 = unsupported).
228
+ pub cluster_info: ClusterInfo,
229
+
230
+ // Config mirror (read-only after init).
231
+ #[allow(dead_code)]
232
+ pub initial_threshold: f32,
233
+ }
234
+
235
+ impl FusedState {
236
+ pub fn new(
237
+ dev: Arc<CudaDevice>,
238
+ n_columns: usize,
239
+ cells_per_column: usize,
240
+ initial_threshold: f32,
241
+ ) -> Result<Self, DriverError> {
242
+ let n_cells = n_columns * cells_per_column;
243
+ assert!(
244
+ n_cells % 32 == 0,
245
+ "n_cells must be divisible by 32 for bitsets"
246
+ );
247
+ let bits_words = n_cells / 32;
248
+
249
+ let mut inhibition_threshold = dev.alloc_zeros::<f32>(n_columns)?;
250
+ let init_vec = vec![initial_threshold; n_columns];
251
+ dev.htod_sync_copy_into(&init_vec, &mut inhibition_threshold)?;
252
+
253
+ let cell_active_bits_a = dev.alloc_zeros::<u32>(bits_words)?;
254
+ let cell_active_bits_b = dev.alloc_zeros::<u32>(bits_words)?;
255
+ let cell_winner_bits_a = dev.alloc_zeros::<u32>(bits_words)?;
256
+ let cell_winner_bits_b = dev.alloc_zeros::<u32>(bits_words)?;
257
+ let step_scratch = dev.alloc_zeros::<u32>(6)?;
258
+
259
+ unsafe {
260
+ result::ctx::set_current(*dev.cu_primary_ctx())?;
261
+ }
262
+ if dev.get_func("htm_fused", "htm_fused_step").is_none() {
263
+ dev.load_ptx(
264
+ Ptx::from_src(PTX_HTM_FUSED),
265
+ "htm_fused",
266
+ &["htm_fused_step", "htm_fused_step_batched"],
267
+ )?;
268
+ }
269
+ let ptx = CString::new(PTX_HTM_FUSED).expect("PTX contains no interior nul bytes");
270
+ let module = unsafe { result::module::load_data(ptx.as_ptr().cast()) }?;
271
+ let function = unsafe {
272
+ result::module::get_function(module, CString::new("htm_fused_step").unwrap())
273
+ }?;
274
+ let function_batched = unsafe {
275
+ result::module::get_function(module, CString::new("htm_fused_step_batched").unwrap())
276
+ }?;
277
+
278
+ // Cluster size 16 on Hopper is "non-portable" (> 8 requires opt-in).
279
+ // Must set CU_FUNC_ATTRIBUTE_NON_PORTABLE_CLUSTER_SIZE_ALLOWED=1 on
280
+ // every launched kernel function, otherwise cuLaunchKernelEx rejects
281
+ // the cluster dim with CUDA_ERROR_INVALID_CLUSTER_SIZE.
282
+ unsafe {
283
+ let attr =
284
+ sys::CUfunction_attribute::CU_FUNC_ATTRIBUTE_NON_PORTABLE_CLUSTER_SIZE_ALLOWED;
285
+ // Ignore errors: older CUDA may lack the attribute, in which case
286
+ // only portable sizes (<= 8) work β€” plan_fused_launch caps at 8.
287
+ let _ = sys::lib().cuFuncSetAttribute(function, attr, 1);
288
+ let _ = sys::lib().cuFuncSetAttribute(function_batched, attr, 1);
289
+ }
290
+
291
+ // Probe SM count.
292
+ let sm_count = match dev.attribute(
293
+ cudarc::driver::sys::CUdevice_attribute::CU_DEVICE_ATTRIBUTE_MULTIPROCESSOR_COUNT,
294
+ ) {
295
+ Ok(v) => v as u32,
296
+ Err(_) => 16u32,
297
+ };
298
+
299
+ // T1: Probe Hopper cluster launch capability.
300
+ let max_cluster_size = match dev
301
+ .attribute(cudarc::driver::sys::CUdevice_attribute::CU_DEVICE_ATTRIBUTE_CLUSTER_LAUNCH)
302
+ {
303
+ Ok(v) if v > 0 => {
304
+ // H200/sm_90a supports up to 16 blocks per cluster.
305
+ // There is no MAX_CLUSTER_SIZE attribute in CUDA 12.4; hard-code the
306
+ // Hopper maximum which is 16 (8 SMs Γ— 2 blocks/SM = 16 blocks/cluster).
307
+ 16u32
308
+ }
309
+ _ => 0u32,
310
+ };
311
+ eprintln!("[htm_rust] cluster: max_cluster_size={}", max_cluster_size);
312
+ let cluster_info = ClusterInfo { max_cluster_size };
313
+
314
+ let cooperative_supported = matches!(
315
+ dev.attribute(sys::CUdevice_attribute::CU_DEVICE_ATTRIBUTE_COOPERATIVE_LAUNCH),
316
+ Ok(v) if v > 0
317
+ );
318
+ let cooperative_grid_limit = if cooperative_supported {
319
+ let blocks_per_sm = unsafe {
320
+ // Must match plan_fused_launch(): the A10G/Ampere-safe fused
321
+ // kernel launch uses 256 threads/block, not the historical
322
+ // 1024-thread Hopper occupancy probe.
323
+ result::occupancy::max_active_block_per_multiprocessor(function, 256, 0)
324
+ }
325
+ .ok()
326
+ .map(|v| v.max(0) as u32)
327
+ .unwrap_or(0);
328
+ sm_count.saturating_mul(blocks_per_sm)
329
+ } else {
330
+ 0
331
+ };
332
+ let launch_plan = plan_fused_launch(
333
+ sm_count,
334
+ cooperative_supported,
335
+ cooperative_grid_limit,
336
+ fused_grid_cap_override(),
337
+ )
338
+ .map_err(|msg| {
339
+ // Surface as a CUDA-ish error so callers can propagate.
340
+ eprintln!("[htm_rust] FATAL: {msg}");
341
+ DriverError(cudarc::driver::sys::CUresult::CUDA_ERROR_NOT_SUPPORTED)
342
+ })?;
343
+
344
+ eprintln!(
345
+ "[htm_rust] fused kernel: sm_count={} grid_dim_x={} cooperative_grid_limit={} cluster_max={}",
346
+ launch_plan.sm_count, launch_plan.grid_dim_x, launch_plan.cooperative_grid_limit,
347
+ cluster_info.max_cluster_size,
348
+ );
349
+
350
+ Ok(Self {
351
+ dev,
352
+ raw_kernel: RawFusedKernel {
353
+ module,
354
+ function,
355
+ function_batched,
356
+ },
357
+ inhibition_threshold,
358
+ cell_active_bits_a,
359
+ cell_active_bits_b,
360
+ cell_winner_bits_a,
361
+ cell_winner_bits_b,
362
+ step_scratch,
363
+ grid_dim_x: launch_plan.grid_dim_x,
364
+ block_dim_x: launch_plan.block_dim_x,
365
+ cooperative_grid_limit: launch_plan.cooperative_grid_limit,
366
+ iter_counter: 0,
367
+ cluster_info,
368
+ initial_threshold,
369
+ })
370
+ }
371
+
372
+ /// Reset fused state. Called at region.reset().
373
+ pub fn reset(&mut self) -> Result<(), DriverError> {
374
+ self.dev.memset_zeros(&mut self.cell_active_bits_a)?;
375
+ self.dev.memset_zeros(&mut self.cell_active_bits_b)?;
376
+ self.dev.memset_zeros(&mut self.cell_winner_bits_a)?;
377
+ self.dev.memset_zeros(&mut self.cell_winner_bits_b)?;
378
+ self.dev.memset_zeros(&mut self.step_scratch)?;
379
+ // Do NOT reset inhibition_threshold β€” it's learned state. A hard
380
+ // reset of TM state should NOT forget the sparsity calibration.
381
+ Ok(())
382
+ }
383
+ }
384
+
385
+ /// Launch the fused megakernel. Processes all T timesteps in one kernel.
386
+ ///
387
+ /// Uses `cuLaunchKernelEx` with `CU_LAUNCH_ATTRIBUTE_CLUSTER_DIMENSION=(16,1,1)`
388
+ /// when the device supports cluster launch, otherwise falls back to a plain
389
+ /// `launch_kernel`. For single-region launches, grid_dim_x <= 16 ensures the
390
+ /// entire grid fits in one cluster.
391
+ #[allow(clippy::too_many_arguments)]
392
+ pub fn launch_fused(
393
+ sp: &mut SpatialPoolerGpu,
394
+ tm: &mut TemporalMemoryGpu,
395
+ fused: &mut FusedState,
396
+ inputs_flat: &CudaSlice<u8>,
397
+ cols_out: &mut CudaSlice<u8>,
398
+ anom_out: &mut CudaSlice<f32>,
399
+ t: usize,
400
+ input_bits: usize,
401
+ learn: bool,
402
+ ) -> Result<(), DriverError> {
403
+ // Reset step_scratch before each launch (safe re-entry).
404
+ sp.dev_ref().memset_zeros(&mut fused.step_scratch)?;
405
+
406
+ fused.iter_counter = fused.iter_counter.wrapping_add(1);
407
+
408
+ let cfg = FusedConfig {
409
+ input_bits: input_bits as u32,
410
+ n_columns: sp.n_columns_accessor() as u32,
411
+ synapses_per_col: sp.synapses_per_col_accessor() as u32,
412
+ conn_thr: sp.conn_thr_accessor(),
413
+ sp_inc: sp.inc_accessor(),
414
+ sp_dec: sp.dec_accessor(),
415
+ sparsity_target: sp.sparsity_accessor(),
416
+ duty_alpha: 1.0f32 / sp.duty_period_accessor().max(1.0),
417
+ thr_adapt_rate: 0.001f32,
418
+ cells_per_column: tm.cells_per_column as u32,
419
+ n_cells: tm.n_cells as u32,
420
+ bits_words: tm.bits_words as u32,
421
+ max_segments_per_cell: MAX_SEGMENTS_PER_CELL as u32,
422
+ synapses_per_segment: MAX_SYN_PER_SEGMENT as u32,
423
+ activation_threshold: tm.activation_threshold,
424
+ learning_threshold: tm.learning_threshold,
425
+ max_new_synapses: tm.max_new_synapse_count,
426
+ conn_thr_i16: tm.conn_thr_i16 as i32,
427
+ perm_inc_i16: tm.perm_inc_i16 as i32,
428
+ perm_dec_i16: tm.perm_dec_i16 as i32,
429
+ predicted_seg_dec_i16: tm.predicted_seg_dec_i16 as i32,
430
+ initial_perm_i16: tm.initial_perm_i16 as i32,
431
+ t: t as u32,
432
+ learn: if learn { 1 } else { 0 },
433
+ iter_seed: fused.iter_counter,
434
+ cooperative_grid_sync: 1,
435
+ };
436
+
437
+ let ptrs = FusedPtrs {
438
+ syn_bit: *sp.syn_bit_accessor().device_ptr(),
439
+ syn_perm: *sp.syn_perm_accessor().device_ptr(),
440
+ boost: *sp.boost_accessor().device_ptr(),
441
+ active_duty: *sp.active_duty_accessor().device_ptr(),
442
+ inhibition_threshold: *fused.inhibition_threshold.device_ptr(),
443
+ seg_cell_id: *tm.seg_cell_id_accessor().device_ptr(),
444
+ seg_syn_count: *tm.seg_syn_count_accessor().device_ptr(),
445
+ syn_presyn: *tm.syn_presyn_accessor().device_ptr(),
446
+ tm_syn_perm: *tm.syn_perm_accessor().device_ptr(),
447
+ cell_seg_count: *tm.cell_seg_count_accessor().device_ptr(),
448
+ cell_active_a: *fused.cell_active_bits_a.device_ptr(),
449
+ cell_active_b: *fused.cell_active_bits_b.device_ptr(),
450
+ cell_winner_a: *fused.cell_winner_bits_a.device_ptr(),
451
+ cell_winner_b: *fused.cell_winner_bits_b.device_ptr(),
452
+ inputs: *inputs_flat.device_ptr(),
453
+ cols_out: *cols_out.device_ptr(),
454
+ anom_out: *anom_out.device_ptr(),
455
+ barrier_counters: 0u64, // ABI-compat dummy; cluster barrier replaces DLB.
456
+ step_scratch: *fused.step_scratch.device_ptr(),
457
+ };
458
+
459
+ let grid_x = fused.grid_dim_x;
460
+ let block_x = fused.block_dim_x;
461
+ let cu_stream = *sp.dev_ref().cu_stream();
462
+ let use_cluster = fused.cluster_info.max_cluster_size > 0;
463
+
464
+ unsafe {
465
+ result::ctx::set_current(*sp.dev_ref().cu_primary_ctx())?;
466
+ let mut kernel_params: [*mut std::ffi::c_void; 2] = [
467
+ (&ptrs as *const FusedPtrs).cast_mut().cast(),
468
+ (&cfg as *const FusedConfig).cast_mut().cast(),
469
+ ];
470
+
471
+ if use_cluster {
472
+ // T10: Hopper cluster launch with CU_LAUNCH_ATTRIBUTE_CLUSTER_DIMENSION.
473
+ // cluster_dim=(16,1,1) maps the entire single-region grid into one cluster.
474
+ let mut attr: sys::CUlaunchAttribute = std::mem::zeroed();
475
+ attr.id = sys::CUlaunchAttributeID::CU_LAUNCH_ATTRIBUTE_CLUSTER_DIMENSION;
476
+ attr.value.clusterDim.x = 16;
477
+ attr.value.clusterDim.y = 1;
478
+ attr.value.clusterDim.z = 1;
479
+
480
+ let mut launch_cfg: sys::CUlaunchConfig = std::mem::zeroed();
481
+ launch_cfg.gridDimX = grid_x;
482
+ launch_cfg.gridDimY = 1;
483
+ launch_cfg.gridDimZ = 1;
484
+ launch_cfg.blockDimX = block_x;
485
+ launch_cfg.blockDimY = 1;
486
+ launch_cfg.blockDimZ = 1;
487
+ launch_cfg.sharedMemBytes = 0;
488
+ launch_cfg.hStream = cu_stream;
489
+ launch_cfg.numAttrs = 1;
490
+ launch_cfg.attrs = &mut attr as *mut sys::CUlaunchAttribute;
491
+
492
+ let ret = sys::lib().cuLaunchKernelEx(
493
+ &launch_cfg as *const sys::CUlaunchConfig,
494
+ fused.raw_kernel.function,
495
+ kernel_params.as_mut_ptr(),
496
+ std::ptr::null_mut(),
497
+ );
498
+ if ret != sys::CUresult::CUDA_SUCCESS {
499
+ return Err(DriverError(ret));
500
+ }
501
+ } else {
502
+ // Pre-Hopper: cooperative kernel launch. The fused kernel uses
503
+ // cg::this_grid().sync(); normal launches poison the CUDA context
504
+ // with an asynchronous unspecified launch failure.
505
+ let ret = sys::lib().cuLaunchCooperativeKernel(
506
+ fused.raw_kernel.function,
507
+ grid_x,
508
+ 1,
509
+ 1,
510
+ block_x,
511
+ 1,
512
+ 1,
513
+ 0,
514
+ cu_stream,
515
+ kernel_params.as_mut_ptr(),
516
+ );
517
+ if ret != sys::CUresult::CUDA_SUCCESS {
518
+ return Err(DriverError(ret));
519
+ }
520
+ }
521
+ }
522
+
523
+ Ok(())
524
+ }
525
+
526
+ /// Single batched non-cooperative launch for B regions with DLB sync. Uses the same kernel
527
+ /// body; each block reads its region's FusedPtrs from a device-side array
528
+ /// indexed by blockIdx.y. All regions share the same config (same
529
+ /// input_bits/n_columns/etc.) so we pass one FusedConfig.
530
+ ///
531
+ /// This breaks through the CUDA cooperative-kernel device-level
532
+ /// serialization: multiple cooperative launches are serialized regardless
533
+ /// of stream, but one cooperative launch with grid.y=B processes all
534
+ /// regions in a single invocation β€” ~BΓ— speedup vs B sequential launches.
535
+ #[allow(clippy::too_many_arguments)]
536
+ /// Low-level raw-pointer entry, called by PyO3 binding which holds the
537
+ /// mutable borrows. Safety: each `*mut HTMRegionGpu` must point to a live,
538
+ /// uniquely-borrowed region. All regions must be distinct.
539
+ pub(super) fn launch_fused_batched_raw(
540
+ region_ptrs: &[*mut super::HTMRegionGpu],
541
+ inputs_per_region: &[u64],
542
+ cols_per_region: &[u64],
543
+ anom_per_region: &[u64],
544
+ t: usize,
545
+ input_bits: usize,
546
+ learn: bool,
547
+ ) -> Result<(), DriverError> {
548
+ let b = region_ptrs.len();
549
+ assert_eq!(inputs_per_region.len(), b);
550
+ assert_eq!(cols_per_region.len(), b);
551
+ assert_eq!(anom_per_region.len(), b);
552
+ assert!(b >= 1, "need at least one region");
553
+
554
+ // Reset per-region step_scratch before each launch.
555
+ for &rp in region_ptrs.iter() {
556
+ let r = unsafe { &mut *rp };
557
+ let dev = r.sp_gpu.dev_ref().clone();
558
+ dev.memset_zeros(&mut r.fused_state.step_scratch)?;
559
+ r.fused_state.iter_counter = r.fused_state.iter_counter.wrapping_add(1);
560
+ }
561
+
562
+ // Shared config β€” all regions use identical sp/tm parameters.
563
+ let (grid_x, block_x, cooperative_grid_limit, function_batched, cu_stream, cu_ctx) = {
564
+ let r0 = unsafe { &*region_ptrs[0] };
565
+ (
566
+ r0.fused_state.grid_dim_x,
567
+ r0.fused_state.block_dim_x,
568
+ r0.fused_state.cooperative_grid_limit,
569
+ r0.fused_state.raw_kernel.function_batched,
570
+ *r0.sp_gpu.dev_ref().cu_stream(),
571
+ *r0.sp_gpu.dev_ref().cu_primary_ctx(),
572
+ )
573
+ };
574
+
575
+ let cfg = {
576
+ let r = unsafe { &*region_ptrs[0] };
577
+ FusedConfig {
578
+ input_bits: input_bits as u32,
579
+ n_columns: r.sp_gpu.n_columns_accessor() as u32,
580
+ synapses_per_col: r.sp_gpu.synapses_per_col_accessor() as u32,
581
+ conn_thr: r.sp_gpu.conn_thr_accessor(),
582
+ sp_inc: r.sp_gpu.inc_accessor(),
583
+ sp_dec: r.sp_gpu.dec_accessor(),
584
+ sparsity_target: r.sp_gpu.sparsity_accessor(),
585
+ duty_alpha: 1.0f32 / r.sp_gpu.duty_period_accessor().max(1.0),
586
+ thr_adapt_rate: 0.001f32,
587
+ cells_per_column: r.tm_gpu.cells_per_column as u32,
588
+ n_cells: r.tm_gpu.n_cells as u32,
589
+ bits_words: r.tm_gpu.bits_words as u32,
590
+ max_segments_per_cell: MAX_SEGMENTS_PER_CELL as u32,
591
+ synapses_per_segment: MAX_SYN_PER_SEGMENT as u32,
592
+ activation_threshold: r.tm_gpu.activation_threshold,
593
+ learning_threshold: r.tm_gpu.learning_threshold,
594
+ max_new_synapses: r.tm_gpu.max_new_synapse_count,
595
+ conn_thr_i16: r.tm_gpu.conn_thr_i16 as i32,
596
+ perm_inc_i16: r.tm_gpu.perm_inc_i16 as i32,
597
+ perm_dec_i16: r.tm_gpu.perm_dec_i16 as i32,
598
+ predicted_seg_dec_i16: r.tm_gpu.predicted_seg_dec_i16 as i32,
599
+ initial_perm_i16: r.tm_gpu.initial_perm_i16 as i32,
600
+ t: t as u32,
601
+ learn: if learn { 1 } else { 0 },
602
+ iter_seed: r.fused_state.iter_counter,
603
+ cooperative_grid_sync: 1,
604
+ }
605
+ };
606
+
607
+ // Build B FusedPtrs per-region.
608
+ let ptrs_vec: Vec<FusedPtrs> = (0..b)
609
+ .map(|i| {
610
+ let r = unsafe { &*region_ptrs[i] };
611
+ FusedPtrs {
612
+ syn_bit: *r.sp_gpu.syn_bit_accessor().device_ptr(),
613
+ syn_perm: *r.sp_gpu.syn_perm_accessor().device_ptr(),
614
+ boost: *r.sp_gpu.boost_accessor().device_ptr(),
615
+ active_duty: *r.sp_gpu.active_duty_accessor().device_ptr(),
616
+ inhibition_threshold: *r.fused_state.inhibition_threshold.device_ptr(),
617
+ seg_cell_id: *r.tm_gpu.seg_cell_id_accessor().device_ptr(),
618
+ seg_syn_count: *r.tm_gpu.seg_syn_count_accessor().device_ptr(),
619
+ syn_presyn: *r.tm_gpu.syn_presyn_accessor().device_ptr(),
620
+ tm_syn_perm: *r.tm_gpu.syn_perm_accessor().device_ptr(),
621
+ cell_seg_count: *r.tm_gpu.cell_seg_count_accessor().device_ptr(),
622
+ cell_active_a: *r.fused_state.cell_active_bits_a.device_ptr(),
623
+ cell_active_b: *r.fused_state.cell_active_bits_b.device_ptr(),
624
+ cell_winner_a: *r.fused_state.cell_winner_bits_a.device_ptr(),
625
+ cell_winner_b: *r.fused_state.cell_winner_bits_b.device_ptr(),
626
+ inputs: inputs_per_region[i],
627
+ cols_out: cols_per_region[i],
628
+ anom_out: anom_per_region[i],
629
+ barrier_counters: 0u64, // ABI-compat dummy; cluster barrier replaces DLB.
630
+ step_scratch: *r.fused_state.step_scratch.device_ptr(),
631
+ }
632
+ })
633
+ .collect();
634
+
635
+ // Upload FusedPtrs array to device (B * sizeof(FusedPtrs) bytes).
636
+ // FusedPtrs is repr(C) + DeviceRepr so htod_sync_copy handles it.
637
+ let dev = unsafe { &*region_ptrs[0] }.sp_gpu.dev_ref().clone();
638
+ let ptrs_dev: CudaSlice<FusedPtrs> = dev.htod_sync_copy(&ptrs_vec)?;
639
+ let ptrs_dev_ptr: u64 = *ptrs_dev.device_ptr();
640
+
641
+ // T10: Cluster launch for batched regions.
642
+ // Grid = (grid_x, B, 1) with cluster_dim=(16,1,1): each region (Y slice)
643
+ // occupies exactly one cluster of 16 blocks. All 8 clusters run concurrently
644
+ // on the H200's 132 SMs (8 Γ— 16 = 128 blocks ≀ 132 SMs).
645
+ let use_cluster = {
646
+ let r0 = unsafe { &*region_ptrs[0] };
647
+ r0.fused_state.cluster_info.max_cluster_size > 0
648
+ };
649
+ let grid_x =
650
+ plan_batched_grid_dim(grid_x, cooperative_grid_limit, b, use_cluster).map_err(|msg| {
651
+ eprintln!("[htm_rust] FATAL: {msg}");
652
+ DriverError(cudarc::driver::sys::CUresult::CUDA_ERROR_COOPERATIVE_LAUNCH_TOO_LARGE)
653
+ })?;
654
+
655
+ unsafe {
656
+ result::ctx::set_current(cu_ctx)?;
657
+ let mut kernel_params: [*mut std::ffi::c_void; 2] = [
658
+ (&ptrs_dev_ptr as *const u64).cast_mut().cast(),
659
+ (&cfg as *const FusedConfig).cast_mut().cast(),
660
+ ];
661
+
662
+ if use_cluster {
663
+ let mut attr: sys::CUlaunchAttribute = std::mem::zeroed();
664
+ attr.id = sys::CUlaunchAttributeID::CU_LAUNCH_ATTRIBUTE_CLUSTER_DIMENSION;
665
+ attr.value.clusterDim.x = 16;
666
+ attr.value.clusterDim.y = 1;
667
+ attr.value.clusterDim.z = 1;
668
+
669
+ let mut launch_cfg: sys::CUlaunchConfig = std::mem::zeroed();
670
+ launch_cfg.gridDimX = grid_x;
671
+ launch_cfg.gridDimY = b as u32;
672
+ launch_cfg.gridDimZ = 1;
673
+ launch_cfg.blockDimX = block_x;
674
+ launch_cfg.blockDimY = 1;
675
+ launch_cfg.blockDimZ = 1;
676
+ launch_cfg.sharedMemBytes = 0;
677
+ launch_cfg.hStream = cu_stream;
678
+ launch_cfg.numAttrs = 1;
679
+ launch_cfg.attrs = &mut attr as *mut sys::CUlaunchAttribute;
680
+
681
+ let ret = sys::lib().cuLaunchKernelEx(
682
+ &launch_cfg as *const sys::CUlaunchConfig,
683
+ function_batched,
684
+ kernel_params.as_mut_ptr(),
685
+ std::ptr::null_mut(),
686
+ );
687
+ if ret != sys::CUresult::CUDA_SUCCESS {
688
+ return Err(DriverError(ret));
689
+ }
690
+ } else {
691
+ // Pre-Hopper: cooperative kernel launch. The fused kernel uses
692
+ // cg::this_grid().sync(), which is only valid under cooperative
693
+ // launch. A normal launch can run until the first grid.sync() and
694
+ // then poison the CUDA context with an unspecified launch failure.
695
+ let ret = sys::lib().cuLaunchCooperativeKernel(
696
+ function_batched,
697
+ grid_x,
698
+ b as u32,
699
+ 1,
700
+ block_x,
701
+ 1,
702
+ 1,
703
+ 0,
704
+ cu_stream,
705
+ kernel_params.as_mut_ptr(),
706
+ );
707
+ if ret != sys::CUresult::CUDA_SUCCESS {
708
+ return Err(DriverError(ret));
709
+ }
710
+ }
711
+ }
712
+
713
+ // `ptrs_dev` is a per-call device array consumed by the async kernel.
714
+ // Keep it alive until the kernel has read it; otherwise dropping/freeing
715
+ // it immediately after launch can surface as a later unrelated CUDA error.
716
+ dev.synchronize()?;
717
+
718
+ Ok(())
719
+ }
overlay/htm_rust/src/gpu/kernels/htm_fused_step.cu CHANGED
@@ -1,677 +1,677 @@
1
- // Fused HTM megakernel β€” SP + TM, all T timesteps in a single launch.
2
- //
3
- // Design rationale:
4
- // - Global top-K column selection requires cross-block synchronization at
5
- // every timestep (grid.sync is unreliable on WSL2/sm_86 without rdc=true).
6
- // - Replace with per-column threshold activation using local lateral
7
- // inhibition: column c activates if overlap[c]*boost[c] > threshold[c].
8
- // Threshold is a per-column running-EMA learned scalar that steers the
9
- // column's long-run activation rate toward the global sparsity target.
10
- // - This is biologically grounded (GABAergic local inhibition) and supported
11
- // by HTM theory (duty-cycle boost already drives this loop; we just
12
- // change which lever the EMA pulls).
13
- //
14
- // Launch shape:
15
- // grid = min(device SM count, 16) // hard cap β€” see below
16
- // block = 1024 threads = 32 warps
17
- // Each warp of 32 owns a contiguous column slice (n_columns / total_warps).
18
- //
19
- // Cross-block coherence:
20
- // - Ping-pong buffers for cell_active/cell_winner: write _a at even t,
21
- // read _b; reversed at odd t.
22
- // - Preferred path: cooperative launch + hardware whole-grid sync.
23
- // - Fallback path: software 3-slot rotating grid barrier for devices/drivers
24
- // that cannot do cooperative launch.
25
- //
26
- // 2026-04-16: grid_dim reduced from 28 to 16 after deadlock RCA. The previous
27
- // cap of 28 relied on all blocks being concurrently resident on a 30-SM RTX
28
- // 3060 Laptop. Under thermal throttling effective residency dropped to ~20-24,
29
- // leaving scheduled blocks spinning on the software grid barrier waiting for
30
- // peer blocks that would never run. 16 blocks is below any realistic residency
31
- // floor and preserves enough warp parallelism (16*32 = 512 warps) to saturate
32
- // memory bandwidth on the spatial-pooler stage.
33
- //
34
- // Kernel signature uses struct-by-value for pointers and config to stay
35
- // inside cudarc's launch-arg count limit.
36
-
37
- #include <cooperative_groups.h>
38
- #include <cooperative_groups/memcpy_async.h>
39
-
40
- namespace cg = cooperative_groups;
41
-
42
- // Maximum columns owned per cluster-block in DSMEM.
43
- // Supports n_columns up to COLS_PER_CLUSTER_BLOCK_MAX * cluster_size.
44
- // At cluster_size=16: supports up to 256*16=4096 columns.
45
- // Each array costs 256*4 = 1024 bytes; three arrays = 3072 bytes per SM β€”
46
- // well under the 228 KB H200 shared-memory cap.
47
- #define COLS_PER_CLUSTER_BLOCK_MAX 256u
48
-
49
- // Maximum input_bits supported by the TMA-multicast staging tile.
50
- // At 32 KB this covers the production SDR width (16384 bits) with 2Γ— headroom.
51
- // Total shared per SM: 32768 (tile) + 3072 (DSMEM float arrays) = ~35 KB β€”
52
- // well under the 228 KB H200 limit.
53
- //
54
- // Expected speedup from TMA multicast input staging (T9/T11):
55
- // - Without staging: 16 SMs Γ— T Γ— (input_bits GMEM reads per timestep)
56
- // - With staging: 1 TMA DMA per timestep, shared reads from L1 thereafter
57
- // - Theoretical DRAM bandwidth reduction: ~16Γ— on input reads
58
- // - Wall-clock reduction estimate: -20 to -40 ms from reduced input fetch latency
59
- #define INPUT_BITS_MAX 32768u
60
-
61
- extern "C" {
62
-
63
- struct FusedPtrs {
64
- unsigned long long syn_bit;
65
- unsigned long long syn_perm;
66
- unsigned long long boost;
67
- unsigned long long active_duty;
68
- unsigned long long inhibition_threshold;
69
- unsigned long long seg_cell_id;
70
- unsigned long long seg_syn_count;
71
- unsigned long long syn_presyn;
72
- unsigned long long tm_syn_perm;
73
- unsigned long long cell_seg_count;
74
- unsigned long long cell_active_a;
75
- unsigned long long cell_active_b;
76
- unsigned long long cell_winner_a;
77
- unsigned long long cell_winner_b;
78
- unsigned long long inputs;
79
- unsigned long long cols_out;
80
- unsigned long long anom_out;
81
- unsigned long long barrier_counters;
82
- unsigned long long step_scratch;
83
- };
84
-
85
- struct FusedConfig {
86
- // SP constants
87
- unsigned int input_bits;
88
- unsigned int n_columns;
89
- unsigned int synapses_per_col;
90
- float conn_thr;
91
- float sp_inc;
92
- float sp_dec;
93
- float sparsity_target;
94
- float duty_alpha;
95
- float thr_adapt_rate;
96
- // TM constants
97
- unsigned int cells_per_column;
98
- unsigned int n_cells;
99
- unsigned int bits_words;
100
- unsigned int max_segments_per_cell;
101
- unsigned int synapses_per_segment;
102
- unsigned int activation_threshold;
103
- unsigned int learning_threshold;
104
- unsigned int max_new_synapses;
105
- int conn_thr_i16;
106
- int perm_inc_i16;
107
- int perm_dec_i16;
108
- int predicted_seg_dec_i16;
109
- int initial_perm_i16;
110
- // Loop constants
111
- unsigned int T;
112
- unsigned int learn;
113
- unsigned int iter_seed;
114
- unsigned int cooperative_grid_sync;
115
- };
116
-
117
- // Hardware cluster barrier using Hopper sm_90a cooperative_groups::this_cluster().sync().
118
- // Replaces the former software Decoupled Look-Back (DLB) atomic-spin barrier.
119
- //
120
- // cluster::sync() is a single PTX instruction (barrier.cluster) that resolves
121
- // in ~10-40 ns inside the cluster, with no device-level serialization.
122
- // Multiple clusters (one per HTM region) run fully concurrently β€” bounded
123
- // only by SM count (8 clusters Γ— 16 SMs = 128 ≀ 132 on H200).
124
- //
125
- // The flags / expected / phase / cooperative_grid_sync parameters are kept
126
- // in the signature for call-site compatibility but are unused.
127
- __device__ static inline void fused_grid_barrier(cg::grid_group grid,
128
- unsigned int * /* flags β€” unused */,
129
- unsigned int /* expected β€” unused */,
130
- unsigned int /* phase β€” unused */,
131
- unsigned int /* cooperative_grid_sync β€” unused */) {
132
- #if !defined(HTM_DISABLE_CLUSTER) && defined(__CUDA_ARCH__) && (__CUDA_ARCH__ >= 900)
133
- // Hopper+ : hardware cluster barrier (~10-40 ns)
134
- auto cluster = cg::this_cluster();
135
- cluster.sync();
136
- #else
137
- // Pre-Hopper (sm_80, sm_86, sm_89): grid-level cooperative sync.
138
- // Requires cooperative kernel launch. ~us-ms range, adequate for HTM
139
- // workload (kernel launch frequency is low).
140
- grid.sync();
141
- #endif
142
- }
143
-
144
- __device__ static inline unsigned int warp_sum_u32(unsigned int v) {
145
- for (int off = 16; off > 0; off >>= 1) {
146
- v += __shfl_down_sync(0xffffffffu, v, off);
147
- }
148
- return v;
149
- }
150
-
151
- // Core kernel body β€” works for both single-region and batched launches.
152
- // Single-region: caller passes the one FusedPtrs struct.
153
- // Batched: each block reads its region's FusedPtrs via blockIdx.y before
154
- // calling this. State is independent per region (each region owns its own
155
- // GPU buffers); grid.sync() is the only cross-block primitive and it
156
- // spans ALL blocks in the grid (harmless over-sync across regions).
157
- __device__ static inline
158
- void htm_fused_step_body(const FusedPtrs& P, const FusedConfig& cfg) {
159
- cg::grid_group grid = cg::this_grid();
160
- // Cast pointers.
161
- const unsigned int * __restrict__ syn_bit = (const unsigned int*)P.syn_bit;
162
- float * __restrict__ syn_perm = (float*)P.syn_perm;
163
- float * __restrict__ boost = (float*)P.boost;
164
- float * __restrict__ active_duty = (float*)P.active_duty;
165
- float * __restrict__ inhibition_threshold = (float*)P.inhibition_threshold;
166
- unsigned int * __restrict__ seg_cell_id = (unsigned int*)P.seg_cell_id;
167
- unsigned int * __restrict__ seg_syn_count = (unsigned int*)P.seg_syn_count;
168
- unsigned int * __restrict__ syn_presyn = (unsigned int*)P.syn_presyn;
169
- short * __restrict__ tm_syn_perm = (short*)P.tm_syn_perm;
170
- unsigned int * __restrict__ cell_seg_count = (unsigned int*)P.cell_seg_count;
171
- unsigned int * __restrict__ cell_active_a = (unsigned int*)P.cell_active_a;
172
- unsigned int * __restrict__ cell_active_b = (unsigned int*)P.cell_active_b;
173
- unsigned int * __restrict__ cell_winner_a = (unsigned int*)P.cell_winner_a;
174
- unsigned int * __restrict__ cell_winner_b = (unsigned int*)P.cell_winner_b;
175
- const unsigned char * __restrict__ inputs = (const unsigned char*)P.inputs;
176
- unsigned char * __restrict__ cols_out = (unsigned char*)P.cols_out;
177
- float * __restrict__ anom_out = (float*)P.anom_out;
178
- unsigned int * __restrict__ barrier_counters = (unsigned int*)P.barrier_counters;
179
- unsigned int * __restrict__ step_scratch = (unsigned int*)P.step_scratch;
180
-
181
- const unsigned int tid = threadIdx.x;
182
- const unsigned int lane = tid & 31u;
183
- const unsigned int warp = tid >> 5;
184
- const unsigned int warps_per_block = blockDim.x >> 5;
185
- const unsigned int gwarp = blockIdx.x * warps_per_block + warp;
186
- const unsigned int n_warps = gridDim.x * warps_per_block;
187
-
188
- const unsigned int n_cols = cfg.n_columns;
189
- const unsigned int col_lo = (gwarp * n_cols) / n_warps;
190
- const unsigned int col_hi = ((gwarp + 1) * n_cols) / n_warps;
191
-
192
- unsigned int phase = 0u;
193
-
194
- // =========================================================
195
- // DSMEM: Cluster-distributed shared memory for hot per-column
196
- // state (inhibition_threshold, boost, active_duty).
197
- //
198
- // On Hopper (sm_90+): Each block in the cluster owns a contiguous
199
- // slice of columns in its own __shared__ arrays. Any block can
200
- // peer-read another block's slice via cluster.map_shared_rank().
201
- //
202
- // On Ampere (sm_86) and other pre-Hopper: No cluster support.
203
- // Read/write directly from/to global memory (inhibition_threshold,
204
- // boost, active_duty device pointers). Slightly higher latency but
205
- // functionally correct.
206
- // =========================================================
207
-
208
- #if !defined(HTM_DISABLE_CLUSTER) && defined(__CUDA_ARCH__) && (__CUDA_ARCH__ >= 900)
209
- // Hopper+ cluster path
210
- auto cluster = cg::this_cluster();
211
- const unsigned int cluster_block_rank = cluster.block_rank(); // 0..cluster_size-1
212
- const unsigned int cluster_sz = cluster.num_blocks(); // == gridDim.x (≀16)
213
- #else
214
- // Pre-Hopper: no cluster, each block is independent.
215
- const unsigned int cluster_block_rank = blockIdx.x;
216
- const unsigned int cluster_sz = gridDim.x;
217
- #endif
218
-
219
- // Partition n_cols evenly across cluster blocks.
220
- // Each block owns cols_per_block columns starting at my_col_start.
221
- const unsigned int cols_per_block =
222
- (n_cols + cluster_sz - 1u) / cluster_sz; // ceil div
223
- const unsigned int my_col_start =
224
- cluster_block_rank * cols_per_block;
225
- const unsigned int my_col_end =
226
- (my_col_start + cols_per_block < n_cols)
227
- ? (my_col_start + cols_per_block) : n_cols; // clamp
228
-
229
- #if !defined(HTM_DISABLE_CLUSTER) && defined(__CUDA_ARCH__) && (__CUDA_ARCH__ >= 900)
230
- // Cluster-distributed shared memory arrays.
231
- // Each block holds at most COLS_PER_CLUSTER_BLOCK_MAX floats per array.
232
- // Peer blocks address into each other's smem via map_shared_rank.
233
- __shared__ float s_inhib_thr [COLS_PER_CLUSTER_BLOCK_MAX];
234
- __shared__ float s_boost [COLS_PER_CLUSTER_BLOCK_MAX];
235
- __shared__ float s_active_duty[COLS_PER_CLUSTER_BLOCK_MAX];
236
- #endif
237
-
238
- // TMA multicast input staging tile (T9) β€” HOPPER ONLY.
239
- //
240
- // On Hopper: cg::memcpy_async with cluster scope multicasts input to all
241
- // 16 SMs, reducing DRAM traffic by ~16Γ—.
242
- // On Ampere: 32 KB smem allocation exceeds per-block budget when
243
- // cooperatively launched (48 KB total, registers eat the rest). Skip the
244
- // tile entirely β€” Stage A reads from GMEM directly (original path).
245
- #if !defined(HTM_DISABLE_CLUSTER) && defined(__CUDA_ARCH__) && (__CUDA_ARCH__ >= 900)
246
- __shared__ __align__(16) unsigned char s_input_tile[INPUT_BITS_MAX];
247
- #endif
248
-
249
- #if !defined(HTM_DISABLE_CLUSTER) && defined(__CUDA_ARCH__) && (__CUDA_ARCH__ >= 900)
250
- // Initial GMEM β†’ smem load (reads state from previous forward call).
251
- // Each block loads only its own slice; tid strides across the slice.
252
- for (unsigned int c = my_col_start + tid; c < my_col_end; c += blockDim.x) {
253
- const unsigned int off = c - my_col_start;
254
- s_inhib_thr [off] = inhibition_threshold[c];
255
- s_boost [off] = boost[c];
256
- s_active_duty[off] = active_duty[c];
257
- }
258
-
259
- // All blocks in the cluster must finish loading before any block
260
- // starts reading peer smem inside the T-loop.
261
- cluster.sync();
262
- #else
263
- // Pre-Hopper: no smem caching needed β€” reads go directly to GMEM.
264
- // Grid sync ensures all blocks have completed Phase 0 init before T-loop.
265
- grid.sync();
266
- #endif
267
-
268
- const unsigned int S = cfg.synapses_per_col;
269
- const unsigned int cpc = cfg.cells_per_column;
270
- const unsigned int SPS = cfg.synapses_per_segment;
271
- const unsigned int MSC = cfg.max_segments_per_cell;
272
-
273
- // Main timestep loop.
274
- for (unsigned int t = 0u; t < cfg.T; t++) {
275
- const unsigned int inp_off = t * cfg.input_bits;
276
- const unsigned int col_base_out = t * n_cols;
277
-
278
- unsigned int * curr_active = (t & 1u) ? cell_active_b : cell_active_a;
279
- unsigned int * prev_active = (t & 1u) ? cell_active_a : cell_active_b;
280
- unsigned int * curr_winner = (t & 1u) ? cell_winner_b : cell_winner_a;
281
- unsigned int * prev_winner = (t & 1u) ? cell_winner_a : cell_winner_b;
282
-
283
- // ---- Phase 0: clear curr bitsets for my cell range ----
284
- const unsigned int my_cell_lo = col_lo * cpc;
285
- const unsigned int my_cell_hi = col_hi * cpc;
286
- if (cpc == 32u) {
287
- // Fast path: one word per column.
288
- for (unsigned int c = col_lo + lane; c < col_hi; c += 32u) {
289
- curr_active[c] = 0u;
290
- curr_winner[c] = 0u;
291
- }
292
- } else {
293
- for (unsigned int cell = my_cell_lo + lane; cell < my_cell_hi; cell += 32u) {
294
- unsigned int w = cell >> 5;
295
- unsigned int m = 1u << (cell & 31u);
296
- atomicAnd(&curr_active[w], ~m);
297
- atomicAnd(&curr_winner[w], ~m);
298
- }
299
- }
300
-
301
- // Block 0, lane 0, warp 0 resets step-scratch counters.
302
- if (blockIdx.x == 0u && tid == 0u) {
303
- step_scratch[0] = 0u;
304
- step_scratch[1] = 0u;
305
- }
306
-
307
- // ---- BARRIER 1 ----
308
- // Fence: make the above clear-bitsets + scratch writes globally
309
- // visible before peer blocks observe "barrier arrived".
310
- __threadfence();
311
- fused_grid_barrier(grid, barrier_counters, 0u, phase++, cfg.cooperative_grid_sync);
312
-
313
- // =========================================================
314
- // T9: TMA MULTICAST INPUT STAGING
315
- //
316
- // Issue a single cluster-scope async DMA to broadcast this
317
- // timestep's input slice into s_input_tile across all 16 SMs
318
- // in the cluster simultaneously. On Hopper sm_90a,
319
- // cg::memcpy_async with cluster scope maps to the TMA
320
- // hardware unit (cp.async.bulk.tensor multicast), reducing
321
- // DRAM input traffic by ~16Γ— vs each block fetching its own
322
- // copy from GMEM.
323
- //
324
- // The staging is gated on cfg.input_bits <= INPUT_BITS_MAX.
325
- // If the tile is too small (custom large input_bits), we fall
326
- // back to per-thread GMEM reads in Stage A (identical to the
327
- // original path; use_input_tile==false).
328
- //
329
- // Ordering: BARRIER 1 completes before we issue the DMA.
330
- // The DMA completes before Stage A reads s_input_tile.
331
- // =========================================================
332
- #if !defined(HTM_DISABLE_CLUSTER) && defined(__CUDA_ARCH__) && (__CUDA_ARCH__ >= 900)
333
- const bool use_input_tile = (cfg.input_bits <= INPUT_BITS_MAX);
334
- if (use_input_tile) {
335
- auto tb = cg::this_thread_block();
336
- cg::memcpy_async(tb, s_input_tile,
337
- inputs + inp_off,
338
- cfg.input_bits);
339
- cg::wait(tb);
340
- cluster.sync();
341
- }
342
- #else
343
- const bool use_input_tile = false;
344
- #endif
345
-
346
- // =========================================================
347
- // STAGE A: Spatial Pooler
348
- //
349
- // Hot per-column state (boost, inhibition_threshold,
350
- // active_duty) is served from cluster DSMEM rather than
351
- // GMEM for each of the T timesteps. GMEM is written on
352
- // update so state persists across forward calls.
353
- // =========================================================
354
- for (unsigned int c = col_lo; c < col_hi; c++) {
355
- unsigned int base = c * S;
356
- unsigned int local = 0u;
357
- for (unsigned int s = lane; s < S; s += 32u) {
358
- unsigned int b = syn_bit[base + s];
359
- float p = syn_perm[base + s];
360
- // T9: read from cluster-broadcast tile when available;
361
- // fall back to direct GMEM when input_bits > INPUT_BITS_MAX.
362
- #if !defined(HTM_DISABLE_CLUSTER) && defined(__CUDA_ARCH__) && (__CUDA_ARCH__ >= 900)
363
- unsigned int inp_byte = use_input_tile
364
- ? (unsigned int)s_input_tile[b]
365
- : (unsigned int)inputs[inp_off + b];
366
- #else
367
- unsigned int inp_byte = (unsigned int)inputs[inp_off + b];
368
- #endif
369
- unsigned int hit = ((inp_byte != 0u) && (p >= cfg.conn_thr)) ? 1u : 0u;
370
- local += hit;
371
- }
372
- unsigned int overlap = warp_sum_u32(local);
373
- overlap = __shfl_sync(0xffffffffu, overlap, 0);
374
-
375
- // Read boost + threshold for column c.
376
- #if !defined(HTM_DISABLE_CLUSTER) && defined(__CUDA_ARCH__) && (__CUDA_ARCH__ >= 900)
377
- // Hopper: read from cluster-distributed shared memory.
378
- const unsigned int owner_block = c / cols_per_block;
379
- const unsigned int owner_offset = c - owner_block * cols_per_block;
380
- float boost_val = cluster.map_shared_rank(s_boost, owner_block)[owner_offset];
381
- float thr = cluster.map_shared_rank(s_inhib_thr, owner_block)[owner_offset];
382
- #else
383
- // Pre-Hopper: read directly from global memory.
384
- float boost_val = boost[c];
385
- float thr = inhibition_threshold[c];
386
- #endif
387
-
388
- float boosted = (float)overlap * boost_val;
389
- unsigned int is_active = (boosted > thr) ? 1u : 0u;
390
-
391
- if (lane == 0) {
392
- cols_out[col_base_out + c] = (unsigned char)is_active;
393
- if (is_active) {
394
- atomicAdd(&step_scratch[0], 1u);
395
- }
396
- }
397
-
398
- // SP learn (Hebbian) on active columns.
399
- // T9: use tile for input reads here too.
400
- if (cfg.learn && is_active) {
401
- for (unsigned int s = lane; s < S; s += 32u) {
402
- unsigned int b = syn_bit[base + s];
403
- float p = syn_perm[base + s];
404
- #if !defined(HTM_DISABLE_CLUSTER) && defined(__CUDA_ARCH__) && (__CUDA_ARCH__ >= 900)
405
- unsigned int inp_byte = use_input_tile
406
- ? (unsigned int)s_input_tile[b]
407
- : (unsigned int)inputs[inp_off + b];
408
- #else
409
- unsigned int inp_byte = (unsigned int)inputs[inp_off + b];
410
- #endif
411
- if (inp_byte != 0u) {
412
- p += cfg.sp_inc;
413
- if (p > 1.0f) p = 1.0f;
414
- } else {
415
- p -= cfg.sp_dec;
416
- if (p < 0.0f) p = 0.0f;
417
- }
418
- syn_perm[base + s] = p;
419
- }
420
- }
421
-
422
- // active_duty EMA + threshold adaptation.
423
- // Writes go to both DSMEM (hot path, Hopper only) and GMEM (persistence).
424
- if (lane == 0) {
425
- #if !defined(HTM_DISABLE_CLUSTER) && defined(__CUDA_ARCH__) && (__CUDA_ARCH__ >= 900)
426
- float ad = cluster.map_shared_rank(s_active_duty, owner_block)[owner_offset];
427
- #else
428
- float ad = active_duty[c];
429
- #endif
430
- float sample = is_active ? 1.0f : 0.0f;
431
- ad = (1.0f - cfg.duty_alpha) * ad + cfg.duty_alpha * sample;
432
-
433
- #if !defined(HTM_DISABLE_CLUSTER) && defined(__CUDA_ARCH__) && (__CUDA_ARCH__ >= 900)
434
- // Writeback: peer smem (for next timestep read) + GMEM (persistence).
435
- cluster.map_shared_rank(s_active_duty, owner_block)[owner_offset] = ad;
436
- #endif
437
- active_duty[c] = ad;
438
-
439
- // Threshold steers toward target sparsity.
440
- float err = ad - cfg.sparsity_target;
441
- float new_thr = thr + cfg.thr_adapt_rate * err * 100.0f;
442
- if (new_thr < 0.1f) new_thr = 0.1f;
443
- if (new_thr > 1000.0f) new_thr = 1000.0f;
444
-
445
- #if !defined(HTM_DISABLE_CLUSTER) && defined(__CUDA_ARCH__) && (__CUDA_ARCH__ >= 900)
446
- // Writeback: peer smem (for next timestep read) + GMEM (persistence).
447
- cluster.map_shared_rank(s_inhib_thr, owner_block)[owner_offset] = new_thr;
448
- #endif
449
- inhibition_threshold[c] = new_thr;
450
- }
451
- }
452
-
453
- // ---- DSMEM WRITEBACK SYNC: peer-smem writes must be visible cluster-wide ----
454
- //
455
- // On Hopper: cluster.sync() ensures all peer smem writes from this
456
- // timestep are visible to all blocks before Stage B / next t.
457
- // On pre-Hopper: no smem peer writes occur (all state in GMEM),
458
- // so no extra sync needed here β€” the grid barrier below suffices.
459
- #if !defined(HTM_DISABLE_CLUSTER) && defined(__CUDA_ARCH__) && (__CUDA_ARCH__ >= 900)
460
- cluster.sync();
461
- #endif
462
-
463
- // ---- BARRIER 2: SP active_mask must be visible before TM reads ----
464
- // Fence: flush cols_out + active_duty + inhibition_threshold + step_scratch
465
- // writes to global memory before peers advance past this barrier.
466
- __threadfence();
467
- fused_grid_barrier(grid, barrier_counters, 0u, phase++, cfg.cooperative_grid_sync);
468
-
469
- // =========================================================
470
- // STAGE B: Temporal Memory
471
- // =========================================================
472
- for (unsigned int c = col_lo; c < col_hi; c++) {
473
- unsigned int col_active = cols_out[col_base_out + c];
474
- if (col_active == 0u) continue;
475
-
476
- unsigned int base_cell = c * cpc;
477
- unsigned int any_predicted = 0u;
478
- unsigned int best_seg_id_for_grow = 0xFFFFFFFFu;
479
- unsigned int best_pot_count = 0u;
480
-
481
- for (unsigned int k = 0u; k < cpc; k++) {
482
- unsigned int cell = base_cell + k;
483
- unsigned int n_segs_here = cell_seg_count[cell];
484
- if (n_segs_here > MSC) n_segs_here = MSC;
485
- if (n_segs_here == 0u) continue;
486
-
487
- unsigned int seg_base_id = cell * MSC;
488
- unsigned int cell_is_predictive = 0u;
489
-
490
- for (unsigned int ls = 0u; ls < n_segs_here; ls++) {
491
- unsigned int seg = seg_base_id + ls;
492
- unsigned int n_syn = seg_syn_count[seg];
493
- if (n_syn == 0u) continue;
494
- unsigned int syn_base = seg * SPS;
495
-
496
- unsigned int l_conn = 0u;
497
- unsigned int l_pot = 0u;
498
- for (unsigned int s = lane; s < n_syn; s += 32u) {
499
- unsigned int presyn = syn_presyn[syn_base + s];
500
- unsigned int w = prev_active[presyn >> 5];
501
- unsigned int bit = (w >> (presyn & 31u)) & 1u;
502
- if (bit) {
503
- l_pot += 1u;
504
- int p = (int)tm_syn_perm[syn_base + s];
505
- if (p >= cfg.conn_thr_i16) l_conn += 1u;
506
- }
507
- }
508
- unsigned int tot_conn = warp_sum_u32(l_conn);
509
- unsigned int tot_pot = warp_sum_u32(l_pot);
510
- tot_conn = __shfl_sync(0xffffffffu, tot_conn, 0);
511
- tot_pot = __shfl_sync(0xffffffffu, tot_pot, 0);
512
-
513
- if (tot_conn >= cfg.activation_threshold) cell_is_predictive = 1u;
514
- if (tot_pot >= cfg.learning_threshold && tot_pot > best_pot_count) {
515
- best_pot_count = tot_pot;
516
- best_seg_id_for_grow = seg;
517
- }
518
-
519
- // Reinforce predicted-and-correct segment.
520
- if (cfg.learn && tot_conn >= cfg.activation_threshold) {
521
- for (unsigned int s = lane; s < n_syn; s += 32u) {
522
- unsigned int presyn = syn_presyn[syn_base + s];
523
- unsigned int w = prev_active[presyn >> 5];
524
- unsigned int bit = (w >> (presyn & 31u)) & 1u;
525
- int p = (int)tm_syn_perm[syn_base + s];
526
- if (bit) {
527
- int np = p + cfg.perm_inc_i16;
528
- if (np > 32767) np = 32767;
529
- tm_syn_perm[syn_base + s] = (short)np;
530
- } else {
531
- int np = p - cfg.perm_dec_i16;
532
- if (np < 0) np = 0;
533
- tm_syn_perm[syn_base + s] = (short)np;
534
- }
535
- }
536
- }
537
- }
538
-
539
- if (cell_is_predictive) {
540
- any_predicted = 1u;
541
- if (lane == 0) {
542
- unsigned int w = cell >> 5;
543
- unsigned int m = 1u << (cell & 31u);
544
- atomicOr(&curr_active[w], m);
545
- atomicOr(&curr_winner[w], m);
546
- }
547
- }
548
- }
549
-
550
- // BURST if no predicted.
551
- if (!any_predicted) {
552
- if (lane == 0) {
553
- for (unsigned int k = 0u; k < cpc; k++) {
554
- unsigned int cell = base_cell + k;
555
- unsigned int w = cell >> 5;
556
- unsigned int m = 1u << (cell & 31u);
557
- atomicOr(&curr_active[w], m);
558
- }
559
- unsigned int win = base_cell;
560
- unsigned int ww = win >> 5;
561
- unsigned int wm = 1u << (win & 31u);
562
- atomicOr(&curr_winner[ww], wm);
563
- atomicAdd(&step_scratch[1], 1u);
564
- }
565
-
566
- if (cfg.learn) {
567
- unsigned int target_seg;
568
- unsigned int existing_syn;
569
- if (best_seg_id_for_grow != 0xFFFFFFFFu) {
570
- // Reuse best matching segment.
571
- target_seg = best_seg_id_for_grow;
572
- existing_syn = seg_syn_count[target_seg];
573
- target_seg = __shfl_sync(0xffffffffu, target_seg, 0);
574
- existing_syn = __shfl_sync(0xffffffffu, existing_syn, 0);
575
-
576
- // Reinforce its existing synapses.
577
- unsigned int syn_base = target_seg * SPS;
578
- for (unsigned int s = lane; s < existing_syn; s += 32u) {
579
- unsigned int presyn = syn_presyn[syn_base + s];
580
- unsigned int w = prev_active[presyn >> 5];
581
- unsigned int bit = (w >> (presyn & 31u)) & 1u;
582
- int p = (int)tm_syn_perm[syn_base + s];
583
- if (bit) {
584
- int np = p + cfg.perm_inc_i16;
585
- if (np > 32767) np = 32767;
586
- tm_syn_perm[syn_base + s] = (short)np;
587
- } else {
588
- int np = p - cfg.perm_dec_i16;
589
- if (np < 0) np = 0;
590
- tm_syn_perm[syn_base + s] = (short)np;
591
- }
592
- }
593
- } else {
594
- // Allocate new segment on winner cell (cell 0 of col).
595
- unsigned int new_seg = 0u;
596
- if (lane == 0) {
597
- unsigned int winner_cell = base_cell;
598
- unsigned int slot = atomicAdd(&cell_seg_count[winner_cell], 1u);
599
- if (slot >= MSC) slot = slot % MSC;
600
- new_seg = winner_cell * MSC + slot;
601
- seg_cell_id[new_seg] = winner_cell;
602
- seg_syn_count[new_seg] = 0u;
603
- }
604
- target_seg = __shfl_sync(0xffffffffu, new_seg, 0);
605
- existing_syn = 0u;
606
- }
607
-
608
- // Grow synapses to prev_winner cells β€” lane 0 serialized.
609
- unsigned int room = (SPS > existing_syn) ? (SPS - existing_syn) : 0u;
610
- unsigned int max_grow = (cfg.max_new_synapses < room) ? cfg.max_new_synapses : room;
611
- if (lane == 0 && max_grow > 0u) {
612
- unsigned int syn_base = target_seg * SPS;
613
- unsigned int grown = 0u;
614
- unsigned int start_off = (c * 2654435761u + cfg.iter_seed + t) % cfg.bits_words;
615
- for (unsigned int w_off = 0u;
616
- w_off < cfg.bits_words && grown < max_grow;
617
- w_off++) {
618
- unsigned int widx = (start_off + w_off) % cfg.bits_words;
619
- unsigned int word = prev_winner[widx];
620
- while (word != 0u && grown < max_grow) {
621
- unsigned int bit_pos = __ffs(word) - 1u;
622
- word &= ~(1u << bit_pos);
623
- unsigned int cell_id = widx * 32u + bit_pos;
624
- if (cell_id >= cfg.n_cells) continue;
625
- bool exists = false;
626
- for (unsigned int es = 0u; es < existing_syn + grown; es++) {
627
- if (syn_presyn[syn_base + es] == cell_id) { exists = true; break; }
628
- }
629
- if (exists) continue;
630
- unsigned int write_idx = existing_syn + grown;
631
- if (write_idx >= SPS) break;
632
- syn_presyn[syn_base + write_idx] = cell_id;
633
- tm_syn_perm[syn_base + write_idx] = (short)cfg.initial_perm_i16;
634
- grown++;
635
- }
636
- }
637
- if (grown > 0u) {
638
- seg_syn_count[target_seg] = existing_syn + grown;
639
- }
640
- }
641
- }
642
- }
643
- }
644
-
645
- // ---- BARRIER 3: TM writes complete before anomaly + next-step read ----
646
- // Fence: flush curr_active/curr_winner bitsets + tm_syn_perm +
647
- // seg_syn_count + syn_presyn before peers advance and consume them as
648
- // prev_active/prev_winner at t+1.
649
- __threadfence();
650
- fused_grid_barrier(grid, barrier_counters, 0u, phase++, cfg.cooperative_grid_sync);
651
-
652
- // Write anomaly for step t.
653
- if (blockIdx.x == 0u && tid == 0u) {
654
- unsigned int total = step_scratch[0];
655
- unsigned int bad = step_scratch[1];
656
- float anom = (total > 0u) ? ((float)bad / (float)total) : 0.0f;
657
- anom_out[t] = anom;
658
- }
659
- }
660
- }
661
-
662
- // Single-region kernel (legacy call site).
663
- __global__ __launch_bounds__(256, 2)
664
- void htm_fused_step(FusedPtrs P, FusedConfig cfg) {
665
- htm_fused_step_body(P, cfg);
666
- }
667
-
668
- // Batched kernel: one cooperative launch for B regions. grid.y = B,
669
- // grid.x = per-region block count. Each block reads its region's
670
- // FusedPtrs from the device array via blockIdx.y.
671
- __global__ __launch_bounds__(256, 2)
672
- void htm_fused_step_batched(const FusedPtrs* __restrict__ P_arr, FusedConfig cfg) {
673
- const FusedPtrs P = P_arr[blockIdx.y];
674
- htm_fused_step_body(P, cfg);
675
- }
676
-
677
- } // extern "C"
 
1
+ // Fused HTM megakernel β€” SP + TM, all T timesteps in a single launch.
2
+ //
3
+ // Design rationale:
4
+ // - Global top-K column selection requires cross-block synchronization at
5
+ // every timestep (grid.sync is unreliable on WSL2/sm_86 without rdc=true).
6
+ // - Replace with per-column threshold activation using local lateral
7
+ // inhibition: column c activates if overlap[c]*boost[c] > threshold[c].
8
+ // Threshold is a per-column running-EMA learned scalar that steers the
9
+ // column's long-run activation rate toward the global sparsity target.
10
+ // - This is biologically grounded (GABAergic local inhibition) and supported
11
+ // by HTM theory (duty-cycle boost already drives this loop; we just
12
+ // change which lever the EMA pulls).
13
+ //
14
+ // Launch shape:
15
+ // grid = min(device SM count, 16) // hard cap β€” see below
16
+ // block = 1024 threads = 32 warps
17
+ // Each warp of 32 owns a contiguous column slice (n_columns / total_warps).
18
+ //
19
+ // Cross-block coherence:
20
+ // - Ping-pong buffers for cell_active/cell_winner: write _a at even t,
21
+ // read _b; reversed at odd t.
22
+ // - Preferred path: cooperative launch + hardware whole-grid sync.
23
+ // - Fallback path: software 3-slot rotating grid barrier for devices/drivers
24
+ // that cannot do cooperative launch.
25
+ //
26
+ // 2026-04-16: grid_dim reduced from 28 to 16 after deadlock RCA. The previous
27
+ // cap of 28 relied on all blocks being concurrently resident on a 30-SM RTX
28
+ // 3060 Laptop. Under thermal throttling effective residency dropped to ~20-24,
29
+ // leaving scheduled blocks spinning on the software grid barrier waiting for
30
+ // peer blocks that would never run. 16 blocks is below any realistic residency
31
+ // floor and preserves enough warp parallelism (16*32 = 512 warps) to saturate
32
+ // memory bandwidth on the spatial-pooler stage.
33
+ //
34
+ // Kernel signature uses struct-by-value for pointers and config to stay
35
+ // inside cudarc's launch-arg count limit.
36
+
37
+ #include <cooperative_groups.h>
38
+ #include <cooperative_groups/memcpy_async.h>
39
+
40
+ namespace cg = cooperative_groups;
41
+
42
+ // Maximum columns owned per cluster-block in DSMEM.
43
+ // Supports n_columns up to COLS_PER_CLUSTER_BLOCK_MAX * cluster_size.
44
+ // At cluster_size=16: supports up to 256*16=4096 columns.
45
+ // Each array costs 256*4 = 1024 bytes; three arrays = 3072 bytes per SM β€”
46
+ // well under the 228 KB H200 shared-memory cap.
47
+ #define COLS_PER_CLUSTER_BLOCK_MAX 256u
48
+
49
+ // Maximum input_bits supported by the TMA-multicast staging tile.
50
+ // At 32 KB this covers the production SDR width (16384 bits) with 2Γ— headroom.
51
+ // Total shared per SM: 32768 (tile) + 3072 (DSMEM float arrays) = ~35 KB β€”
52
+ // well under the 228 KB H200 limit.
53
+ //
54
+ // Expected speedup from TMA multicast input staging (T9/T11):
55
+ // - Without staging: 16 SMs Γ— T Γ— (input_bits GMEM reads per timestep)
56
+ // - With staging: 1 TMA DMA per timestep, shared reads from L1 thereafter
57
+ // - Theoretical DRAM bandwidth reduction: ~16Γ— on input reads
58
+ // - Wall-clock reduction estimate: -20 to -40 ms from reduced input fetch latency
59
+ #define INPUT_BITS_MAX 32768u
60
+
61
+ extern "C" {
62
+
63
+ struct FusedPtrs {
64
+ unsigned long long syn_bit;
65
+ unsigned long long syn_perm;
66
+ unsigned long long boost;
67
+ unsigned long long active_duty;
68
+ unsigned long long inhibition_threshold;
69
+ unsigned long long seg_cell_id;
70
+ unsigned long long seg_syn_count;
71
+ unsigned long long syn_presyn;
72
+ unsigned long long tm_syn_perm;
73
+ unsigned long long cell_seg_count;
74
+ unsigned long long cell_active_a;
75
+ unsigned long long cell_active_b;
76
+ unsigned long long cell_winner_a;
77
+ unsigned long long cell_winner_b;
78
+ unsigned long long inputs;
79
+ unsigned long long cols_out;
80
+ unsigned long long anom_out;
81
+ unsigned long long barrier_counters;
82
+ unsigned long long step_scratch;
83
+ };
84
+
85
+ struct FusedConfig {
86
+ // SP constants
87
+ unsigned int input_bits;
88
+ unsigned int n_columns;
89
+ unsigned int synapses_per_col;
90
+ float conn_thr;
91
+ float sp_inc;
92
+ float sp_dec;
93
+ float sparsity_target;
94
+ float duty_alpha;
95
+ float thr_adapt_rate;
96
+ // TM constants
97
+ unsigned int cells_per_column;
98
+ unsigned int n_cells;
99
+ unsigned int bits_words;
100
+ unsigned int max_segments_per_cell;
101
+ unsigned int synapses_per_segment;
102
+ unsigned int activation_threshold;
103
+ unsigned int learning_threshold;
104
+ unsigned int max_new_synapses;
105
+ int conn_thr_i16;
106
+ int perm_inc_i16;
107
+ int perm_dec_i16;
108
+ int predicted_seg_dec_i16;
109
+ int initial_perm_i16;
110
+ // Loop constants
111
+ unsigned int T;
112
+ unsigned int learn;
113
+ unsigned int iter_seed;
114
+ unsigned int cooperative_grid_sync;
115
+ };
116
+
117
+ // Hardware cluster barrier using Hopper sm_90a cooperative_groups::this_cluster().sync().
118
+ // Replaces the former software Decoupled Look-Back (DLB) atomic-spin barrier.
119
+ //
120
+ // cluster::sync() is a single PTX instruction (barrier.cluster) that resolves
121
+ // in ~10-40 ns inside the cluster, with no device-level serialization.
122
+ // Multiple clusters (one per HTM region) run fully concurrently β€” bounded
123
+ // only by SM count (8 clusters Γ— 16 SMs = 128 ≀ 132 on H200).
124
+ //
125
+ // The flags / expected / phase / cooperative_grid_sync parameters are kept
126
+ // in the signature for call-site compatibility but are unused.
127
+ __device__ static inline void fused_grid_barrier(cg::grid_group grid,
128
+ unsigned int * /* flags β€” unused */,
129
+ unsigned int /* expected β€” unused */,
130
+ unsigned int /* phase β€” unused */,
131
+ unsigned int /* cooperative_grid_sync β€” unused */) {
132
+ #if !defined(HTM_DISABLE_CLUSTER) && defined(__CUDA_ARCH__) && (__CUDA_ARCH__ >= 900)
133
+ // Hopper+ : hardware cluster barrier (~10-40 ns)
134
+ auto cluster = cg::this_cluster();
135
+ cluster.sync();
136
+ #else
137
+ // Pre-Hopper (sm_80, sm_86, sm_89): grid-level cooperative sync.
138
+ // Requires cooperative kernel launch. ~us-ms range, adequate for HTM
139
+ // workload (kernel launch frequency is low).
140
+ grid.sync();
141
+ #endif
142
+ }
143
+
144
+ __device__ static inline unsigned int warp_sum_u32(unsigned int v) {
145
+ for (int off = 16; off > 0; off >>= 1) {
146
+ v += __shfl_down_sync(0xffffffffu, v, off);
147
+ }
148
+ return v;
149
+ }
150
+
151
+ // Core kernel body β€” works for both single-region and batched launches.
152
+ // Single-region: caller passes the one FusedPtrs struct.
153
+ // Batched: each block reads its region's FusedPtrs via blockIdx.y before
154
+ // calling this. State is independent per region (each region owns its own
155
+ // GPU buffers); grid.sync() is the only cross-block primitive and it
156
+ // spans ALL blocks in the grid (harmless over-sync across regions).
157
+ __device__ static inline
158
+ void htm_fused_step_body(const FusedPtrs& P, const FusedConfig& cfg) {
159
+ cg::grid_group grid = cg::this_grid();
160
+ // Cast pointers.
161
+ const unsigned int * __restrict__ syn_bit = (const unsigned int*)P.syn_bit;
162
+ float * __restrict__ syn_perm = (float*)P.syn_perm;
163
+ float * __restrict__ boost = (float*)P.boost;
164
+ float * __restrict__ active_duty = (float*)P.active_duty;
165
+ float * __restrict__ inhibition_threshold = (float*)P.inhibition_threshold;
166
+ unsigned int * __restrict__ seg_cell_id = (unsigned int*)P.seg_cell_id;
167
+ unsigned int * __restrict__ seg_syn_count = (unsigned int*)P.seg_syn_count;
168
+ unsigned int * __restrict__ syn_presyn = (unsigned int*)P.syn_presyn;
169
+ short * __restrict__ tm_syn_perm = (short*)P.tm_syn_perm;
170
+ unsigned int * __restrict__ cell_seg_count = (unsigned int*)P.cell_seg_count;
171
+ unsigned int * __restrict__ cell_active_a = (unsigned int*)P.cell_active_a;
172
+ unsigned int * __restrict__ cell_active_b = (unsigned int*)P.cell_active_b;
173
+ unsigned int * __restrict__ cell_winner_a = (unsigned int*)P.cell_winner_a;
174
+ unsigned int * __restrict__ cell_winner_b = (unsigned int*)P.cell_winner_b;
175
+ const unsigned char * __restrict__ inputs = (const unsigned char*)P.inputs;
176
+ unsigned char * __restrict__ cols_out = (unsigned char*)P.cols_out;
177
+ float * __restrict__ anom_out = (float*)P.anom_out;
178
+ unsigned int * __restrict__ barrier_counters = (unsigned int*)P.barrier_counters;
179
+ unsigned int * __restrict__ step_scratch = (unsigned int*)P.step_scratch;
180
+
181
+ const unsigned int tid = threadIdx.x;
182
+ const unsigned int lane = tid & 31u;
183
+ const unsigned int warp = tid >> 5;
184
+ const unsigned int warps_per_block = blockDim.x >> 5;
185
+ const unsigned int gwarp = blockIdx.x * warps_per_block + warp;
186
+ const unsigned int n_warps = gridDim.x * warps_per_block;
187
+
188
+ const unsigned int n_cols = cfg.n_columns;
189
+ const unsigned int col_lo = (gwarp * n_cols) / n_warps;
190
+ const unsigned int col_hi = ((gwarp + 1) * n_cols) / n_warps;
191
+
192
+ unsigned int phase = 0u;
193
+
194
+ // =========================================================
195
+ // DSMEM: Cluster-distributed shared memory for hot per-column
196
+ // state (inhibition_threshold, boost, active_duty).
197
+ //
198
+ // On Hopper (sm_90+): Each block in the cluster owns a contiguous
199
+ // slice of columns in its own __shared__ arrays. Any block can
200
+ // peer-read another block's slice via cluster.map_shared_rank().
201
+ //
202
+ // On Ampere (sm_86) and other pre-Hopper: No cluster support.
203
+ // Read/write directly from/to global memory (inhibition_threshold,
204
+ // boost, active_duty device pointers). Slightly higher latency but
205
+ // functionally correct.
206
+ // =========================================================
207
+
208
+ #if !defined(HTM_DISABLE_CLUSTER) && defined(__CUDA_ARCH__) && (__CUDA_ARCH__ >= 900)
209
+ // Hopper+ cluster path
210
+ auto cluster = cg::this_cluster();
211
+ const unsigned int cluster_block_rank = cluster.block_rank(); // 0..cluster_size-1
212
+ const unsigned int cluster_sz = cluster.num_blocks(); // == gridDim.x (≀16)
213
+ #else
214
+ // Pre-Hopper: no cluster, each block is independent.
215
+ const unsigned int cluster_block_rank = blockIdx.x;
216
+ const unsigned int cluster_sz = gridDim.x;
217
+ #endif
218
+
219
+ // Partition n_cols evenly across cluster blocks.
220
+ // Each block owns cols_per_block columns starting at my_col_start.
221
+ const unsigned int cols_per_block =
222
+ (n_cols + cluster_sz - 1u) / cluster_sz; // ceil div
223
+ const unsigned int my_col_start =
224
+ cluster_block_rank * cols_per_block;
225
+ const unsigned int my_col_end =
226
+ (my_col_start + cols_per_block < n_cols)
227
+ ? (my_col_start + cols_per_block) : n_cols; // clamp
228
+
229
+ #if !defined(HTM_DISABLE_CLUSTER) && defined(__CUDA_ARCH__) && (__CUDA_ARCH__ >= 900)
230
+ // Cluster-distributed shared memory arrays.
231
+ // Each block holds at most COLS_PER_CLUSTER_BLOCK_MAX floats per array.
232
+ // Peer blocks address into each other's smem via map_shared_rank.
233
+ __shared__ float s_inhib_thr [COLS_PER_CLUSTER_BLOCK_MAX];
234
+ __shared__ float s_boost [COLS_PER_CLUSTER_BLOCK_MAX];
235
+ __shared__ float s_active_duty[COLS_PER_CLUSTER_BLOCK_MAX];
236
+ #endif
237
+
238
+ // TMA multicast input staging tile (T9) β€” HOPPER ONLY.
239
+ //
240
+ // On Hopper: cg::memcpy_async with cluster scope multicasts input to all
241
+ // 16 SMs, reducing DRAM traffic by ~16Γ—.
242
+ // On Ampere: 32 KB smem allocation exceeds per-block budget when
243
+ // cooperatively launched (48 KB total, registers eat the rest). Skip the
244
+ // tile entirely β€” Stage A reads from GMEM directly (original path).
245
+ #if !defined(HTM_DISABLE_CLUSTER) && defined(__CUDA_ARCH__) && (__CUDA_ARCH__ >= 900)
246
+ __shared__ __align__(16) unsigned char s_input_tile[INPUT_BITS_MAX];
247
+ #endif
248
+
249
+ #if !defined(HTM_DISABLE_CLUSTER) && defined(__CUDA_ARCH__) && (__CUDA_ARCH__ >= 900)
250
+ // Initial GMEM β†’ smem load (reads state from previous forward call).
251
+ // Each block loads only its own slice; tid strides across the slice.
252
+ for (unsigned int c = my_col_start + tid; c < my_col_end; c += blockDim.x) {
253
+ const unsigned int off = c - my_col_start;
254
+ s_inhib_thr [off] = inhibition_threshold[c];
255
+ s_boost [off] = boost[c];
256
+ s_active_duty[off] = active_duty[c];
257
+ }
258
+
259
+ // All blocks in the cluster must finish loading before any block
260
+ // starts reading peer smem inside the T-loop.
261
+ cluster.sync();
262
+ #else
263
+ // Pre-Hopper: no smem caching needed β€” reads go directly to GMEM.
264
+ // Grid sync ensures all blocks have completed Phase 0 init before T-loop.
265
+ grid.sync();
266
+ #endif
267
+
268
+ const unsigned int S = cfg.synapses_per_col;
269
+ const unsigned int cpc = cfg.cells_per_column;
270
+ const unsigned int SPS = cfg.synapses_per_segment;
271
+ const unsigned int MSC = cfg.max_segments_per_cell;
272
+
273
+ // Main timestep loop.
274
+ for (unsigned int t = 0u; t < cfg.T; t++) {
275
+ const unsigned int inp_off = t * cfg.input_bits;
276
+ const unsigned int col_base_out = t * n_cols;
277
+
278
+ unsigned int * curr_active = (t & 1u) ? cell_active_b : cell_active_a;
279
+ unsigned int * prev_active = (t & 1u) ? cell_active_a : cell_active_b;
280
+ unsigned int * curr_winner = (t & 1u) ? cell_winner_b : cell_winner_a;
281
+ unsigned int * prev_winner = (t & 1u) ? cell_winner_a : cell_winner_b;
282
+
283
+ // ---- Phase 0: clear curr bitsets for my cell range ----
284
+ const unsigned int my_cell_lo = col_lo * cpc;
285
+ const unsigned int my_cell_hi = col_hi * cpc;
286
+ if (cpc == 32u) {
287
+ // Fast path: one word per column.
288
+ for (unsigned int c = col_lo + lane; c < col_hi; c += 32u) {
289
+ curr_active[c] = 0u;
290
+ curr_winner[c] = 0u;
291
+ }
292
+ } else {
293
+ for (unsigned int cell = my_cell_lo + lane; cell < my_cell_hi; cell += 32u) {
294
+ unsigned int w = cell >> 5;
295
+ unsigned int m = 1u << (cell & 31u);
296
+ atomicAnd(&curr_active[w], ~m);
297
+ atomicAnd(&curr_winner[w], ~m);
298
+ }
299
+ }
300
+
301
+ // Block 0, lane 0, warp 0 resets step-scratch counters.
302
+ if (blockIdx.x == 0u && tid == 0u) {
303
+ step_scratch[0] = 0u;
304
+ step_scratch[1] = 0u;
305
+ }
306
+
307
+ // ---- BARRIER 1 ----
308
+ // Fence: make the above clear-bitsets + scratch writes globally
309
+ // visible before peer blocks observe "barrier arrived".
310
+ __threadfence();
311
+ fused_grid_barrier(grid, barrier_counters, 0u, phase++, cfg.cooperative_grid_sync);
312
+
313
+ // =========================================================
314
+ // T9: TMA MULTICAST INPUT STAGING
315
+ //
316
+ // Issue a single cluster-scope async DMA to broadcast this
317
+ // timestep's input slice into s_input_tile across all 16 SMs
318
+ // in the cluster simultaneously. On Hopper sm_90a,
319
+ // cg::memcpy_async with cluster scope maps to the TMA
320
+ // hardware unit (cp.async.bulk.tensor multicast), reducing
321
+ // DRAM input traffic by ~16Γ— vs each block fetching its own
322
+ // copy from GMEM.
323
+ //
324
+ // The staging is gated on cfg.input_bits <= INPUT_BITS_MAX.
325
+ // If the tile is too small (custom large input_bits), we fall
326
+ // back to per-thread GMEM reads in Stage A (identical to the
327
+ // original path; use_input_tile==false).
328
+ //
329
+ // Ordering: BARRIER 1 completes before we issue the DMA.
330
+ // The DMA completes before Stage A reads s_input_tile.
331
+ // =========================================================
332
+ #if !defined(HTM_DISABLE_CLUSTER) && defined(__CUDA_ARCH__) && (__CUDA_ARCH__ >= 900)
333
+ const bool use_input_tile = (cfg.input_bits <= INPUT_BITS_MAX);
334
+ if (use_input_tile) {
335
+ auto tb = cg::this_thread_block();
336
+ cg::memcpy_async(tb, s_input_tile,
337
+ inputs + inp_off,
338
+ cfg.input_bits);
339
+ cg::wait(tb);
340
+ cluster.sync();
341
+ }
342
+ #else
343
+ const bool use_input_tile = false;
344
+ #endif
345
+
346
+ // =========================================================
347
+ // STAGE A: Spatial Pooler
348
+ //
349
+ // Hot per-column state (boost, inhibition_threshold,
350
+ // active_duty) is served from cluster DSMEM rather than
351
+ // GMEM for each of the T timesteps. GMEM is written on
352
+ // update so state persists across forward calls.
353
+ // =========================================================
354
+ for (unsigned int c = col_lo; c < col_hi; c++) {
355
+ unsigned int base = c * S;
356
+ unsigned int local = 0u;
357
+ for (unsigned int s = lane; s < S; s += 32u) {
358
+ unsigned int b = syn_bit[base + s];
359
+ float p = syn_perm[base + s];
360
+ // T9: read from cluster-broadcast tile when available;
361
+ // fall back to direct GMEM when input_bits > INPUT_BITS_MAX.
362
+ #if !defined(HTM_DISABLE_CLUSTER) && defined(__CUDA_ARCH__) && (__CUDA_ARCH__ >= 900)
363
+ unsigned int inp_byte = use_input_tile
364
+ ? (unsigned int)s_input_tile[b]
365
+ : (unsigned int)inputs[inp_off + b];
366
+ #else
367
+ unsigned int inp_byte = (unsigned int)inputs[inp_off + b];
368
+ #endif
369
+ unsigned int hit = ((inp_byte != 0u) && (p >= cfg.conn_thr)) ? 1u : 0u;
370
+ local += hit;
371
+ }
372
+ unsigned int overlap = warp_sum_u32(local);
373
+ overlap = __shfl_sync(0xffffffffu, overlap, 0);
374
+
375
+ // Read boost + threshold for column c.
376
+ #if !defined(HTM_DISABLE_CLUSTER) && defined(__CUDA_ARCH__) && (__CUDA_ARCH__ >= 900)
377
+ // Hopper: read from cluster-distributed shared memory.
378
+ const unsigned int owner_block = c / cols_per_block;
379
+ const unsigned int owner_offset = c - owner_block * cols_per_block;
380
+ float boost_val = cluster.map_shared_rank(s_boost, owner_block)[owner_offset];
381
+ float thr = cluster.map_shared_rank(s_inhib_thr, owner_block)[owner_offset];
382
+ #else
383
+ // Pre-Hopper: read directly from global memory.
384
+ float boost_val = boost[c];
385
+ float thr = inhibition_threshold[c];
386
+ #endif
387
+
388
+ float boosted = (float)overlap * boost_val;
389
+ unsigned int is_active = (boosted > thr) ? 1u : 0u;
390
+
391
+ if (lane == 0) {
392
+ cols_out[col_base_out + c] = (unsigned char)is_active;
393
+ if (is_active) {
394
+ atomicAdd(&step_scratch[0], 1u);
395
+ }
396
+ }
397
+
398
+ // SP learn (Hebbian) on active columns.
399
+ // T9: use tile for input reads here too.
400
+ if (cfg.learn && is_active) {
401
+ for (unsigned int s = lane; s < S; s += 32u) {
402
+ unsigned int b = syn_bit[base + s];
403
+ float p = syn_perm[base + s];
404
+ #if !defined(HTM_DISABLE_CLUSTER) && defined(__CUDA_ARCH__) && (__CUDA_ARCH__ >= 900)
405
+ unsigned int inp_byte = use_input_tile
406
+ ? (unsigned int)s_input_tile[b]
407
+ : (unsigned int)inputs[inp_off + b];
408
+ #else
409
+ unsigned int inp_byte = (unsigned int)inputs[inp_off + b];
410
+ #endif
411
+ if (inp_byte != 0u) {
412
+ p += cfg.sp_inc;
413
+ if (p > 1.0f) p = 1.0f;
414
+ } else {
415
+ p -= cfg.sp_dec;
416
+ if (p < 0.0f) p = 0.0f;
417
+ }
418
+ syn_perm[base + s] = p;
419
+ }
420
+ }
421
+
422
+ // active_duty EMA + threshold adaptation.
423
+ // Writes go to both DSMEM (hot path, Hopper only) and GMEM (persistence).
424
+ if (lane == 0) {
425
+ #if !defined(HTM_DISABLE_CLUSTER) && defined(__CUDA_ARCH__) && (__CUDA_ARCH__ >= 900)
426
+ float ad = cluster.map_shared_rank(s_active_duty, owner_block)[owner_offset];
427
+ #else
428
+ float ad = active_duty[c];
429
+ #endif
430
+ float sample = is_active ? 1.0f : 0.0f;
431
+ ad = (1.0f - cfg.duty_alpha) * ad + cfg.duty_alpha * sample;
432
+
433
+ #if !defined(HTM_DISABLE_CLUSTER) && defined(__CUDA_ARCH__) && (__CUDA_ARCH__ >= 900)
434
+ // Writeback: peer smem (for next timestep read) + GMEM (persistence).
435
+ cluster.map_shared_rank(s_active_duty, owner_block)[owner_offset] = ad;
436
+ #endif
437
+ active_duty[c] = ad;
438
+
439
+ // Threshold steers toward target sparsity.
440
+ float err = ad - cfg.sparsity_target;
441
+ float new_thr = thr + cfg.thr_adapt_rate * err * 100.0f;
442
+ if (new_thr < 0.1f) new_thr = 0.1f;
443
+ if (new_thr > 1000.0f) new_thr = 1000.0f;
444
+
445
+ #if !defined(HTM_DISABLE_CLUSTER) && defined(__CUDA_ARCH__) && (__CUDA_ARCH__ >= 900)
446
+ // Writeback: peer smem (for next timestep read) + GMEM (persistence).
447
+ cluster.map_shared_rank(s_inhib_thr, owner_block)[owner_offset] = new_thr;
448
+ #endif
449
+ inhibition_threshold[c] = new_thr;
450
+ }
451
+ }
452
+
453
+ // ---- DSMEM WRITEBACK SYNC: peer-smem writes must be visible cluster-wide ----
454
+ //
455
+ // On Hopper: cluster.sync() ensures all peer smem writes from this
456
+ // timestep are visible to all blocks before Stage B / next t.
457
+ // On pre-Hopper: no smem peer writes occur (all state in GMEM),
458
+ // so no extra sync needed here β€” the grid barrier below suffices.
459
+ #if !defined(HTM_DISABLE_CLUSTER) && defined(__CUDA_ARCH__) && (__CUDA_ARCH__ >= 900)
460
+ cluster.sync();
461
+ #endif
462
+
463
+ // ---- BARRIER 2: SP active_mask must be visible before TM reads ----
464
+ // Fence: flush cols_out + active_duty + inhibition_threshold + step_scratch
465
+ // writes to global memory before peers advance past this barrier.
466
+ __threadfence();
467
+ fused_grid_barrier(grid, barrier_counters, 0u, phase++, cfg.cooperative_grid_sync);
468
+
469
+ // =========================================================
470
+ // STAGE B: Temporal Memory
471
+ // =========================================================
472
+ for (unsigned int c = col_lo; c < col_hi; c++) {
473
+ unsigned int col_active = cols_out[col_base_out + c];
474
+ if (col_active == 0u) continue;
475
+
476
+ unsigned int base_cell = c * cpc;
477
+ unsigned int any_predicted = 0u;
478
+ unsigned int best_seg_id_for_grow = 0xFFFFFFFFu;
479
+ unsigned int best_pot_count = 0u;
480
+
481
+ for (unsigned int k = 0u; k < cpc; k++) {
482
+ unsigned int cell = base_cell + k;
483
+ unsigned int n_segs_here = cell_seg_count[cell];
484
+ if (n_segs_here > MSC) n_segs_here = MSC;
485
+ if (n_segs_here == 0u) continue;
486
+
487
+ unsigned int seg_base_id = cell * MSC;
488
+ unsigned int cell_is_predictive = 0u;
489
+
490
+ for (unsigned int ls = 0u; ls < n_segs_here; ls++) {
491
+ unsigned int seg = seg_base_id + ls;
492
+ unsigned int n_syn = seg_syn_count[seg];
493
+ if (n_syn == 0u) continue;
494
+ unsigned int syn_base = seg * SPS;
495
+
496
+ unsigned int l_conn = 0u;
497
+ unsigned int l_pot = 0u;
498
+ for (unsigned int s = lane; s < n_syn; s += 32u) {
499
+ unsigned int presyn = syn_presyn[syn_base + s];
500
+ unsigned int w = prev_active[presyn >> 5];
501
+ unsigned int bit = (w >> (presyn & 31u)) & 1u;
502
+ if (bit) {
503
+ l_pot += 1u;
504
+ int p = (int)tm_syn_perm[syn_base + s];
505
+ if (p >= cfg.conn_thr_i16) l_conn += 1u;
506
+ }
507
+ }
508
+ unsigned int tot_conn = warp_sum_u32(l_conn);
509
+ unsigned int tot_pot = warp_sum_u32(l_pot);
510
+ tot_conn = __shfl_sync(0xffffffffu, tot_conn, 0);
511
+ tot_pot = __shfl_sync(0xffffffffu, tot_pot, 0);
512
+
513
+ if (tot_conn >= cfg.activation_threshold) cell_is_predictive = 1u;
514
+ if (tot_pot >= cfg.learning_threshold && tot_pot > best_pot_count) {
515
+ best_pot_count = tot_pot;
516
+ best_seg_id_for_grow = seg;
517
+ }
518
+
519
+ // Reinforce predicted-and-correct segment.
520
+ if (cfg.learn && tot_conn >= cfg.activation_threshold) {
521
+ for (unsigned int s = lane; s < n_syn; s += 32u) {
522
+ unsigned int presyn = syn_presyn[syn_base + s];
523
+ unsigned int w = prev_active[presyn >> 5];
524
+ unsigned int bit = (w >> (presyn & 31u)) & 1u;
525
+ int p = (int)tm_syn_perm[syn_base + s];
526
+ if (bit) {
527
+ int np = p + cfg.perm_inc_i16;
528
+ if (np > 32767) np = 32767;
529
+ tm_syn_perm[syn_base + s] = (short)np;
530
+ } else {
531
+ int np = p - cfg.perm_dec_i16;
532
+ if (np < 0) np = 0;
533
+ tm_syn_perm[syn_base + s] = (short)np;
534
+ }
535
+ }
536
+ }
537
+ }
538
+
539
+ if (cell_is_predictive) {
540
+ any_predicted = 1u;
541
+ if (lane == 0) {
542
+ unsigned int w = cell >> 5;
543
+ unsigned int m = 1u << (cell & 31u);
544
+ atomicOr(&curr_active[w], m);
545
+ atomicOr(&curr_winner[w], m);
546
+ }
547
+ }
548
+ }
549
+
550
+ // BURST if no predicted.
551
+ if (!any_predicted) {
552
+ if (lane == 0) {
553
+ for (unsigned int k = 0u; k < cpc; k++) {
554
+ unsigned int cell = base_cell + k;
555
+ unsigned int w = cell >> 5;
556
+ unsigned int m = 1u << (cell & 31u);
557
+ atomicOr(&curr_active[w], m);
558
+ }
559
+ unsigned int win = base_cell;
560
+ unsigned int ww = win >> 5;
561
+ unsigned int wm = 1u << (win & 31u);
562
+ atomicOr(&curr_winner[ww], wm);
563
+ atomicAdd(&step_scratch[1], 1u);
564
+ }
565
+
566
+ if (cfg.learn) {
567
+ unsigned int target_seg;
568
+ unsigned int existing_syn;
569
+ if (best_seg_id_for_grow != 0xFFFFFFFFu) {
570
+ // Reuse best matching segment.
571
+ target_seg = best_seg_id_for_grow;
572
+ existing_syn = seg_syn_count[target_seg];
573
+ target_seg = __shfl_sync(0xffffffffu, target_seg, 0);
574
+ existing_syn = __shfl_sync(0xffffffffu, existing_syn, 0);
575
+
576
+ // Reinforce its existing synapses.
577
+ unsigned int syn_base = target_seg * SPS;
578
+ for (unsigned int s = lane; s < existing_syn; s += 32u) {
579
+ unsigned int presyn = syn_presyn[syn_base + s];
580
+ unsigned int w = prev_active[presyn >> 5];
581
+ unsigned int bit = (w >> (presyn & 31u)) & 1u;
582
+ int p = (int)tm_syn_perm[syn_base + s];
583
+ if (bit) {
584
+ int np = p + cfg.perm_inc_i16;
585
+ if (np > 32767) np = 32767;
586
+ tm_syn_perm[syn_base + s] = (short)np;
587
+ } else {
588
+ int np = p - cfg.perm_dec_i16;
589
+ if (np < 0) np = 0;
590
+ tm_syn_perm[syn_base + s] = (short)np;
591
+ }
592
+ }
593
+ } else {
594
+ // Allocate new segment on winner cell (cell 0 of col).
595
+ unsigned int new_seg = 0u;
596
+ if (lane == 0) {
597
+ unsigned int winner_cell = base_cell;
598
+ unsigned int slot = atomicAdd(&cell_seg_count[winner_cell], 1u);
599
+ if (slot >= MSC) slot = slot % MSC;
600
+ new_seg = winner_cell * MSC + slot;
601
+ seg_cell_id[new_seg] = winner_cell;
602
+ seg_syn_count[new_seg] = 0u;
603
+ }
604
+ target_seg = __shfl_sync(0xffffffffu, new_seg, 0);
605
+ existing_syn = 0u;
606
+ }
607
+
608
+ // Grow synapses to prev_winner cells β€” lane 0 serialized.
609
+ unsigned int room = (SPS > existing_syn) ? (SPS - existing_syn) : 0u;
610
+ unsigned int max_grow = (cfg.max_new_synapses < room) ? cfg.max_new_synapses : room;
611
+ if (lane == 0 && max_grow > 0u) {
612
+ unsigned int syn_base = target_seg * SPS;
613
+ unsigned int grown = 0u;
614
+ unsigned int start_off = (c * 2654435761u + cfg.iter_seed + t) % cfg.bits_words;
615
+ for (unsigned int w_off = 0u;
616
+ w_off < cfg.bits_words && grown < max_grow;
617
+ w_off++) {
618
+ unsigned int widx = (start_off + w_off) % cfg.bits_words;
619
+ unsigned int word = prev_winner[widx];
620
+ while (word != 0u && grown < max_grow) {
621
+ unsigned int bit_pos = __ffs(word) - 1u;
622
+ word &= ~(1u << bit_pos);
623
+ unsigned int cell_id = widx * 32u + bit_pos;
624
+ if (cell_id >= cfg.n_cells) continue;
625
+ bool exists = false;
626
+ for (unsigned int es = 0u; es < existing_syn + grown; es++) {
627
+ if (syn_presyn[syn_base + es] == cell_id) { exists = true; break; }
628
+ }
629
+ if (exists) continue;
630
+ unsigned int write_idx = existing_syn + grown;
631
+ if (write_idx >= SPS) break;
632
+ syn_presyn[syn_base + write_idx] = cell_id;
633
+ tm_syn_perm[syn_base + write_idx] = (short)cfg.initial_perm_i16;
634
+ grown++;
635
+ }
636
+ }
637
+ if (grown > 0u) {
638
+ seg_syn_count[target_seg] = existing_syn + grown;
639
+ }
640
+ }
641
+ }
642
+ }
643
+ }
644
+
645
+ // ---- BARRIER 3: TM writes complete before anomaly + next-step read ----
646
+ // Fence: flush curr_active/curr_winner bitsets + tm_syn_perm +
647
+ // seg_syn_count + syn_presyn before peers advance and consume them as
648
+ // prev_active/prev_winner at t+1.
649
+ __threadfence();
650
+ fused_grid_barrier(grid, barrier_counters, 0u, phase++, cfg.cooperative_grid_sync);
651
+
652
+ // Write anomaly for step t.
653
+ if (blockIdx.x == 0u && tid == 0u) {
654
+ unsigned int total = step_scratch[0];
655
+ unsigned int bad = step_scratch[1];
656
+ float anom = (total > 0u) ? ((float)bad / (float)total) : 0.0f;
657
+ anom_out[t] = anom;
658
+ }
659
+ }
660
+ }
661
+
662
+ // Single-region kernel (legacy call site).
663
+ __global__ __launch_bounds__(256, 2)
664
+ void htm_fused_step(FusedPtrs P, FusedConfig cfg) {
665
+ htm_fused_step_body(P, cfg);
666
+ }
667
+
668
+ // Batched kernel: one cooperative launch for B regions. grid.y = B,
669
+ // grid.x = per-region block count. Each block reads its region's
670
+ // FusedPtrs from the device array via blockIdx.y.
671
+ __global__ __launch_bounds__(256, 2)
672
+ void htm_fused_step_batched(const FusedPtrs* __restrict__ P_arr, FusedConfig cfg) {
673
+ const FusedPtrs P = P_arr[blockIdx.y];
674
+ htm_fused_step_body(P, cfg);
675
+ }
676
+
677
+ } // extern "C"
overlay/htm_rust/src/gpu/kernels/sp_boost_fused.cu CHANGED
@@ -1,59 +1,59 @@
1
- // Fused mean-reduction + boost-update kernel.
2
- //
3
- // Inputs:
4
- // active_duty[n] (f32)
5
- // boost_strength (f32)
6
- //
7
- // Output:
8
- // boost[n] (f32) = expf(-boost_strength * (active_duty[c] - mean))
9
- //
10
- // Launch: single block (1024 threads), shared mem for reduction. At n=2048
11
- // each thread handles 2 elements.
12
-
13
- extern "C" __global__
14
- void sp_boost_from_duty(
15
- const float * __restrict__ active_duty, // (n,)
16
- float * __restrict__ boost, // (n,) in-place out
17
- float boost_strength,
18
- unsigned int n
19
- ) {
20
- extern __shared__ float smem_raw[];
21
- float * smem = smem_raw;
22
- const unsigned int tid = threadIdx.x;
23
- const unsigned int bsz = blockDim.x;
24
-
25
- // Phase 1: parallel sum of active_duty into smem[0..32] (warp-level).
26
- float local_sum = 0.0f;
27
- for (unsigned int i = tid; i < n; i += bsz) {
28
- local_sum += active_duty[i];
29
- }
30
- // Warp reduction.
31
- for (int off = 16; off > 0; off >>= 1) {
32
- local_sum += __shfl_down_sync(0xffffffff, local_sum, off);
33
- }
34
- unsigned int lane = tid & 31;
35
- unsigned int warp = tid >> 5;
36
- if (lane == 0) smem[warp] = local_sum;
37
- __syncthreads();
38
-
39
- // Warp 0 reduces warp-sums.
40
- __shared__ float mean_s;
41
- if (warp == 0) {
42
- unsigned int nwarps = (bsz + 31) / 32;
43
- float v = (lane < nwarps) ? smem[lane] : 0.0f;
44
- for (int off = 16; off > 0; off >>= 1) {
45
- v += __shfl_down_sync(0xffffffff, v, off);
46
- }
47
- if (tid == 0) {
48
- mean_s = v / (float)n;
49
- }
50
- }
51
- __syncthreads();
52
-
53
- // Phase 2: boost[c] = expf(-strength * (active_duty[c] - mean)).
54
- float mean = mean_s;
55
- for (unsigned int i = tid; i < n; i += bsz) {
56
- float d = active_duty[i] - mean;
57
- boost[i] = expf(-boost_strength * d);
58
- }
59
- }
 
1
+ // Fused mean-reduction + boost-update kernel.
2
+ //
3
+ // Inputs:
4
+ // active_duty[n] (f32)
5
+ // boost_strength (f32)
6
+ //
7
+ // Output:
8
+ // boost[n] (f32) = expf(-boost_strength * (active_duty[c] - mean))
9
+ //
10
+ // Launch: single block (1024 threads), shared mem for reduction. At n=2048
11
+ // each thread handles 2 elements.
12
+
13
+ extern "C" __global__
14
+ void sp_boost_from_duty(
15
+ const float * __restrict__ active_duty, // (n,)
16
+ float * __restrict__ boost, // (n,) in-place out
17
+ float boost_strength,
18
+ unsigned int n
19
+ ) {
20
+ extern __shared__ float smem_raw[];
21
+ float * smem = smem_raw;
22
+ const unsigned int tid = threadIdx.x;
23
+ const unsigned int bsz = blockDim.x;
24
+
25
+ // Phase 1: parallel sum of active_duty into smem[0..32] (warp-level).
26
+ float local_sum = 0.0f;
27
+ for (unsigned int i = tid; i < n; i += bsz) {
28
+ local_sum += active_duty[i];
29
+ }
30
+ // Warp reduction.
31
+ for (int off = 16; off > 0; off >>= 1) {
32
+ local_sum += __shfl_down_sync(0xffffffff, local_sum, off);
33
+ }
34
+ unsigned int lane = tid & 31;
35
+ unsigned int warp = tid >> 5;
36
+ if (lane == 0) smem[warp] = local_sum;
37
+ __syncthreads();
38
+
39
+ // Warp 0 reduces warp-sums.
40
+ __shared__ float mean_s;
41
+ if (warp == 0) {
42
+ unsigned int nwarps = (bsz + 31) / 32;
43
+ float v = (lane < nwarps) ? smem[lane] : 0.0f;
44
+ for (int off = 16; off > 0; off >>= 1) {
45
+ v += __shfl_down_sync(0xffffffff, v, off);
46
+ }
47
+ if (tid == 0) {
48
+ mean_s = v / (float)n;
49
+ }
50
+ }
51
+ __syncthreads();
52
+
53
+ // Phase 2: boost[c] = expf(-strength * (active_duty[c] - mean)).
54
+ float mean = mean_s;
55
+ for (unsigned int i = tid; i < n; i += bsz) {
56
+ float d = active_duty[i] - mean;
57
+ boost[i] = expf(-boost_strength * d);
58
+ }
59
+ }
overlay/htm_rust/src/gpu/kernels/sp_duty.cu CHANGED
@@ -1,45 +1,45 @@
1
- // Duty cycle + boost update kernel.
2
- //
3
- // For each column c (one thread each):
4
- // active_sample = active_mask[c] ? 1 : 0
5
- // overlap_sample = raw_overlap[c] >= stim_thr ? 1 : 0
6
- // active_duty[c] = (1-alpha) * active_duty[c] + alpha * active_sample
7
- // overlap_duty[c] = (1-alpha) * overlap_duty[c] + alpha * overlap_sample
8
- //
9
- // Then, if learn:
10
- // boost[c] = exp(-boost_strength * (active_duty[c] - mean_duty))
11
- // mean_duty is computed on the host (one reduction) and passed in.
12
-
13
- extern "C" __global__
14
- void sp_duty_update(
15
- const unsigned char * __restrict__ active_mask, // (n_columns,)
16
- const unsigned int * __restrict__ raw_overlap, // (n_columns,)
17
- float * __restrict__ active_duty, // (n_columns,) in-place
18
- float * __restrict__ overlap_duty, // (n_columns,) in-place
19
- float * __restrict__ boost, // (n_columns,) in-place
20
- float alpha,
21
- float stim_thr,
22
- float boost_strength, // 0 to skip boost
23
- float mean_duty,
24
- unsigned int learn_flag, // 0 or 1
25
- unsigned int n_columns
26
- ) {
27
- unsigned int c = blockIdx.x * blockDim.x + threadIdx.x;
28
- if (c >= n_columns) return;
29
-
30
- float ad = active_duty[c];
31
- float od = overlap_duty[c];
32
-
33
- float a_sample = (active_mask[c] != 0) ? 1.0f : 0.0f;
34
- float o_sample = ((float)raw_overlap[c] >= stim_thr) ? 1.0f : 0.0f;
35
-
36
- ad = (1.0f - alpha) * ad + alpha * a_sample;
37
- od = (1.0f - alpha) * od + alpha * o_sample;
38
-
39
- active_duty[c] = ad;
40
- overlap_duty[c] = od;
41
-
42
- if (learn_flag && boost_strength > 0.0f) {
43
- boost[c] = expf(-boost_strength * (ad - mean_duty));
44
- }
45
- }
 
1
+ // Duty cycle + boost update kernel.
2
+ //
3
+ // For each column c (one thread each):
4
+ // active_sample = active_mask[c] ? 1 : 0
5
+ // overlap_sample = raw_overlap[c] >= stim_thr ? 1 : 0
6
+ // active_duty[c] = (1-alpha) * active_duty[c] + alpha * active_sample
7
+ // overlap_duty[c] = (1-alpha) * overlap_duty[c] + alpha * overlap_sample
8
+ //
9
+ // Then, if learn:
10
+ // boost[c] = exp(-boost_strength * (active_duty[c] - mean_duty))
11
+ // mean_duty is computed on the host (one reduction) and passed in.
12
+
13
+ extern "C" __global__
14
+ void sp_duty_update(
15
+ const unsigned char * __restrict__ active_mask, // (n_columns,)
16
+ const unsigned int * __restrict__ raw_overlap, // (n_columns,)
17
+ float * __restrict__ active_duty, // (n_columns,) in-place
18
+ float * __restrict__ overlap_duty, // (n_columns,) in-place
19
+ float * __restrict__ boost, // (n_columns,) in-place
20
+ float alpha,
21
+ float stim_thr,
22
+ float boost_strength, // 0 to skip boost
23
+ float mean_duty,
24
+ unsigned int learn_flag, // 0 or 1
25
+ unsigned int n_columns
26
+ ) {
27
+ unsigned int c = blockIdx.x * blockDim.x + threadIdx.x;
28
+ if (c >= n_columns) return;
29
+
30
+ float ad = active_duty[c];
31
+ float od = overlap_duty[c];
32
+
33
+ float a_sample = (active_mask[c] != 0) ? 1.0f : 0.0f;
34
+ float o_sample = ((float)raw_overlap[c] >= stim_thr) ? 1.0f : 0.0f;
35
+
36
+ ad = (1.0f - alpha) * ad + alpha * a_sample;
37
+ od = (1.0f - alpha) * od + alpha * o_sample;
38
+
39
+ active_duty[c] = ad;
40
+ overlap_duty[c] = od;
41
+
42
+ if (learn_flag && boost_strength > 0.0f) {
43
+ boost[c] = expf(-boost_strength * (ad - mean_duty));
44
+ }
45
+ }
overlay/htm_rust/src/gpu/kernels/sp_learn.cu CHANGED
@@ -1,45 +1,45 @@
1
- // SP Hebbian learning kernel.
2
- //
3
- // For each active (winner) column c, for each of its synapses s:
4
- // if input[bit[c][s]] active: perm += inc
5
- // else: perm -= dec
6
- // Clamp to [0, 1].
7
- //
8
- // Launch: one block per column (2048 blocks), but we predicate on
9
- // active_mask[c] to avoid launching k-specific blocks.
10
- //
11
- // This matches the CPU reference line-for-line:
12
- // src/sp.rs lines 157-169.
13
-
14
- extern "C" __global__
15
- void sp_learn(
16
- const unsigned char * __restrict__ active_mask, // (n_columns,) 0/1
17
- const unsigned char * __restrict__ inp, // (input_bits,)
18
- const unsigned int * __restrict__ syn_bit, // (n_columns * S,)
19
- float * __restrict__ syn_perm, // (n_columns * S,) in-place
20
- float inc,
21
- float dec,
22
- unsigned int synapses_per_col,
23
- unsigned int n_columns
24
- ) {
25
- const unsigned int c = blockIdx.x;
26
- if (c >= n_columns) return;
27
- if (active_mask[c] == 0) return;
28
-
29
- const unsigned int base = c * synapses_per_col;
30
- const unsigned int tid = threadIdx.x;
31
- const unsigned int bsz = blockDim.x;
32
-
33
- for (unsigned int s = tid; s < synapses_per_col; s += bsz) {
34
- unsigned int b = syn_bit[base + s];
35
- float p = syn_perm[base + s];
36
- if (inp[b] != 0) {
37
- p += inc;
38
- if (p > 1.0f) p = 1.0f;
39
- } else {
40
- p -= dec;
41
- if (p < 0.0f) p = 0.0f;
42
- }
43
- syn_perm[base + s] = p;
44
- }
45
- }
 
1
+ // SP Hebbian learning kernel.
2
+ //
3
+ // For each active (winner) column c, for each of its synapses s:
4
+ // if input[bit[c][s]] active: perm += inc
5
+ // else: perm -= dec
6
+ // Clamp to [0, 1].
7
+ //
8
+ // Launch: one block per column (2048 blocks), but we predicate on
9
+ // active_mask[c] to avoid launching k-specific blocks.
10
+ //
11
+ // This matches the CPU reference line-for-line:
12
+ // src/sp.rs lines 157-169.
13
+
14
+ extern "C" __global__
15
+ void sp_learn(
16
+ const unsigned char * __restrict__ active_mask, // (n_columns,) 0/1
17
+ const unsigned char * __restrict__ inp, // (input_bits,)
18
+ const unsigned int * __restrict__ syn_bit, // (n_columns * S,)
19
+ float * __restrict__ syn_perm, // (n_columns * S,) in-place
20
+ float inc,
21
+ float dec,
22
+ unsigned int synapses_per_col,
23
+ unsigned int n_columns
24
+ ) {
25
+ const unsigned int c = blockIdx.x;
26
+ if (c >= n_columns) return;
27
+ if (active_mask[c] == 0) return;
28
+
29
+ const unsigned int base = c * synapses_per_col;
30
+ const unsigned int tid = threadIdx.x;
31
+ const unsigned int bsz = blockDim.x;
32
+
33
+ for (unsigned int s = tid; s < synapses_per_col; s += bsz) {
34
+ unsigned int b = syn_bit[base + s];
35
+ float p = syn_perm[base + s];
36
+ if (inp[b] != 0) {
37
+ p += inc;
38
+ if (p > 1.0f) p = 1.0f;
39
+ } else {
40
+ p -= dec;
41
+ if (p < 0.0f) p = 0.0f;
42
+ }
43
+ syn_perm[base + s] = p;
44
+ }
45
+ }
overlay/htm_rust/src/gpu/kernels/sp_overlap.cu CHANGED
@@ -1,78 +1,78 @@
1
- // SP overlap kernel.
2
- //
3
- // For each column c (one CUDA block), compute:
4
- // overlap[c] = sum over its synapse list of {inp[bit[c][s]] && perm[c][s] >= conn_thr}
5
- // boosted[c] = overlap[c] * boost[c]
6
- // raw_overlap[c] = overlap[c] (also returned so host can drive duty cycle)
7
- //
8
- // Memory layout (flat, column-major with per-column stride = synapses_per_col):
9
- // syn_bit[c * S + s] : u32 index into input SDR
10
- // syn_perm[c * S + s] : f32 permanence in [0, 1]
11
- // boost[c] : f32
12
- // inp[b] : u8 0/1
13
- // Output:
14
- // raw[c] : u32
15
- // boosted[c] : f32
16
- //
17
- // Launch:
18
- // grid = n_columns
19
- // block = 128 (or 256) β€” one warp-sweep across synapses; many warps give
20
- // parallel reduction across S (typically S=40).
21
- //
22
- // At S=40 this is completely latency-bound; we coalesce reads and do a
23
- // warp-shuffle reduction. For clarity we use a simple block-wide shared-mem
24
- // reduction which is sufficient for S <= 1024 and has zero correctness risk.
25
-
26
- extern "C" __global__
27
- void sp_overlap(
28
- const unsigned char * __restrict__ inp, // (input_bits,)
29
- const unsigned int * __restrict__ syn_bit, // (n_columns * S,)
30
- const float * __restrict__ syn_perm,// (n_columns * S,)
31
- const float * __restrict__ boost, // (n_columns,)
32
- float conn_thr,
33
- unsigned int synapses_per_col, // S
34
- unsigned int n_columns,
35
- unsigned int * __restrict__ raw_out, // (n_columns,)
36
- float * __restrict__ boosted_out // (n_columns,)
37
- ) {
38
- const unsigned int c = blockIdx.x;
39
- if (c >= n_columns) return;
40
-
41
- const unsigned int base = c * synapses_per_col;
42
- const unsigned int tid = threadIdx.x;
43
- const unsigned int bsz = blockDim.x;
44
-
45
- // Per-thread partial count.
46
- unsigned int local = 0;
47
- for (unsigned int s = tid; s < synapses_per_col; s += bsz) {
48
- unsigned int b = syn_bit[base + s];
49
- float p = syn_perm[base + s];
50
- // Branchless: only counts when input active AND perm connected.
51
- // Using (inp != 0) to tolerate u8 layout.
52
- unsigned int hit = ((inp[b] != 0) && (p >= conn_thr)) ? 1u : 0u;
53
- local += hit;
54
- }
55
-
56
- // Block-wide reduction in shared memory.
57
- __shared__ unsigned int smem[32];
58
-
59
- // Warp-level reduction via shuffle.
60
- unsigned int lane = tid & 31;
61
- unsigned int warp = tid >> 5;
62
- for (int off = 16; off > 0; off >>= 1) {
63
- local += __shfl_down_sync(0xffffffff, local, off);
64
- }
65
- if (lane == 0) smem[warp] = local;
66
- __syncthreads();
67
-
68
- if (warp == 0) {
69
- unsigned int v = (tid < (bsz + 31) / 32) ? smem[lane] : 0;
70
- for (int off = 16; off > 0; off >>= 1) {
71
- v += __shfl_down_sync(0xffffffff, v, off);
72
- }
73
- if (tid == 0) {
74
- raw_out[c] = v;
75
- boosted_out[c] = (float)v * boost[c];
76
- }
77
- }
78
- }
 
1
+ // SP overlap kernel.
2
+ //
3
+ // For each column c (one CUDA block), compute:
4
+ // overlap[c] = sum over its synapse list of {inp[bit[c][s]] && perm[c][s] >= conn_thr}
5
+ // boosted[c] = overlap[c] * boost[c]
6
+ // raw_overlap[c] = overlap[c] (also returned so host can drive duty cycle)
7
+ //
8
+ // Memory layout (flat, column-major with per-column stride = synapses_per_col):
9
+ // syn_bit[c * S + s] : u32 index into input SDR
10
+ // syn_perm[c * S + s] : f32 permanence in [0, 1]
11
+ // boost[c] : f32
12
+ // inp[b] : u8 0/1
13
+ // Output:
14
+ // raw[c] : u32
15
+ // boosted[c] : f32
16
+ //
17
+ // Launch:
18
+ // grid = n_columns
19
+ // block = 128 (or 256) β€” one warp-sweep across synapses; many warps give
20
+ // parallel reduction across S (typically S=40).
21
+ //
22
+ // At S=40 this is completely latency-bound; we coalesce reads and do a
23
+ // warp-shuffle reduction. For clarity we use a simple block-wide shared-mem
24
+ // reduction which is sufficient for S <= 1024 and has zero correctness risk.
25
+
26
+ extern "C" __global__
27
+ void sp_overlap(
28
+ const unsigned char * __restrict__ inp, // (input_bits,)
29
+ const unsigned int * __restrict__ syn_bit, // (n_columns * S,)
30
+ const float * __restrict__ syn_perm,// (n_columns * S,)
31
+ const float * __restrict__ boost, // (n_columns,)
32
+ float conn_thr,
33
+ unsigned int synapses_per_col, // S
34
+ unsigned int n_columns,
35
+ unsigned int * __restrict__ raw_out, // (n_columns,)
36
+ float * __restrict__ boosted_out // (n_columns,)
37
+ ) {
38
+ const unsigned int c = blockIdx.x;
39
+ if (c >= n_columns) return;
40
+
41
+ const unsigned int base = c * synapses_per_col;
42
+ const unsigned int tid = threadIdx.x;
43
+ const unsigned int bsz = blockDim.x;
44
+
45
+ // Per-thread partial count.
46
+ unsigned int local = 0;
47
+ for (unsigned int s = tid; s < synapses_per_col; s += bsz) {
48
+ unsigned int b = syn_bit[base + s];
49
+ float p = syn_perm[base + s];
50
+ // Branchless: only counts when input active AND perm connected.
51
+ // Using (inp != 0) to tolerate u8 layout.
52
+ unsigned int hit = ((inp[b] != 0) && (p >= conn_thr)) ? 1u : 0u;
53
+ local += hit;
54
+ }
55
+
56
+ // Block-wide reduction in shared memory.
57
+ __shared__ unsigned int smem[32];
58
+
59
+ // Warp-level reduction via shuffle.
60
+ unsigned int lane = tid & 31;
61
+ unsigned int warp = tid >> 5;
62
+ for (int off = 16; off > 0; off >>= 1) {
63
+ local += __shfl_down_sync(0xffffffff, local, off);
64
+ }
65
+ if (lane == 0) smem[warp] = local;
66
+ __syncthreads();
67
+
68
+ if (warp == 0) {
69
+ unsigned int v = (tid < (bsz + 31) / 32) ? smem[lane] : 0;
70
+ for (int off = 16; off > 0; off >>= 1) {
71
+ v += __shfl_down_sync(0xffffffff, v, off);
72
+ }
73
+ if (tid == 0) {
74
+ raw_out[c] = v;
75
+ boosted_out[c] = (float)v * boost[c];
76
+ }
77
+ }
78
+ }
overlay/htm_rust/src/gpu/kernels/sp_topk.cu CHANGED
@@ -1,117 +1,117 @@
1
- // Top-K column selection.
2
- //
3
- // Inputs:
4
- // boosted[n_columns] : f32 score
5
- // Output:
6
- // active_mask[n_columns] : u8 0/1, exactly k ones
7
- //
8
- // Tie-breaking: when scores are equal, the LOWER column index wins (matches
9
- // CPU reference `select_nth_unstable_by` with secondary index comparator).
10
- //
11
- // Strategy: a single-block implementation. n_columns is typically 2048, which
12
- // fits comfortably in shared memory. We use a bitonic top-k via per-thread
13
- // radix-select of the (score, -index) key. At kβ‰ˆ41 of n=2048 the simplest
14
- // correct approach is a thresholding pass:
15
- //
16
- // 1. Radix-like bucket pass to find the k-th largest score.
17
- // 2. Mark winners = strictly-greater-than-threshold AND ties until count hits k.
18
- //
19
- // For strict index-ordered tie-break we materialise a 64-bit key:
20
- // key = (float_to_sortable_u32(score) << 32) | (0xffffffff - index)
21
- // Larger key = (higher score) OR (same score, smaller index).
22
- //
23
- // Then we find the k-th largest 64-bit key via radix-select and mark all
24
- // columns whose key >= threshold. This is O(n_cols * log k) and well under
25
- // 100 ΞΌs for n=2048, k=41 on sm_86.
26
- //
27
- // For simplicity and correctness this kernel uses a single-block parallel
28
- // selection sort variant (find max β†’ mark β†’ zero β†’ repeat, k iterations).
29
- // At k=41 this is 41 passes of 2048 threads = ~2048*41 = 84K ops, trivially
30
- // fast.
31
-
32
- extern "C" __global__
33
- void sp_topk_select(
34
- const float * __restrict__ scores, // (n_columns,)
35
- unsigned int n_columns,
36
- unsigned int k,
37
- unsigned char * __restrict__ active_out // (n_columns,)
38
- ) {
39
- extern __shared__ float smem[];
40
- // Layout: smem[0..n] = working scores (we'll mark selected entries as -inf)
41
- // smem[n..n+32*2] = reduction scratch (score + index, per warp)
42
- float * work = smem;
43
- const unsigned int tid = threadIdx.x;
44
- const unsigned int bsz = blockDim.x;
45
-
46
- // Load scores into shared; also init active_out = 0.
47
- for (unsigned int i = tid; i < n_columns; i += bsz) {
48
- work[i] = scores[i];
49
- active_out[i] = 0;
50
- }
51
- __syncthreads();
52
-
53
- __shared__ int winner_idx;
54
- __shared__ float winner_score;
55
-
56
- for (unsigned int iter = 0; iter < k; ++iter) {
57
- // Find (argmax score, lowest index for ties).
58
- float best_s = -INFINITY;
59
- int best_i = n_columns; // sentinel larger than any index
60
-
61
- for (unsigned int i = tid; i < n_columns; i += bsz) {
62
- float s = work[i];
63
- if (s > best_s || (s == best_s && (int)i < best_i)) {
64
- best_s = s;
65
- best_i = (int)i;
66
- }
67
- }
68
-
69
- // Warp reduction. We reduce pairs (score, idx) keeping (max score, min idx on tie).
70
- unsigned int mask = 0xffffffff;
71
- for (int off = 16; off > 0; off >>= 1) {
72
- float os = __shfl_down_sync(mask, best_s, off);
73
- int oi = __shfl_down_sync(mask, best_i, off);
74
- if (os > best_s || (os == best_s && oi < best_i)) {
75
- best_s = os;
76
- best_i = oi;
77
- }
78
- }
79
- // Warp 0 collects lane 0 values from other warps via shared mem.
80
- __shared__ float warp_s[32];
81
- __shared__ int warp_i[32];
82
- unsigned int lane = tid & 31;
83
- unsigned int warp = tid >> 5;
84
- if (lane == 0) {
85
- warp_s[warp] = best_s;
86
- warp_i[warp] = best_i;
87
- }
88
- __syncthreads();
89
-
90
- if (warp == 0) {
91
- unsigned int nwarps = (bsz + 31) / 32;
92
- float s = (lane < nwarps) ? warp_s[lane] : -INFINITY;
93
- int i = (lane < nwarps) ? warp_i[lane] : (int)n_columns;
94
- for (int off = 16; off > 0; off >>= 1) {
95
- float os = __shfl_down_sync(mask, s, off);
96
- int oi = __shfl_down_sync(mask, i, off);
97
- if (os > s || (os == s && oi < i)) {
98
- s = os;
99
- i = oi;
100
- }
101
- }
102
- if (tid == 0) {
103
- winner_score = s;
104
- winner_idx = i;
105
- }
106
- }
107
- __syncthreads();
108
-
109
- if (tid == 0) {
110
- if (winner_idx < (int)n_columns) {
111
- active_out[winner_idx] = 1;
112
- work[winner_idx] = -INFINITY;
113
- }
114
- }
115
- __syncthreads();
116
- }
117
- }
 
1
+ // Top-K column selection.
2
+ //
3
+ // Inputs:
4
+ // boosted[n_columns] : f32 score
5
+ // Output:
6
+ // active_mask[n_columns] : u8 0/1, exactly k ones
7
+ //
8
+ // Tie-breaking: when scores are equal, the LOWER column index wins (matches
9
+ // CPU reference `select_nth_unstable_by` with secondary index comparator).
10
+ //
11
+ // Strategy: a single-block implementation. n_columns is typically 2048, which
12
+ // fits comfortably in shared memory. We use a bitonic top-k via per-thread
13
+ // radix-select of the (score, -index) key. At kβ‰ˆ41 of n=2048 the simplest
14
+ // correct approach is a thresholding pass:
15
+ //
16
+ // 1. Radix-like bucket pass to find the k-th largest score.
17
+ // 2. Mark winners = strictly-greater-than-threshold AND ties until count hits k.
18
+ //
19
+ // For strict index-ordered tie-break we materialise a 64-bit key:
20
+ // key = (float_to_sortable_u32(score) << 32) | (0xffffffff - index)
21
+ // Larger key = (higher score) OR (same score, smaller index).
22
+ //
23
+ // Then we find the k-th largest 64-bit key via radix-select and mark all
24
+ // columns whose key >= threshold. This is O(n_cols * log k) and well under
25
+ // 100 ΞΌs for n=2048, k=41 on sm_86.
26
+ //
27
+ // For simplicity and correctness this kernel uses a single-block parallel
28
+ // selection sort variant (find max β†’ mark β†’ zero β†’ repeat, k iterations).
29
+ // At k=41 this is 41 passes of 2048 threads = ~2048*41 = 84K ops, trivially
30
+ // fast.
31
+
32
+ extern "C" __global__
33
+ void sp_topk_select(
34
+ const float * __restrict__ scores, // (n_columns,)
35
+ unsigned int n_columns,
36
+ unsigned int k,
37
+ unsigned char * __restrict__ active_out // (n_columns,)
38
+ ) {
39
+ extern __shared__ float smem[];
40
+ // Layout: smem[0..n] = working scores (we'll mark selected entries as -inf)
41
+ // smem[n..n+32*2] = reduction scratch (score + index, per warp)
42
+ float * work = smem;
43
+ const unsigned int tid = threadIdx.x;
44
+ const unsigned int bsz = blockDim.x;
45
+
46
+ // Load scores into shared; also init active_out = 0.
47
+ for (unsigned int i = tid; i < n_columns; i += bsz) {
48
+ work[i] = scores[i];
49
+ active_out[i] = 0;
50
+ }
51
+ __syncthreads();
52
+
53
+ __shared__ int winner_idx;
54
+ __shared__ float winner_score;
55
+
56
+ for (unsigned int iter = 0; iter < k; ++iter) {
57
+ // Find (argmax score, lowest index for ties).
58
+ float best_s = -INFINITY;
59
+ int best_i = n_columns; // sentinel larger than any index
60
+
61
+ for (unsigned int i = tid; i < n_columns; i += bsz) {
62
+ float s = work[i];
63
+ if (s > best_s || (s == best_s && (int)i < best_i)) {
64
+ best_s = s;
65
+ best_i = (int)i;
66
+ }
67
+ }
68
+
69
+ // Warp reduction. We reduce pairs (score, idx) keeping (max score, min idx on tie).
70
+ unsigned int mask = 0xffffffff;
71
+ for (int off = 16; off > 0; off >>= 1) {
72
+ float os = __shfl_down_sync(mask, best_s, off);
73
+ int oi = __shfl_down_sync(mask, best_i, off);
74
+ if (os > best_s || (os == best_s && oi < best_i)) {
75
+ best_s = os;
76
+ best_i = oi;
77
+ }
78
+ }
79
+ // Warp 0 collects lane 0 values from other warps via shared mem.
80
+ __shared__ float warp_s[32];
81
+ __shared__ int warp_i[32];
82
+ unsigned int lane = tid & 31;
83
+ unsigned int warp = tid >> 5;
84
+ if (lane == 0) {
85
+ warp_s[warp] = best_s;
86
+ warp_i[warp] = best_i;
87
+ }
88
+ __syncthreads();
89
+
90
+ if (warp == 0) {
91
+ unsigned int nwarps = (bsz + 31) / 32;
92
+ float s = (lane < nwarps) ? warp_s[lane] : -INFINITY;
93
+ int i = (lane < nwarps) ? warp_i[lane] : (int)n_columns;
94
+ for (int off = 16; off > 0; off >>= 1) {
95
+ float os = __shfl_down_sync(mask, s, off);
96
+ int oi = __shfl_down_sync(mask, i, off);
97
+ if (os > s || (os == s && oi < i)) {
98
+ s = os;
99
+ i = oi;
100
+ }
101
+ }
102
+ if (tid == 0) {
103
+ winner_score = s;
104
+ winner_idx = i;
105
+ }
106
+ }
107
+ __syncthreads();
108
+
109
+ if (tid == 0) {
110
+ if (winner_idx < (int)n_columns) {
111
+ active_out[winner_idx] = 1;
112
+ work[winner_idx] = -INFINITY;
113
+ }
114
+ }
115
+ __syncthreads();
116
+ }
117
+ }
overlay/htm_rust/src/gpu/kernels/tm_activate.cu CHANGED
@@ -1,66 +1,66 @@
1
- // TM activate kernel. See tm_predict.cu for TmConfig.
2
-
3
- struct TmConfig {
4
- unsigned int activation_threshold;
5
- unsigned int learning_threshold;
6
- unsigned int cells_per_column;
7
- unsigned int synapses_per_segment;
8
- unsigned int n_segments;
9
- unsigned int n_cells;
10
- unsigned int max_segments_per_cell;
11
- unsigned int max_new_synapses;
12
- int conn_thr_i16;
13
- int perm_inc_i16;
14
- int perm_dec_i16;
15
- int predicted_seg_dec_i16;
16
- int initial_perm_i16;
17
- unsigned int iter_seed;
18
- unsigned int n_cols;
19
- unsigned int bits_words;
20
- };
21
-
22
- extern "C" __global__
23
- void tm_activate(
24
- const unsigned char * __restrict__ sp_active_mask,
25
- const unsigned char * __restrict__ col_predicted,
26
- const unsigned int * __restrict__ cell_predictive_bits,
27
- unsigned int * __restrict__ cell_active_bits,
28
- unsigned int * __restrict__ cell_winner_bits,
29
- unsigned int * __restrict__ unpredicted_count,
30
- unsigned int * __restrict__ burst_cols_flat,
31
- unsigned int * __restrict__ burst_cols_count,
32
- TmConfig cfg
33
- ) {
34
- unsigned int col = blockIdx.x * blockDim.x + threadIdx.x;
35
- if (col >= cfg.n_cols) return;
36
- if (sp_active_mask[col] == 0) return;
37
-
38
- unsigned int base_cell = col * cfg.cells_per_column;
39
-
40
- if (col_predicted[col]) {
41
- for (unsigned int k = 0; k < cfg.cells_per_column; k++) {
42
- unsigned int cell = base_cell + k;
43
- unsigned int word_idx = cell >> 5;
44
- unsigned int bit_mask = 1u << (cell & 31u);
45
- unsigned int pred_word = cell_predictive_bits[word_idx];
46
- if (pred_word & bit_mask) {
47
- atomicOr(&cell_active_bits[word_idx], bit_mask);
48
- atomicOr(&cell_winner_bits[word_idx], bit_mask);
49
- }
50
- }
51
- } else {
52
- atomicAdd(unpredicted_count, 1u);
53
- for (unsigned int k = 0; k < cfg.cells_per_column; k++) {
54
- unsigned int cell = base_cell + k;
55
- unsigned int word_idx = cell >> 5;
56
- unsigned int bit_mask = 1u << (cell & 31u);
57
- atomicOr(&cell_active_bits[word_idx], bit_mask);
58
- }
59
- unsigned int winner = base_cell;
60
- unsigned int word_idx = winner >> 5;
61
- unsigned int bit_mask = 1u << (winner & 31u);
62
- atomicOr(&cell_winner_bits[word_idx], bit_mask);
63
- unsigned int slot = atomicAdd(burst_cols_count, 1u);
64
- burst_cols_flat[slot] = col;
65
- }
66
- }
 
1
+ // TM activate kernel. See tm_predict.cu for TmConfig.
2
+
3
+ struct TmConfig {
4
+ unsigned int activation_threshold;
5
+ unsigned int learning_threshold;
6
+ unsigned int cells_per_column;
7
+ unsigned int synapses_per_segment;
8
+ unsigned int n_segments;
9
+ unsigned int n_cells;
10
+ unsigned int max_segments_per_cell;
11
+ unsigned int max_new_synapses;
12
+ int conn_thr_i16;
13
+ int perm_inc_i16;
14
+ int perm_dec_i16;
15
+ int predicted_seg_dec_i16;
16
+ int initial_perm_i16;
17
+ unsigned int iter_seed;
18
+ unsigned int n_cols;
19
+ unsigned int bits_words;
20
+ };
21
+
22
+ extern "C" __global__
23
+ void tm_activate(
24
+ const unsigned char * __restrict__ sp_active_mask,
25
+ const unsigned char * __restrict__ col_predicted,
26
+ const unsigned int * __restrict__ cell_predictive_bits,
27
+ unsigned int * __restrict__ cell_active_bits,
28
+ unsigned int * __restrict__ cell_winner_bits,
29
+ unsigned int * __restrict__ unpredicted_count,
30
+ unsigned int * __restrict__ burst_cols_flat,
31
+ unsigned int * __restrict__ burst_cols_count,
32
+ TmConfig cfg
33
+ ) {
34
+ unsigned int col = blockIdx.x * blockDim.x + threadIdx.x;
35
+ if (col >= cfg.n_cols) return;
36
+ if (sp_active_mask[col] == 0) return;
37
+
38
+ unsigned int base_cell = col * cfg.cells_per_column;
39
+
40
+ if (col_predicted[col]) {
41
+ for (unsigned int k = 0; k < cfg.cells_per_column; k++) {
42
+ unsigned int cell = base_cell + k;
43
+ unsigned int word_idx = cell >> 5;
44
+ unsigned int bit_mask = 1u << (cell & 31u);
45
+ unsigned int pred_word = cell_predictive_bits[word_idx];
46
+ if (pred_word & bit_mask) {
47
+ atomicOr(&cell_active_bits[word_idx], bit_mask);
48
+ atomicOr(&cell_winner_bits[word_idx], bit_mask);
49
+ }
50
+ }
51
+ } else {
52
+ atomicAdd(unpredicted_count, 1u);
53
+ for (unsigned int k = 0; k < cfg.cells_per_column; k++) {
54
+ unsigned int cell = base_cell + k;
55
+ unsigned int word_idx = cell >> 5;
56
+ unsigned int bit_mask = 1u << (cell & 31u);
57
+ atomicOr(&cell_active_bits[word_idx], bit_mask);
58
+ }
59
+ unsigned int winner = base_cell;
60
+ unsigned int word_idx = winner >> 5;
61
+ unsigned int bit_mask = 1u << (winner & 31u);
62
+ atomicOr(&cell_winner_bits[word_idx], bit_mask);
63
+ unsigned int slot = atomicAdd(burst_cols_count, 1u);
64
+ burst_cols_flat[slot] = col;
65
+ }
66
+ }
overlay/htm_rust/src/gpu/kernels/tm_anomaly.cu CHANGED
@@ -1,43 +1,43 @@
1
- // TM anomaly kernel.
2
- //
3
- // Computes:
4
- // n_active = sum of sp_active_mask
5
- // anomaly = unpredicted_count / n_active (if n_active > 0)
6
- // = 0 (else)
7
- //
8
- // Launch: single block, 256 threads.
9
-
10
- extern "C" __global__
11
- void tm_anomaly(
12
- const unsigned char * __restrict__ sp_active_mask,
13
- const unsigned int * __restrict__ unpredicted_count,
14
- float * __restrict__ anomaly_out, // (1,) or (t_slot,)
15
- unsigned int t_slot,
16
- unsigned int n_cols
17
- ) {
18
- const unsigned int tid = threadIdx.x;
19
- __shared__ unsigned int n_active_s;
20
-
21
- if (tid == 0) n_active_s = 0u;
22
- __syncthreads();
23
-
24
- unsigned int local = 0u;
25
- for (unsigned int i = tid; i < n_cols; i += blockDim.x) {
26
- if (sp_active_mask[i]) local += 1u;
27
- }
28
- // Warp reduce.
29
- for (int off = 16; off > 0; off >>= 1) {
30
- local += __shfl_down_sync(0xffffffffu, local, off);
31
- }
32
- if ((tid & 31u) == 0) {
33
- atomicAdd(&n_active_s, local);
34
- }
35
- __syncthreads();
36
-
37
- if (tid == 0) {
38
- unsigned int total = n_active_s;
39
- unsigned int bad = unpredicted_count[0];
40
- float anom = (total > 0u) ? ((float)bad / (float)total) : 0.0f;
41
- anomaly_out[t_slot] = anom;
42
- }
43
- }
 
1
+ // TM anomaly kernel.
2
+ //
3
+ // Computes:
4
+ // n_active = sum of sp_active_mask
5
+ // anomaly = unpredicted_count / n_active (if n_active > 0)
6
+ // = 0 (else)
7
+ //
8
+ // Launch: single block, 256 threads.
9
+
10
+ extern "C" __global__
11
+ void tm_anomaly(
12
+ const unsigned char * __restrict__ sp_active_mask,
13
+ const unsigned int * __restrict__ unpredicted_count,
14
+ float * __restrict__ anomaly_out, // (1,) or (t_slot,)
15
+ unsigned int t_slot,
16
+ unsigned int n_cols
17
+ ) {
18
+ const unsigned int tid = threadIdx.x;
19
+ __shared__ unsigned int n_active_s;
20
+
21
+ if (tid == 0) n_active_s = 0u;
22
+ __syncthreads();
23
+
24
+ unsigned int local = 0u;
25
+ for (unsigned int i = tid; i < n_cols; i += blockDim.x) {
26
+ if (sp_active_mask[i]) local += 1u;
27
+ }
28
+ // Warp reduce.
29
+ for (int off = 16; off > 0; off >>= 1) {
30
+ local += __shfl_down_sync(0xffffffffu, local, off);
31
+ }
32
+ if ((tid & 31u) == 0) {
33
+ atomicAdd(&n_active_s, local);
34
+ }
35
+ __syncthreads();
36
+
37
+ if (tid == 0) {
38
+ unsigned int total = n_active_s;
39
+ unsigned int bad = unpredicted_count[0];
40
+ float anom = (total > 0u) ? ((float)bad / (float)total) : 0.0f;
41
+ anomaly_out[t_slot] = anom;
42
+ }
43
+ }
overlay/htm_rust/src/gpu/kernels/tm_grow.cu CHANGED
@@ -1,155 +1,155 @@
1
- // TM grow+reinforce kernel.
2
- //
3
- // For each bursting column:
4
- // If col_best_match[col] is non-zero (i.e. at least one matching segment
5
- // with num_active_potential >= learning_threshold exists on cells in this col):
6
- // Target = that matching segment.
7
- // Reinforce its existing synapses: +inc if presyn in prev_active, -dec otherwise.
8
- // Grow up to (max_new - current_syn_count) additional synapses to prev_winners.
9
- // Else:
10
- // Allocate a fresh segment slot on winner cell (cell 0 of col).
11
- // Grow up to max_new synapses to prev_winners (no reinforce needed β€” new seg).
12
- //
13
- // This mirrors the CPU TM burst logic.
14
-
15
- struct TmConfig {
16
- unsigned int activation_threshold;
17
- unsigned int learning_threshold;
18
- unsigned int cells_per_column;
19
- unsigned int synapses_per_segment;
20
- unsigned int n_segments;
21
- unsigned int n_cells;
22
- unsigned int max_segments_per_cell;
23
- unsigned int max_new_synapses;
24
- int conn_thr_i16;
25
- int perm_inc_i16;
26
- int perm_dec_i16;
27
- int predicted_seg_dec_i16;
28
- int initial_perm_i16;
29
- unsigned int iter_seed;
30
- unsigned int n_cols;
31
- unsigned int bits_words;
32
- };
33
-
34
- extern "C" __global__
35
- void tm_grow(
36
- unsigned int * __restrict__ seg_cell_id,
37
- unsigned int * __restrict__ seg_syn_count,
38
- unsigned int * __restrict__ syn_presyn,
39
- short * __restrict__ syn_perm,
40
- unsigned int * __restrict__ cell_seg_count,
41
- const unsigned int * __restrict__ burst_cols_flat,
42
- const unsigned int * __restrict__ burst_cols_count,
43
- const unsigned int * __restrict__ prev_winner_bits,
44
- const unsigned int * __restrict__ prev_active_bits,
45
- const unsigned int * __restrict__ col_best_match,
46
- TmConfig cfg
47
- ) {
48
- const unsigned int b = blockIdx.x;
49
- const unsigned int n_burst_cols = burst_cols_count[0];
50
- if (b >= n_burst_cols) return;
51
- const unsigned int tid = threadIdx.x;
52
-
53
- const unsigned int col = burst_cols_flat[b];
54
-
55
- __shared__ unsigned int shared_seg_id;
56
- __shared__ unsigned int shared_existing_syn_count;
57
- __shared__ unsigned int shared_grown;
58
- __shared__ unsigned int shared_is_new;
59
- __shared__ unsigned int shared_start_offset;
60
-
61
- if (tid == 0) {
62
- unsigned int match_key = col_best_match[col];
63
- if (match_key != 0u) {
64
- // Reuse matching segment.
65
- unsigned int seg_id = match_key & 0x1FFFFFu;
66
- shared_seg_id = seg_id;
67
- shared_existing_syn_count = seg_syn_count[seg_id];
68
- shared_is_new = 0u;
69
- } else {
70
- // Allocate new segment on winner cell (cell 0 of col).
71
- unsigned int winner_cell = col * cfg.cells_per_column;
72
- unsigned int slot = atomicAdd(&cell_seg_count[winner_cell], 1u);
73
- if (slot >= cfg.max_segments_per_cell) {
74
- slot = slot % cfg.max_segments_per_cell;
75
- }
76
- unsigned int seg_id = winner_cell * cfg.max_segments_per_cell + slot;
77
- seg_cell_id[seg_id] = winner_cell;
78
- seg_syn_count[seg_id] = 0;
79
- shared_seg_id = seg_id;
80
- shared_existing_syn_count = 0u;
81
- shared_is_new = 1u;
82
- }
83
- shared_grown = 0u;
84
- shared_start_offset = (b * 2654435761u + cfg.iter_seed) % cfg.bits_words;
85
- }
86
- __syncthreads();
87
-
88
- const unsigned int seg_id = shared_seg_id;
89
- const unsigned int seg_base = seg_id * cfg.synapses_per_segment;
90
- const unsigned int existing_syn = shared_existing_syn_count;
91
- const unsigned int is_new = shared_is_new;
92
- const unsigned int start = shared_start_offset;
93
-
94
- // PHASE 1: If reusing, reinforce existing synapses.
95
- if (!is_new) {
96
- for (unsigned int s = tid; s < existing_syn; s += 32u) {
97
- unsigned int presyn = syn_presyn[seg_base + s];
98
- unsigned int word = prev_active_bits[presyn >> 5];
99
- unsigned int bit = (word >> (presyn & 31u)) & 1u;
100
- int p = (int)syn_perm[seg_base + s];
101
- if (bit) {
102
- int np = p + cfg.perm_inc_i16;
103
- if (np > 32767) np = 32767;
104
- syn_perm[seg_base + s] = (short)np;
105
- } else {
106
- int np = p - cfg.perm_dec_i16;
107
- if (np < 0) np = 0;
108
- syn_perm[seg_base + s] = (short)np;
109
- }
110
- }
111
- __syncthreads();
112
- }
113
-
114
- // PHASE 2: Grow up to `max_new_synapses` (or room) synapses to prev_winners
115
- // that aren't already presynaptic to this segment.
116
- const unsigned int room = (cfg.synapses_per_segment > existing_syn)
117
- ? (cfg.synapses_per_segment - existing_syn) : 0u;
118
- const unsigned int max_grow = (cfg.max_new_synapses < room) ? cfg.max_new_synapses : room;
119
-
120
- for (unsigned int w_off = 0; w_off < cfg.bits_words; w_off += 32u) {
121
- if (shared_grown >= max_grow) break;
122
- unsigned int widx = (start + w_off + tid) % cfg.bits_words;
123
- unsigned int word = prev_winner_bits[widx];
124
- while (word != 0u) {
125
- if (shared_grown >= max_grow) break;
126
- unsigned int bit_pos = __ffs(word) - 1u;
127
- word &= ~(1u << bit_pos);
128
- unsigned int cell = widx * 32u + bit_pos;
129
- if (cell >= cfg.n_cells) continue;
130
-
131
- // Skip if already presynaptic (O(existing_syn) scan; usually small).
132
- bool exists = false;
133
- for (unsigned int s = 0; s < existing_syn; s++) {
134
- if (syn_presyn[seg_base + s] == cell) { exists = true; break; }
135
- }
136
- if (exists) continue;
137
-
138
- unsigned int slot = atomicAdd(&shared_grown, 1u);
139
- if (slot >= max_grow) break;
140
- unsigned int write_idx = existing_syn + slot;
141
- if (write_idx >= cfg.synapses_per_segment) break;
142
- syn_presyn[seg_base + write_idx] = cell;
143
- syn_perm[seg_base + write_idx] = (short)cfg.initial_perm_i16;
144
- }
145
- }
146
- __syncthreads();
147
-
148
- if (tid == 0) {
149
- unsigned int grown = shared_grown;
150
- if (grown > max_grow) grown = max_grow;
151
- unsigned int new_count = existing_syn + grown;
152
- if (new_count > cfg.synapses_per_segment) new_count = cfg.synapses_per_segment;
153
- seg_syn_count[seg_id] = new_count;
154
- }
155
- }
 
1
+ // TM grow+reinforce kernel.
2
+ //
3
+ // For each bursting column:
4
+ // If col_best_match[col] is non-zero (i.e. at least one matching segment
5
+ // with num_active_potential >= learning_threshold exists on cells in this col):
6
+ // Target = that matching segment.
7
+ // Reinforce its existing synapses: +inc if presyn in prev_active, -dec otherwise.
8
+ // Grow up to (max_new - current_syn_count) additional synapses to prev_winners.
9
+ // Else:
10
+ // Allocate a fresh segment slot on winner cell (cell 0 of col).
11
+ // Grow up to max_new synapses to prev_winners (no reinforce needed β€” new seg).
12
+ //
13
+ // This mirrors the CPU TM burst logic.
14
+
15
+ struct TmConfig {
16
+ unsigned int activation_threshold;
17
+ unsigned int learning_threshold;
18
+ unsigned int cells_per_column;
19
+ unsigned int synapses_per_segment;
20
+ unsigned int n_segments;
21
+ unsigned int n_cells;
22
+ unsigned int max_segments_per_cell;
23
+ unsigned int max_new_synapses;
24
+ int conn_thr_i16;
25
+ int perm_inc_i16;
26
+ int perm_dec_i16;
27
+ int predicted_seg_dec_i16;
28
+ int initial_perm_i16;
29
+ unsigned int iter_seed;
30
+ unsigned int n_cols;
31
+ unsigned int bits_words;
32
+ };
33
+
34
+ extern "C" __global__
35
+ void tm_grow(
36
+ unsigned int * __restrict__ seg_cell_id,
37
+ unsigned int * __restrict__ seg_syn_count,
38
+ unsigned int * __restrict__ syn_presyn,
39
+ short * __restrict__ syn_perm,
40
+ unsigned int * __restrict__ cell_seg_count,
41
+ const unsigned int * __restrict__ burst_cols_flat,
42
+ const unsigned int * __restrict__ burst_cols_count,
43
+ const unsigned int * __restrict__ prev_winner_bits,
44
+ const unsigned int * __restrict__ prev_active_bits,
45
+ const unsigned int * __restrict__ col_best_match,
46
+ TmConfig cfg
47
+ ) {
48
+ const unsigned int b = blockIdx.x;
49
+ const unsigned int n_burst_cols = burst_cols_count[0];
50
+ if (b >= n_burst_cols) return;
51
+ const unsigned int tid = threadIdx.x;
52
+
53
+ const unsigned int col = burst_cols_flat[b];
54
+
55
+ __shared__ unsigned int shared_seg_id;
56
+ __shared__ unsigned int shared_existing_syn_count;
57
+ __shared__ unsigned int shared_grown;
58
+ __shared__ unsigned int shared_is_new;
59
+ __shared__ unsigned int shared_start_offset;
60
+
61
+ if (tid == 0) {
62
+ unsigned int match_key = col_best_match[col];
63
+ if (match_key != 0u) {
64
+ // Reuse matching segment.
65
+ unsigned int seg_id = match_key & 0x1FFFFFu;
66
+ shared_seg_id = seg_id;
67
+ shared_existing_syn_count = seg_syn_count[seg_id];
68
+ shared_is_new = 0u;
69
+ } else {
70
+ // Allocate new segment on winner cell (cell 0 of col).
71
+ unsigned int winner_cell = col * cfg.cells_per_column;
72
+ unsigned int slot = atomicAdd(&cell_seg_count[winner_cell], 1u);
73
+ if (slot >= cfg.max_segments_per_cell) {
74
+ slot = slot % cfg.max_segments_per_cell;
75
+ }
76
+ unsigned int seg_id = winner_cell * cfg.max_segments_per_cell + slot;
77
+ seg_cell_id[seg_id] = winner_cell;
78
+ seg_syn_count[seg_id] = 0;
79
+ shared_seg_id = seg_id;
80
+ shared_existing_syn_count = 0u;
81
+ shared_is_new = 1u;
82
+ }
83
+ shared_grown = 0u;
84
+ shared_start_offset = (b * 2654435761u + cfg.iter_seed) % cfg.bits_words;
85
+ }
86
+ __syncthreads();
87
+
88
+ const unsigned int seg_id = shared_seg_id;
89
+ const unsigned int seg_base = seg_id * cfg.synapses_per_segment;
90
+ const unsigned int existing_syn = shared_existing_syn_count;
91
+ const unsigned int is_new = shared_is_new;
92
+ const unsigned int start = shared_start_offset;
93
+
94
+ // PHASE 1: If reusing, reinforce existing synapses.
95
+ if (!is_new) {
96
+ for (unsigned int s = tid; s < existing_syn; s += 32u) {
97
+ unsigned int presyn = syn_presyn[seg_base + s];
98
+ unsigned int word = prev_active_bits[presyn >> 5];
99
+ unsigned int bit = (word >> (presyn & 31u)) & 1u;
100
+ int p = (int)syn_perm[seg_base + s];
101
+ if (bit) {
102
+ int np = p + cfg.perm_inc_i16;
103
+ if (np > 32767) np = 32767;
104
+ syn_perm[seg_base + s] = (short)np;
105
+ } else {
106
+ int np = p - cfg.perm_dec_i16;
107
+ if (np < 0) np = 0;
108
+ syn_perm[seg_base + s] = (short)np;
109
+ }
110
+ }
111
+ __syncthreads();
112
+ }
113
+
114
+ // PHASE 2: Grow up to `max_new_synapses` (or room) synapses to prev_winners
115
+ // that aren't already presynaptic to this segment.
116
+ const unsigned int room = (cfg.synapses_per_segment > existing_syn)
117
+ ? (cfg.synapses_per_segment - existing_syn) : 0u;
118
+ const unsigned int max_grow = (cfg.max_new_synapses < room) ? cfg.max_new_synapses : room;
119
+
120
+ for (unsigned int w_off = 0; w_off < cfg.bits_words; w_off += 32u) {
121
+ if (shared_grown >= max_grow) break;
122
+ unsigned int widx = (start + w_off + tid) % cfg.bits_words;
123
+ unsigned int word = prev_winner_bits[widx];
124
+ while (word != 0u) {
125
+ if (shared_grown >= max_grow) break;
126
+ unsigned int bit_pos = __ffs(word) - 1u;
127
+ word &= ~(1u << bit_pos);
128
+ unsigned int cell = widx * 32u + bit_pos;
129
+ if (cell >= cfg.n_cells) continue;
130
+
131
+ // Skip if already presynaptic (O(existing_syn) scan; usually small).
132
+ bool exists = false;
133
+ for (unsigned int s = 0; s < existing_syn; s++) {
134
+ if (syn_presyn[seg_base + s] == cell) { exists = true; break; }
135
+ }
136
+ if (exists) continue;
137
+
138
+ unsigned int slot = atomicAdd(&shared_grown, 1u);
139
+ if (slot >= max_grow) break;
140
+ unsigned int write_idx = existing_syn + slot;
141
+ if (write_idx >= cfg.synapses_per_segment) break;
142
+ syn_presyn[seg_base + write_idx] = cell;
143
+ syn_perm[seg_base + write_idx] = (short)cfg.initial_perm_i16;
144
+ }
145
+ }
146
+ __syncthreads();
147
+
148
+ if (tid == 0) {
149
+ unsigned int grown = shared_grown;
150
+ if (grown > max_grow) grown = max_grow;
151
+ unsigned int new_count = existing_syn + grown;
152
+ if (new_count > cfg.synapses_per_segment) new_count = cfg.synapses_per_segment;
153
+ seg_syn_count[seg_id] = new_count;
154
+ }
155
+ }
overlay/htm_rust/src/gpu/kernels/tm_learn.cu CHANGED
@@ -1,75 +1,75 @@
1
- // TM learn (reinforce correctly predicted segments) β€” cell-grouped launch.
2
- //
3
- // Grid: n_cells.
4
- // For each cell in a predicted, SP-active column: iterate its segments.
5
- // For each segment with num_active_connected >= activation_threshold,
6
- // reinforce its synapses against prev_active_bits.
7
-
8
- struct TmConfig {
9
- unsigned int activation_threshold;
10
- unsigned int learning_threshold;
11
- unsigned int cells_per_column;
12
- unsigned int synapses_per_segment;
13
- unsigned int n_segments;
14
- unsigned int n_cells;
15
- unsigned int max_segments_per_cell;
16
- unsigned int max_new_synapses;
17
- int conn_thr_i16;
18
- int perm_inc_i16;
19
- int perm_dec_i16;
20
- int predicted_seg_dec_i16;
21
- int initial_perm_i16;
22
- unsigned int iter_seed;
23
- unsigned int n_cols;
24
- unsigned int bits_words;
25
- };
26
-
27
- extern "C" __global__
28
- void tm_learn_reinforce(
29
- const unsigned int * __restrict__ seg_cell_id,
30
- const unsigned int * __restrict__ seg_syn_count,
31
- const unsigned int * __restrict__ syn_presyn,
32
- short * __restrict__ syn_perm,
33
- const unsigned int * __restrict__ seg_num_active_connected,
34
- const unsigned int * __restrict__ prev_active_bits,
35
- const unsigned char * __restrict__ sp_active_mask,
36
- const unsigned char * __restrict__ col_predicted,
37
- const unsigned int * __restrict__ cell_seg_count,
38
- TmConfig cfg
39
- ) {
40
- const unsigned int cell = blockIdx.x;
41
- if (cell >= cfg.n_cells) return;
42
- const unsigned int col = cell / cfg.cells_per_column;
43
- if (sp_active_mask[col] == 0) return;
44
- if (col_predicted[col] == 0) return;
45
-
46
- const unsigned int n_segs_here = min(cell_seg_count[cell], cfg.max_segments_per_cell);
47
- if (n_segs_here == 0) return;
48
-
49
- const unsigned int tid = threadIdx.x;
50
- const unsigned int seg_base_id = cell * cfg.max_segments_per_cell;
51
-
52
- for (unsigned int local_seg = 0; local_seg < n_segs_here; local_seg++) {
53
- const unsigned int seg = seg_base_id + local_seg;
54
- if (seg_num_active_connected[seg] < cfg.activation_threshold) continue;
55
- const unsigned int n_syn = seg_syn_count[seg];
56
- if (n_syn == 0) continue;
57
- const unsigned int syn_base = seg * cfg.synapses_per_segment;
58
-
59
- for (unsigned int s = tid; s < n_syn; s += 32u) {
60
- unsigned int presyn = syn_presyn[syn_base + s];
61
- unsigned int word = prev_active_bits[presyn >> 5];
62
- unsigned int bit = (word >> (presyn & 31u)) & 1u;
63
- int p = (int)syn_perm[syn_base + s];
64
- if (bit) {
65
- int np = p + cfg.perm_inc_i16;
66
- if (np > 32767) np = 32767;
67
- syn_perm[syn_base + s] = (short)np;
68
- } else {
69
- int np = p - cfg.perm_dec_i16;
70
- if (np < 0) np = 0;
71
- syn_perm[syn_base + s] = (short)np;
72
- }
73
- }
74
- }
75
- }
 
1
+ // TM learn (reinforce correctly predicted segments) β€” cell-grouped launch.
2
+ //
3
+ // Grid: n_cells.
4
+ // For each cell in a predicted, SP-active column: iterate its segments.
5
+ // For each segment with num_active_connected >= activation_threshold,
6
+ // reinforce its synapses against prev_active_bits.
7
+
8
+ struct TmConfig {
9
+ unsigned int activation_threshold;
10
+ unsigned int learning_threshold;
11
+ unsigned int cells_per_column;
12
+ unsigned int synapses_per_segment;
13
+ unsigned int n_segments;
14
+ unsigned int n_cells;
15
+ unsigned int max_segments_per_cell;
16
+ unsigned int max_new_synapses;
17
+ int conn_thr_i16;
18
+ int perm_inc_i16;
19
+ int perm_dec_i16;
20
+ int predicted_seg_dec_i16;
21
+ int initial_perm_i16;
22
+ unsigned int iter_seed;
23
+ unsigned int n_cols;
24
+ unsigned int bits_words;
25
+ };
26
+
27
+ extern "C" __global__
28
+ void tm_learn_reinforce(
29
+ const unsigned int * __restrict__ seg_cell_id,
30
+ const unsigned int * __restrict__ seg_syn_count,
31
+ const unsigned int * __restrict__ syn_presyn,
32
+ short * __restrict__ syn_perm,
33
+ const unsigned int * __restrict__ seg_num_active_connected,
34
+ const unsigned int * __restrict__ prev_active_bits,
35
+ const unsigned char * __restrict__ sp_active_mask,
36
+ const unsigned char * __restrict__ col_predicted,
37
+ const unsigned int * __restrict__ cell_seg_count,
38
+ TmConfig cfg
39
+ ) {
40
+ const unsigned int cell = blockIdx.x;
41
+ if (cell >= cfg.n_cells) return;
42
+ const unsigned int col = cell / cfg.cells_per_column;
43
+ if (sp_active_mask[col] == 0) return;
44
+ if (col_predicted[col] == 0) return;
45
+
46
+ const unsigned int n_segs_here = min(cell_seg_count[cell], cfg.max_segments_per_cell);
47
+ if (n_segs_here == 0) return;
48
+
49
+ const unsigned int tid = threadIdx.x;
50
+ const unsigned int seg_base_id = cell * cfg.max_segments_per_cell;
51
+
52
+ for (unsigned int local_seg = 0; local_seg < n_segs_here; local_seg++) {
53
+ const unsigned int seg = seg_base_id + local_seg;
54
+ if (seg_num_active_connected[seg] < cfg.activation_threshold) continue;
55
+ const unsigned int n_syn = seg_syn_count[seg];
56
+ if (n_syn == 0) continue;
57
+ const unsigned int syn_base = seg * cfg.synapses_per_segment;
58
+
59
+ for (unsigned int s = tid; s < n_syn; s += 32u) {
60
+ unsigned int presyn = syn_presyn[syn_base + s];
61
+ unsigned int word = prev_active_bits[presyn >> 5];
62
+ unsigned int bit = (word >> (presyn & 31u)) & 1u;
63
+ int p = (int)syn_perm[syn_base + s];
64
+ if (bit) {
65
+ int np = p + cfg.perm_inc_i16;
66
+ if (np > 32767) np = 32767;
67
+ syn_perm[syn_base + s] = (short)np;
68
+ } else {
69
+ int np = p - cfg.perm_dec_i16;
70
+ if (np < 0) np = 0;
71
+ syn_perm[syn_base + s] = (short)np;
72
+ }
73
+ }
74
+ }
75
+ }
overlay/htm_rust/src/gpu/kernels/tm_predict.cu CHANGED
@@ -1,102 +1,102 @@
1
- // TM predict kernel β€” cell-grouped launch.
2
- //
3
- // Grid: n_cells blocks (one per cell).
4
- // Block: 32 threads (one warp).
5
- //
6
- // Each block iterates the segments owned by its cell (count in cell_seg_count[cell]).
7
- // For each live segment, counts active connected/potential synapses against
8
- // prev_active_bits. Updates per-segment counters, cell_predictive bit, and
9
- // col_predicted flag.
10
-
11
- struct TmConfig {
12
- unsigned int activation_threshold;
13
- unsigned int learning_threshold;
14
- unsigned int cells_per_column;
15
- unsigned int synapses_per_segment;
16
- unsigned int n_segments;
17
- unsigned int n_cells;
18
- unsigned int max_segments_per_cell;
19
- unsigned int max_new_synapses;
20
- int conn_thr_i16;
21
- int perm_inc_i16;
22
- int perm_dec_i16;
23
- int predicted_seg_dec_i16;
24
- int initial_perm_i16;
25
- unsigned int iter_seed;
26
- unsigned int n_cols;
27
- unsigned int bits_words;
28
- };
29
-
30
- extern "C" __global__
31
- void tm_predict(
32
- const unsigned int * __restrict__ seg_cell_id,
33
- const unsigned int * __restrict__ seg_syn_count,
34
- const unsigned int * __restrict__ syn_presyn,
35
- const short * __restrict__ syn_perm,
36
- const unsigned int * __restrict__ cell_active_bits,
37
- unsigned int * __restrict__ cell_predictive_bits,
38
- unsigned char * __restrict__ col_predicted,
39
- unsigned int * __restrict__ seg_num_active_connected,
40
- unsigned int * __restrict__ seg_num_active_potential,
41
- unsigned int * __restrict__ col_best_match,
42
- const unsigned int * __restrict__ cell_seg_count,
43
- TmConfig cfg
44
- ) {
45
- const unsigned int cell = blockIdx.x;
46
- if (cell >= cfg.n_cells) return;
47
-
48
- const unsigned int n_segs_here = min(cell_seg_count[cell], cfg.max_segments_per_cell);
49
- if (n_segs_here == 0) return;
50
-
51
- const unsigned int tid = threadIdx.x;
52
- const unsigned int col = cell / cfg.cells_per_column;
53
- const unsigned int seg_base_id = cell * cfg.max_segments_per_cell;
54
-
55
- for (unsigned int local_seg = 0; local_seg < n_segs_here; local_seg++) {
56
- const unsigned int seg = seg_base_id + local_seg;
57
- const unsigned int n_syn = seg_syn_count[seg];
58
- if (n_syn == 0) {
59
- if (tid == 0) {
60
- seg_num_active_connected[seg] = 0;
61
- seg_num_active_potential[seg] = 0;
62
- }
63
- continue;
64
- }
65
- const unsigned int syn_base = seg * cfg.synapses_per_segment;
66
-
67
- unsigned int local_conn = 0;
68
- unsigned int local_pot = 0;
69
- for (unsigned int s = tid; s < n_syn; s += 32u) {
70
- unsigned int presyn = syn_presyn[syn_base + s];
71
- unsigned int word = cell_active_bits[presyn >> 5];
72
- unsigned int bit = (word >> (presyn & 31u)) & 1u;
73
- if (bit) {
74
- local_pot += 1u;
75
- int p = (int)syn_perm[syn_base + s];
76
- if (p >= cfg.conn_thr_i16) {
77
- local_conn += 1u;
78
- }
79
- }
80
- }
81
- for (int off = 16; off > 0; off >>= 1) {
82
- local_conn += __shfl_down_sync(0xffffffffu, local_conn, off);
83
- local_pot += __shfl_down_sync(0xffffffffu, local_pot, off);
84
- }
85
-
86
- if (tid == 0) {
87
- seg_num_active_connected[seg] = local_conn;
88
- seg_num_active_potential[seg] = local_pot;
89
- if (local_conn >= cfg.activation_threshold) {
90
- unsigned int word_idx = cell >> 5;
91
- unsigned int bit_mask = 1u << (cell & 31u);
92
- atomicOr(&cell_predictive_bits[word_idx], bit_mask);
93
- col_predicted[col] = 1;
94
- }
95
- if (local_pot >= cfg.learning_threshold) {
96
- unsigned int pot_c = local_pot > 2047u ? 2047u : local_pot;
97
- unsigned int key = (pot_c << 21) | (seg & 0x1FFFFFu);
98
- atomicMax(&col_best_match[col], key);
99
- }
100
- }
101
- }
102
- }
 
1
+ // TM predict kernel β€” cell-grouped launch.
2
+ //
3
+ // Grid: n_cells blocks (one per cell).
4
+ // Block: 32 threads (one warp).
5
+ //
6
+ // Each block iterates the segments owned by its cell (count in cell_seg_count[cell]).
7
+ // For each live segment, counts active connected/potential synapses against
8
+ // prev_active_bits. Updates per-segment counters, cell_predictive bit, and
9
+ // col_predicted flag.
10
+
11
+ struct TmConfig {
12
+ unsigned int activation_threshold;
13
+ unsigned int learning_threshold;
14
+ unsigned int cells_per_column;
15
+ unsigned int synapses_per_segment;
16
+ unsigned int n_segments;
17
+ unsigned int n_cells;
18
+ unsigned int max_segments_per_cell;
19
+ unsigned int max_new_synapses;
20
+ int conn_thr_i16;
21
+ int perm_inc_i16;
22
+ int perm_dec_i16;
23
+ int predicted_seg_dec_i16;
24
+ int initial_perm_i16;
25
+ unsigned int iter_seed;
26
+ unsigned int n_cols;
27
+ unsigned int bits_words;
28
+ };
29
+
30
+ extern "C" __global__
31
+ void tm_predict(
32
+ const unsigned int * __restrict__ seg_cell_id,
33
+ const unsigned int * __restrict__ seg_syn_count,
34
+ const unsigned int * __restrict__ syn_presyn,
35
+ const short * __restrict__ syn_perm,
36
+ const unsigned int * __restrict__ cell_active_bits,
37
+ unsigned int * __restrict__ cell_predictive_bits,
38
+ unsigned char * __restrict__ col_predicted,
39
+ unsigned int * __restrict__ seg_num_active_connected,
40
+ unsigned int * __restrict__ seg_num_active_potential,
41
+ unsigned int * __restrict__ col_best_match,
42
+ const unsigned int * __restrict__ cell_seg_count,
43
+ TmConfig cfg
44
+ ) {
45
+ const unsigned int cell = blockIdx.x;
46
+ if (cell >= cfg.n_cells) return;
47
+
48
+ const unsigned int n_segs_here = min(cell_seg_count[cell], cfg.max_segments_per_cell);
49
+ if (n_segs_here == 0) return;
50
+
51
+ const unsigned int tid = threadIdx.x;
52
+ const unsigned int col = cell / cfg.cells_per_column;
53
+ const unsigned int seg_base_id = cell * cfg.max_segments_per_cell;
54
+
55
+ for (unsigned int local_seg = 0; local_seg < n_segs_here; local_seg++) {
56
+ const unsigned int seg = seg_base_id + local_seg;
57
+ const unsigned int n_syn = seg_syn_count[seg];
58
+ if (n_syn == 0) {
59
+ if (tid == 0) {
60
+ seg_num_active_connected[seg] = 0;
61
+ seg_num_active_potential[seg] = 0;
62
+ }
63
+ continue;
64
+ }
65
+ const unsigned int syn_base = seg * cfg.synapses_per_segment;
66
+
67
+ unsigned int local_conn = 0;
68
+ unsigned int local_pot = 0;
69
+ for (unsigned int s = tid; s < n_syn; s += 32u) {
70
+ unsigned int presyn = syn_presyn[syn_base + s];
71
+ unsigned int word = cell_active_bits[presyn >> 5];
72
+ unsigned int bit = (word >> (presyn & 31u)) & 1u;
73
+ if (bit) {
74
+ local_pot += 1u;
75
+ int p = (int)syn_perm[syn_base + s];
76
+ if (p >= cfg.conn_thr_i16) {
77
+ local_conn += 1u;
78
+ }
79
+ }
80
+ }
81
+ for (int off = 16; off > 0; off >>= 1) {
82
+ local_conn += __shfl_down_sync(0xffffffffu, local_conn, off);
83
+ local_pot += __shfl_down_sync(0xffffffffu, local_pot, off);
84
+ }
85
+
86
+ if (tid == 0) {
87
+ seg_num_active_connected[seg] = local_conn;
88
+ seg_num_active_potential[seg] = local_pot;
89
+ if (local_conn >= cfg.activation_threshold) {
90
+ unsigned int word_idx = cell >> 5;
91
+ unsigned int bit_mask = 1u << (cell & 31u);
92
+ atomicOr(&cell_predictive_bits[word_idx], bit_mask);
93
+ col_predicted[col] = 1;
94
+ }
95
+ if (local_pot >= cfg.learning_threshold) {
96
+ unsigned int pot_c = local_pot > 2047u ? 2047u : local_pot;
97
+ unsigned int key = (pot_c << 21) | (seg & 0x1FFFFFu);
98
+ atomicMax(&col_best_match[col], key);
99
+ }
100
+ }
101
+ }
102
+ }
overlay/htm_rust/src/gpu/kernels/tm_punish.cu CHANGED
@@ -1,64 +1,64 @@
1
- // TM punish β€” cell-grouped launch.
2
-
3
- struct TmConfig {
4
- unsigned int activation_threshold;
5
- unsigned int learning_threshold;
6
- unsigned int cells_per_column;
7
- unsigned int synapses_per_segment;
8
- unsigned int n_segments;
9
- unsigned int n_cells;
10
- unsigned int max_segments_per_cell;
11
- unsigned int max_new_synapses;
12
- int conn_thr_i16;
13
- int perm_inc_i16;
14
- int perm_dec_i16;
15
- int predicted_seg_dec_i16;
16
- int initial_perm_i16;
17
- unsigned int iter_seed;
18
- unsigned int n_cols;
19
- unsigned int bits_words;
20
- };
21
-
22
- extern "C" __global__
23
- void tm_punish(
24
- const unsigned int * __restrict__ seg_cell_id,
25
- const unsigned int * __restrict__ seg_syn_count,
26
- const unsigned int * __restrict__ syn_presyn,
27
- short * __restrict__ syn_perm,
28
- const unsigned int * __restrict__ seg_num_active_potential,
29
- const unsigned int * __restrict__ prev_active_bits,
30
- const unsigned char * __restrict__ sp_active_mask,
31
- const unsigned int * __restrict__ cell_seg_count,
32
- TmConfig cfg
33
- ) {
34
- const unsigned int cell = blockIdx.x;
35
- if (cell >= cfg.n_cells) return;
36
- const unsigned int col = cell / cfg.cells_per_column;
37
- if (sp_active_mask[col] != 0) return; // skip: col became active
38
-
39
- const unsigned int n_segs_here = min(cell_seg_count[cell], cfg.max_segments_per_cell);
40
- if (n_segs_here == 0) return;
41
-
42
- const unsigned int tid = threadIdx.x;
43
- const unsigned int seg_base_id = cell * cfg.max_segments_per_cell;
44
-
45
- for (unsigned int local_seg = 0; local_seg < n_segs_here; local_seg++) {
46
- const unsigned int seg = seg_base_id + local_seg;
47
- if (seg_num_active_potential[seg] < cfg.learning_threshold) continue;
48
- const unsigned int n_syn = seg_syn_count[seg];
49
- if (n_syn == 0) continue;
50
- const unsigned int syn_base = seg * cfg.synapses_per_segment;
51
-
52
- for (unsigned int s = tid; s < n_syn; s += 32u) {
53
- unsigned int presyn = syn_presyn[syn_base + s];
54
- unsigned int word = prev_active_bits[presyn >> 5];
55
- unsigned int bit = (word >> (presyn & 31u)) & 1u;
56
- if (bit) {
57
- int p = (int)syn_perm[syn_base + s];
58
- int np = p - cfg.predicted_seg_dec_i16;
59
- if (np < 0) np = 0;
60
- syn_perm[syn_base + s] = (short)np;
61
- }
62
- }
63
- }
64
- }
 
1
+ // TM punish β€” cell-grouped launch.
2
+
3
+ struct TmConfig {
4
+ unsigned int activation_threshold;
5
+ unsigned int learning_threshold;
6
+ unsigned int cells_per_column;
7
+ unsigned int synapses_per_segment;
8
+ unsigned int n_segments;
9
+ unsigned int n_cells;
10
+ unsigned int max_segments_per_cell;
11
+ unsigned int max_new_synapses;
12
+ int conn_thr_i16;
13
+ int perm_inc_i16;
14
+ int perm_dec_i16;
15
+ int predicted_seg_dec_i16;
16
+ int initial_perm_i16;
17
+ unsigned int iter_seed;
18
+ unsigned int n_cols;
19
+ unsigned int bits_words;
20
+ };
21
+
22
+ extern "C" __global__
23
+ void tm_punish(
24
+ const unsigned int * __restrict__ seg_cell_id,
25
+ const unsigned int * __restrict__ seg_syn_count,
26
+ const unsigned int * __restrict__ syn_presyn,
27
+ short * __restrict__ syn_perm,
28
+ const unsigned int * __restrict__ seg_num_active_potential,
29
+ const unsigned int * __restrict__ prev_active_bits,
30
+ const unsigned char * __restrict__ sp_active_mask,
31
+ const unsigned int * __restrict__ cell_seg_count,
32
+ TmConfig cfg
33
+ ) {
34
+ const unsigned int cell = blockIdx.x;
35
+ if (cell >= cfg.n_cells) return;
36
+ const unsigned int col = cell / cfg.cells_per_column;
37
+ if (sp_active_mask[col] != 0) return; // skip: col became active
38
+
39
+ const unsigned int n_segs_here = min(cell_seg_count[cell], cfg.max_segments_per_cell);
40
+ if (n_segs_here == 0) return;
41
+
42
+ const unsigned int tid = threadIdx.x;
43
+ const unsigned int seg_base_id = cell * cfg.max_segments_per_cell;
44
+
45
+ for (unsigned int local_seg = 0; local_seg < n_segs_here; local_seg++) {
46
+ const unsigned int seg = seg_base_id + local_seg;
47
+ if (seg_num_active_potential[seg] < cfg.learning_threshold) continue;
48
+ const unsigned int n_syn = seg_syn_count[seg];
49
+ if (n_syn == 0) continue;
50
+ const unsigned int syn_base = seg * cfg.synapses_per_segment;
51
+
52
+ for (unsigned int s = tid; s < n_syn; s += 32u) {
53
+ unsigned int presyn = syn_presyn[syn_base + s];
54
+ unsigned int word = prev_active_bits[presyn >> 5];
55
+ unsigned int bit = (word >> (presyn & 31u)) & 1u;
56
+ if (bit) {
57
+ int p = (int)syn_perm[syn_base + s];
58
+ int np = p - cfg.predicted_seg_dec_i16;
59
+ if (np < 0) np = 0;
60
+ syn_perm[syn_base + s] = (short)np;
61
+ }
62
+ }
63
+ }
64
+ }
overlay/htm_rust/src/gpu/kernels/tm_reset.cu CHANGED
@@ -1,36 +1,36 @@
1
- // TM reset-per-step kernel.
2
-
3
- extern "C" __global__
4
- void tm_reset_step(
5
- unsigned int * __restrict__ cell_active_bits,
6
- unsigned int * __restrict__ cell_winner_bits,
7
- unsigned int * __restrict__ cell_predictive_bits,
8
- unsigned int * __restrict__ prev_active_bits,
9
- unsigned int * __restrict__ prev_winner_bits,
10
- unsigned char * __restrict__ col_predicted,
11
- unsigned int * __restrict__ unpredicted_count,
12
- unsigned int * __restrict__ burst_cols_count,
13
- unsigned int * __restrict__ col_best_match,
14
- unsigned int bits_words,
15
- unsigned int n_cols
16
- ) {
17
- unsigned int tid_global = blockIdx.x * blockDim.x + threadIdx.x;
18
-
19
- if (tid_global < bits_words) {
20
- prev_active_bits[tid_global] = cell_active_bits[tid_global];
21
- prev_winner_bits[tid_global] = cell_winner_bits[tid_global];
22
- cell_active_bits[tid_global] = 0u;
23
- cell_winner_bits[tid_global] = 0u;
24
- cell_predictive_bits[tid_global] = 0u;
25
- }
26
-
27
- if (tid_global < n_cols) {
28
- col_predicted[tid_global] = 0;
29
- col_best_match[tid_global] = 0u;
30
- }
31
-
32
- if (tid_global == 0) {
33
- unpredicted_count[0] = 0u;
34
- burst_cols_count[0] = 0u;
35
- }
36
- }
 
1
+ // TM reset-per-step kernel.
2
+
3
+ extern "C" __global__
4
+ void tm_reset_step(
5
+ unsigned int * __restrict__ cell_active_bits,
6
+ unsigned int * __restrict__ cell_winner_bits,
7
+ unsigned int * __restrict__ cell_predictive_bits,
8
+ unsigned int * __restrict__ prev_active_bits,
9
+ unsigned int * __restrict__ prev_winner_bits,
10
+ unsigned char * __restrict__ col_predicted,
11
+ unsigned int * __restrict__ unpredicted_count,
12
+ unsigned int * __restrict__ burst_cols_count,
13
+ unsigned int * __restrict__ col_best_match,
14
+ unsigned int bits_words,
15
+ unsigned int n_cols
16
+ ) {
17
+ unsigned int tid_global = blockIdx.x * blockDim.x + threadIdx.x;
18
+
19
+ if (tid_global < bits_words) {
20
+ prev_active_bits[tid_global] = cell_active_bits[tid_global];
21
+ prev_winner_bits[tid_global] = cell_winner_bits[tid_global];
22
+ cell_active_bits[tid_global] = 0u;
23
+ cell_winner_bits[tid_global] = 0u;
24
+ cell_predictive_bits[tid_global] = 0u;
25
+ }
26
+
27
+ if (tid_global < n_cols) {
28
+ col_predicted[tid_global] = 0;
29
+ col_best_match[tid_global] = 0u;
30
+ }
31
+
32
+ if (tid_global == 0) {
33
+ unpredicted_count[0] = 0u;
34
+ burst_cols_count[0] = 0u;
35
+ }
36
+ }
overlay/htm_rust/src/gpu/mod.rs CHANGED
@@ -1,549 +1,549 @@
1
- //! GPU backend for HTM.
2
- //!
3
- //! Full-GPU pipeline (SP + TM). Per-step state lives entirely on device; the
4
- //! batch API (`step_many_gpu`) uploads T steps of input once, runs T iterations
5
- //! of the full HTM pipeline on GPU, and copies (T, n_cols) u8 + (T,) f32 back
6
- //! to the host in one shot.
7
- //!
8
- //! TM parity with the CPU reference is approximate:
9
- //! - Segment growth: winner = cell 0 of bursting column (CPU picks
10
- //! least-used-cell with RNG tiebreak). This is a pragmatic simplification
11
- //! for GPU atomicity; learning dynamics are preserved.
12
- //! - Permanences stored as i16 (scaled 0..32767). Rounding differs from
13
- //! f32 by <= 1 ULP of the scale factor (β‰ˆ 3e-5) β€” inside any meaningful
14
- //! HTM learning quantum.
15
-
16
- #![cfg(feature = "gpu")]
17
-
18
- pub mod sp_gpu;
19
- pub mod tm_gpu;
20
- pub mod fused;
21
-
22
- #[cfg(test)]
23
- mod tests;
24
-
25
- use std::mem::ManuallyDrop;
26
-
27
- use pyo3::prelude::*;
28
- use pyo3::types::{PyDict, PyTuple};
29
- use numpy::{PyArray1, PyArray2, PyArrayMethods, PyReadonlyArray2, PyUntypedArrayMethods};
30
-
31
- use crate::region::HTMRegionCore;
32
- use crate::sp::SpatialPoolerConfig;
33
- use sp_gpu::SpatialPoolerGpu;
34
- use tm_gpu::TemporalMemoryGpu;
35
- use fused::FusedState;
36
-
37
- /// Extract (device_ptr, shape, typestr) from a `__cuda_array_interface__` dict.
38
- /// Returns Err if the dict is malformed. Used by `step_many_cuda` to wrap
39
- /// torch-owned CUDA allocations zero-copy.
40
- fn cai_parse(cai: &Bound<'_, PyDict>) -> PyResult<(u64, Vec<usize>, String)> {
41
- // `data` is a (ptr: int, readonly: bool) tuple.
42
- let data_obj = cai.get_item("data")?
43
- .ok_or_else(|| pyo3::exceptions::PyValueError::new_err("CAI missing 'data'"))?;
44
- let data_tup: Bound<'_, PyTuple> = data_obj.downcast_into()
45
- .map_err(|_| pyo3::exceptions::PyValueError::new_err("CAI 'data' must be a tuple"))?;
46
- let ptr: u64 = data_tup.get_item(0)?.extract()?;
47
-
48
- // `shape` is a tuple of ints.
49
- let shape_obj = cai.get_item("shape")?
50
- .ok_or_else(|| pyo3::exceptions::PyValueError::new_err("CAI missing 'shape'"))?;
51
- let shape_tup: Bound<'_, PyTuple> = shape_obj.downcast_into()
52
- .map_err(|_| pyo3::exceptions::PyValueError::new_err("CAI 'shape' must be a tuple"))?;
53
- let shape: Vec<usize> = (0..shape_tup.len())
54
- .map(|i| shape_tup.get_item(i).and_then(|v| v.extract::<usize>()))
55
- .collect::<PyResult<Vec<_>>>()?;
56
-
57
- // `typestr` (e.g. "|u1", "<f4").
58
- let typestr_obj = cai.get_item("typestr")?
59
- .ok_or_else(|| pyo3::exceptions::PyValueError::new_err("CAI missing 'typestr'"))?;
60
- let typestr: String = typestr_obj.extract()?;
61
-
62
- // Reject non-contiguous tensors β€” we don't handle strides.
63
- if let Some(strides) = cai.get_item("strides")? {
64
- if !strides.is_none() {
65
- return Err(pyo3::exceptions::PyValueError::new_err(
66
- "CAI 'strides' must be None (tensor must be contiguous)",
67
- ));
68
- }
69
- }
70
-
71
- Ok((ptr, shape, typestr))
72
- }
73
-
74
- /// Python-exposed GPU HTM region. Drop-in replacement for `HTMRegion`.
75
- #[pyclass(module = "htm_rust")]
76
- pub struct HTMRegionGpu {
77
- pub(super) sp_gpu: SpatialPoolerGpu,
78
- pub(super) tm_gpu: TemporalMemoryGpu,
79
- pub(super) fused_state: FusedState,
80
- pub(super) n_columns: usize,
81
- pub(super) input_bits: usize,
82
- pub(super) cells_per_column: usize,
83
- }
84
-
85
- #[pymethods]
86
- impl HTMRegionGpu {
87
- #[new]
88
- #[pyo3(signature = (input_bits, n_columns, cells_per_column, seed=42))]
89
- fn new(
90
- input_bits: usize,
91
- n_columns: usize,
92
- cells_per_column: usize,
93
- seed: u64,
94
- ) -> PyResult<Self> {
95
- if input_bits == 0 || n_columns == 0 || cells_per_column == 0 {
96
- return Err(pyo3::exceptions::PyValueError::new_err(
97
- "input_bits, n_columns, cells_per_column must all be > 0",
98
- ));
99
- }
100
- // CPU reference for deterministic SP init.
101
- let cpu_ref = HTMRegionCore::new(input_bits, n_columns, cells_per_column, seed);
102
- let sp_cfg: &SpatialPoolerConfig = &cpu_ref.sp.cfg;
103
- let sp_gpu = SpatialPoolerGpu::from_cpu(&cpu_ref.sp).map_err(|e| {
104
- pyo3::exceptions::PyRuntimeError::new_err(format!(
105
- "GPU SP init failed: {e:?}. Config: input_bits={}, n_columns={}",
106
- sp_cfg.input_bits, sp_cfg.n_columns,
107
- ))
108
- })?;
109
- let dev = sp_gpu.dev_ref().clone();
110
- let tm_gpu = TemporalMemoryGpu::new(dev.clone(), n_columns, cells_per_column).map_err(|e| {
111
- pyo3::exceptions::PyRuntimeError::new_err(format!(
112
- "GPU TM init failed: {e:?}",
113
- ))
114
- })?;
115
- let initial_threshold = sp_gpu.initial_threshold_estimate();
116
- let fused_state = FusedState::new(dev, n_columns, cells_per_column, initial_threshold)
117
- .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(format!(
118
- "GPU fused state init failed: {e:?}",
119
- )))?;
120
- Ok(Self {
121
- sp_gpu,
122
- tm_gpu,
123
- fused_state,
124
- n_columns,
125
- input_bits,
126
- cells_per_column,
127
- })
128
- }
129
-
130
- #[getter] fn input_bits(&self) -> usize { self.input_bits }
131
- #[getter] fn n_columns(&self) -> usize { self.n_columns }
132
- #[getter] fn cells_per_column(&self) -> usize { self.cells_per_column }
133
-
134
- /// Process T timesteps in one call on GPU. Per-step state (SP + TM) stays
135
- /// on device; only the final (T, n_cols) mask and (T,) anomaly are copied
136
- /// to the host at the end.
137
- #[pyo3(signature = (inputs, learn=true))]
138
- fn step_many_gpu<'py>(
139
- &mut self,
140
- py: Python<'py>,
141
- inputs: PyReadonlyArray2<'py, bool>,
142
- learn: bool,
143
- ) -> PyResult<(Bound<'py, PyArray2<f32>>, Bound<'py, PyArray1<f32>>)> {
144
- let shape = inputs.shape();
145
- if shape.len() != 2 {
146
- return Err(pyo3::exceptions::PyValueError::new_err(
147
- "inputs must be 2-D (T, input_bits)",
148
- ));
149
- }
150
- let t = shape[0];
151
- let bits = shape[1];
152
- if bits != self.input_bits {
153
- return Err(pyo3::exceptions::PyValueError::new_err(format!(
154
- "inputs last dim {bits} != expected input_bits {}",
155
- self.input_bits,
156
- )));
157
- }
158
- let slice = inputs.as_slice()?;
159
- let n_cols = self.n_columns;
160
- let input_vec: Vec<bool> = slice.to_vec();
161
-
162
- let result = py.allow_threads(|| -> Result<(Vec<u8>, Vec<f32>), String> {
163
- // 1. Upload T*input_bits bytes (32 MB at T=2048, bits=16384).
164
- let sdr_u8_all: Vec<u8> = input_vec.iter().map(|&b| b as u8).collect();
165
- let inputs_dev = self
166
- .sp_gpu
167
- .dev_ref()
168
- .htod_sync_copy(&sdr_u8_all)
169
- .map_err(|e| format!("H2D inputs: {e:?}"))?;
170
-
171
- // 2. Allocate output buffers on device.
172
- let mut cols_dev = self.sp_gpu.dev_ref()
173
- .alloc_zeros::<u8>(t * n_cols)
174
- .map_err(|e| format!("alloc cols: {e:?}"))?;
175
- let mut anom_dev = self.sp_gpu.dev_ref()
176
- .alloc_zeros::<f32>(t)
177
- .map_err(|e| format!("alloc anom: {e:?}"))?;
178
-
179
- // 3. Run T steps of SP + TM on GPU with NO per-step host sync.
180
- self.sp_gpu.step_batch_with_tm(
181
- &inputs_dev,
182
- t,
183
- self.input_bits,
184
- learn,
185
- &mut cols_dev,
186
- &mut anom_dev,
187
- &mut self.tm_gpu,
188
- ).map_err(|e| format!("step_batch_with_tm: {e:?}"))?;
189
-
190
- // 4. ONE D2H for the whole run (T * n_cols bytes + T floats).
191
- let cols_host: Vec<u8> = self.sp_gpu.dev_ref()
192
- .dtoh_sync_copy(&cols_dev)
193
- .map_err(|e| format!("D2H cols: {e:?}"))?;
194
- let anom_host: Vec<f32> = self.sp_gpu.dev_ref()
195
- .dtoh_sync_copy(&anom_dev)
196
- .map_err(|e| format!("D2H anom: {e:?}"))?;
197
-
198
- Ok((cols_host, anom_host))
199
- });
200
-
201
- let (cols_u8, anom) = result.map_err(pyo3::exceptions::PyRuntimeError::new_err)?;
202
-
203
- let cols_f32: Vec<f32> = cols_u8.iter().map(|&b| b as f32).collect();
204
- let cols_arr = numpy::PyArray1::from_vec_bound(py, cols_f32)
205
- .reshape([t, n_cols])
206
- .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(format!("{e}")))?;
207
- let anom_arr = numpy::PyArray1::from_vec_bound(py, anom);
208
- Ok((cols_arr, anom_arr))
209
- }
210
-
211
- /// Zero-copy CUDA path: accept torch tensors via __cuda_array_interface__,
212
- /// write outputs directly into caller-allocated torch tensors. Skips the
213
- /// host round-trip that `step_many_gpu` pays on every call (sdr.cpu() +
214
- /// two D2H copies at the end). This is the hot path for `train.py`.
215
- ///
216
- /// Contract:
217
- /// sdr_cai.shape == (T, input_bits), dtype u8 (0/1 mask)
218
- /// cols_cai.shape == (T, n_columns), dtype u8 (written)
219
- /// anom_cai.shape == (T,), dtype f32 (written)
220
- /// All three tensors must live on the SAME CUDA device as this region.
221
- ///
222
- /// The torch tensors still own their memory β€” this method only wraps
223
- /// them as borrowed CudaSlice views (via ManuallyDrop) so cudarc's Drop
224
- /// impl can't free pytorch's allocator.
225
- #[pyo3(signature = (sdr_cai, cols_cai, anom_cai, learn=true))]
226
- fn step_many_cuda(
227
- &mut self,
228
- py: Python<'_>,
229
- sdr_cai: &Bound<'_, PyDict>,
230
- cols_cai: &Bound<'_, PyDict>,
231
- anom_cai: &Bound<'_, PyDict>,
232
- learn: bool,
233
- ) -> PyResult<()> {
234
- let (sdr_ptr, sdr_shape, sdr_type) = cai_parse(sdr_cai)?;
235
- let (cols_ptr, cols_shape, cols_type) = cai_parse(cols_cai)?;
236
- let (anom_ptr, anom_shape, anom_type) = cai_parse(anom_cai)?;
237
-
238
- // typestr sanity. numpy u1 is what torch.uint8 exports.
239
- if sdr_type != "|u1" {
240
- return Err(pyo3::exceptions::PyValueError::new_err(format!(
241
- "sdr_cai typestr must be '|u1' (uint8), got {sdr_type}",
242
- )));
243
- }
244
- if cols_type != "|u1" {
245
- return Err(pyo3::exceptions::PyValueError::new_err(format!(
246
- "cols_cai typestr must be '|u1' (uint8), got {cols_type}",
247
- )));
248
- }
249
- if anom_type != "<f4" && anom_type != "=f4" {
250
- return Err(pyo3::exceptions::PyValueError::new_err(format!(
251
- "anom_cai typestr must be '<f4' (float32), got {anom_type}",
252
- )));
253
- }
254
-
255
- // Shape validation.
256
- if sdr_shape.len() != 2 || sdr_shape[1] != self.input_bits {
257
- return Err(pyo3::exceptions::PyValueError::new_err(format!(
258
- "sdr_cai shape {sdr_shape:?} != (T, {})",
259
- self.input_bits,
260
- )));
261
- }
262
- let t = sdr_shape[0];
263
- if cols_shape != [t, self.n_columns] {
264
- return Err(pyo3::exceptions::PyValueError::new_err(format!(
265
- "cols_cai shape {cols_shape:?} != ({t}, {})",
266
- self.n_columns,
267
- )));
268
- }
269
- if anom_shape != [t] {
270
- return Err(pyo3::exceptions::PyValueError::new_err(format!(
271
- "anom_cai shape {anom_shape:?} != ({t},)",
272
- )));
273
- }
274
-
275
- let dev = self.sp_gpu.dev_ref().clone();
276
- let n_cols = self.n_columns;
277
- let input_bits = self.input_bits;
278
-
279
- let result = py.allow_threads(|| -> Result<(), String> {
280
- // SAFETY:
281
- // - ptrs came from torch CUDA tensors validated non-null by the
282
- // __cuda_array_interface__ contract.
283
- // - lens computed from validated shapes.
284
- // - We wrap the returned CudaSlice in ManuallyDrop so cudarc's
285
- // Drop (which calls cuMemFree) never runs against torch memory.
286
- // The underlying allocation is owned+freed by torch.
287
- // - The slices are used only for the duration of this call;
288
- // torch guarantees the backing tensors are live across it
289
- // (Python holds refs on the wrapping tensors).
290
- let inputs_dev = ManuallyDrop::new(unsafe {
291
- dev.upgrade_device_ptr::<u8>(sdr_ptr, t * input_bits)
292
- });
293
- let mut cols_dev = ManuallyDrop::new(unsafe {
294
- dev.upgrade_device_ptr::<u8>(cols_ptr, t * n_cols)
295
- });
296
- let mut anom_dev = ManuallyDrop::new(unsafe {
297
- dev.upgrade_device_ptr::<f32>(anom_ptr, t)
298
- });
299
-
300
- self.sp_gpu.step_batch_with_tm(
301
- &inputs_dev,
302
- t,
303
- input_bits,
304
- learn,
305
- &mut cols_dev,
306
- &mut anom_dev,
307
- &mut self.tm_gpu,
308
- ).map_err(|e| format!("step_batch_with_tm: {e:?}"))?;
309
-
310
- // Synchronize: kernel writes must be visible to the next torch
311
- // op that reads cols/anom. Pytorch's default stream is stream 0,
312
- // and cudarc launches on its own stream β€” a full device sync
313
- // is the simplest correct barrier. (Could narrow to a stream
314
- // wait event in PR 2.)
315
- // No dev.synchronize() here: caller must explicitly sync via the
316
- // `device_sync()` method (or PyTorch auto-syncs when the output
317
- // tensor is next consumed). Removing the per-launch barrier lets
318
- // subsequent GPU work (mamba3 fwd, etc.) overlap in time.
319
- Ok(())
320
- });
321
-
322
- result.map_err(pyo3::exceptions::PyRuntimeError::new_err)?;
323
- Ok(())
324
- }
325
-
326
- /// Clear TM state on the GPU.
327
- fn reset(&mut self) -> PyResult<()> {
328
- self.tm_gpu.reset().map_err(|e| {
329
- pyo3::exceptions::PyRuntimeError::new_err(format!("GPU TM reset: {e:?}"))
330
- })?;
331
- self.fused_state.reset().map_err(|e| {
332
- pyo3::exceptions::PyRuntimeError::new_err(format!("GPU fused reset: {e:?}"))
333
- })
334
- }
335
-
336
- /// FUSED MEGAKERNEL PATH: single CUDA launch for the entire T-step
337
- /// forward (SP + TM all in one). Accepts torch CUDA tensors via
338
- /// `__cuda_array_interface__` (zero-copy). Writes active-column mask +
339
- /// anomaly directly into caller-allocated torch tensors.
340
- ///
341
- /// Semantics diverge from `step_many_cuda` in one important way: column
342
- /// activation uses per-column threshold inhibition instead of global
343
- /// top-K. The threshold is EMA-adapted per column toward the sparsity
344
- /// target. See `docs/GPU_HTM.md` Β§Fused Kernel.
345
- #[pyo3(signature = (sdr_cai, cols_cai, anom_cai, learn=true))]
346
- fn step_many_fused_cuda(
347
- &mut self,
348
- py: Python<'_>,
349
- sdr_cai: &Bound<'_, PyDict>,
350
- cols_cai: &Bound<'_, PyDict>,
351
- anom_cai: &Bound<'_, PyDict>,
352
- learn: bool,
353
- ) -> PyResult<()> {
354
- let (sdr_ptr, sdr_shape, sdr_type) = cai_parse(sdr_cai)?;
355
- let (cols_ptr, cols_shape, cols_type) = cai_parse(cols_cai)?;
356
- let (anom_ptr, anom_shape, anom_type) = cai_parse(anom_cai)?;
357
-
358
- if sdr_type != "|u1" {
359
- return Err(pyo3::exceptions::PyValueError::new_err(format!(
360
- "sdr_cai typestr must be '|u1' (uint8), got {sdr_type}",
361
- )));
362
- }
363
- if cols_type != "|u1" {
364
- return Err(pyo3::exceptions::PyValueError::new_err(format!(
365
- "cols_cai typestr must be '|u1' (uint8), got {cols_type}",
366
- )));
367
- }
368
- if anom_type != "<f4" && anom_type != "=f4" {
369
- return Err(pyo3::exceptions::PyValueError::new_err(format!(
370
- "anom_cai typestr must be '<f4' (float32), got {anom_type}",
371
- )));
372
- }
373
-
374
- if sdr_shape.len() != 2 || sdr_shape[1] != self.input_bits {
375
- return Err(pyo3::exceptions::PyValueError::new_err(format!(
376
- "sdr_cai shape {sdr_shape:?} != (T, {})",
377
- self.input_bits,
378
- )));
379
- }
380
- let t = sdr_shape[0];
381
- if cols_shape != [t, self.n_columns] {
382
- return Err(pyo3::exceptions::PyValueError::new_err(format!(
383
- "cols_cai shape {cols_shape:?} != ({t}, {})",
384
- self.n_columns,
385
- )));
386
- }
387
- if anom_shape != [t] {
388
- return Err(pyo3::exceptions::PyValueError::new_err(format!(
389
- "anom_cai shape {anom_shape:?} != ({t},)",
390
- )));
391
- }
392
-
393
- let dev = self.sp_gpu.dev_ref().clone();
394
- let n_cols = self.n_columns;
395
- let input_bits = self.input_bits;
396
-
397
- let result = py.allow_threads(|| -> Result<(), String> {
398
- let inputs_dev = ManuallyDrop::new(unsafe {
399
- dev.upgrade_device_ptr::<u8>(sdr_ptr, t * input_bits)
400
- });
401
- let mut cols_dev = ManuallyDrop::new(unsafe {
402
- dev.upgrade_device_ptr::<u8>(cols_ptr, t * n_cols)
403
- });
404
- let mut anom_dev = ManuallyDrop::new(unsafe {
405
- dev.upgrade_device_ptr::<f32>(anom_ptr, t)
406
- });
407
-
408
- fused::launch_fused(
409
- &mut self.sp_gpu,
410
- &mut self.tm_gpu,
411
- &mut self.fused_state,
412
- &inputs_dev,
413
- &mut cols_dev,
414
- &mut anom_dev,
415
- t,
416
- input_bits,
417
- learn,
418
- ).map_err(|e| format!("launch_fused: {e:?}"))?;
419
-
420
- // No dev.synchronize() here: caller must explicitly sync via the
421
- // `device_sync()` method (or PyTorch auto-syncs when the output
422
- // tensor is next consumed). Removing the per-launch barrier lets
423
- // subsequent GPU work (mamba3 fwd, etc.) overlap in time.
424
- Ok(())
425
- });
426
-
427
- result.map_err(pyo3::exceptions::PyRuntimeError::new_err)?;
428
- Ok(())
429
- }
430
-
431
- /// Explicit device synchronization β€” the caller must invoke this after
432
- /// all batched `step_many_*_cuda` calls complete, before reading the
433
- /// output tensors from a different CUDA stream. Equivalent to the old
434
- /// per-call `dev.synchronize()` that was removed for overlap.
435
- fn device_sync(&self) -> PyResult<()> {
436
- let dev = self.sp_gpu.dev_ref();
437
- dev.synchronize()
438
- .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(format!("sync: {e:?}")))?;
439
- Ok(())
440
- }
441
- }
442
-
443
- /// Batch B regions into ONE cooperative kernel launch. Breaks through the
444
- /// CUDA cooperative-kernel device-level serialization: a single cooperative
445
- /// launch with grid.y=B processes all regions concurrently β€” ~BΓ— speedup
446
- /// over B sequential launches.
447
- ///
448
- /// All regions must have the same config (input_bits, n_columns,
449
- /// cells_per_column). Each region keeps its independent GPU state.
450
- /// Does NOT sync; caller must invoke `device_sync()` on any region
451
- /// afterwards (or rely on a downstream torch op to auto-sync).
452
- #[pyfunction]
453
- #[pyo3(signature = (regions, sdr_cais, cols_cais, anom_cais, learn=true))]
454
- fn step_batch_fused_cuda(
455
- py: Python<'_>,
456
- regions: Vec<Py<HTMRegionGpu>>,
457
- sdr_cais: Vec<Bound<'_, PyDict>>,
458
- cols_cais: Vec<Bound<'_, PyDict>>,
459
- anom_cais: Vec<Bound<'_, PyDict>>,
460
- learn: bool,
461
- ) -> PyResult<()> {
462
- let b = regions.len();
463
- if b == 0 {
464
- return Err(pyo3::exceptions::PyValueError::new_err("regions is empty"));
465
- }
466
- if sdr_cais.len() != b || cols_cais.len() != b || anom_cais.len() != b {
467
- return Err(pyo3::exceptions::PyValueError::new_err(
468
- "sdr_cais / cols_cais / anom_cais length must match regions",
469
- ));
470
- }
471
-
472
- // Parse all CAI dicts; collect device pointers. Validate shapes/dtypes.
473
- let mut sdr_ptrs = Vec::with_capacity(b);
474
- let mut cols_ptrs = Vec::with_capacity(b);
475
- let mut anom_ptrs = Vec::with_capacity(b);
476
- let (input_bits, n_columns, t) = {
477
- let r0 = regions[0].bind(py).borrow();
478
- (r0.input_bits, r0.n_columns, {
479
- let (_p, sh, _ty) = cai_parse(&sdr_cais[0])?;
480
- if sh.len() != 2 {
481
- return Err(pyo3::exceptions::PyValueError::new_err(
482
- format!("sdr_cai must be 2-D (T, input_bits), got {sh:?}"),
483
- ));
484
- }
485
- sh[0]
486
- })
487
- };
488
-
489
- for i in 0..b {
490
- let (sdr_ptr, sdr_shape, sdr_type) = cai_parse(&sdr_cais[i])?;
491
- let (cols_ptr, cols_shape, cols_type) = cai_parse(&cols_cais[i])?;
492
- let (anom_ptr, anom_shape, anom_type) = cai_parse(&anom_cais[i])?;
493
- if sdr_type != "|u1" || cols_type != "|u1" {
494
- return Err(pyo3::exceptions::PyValueError::new_err(
495
- "sdr/cols typestr must be '|u1' (uint8)",
496
- ));
497
- }
498
- if anom_type != "<f4" && anom_type != "=f4" {
499
- return Err(pyo3::exceptions::PyValueError::new_err(
500
- "anom typestr must be '<f4' (float32)",
501
- ));
502
- }
503
- if sdr_shape != [t, input_bits] {
504
- return Err(pyo3::exceptions::PyValueError::new_err(format!(
505
- "sdr[{i}] shape {sdr_shape:?} != ({t}, {input_bits})"
506
- )));
507
- }
508
- if cols_shape != [t, n_columns] {
509
- return Err(pyo3::exceptions::PyValueError::new_err(format!(
510
- "cols[{i}] shape {cols_shape:?} != ({t}, {n_columns})"
511
- )));
512
- }
513
- if anom_shape != [t] {
514
- return Err(pyo3::exceptions::PyValueError::new_err(format!(
515
- "anom[{i}] shape {anom_shape:?} != ({t},)"
516
- )));
517
- }
518
- sdr_ptrs.push(sdr_ptr);
519
- cols_ptrs.push(cols_ptr);
520
- anom_ptrs.push(anom_ptr);
521
- }
522
-
523
- // Exclusively borrow each region. PyRefMut guarantees uniqueness.
524
- let mut region_refs: Vec<pyo3::PyRefMut<HTMRegionGpu>> =
525
- regions.iter().map(|p| p.bind(py).borrow_mut()).collect();
526
- // Collect raw mutable pointers β€” each PyRefMut exclusively borrows its
527
- // region for the lifetime of this call, so pointers stay valid and
528
- // unique. launch_fused_batched_raw only dereferences one region at a
529
- // time, not constructing an aliased slice.
530
- let raw_ptrs: Vec<*mut HTMRegionGpu> = region_refs
531
- .iter_mut()
532
- .map(|r| &mut **r as *mut HTMRegionGpu)
533
- .collect();
534
-
535
- // No allow_threads: raw pointers aren't Send. The launch is GPU-queued
536
- // and sync'd downstream; holding the GIL for the duration is cheap.
537
- fused::launch_fused_batched_raw(
538
- &raw_ptrs, &sdr_ptrs, &cols_ptrs, &anom_ptrs,
539
- t, input_bits, learn,
540
- )
541
- .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(format!("launch_fused_batched: {e:?}")))?;
542
- Ok(())
543
- }
544
-
545
- pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> {
546
- m.add_class::<HTMRegionGpu>()?;
547
- m.add_function(pyo3::wrap_pyfunction!(step_batch_fused_cuda, m)?)?;
548
- Ok(())
549
- }
 
1
+ //! GPU backend for HTM.
2
+ //!
3
+ //! Full-GPU pipeline (SP + TM). Per-step state lives entirely on device; the
4
+ //! batch API (`step_many_gpu`) uploads T steps of input once, runs T iterations
5
+ //! of the full HTM pipeline on GPU, and copies (T, n_cols) u8 + (T,) f32 back
6
+ //! to the host in one shot.
7
+ //!
8
+ //! TM parity with the CPU reference is approximate:
9
+ //! - Segment growth: winner = cell 0 of bursting column (CPU picks
10
+ //! least-used-cell with RNG tiebreak). This is a pragmatic simplification
11
+ //! for GPU atomicity; learning dynamics are preserved.
12
+ //! - Permanences stored as i16 (scaled 0..32767). Rounding differs from
13
+ //! f32 by <= 1 ULP of the scale factor (β‰ˆ 3e-5) β€” inside any meaningful
14
+ //! HTM learning quantum.
15
+
16
+ #![cfg(feature = "gpu")]
17
+
18
+ pub mod sp_gpu;
19
+ pub mod tm_gpu;
20
+ pub mod fused;
21
+
22
+ #[cfg(test)]
23
+ mod tests;
24
+
25
+ use std::mem::ManuallyDrop;
26
+
27
+ use pyo3::prelude::*;
28
+ use pyo3::types::{PyDict, PyTuple};
29
+ use numpy::{PyArray1, PyArray2, PyArrayMethods, PyReadonlyArray2, PyUntypedArrayMethods};
30
+
31
+ use crate::region::HTMRegionCore;
32
+ use crate::sp::SpatialPoolerConfig;
33
+ use sp_gpu::SpatialPoolerGpu;
34
+ use tm_gpu::TemporalMemoryGpu;
35
+ use fused::FusedState;
36
+
37
+ /// Extract (device_ptr, shape, typestr) from a `__cuda_array_interface__` dict.
38
+ /// Returns Err if the dict is malformed. Used by `step_many_cuda` to wrap
39
+ /// torch-owned CUDA allocations zero-copy.
40
+ fn cai_parse(cai: &Bound<'_, PyDict>) -> PyResult<(u64, Vec<usize>, String)> {
41
+ // `data` is a (ptr: int, readonly: bool) tuple.
42
+ let data_obj = cai.get_item("data")?
43
+ .ok_or_else(|| pyo3::exceptions::PyValueError::new_err("CAI missing 'data'"))?;
44
+ let data_tup: Bound<'_, PyTuple> = data_obj.downcast_into()
45
+ .map_err(|_| pyo3::exceptions::PyValueError::new_err("CAI 'data' must be a tuple"))?;
46
+ let ptr: u64 = data_tup.get_item(0)?.extract()?;
47
+
48
+ // `shape` is a tuple of ints.
49
+ let shape_obj = cai.get_item("shape")?
50
+ .ok_or_else(|| pyo3::exceptions::PyValueError::new_err("CAI missing 'shape'"))?;
51
+ let shape_tup: Bound<'_, PyTuple> = shape_obj.downcast_into()
52
+ .map_err(|_| pyo3::exceptions::PyValueError::new_err("CAI 'shape' must be a tuple"))?;
53
+ let shape: Vec<usize> = (0..shape_tup.len())
54
+ .map(|i| shape_tup.get_item(i).and_then(|v| v.extract::<usize>()))
55
+ .collect::<PyResult<Vec<_>>>()?;
56
+
57
+ // `typestr` (e.g. "|u1", "<f4").
58
+ let typestr_obj = cai.get_item("typestr")?
59
+ .ok_or_else(|| pyo3::exceptions::PyValueError::new_err("CAI missing 'typestr'"))?;
60
+ let typestr: String = typestr_obj.extract()?;
61
+
62
+ // Reject non-contiguous tensors β€” we don't handle strides.
63
+ if let Some(strides) = cai.get_item("strides")? {
64
+ if !strides.is_none() {
65
+ return Err(pyo3::exceptions::PyValueError::new_err(
66
+ "CAI 'strides' must be None (tensor must be contiguous)",
67
+ ));
68
+ }
69
+ }
70
+
71
+ Ok((ptr, shape, typestr))
72
+ }
73
+
74
+ /// Python-exposed GPU HTM region. Drop-in replacement for `HTMRegion`.
75
+ #[pyclass(module = "htm_rust")]
76
+ pub struct HTMRegionGpu {
77
+ pub(super) sp_gpu: SpatialPoolerGpu,
78
+ pub(super) tm_gpu: TemporalMemoryGpu,
79
+ pub(super) fused_state: FusedState,
80
+ pub(super) n_columns: usize,
81
+ pub(super) input_bits: usize,
82
+ pub(super) cells_per_column: usize,
83
+ }
84
+
85
+ #[pymethods]
86
+ impl HTMRegionGpu {
87
+ #[new]
88
+ #[pyo3(signature = (input_bits, n_columns, cells_per_column, seed=42))]
89
+ fn new(
90
+ input_bits: usize,
91
+ n_columns: usize,
92
+ cells_per_column: usize,
93
+ seed: u64,
94
+ ) -> PyResult<Self> {
95
+ if input_bits == 0 || n_columns == 0 || cells_per_column == 0 {
96
+ return Err(pyo3::exceptions::PyValueError::new_err(
97
+ "input_bits, n_columns, cells_per_column must all be > 0",
98
+ ));
99
+ }
100
+ // CPU reference for deterministic SP init.
101
+ let cpu_ref = HTMRegionCore::new(input_bits, n_columns, cells_per_column, seed);
102
+ let sp_cfg: &SpatialPoolerConfig = &cpu_ref.sp.cfg;
103
+ let sp_gpu = SpatialPoolerGpu::from_cpu(&cpu_ref.sp).map_err(|e| {
104
+ pyo3::exceptions::PyRuntimeError::new_err(format!(
105
+ "GPU SP init failed: {e:?}. Config: input_bits={}, n_columns={}",
106
+ sp_cfg.input_bits, sp_cfg.n_columns,
107
+ ))
108
+ })?;
109
+ let dev = sp_gpu.dev_ref().clone();
110
+ let tm_gpu = TemporalMemoryGpu::new(dev.clone(), n_columns, cells_per_column).map_err(|e| {
111
+ pyo3::exceptions::PyRuntimeError::new_err(format!(
112
+ "GPU TM init failed: {e:?}",
113
+ ))
114
+ })?;
115
+ let initial_threshold = sp_gpu.initial_threshold_estimate();
116
+ let fused_state = FusedState::new(dev, n_columns, cells_per_column, initial_threshold)
117
+ .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(format!(
118
+ "GPU fused state init failed: {e:?}",
119
+ )))?;
120
+ Ok(Self {
121
+ sp_gpu,
122
+ tm_gpu,
123
+ fused_state,
124
+ n_columns,
125
+ input_bits,
126
+ cells_per_column,
127
+ })
128
+ }
129
+
130
+ #[getter] fn input_bits(&self) -> usize { self.input_bits }
131
+ #[getter] fn n_columns(&self) -> usize { self.n_columns }
132
+ #[getter] fn cells_per_column(&self) -> usize { self.cells_per_column }
133
+
134
+ /// Process T timesteps in one call on GPU. Per-step state (SP + TM) stays
135
+ /// on device; only the final (T, n_cols) mask and (T,) anomaly are copied
136
+ /// to the host at the end.
137
+ #[pyo3(signature = (inputs, learn=true))]
138
+ fn step_many_gpu<'py>(
139
+ &mut self,
140
+ py: Python<'py>,
141
+ inputs: PyReadonlyArray2<'py, bool>,
142
+ learn: bool,
143
+ ) -> PyResult<(Bound<'py, PyArray2<f32>>, Bound<'py, PyArray1<f32>>)> {
144
+ let shape = inputs.shape();
145
+ if shape.len() != 2 {
146
+ return Err(pyo3::exceptions::PyValueError::new_err(
147
+ "inputs must be 2-D (T, input_bits)",
148
+ ));
149
+ }
150
+ let t = shape[0];
151
+ let bits = shape[1];
152
+ if bits != self.input_bits {
153
+ return Err(pyo3::exceptions::PyValueError::new_err(format!(
154
+ "inputs last dim {bits} != expected input_bits {}",
155
+ self.input_bits,
156
+ )));
157
+ }
158
+ let slice = inputs.as_slice()?;
159
+ let n_cols = self.n_columns;
160
+ let input_vec: Vec<bool> = slice.to_vec();
161
+
162
+ let result = py.allow_threads(|| -> Result<(Vec<u8>, Vec<f32>), String> {
163
+ // 1. Upload T*input_bits bytes (32 MB at T=2048, bits=16384).
164
+ let sdr_u8_all: Vec<u8> = input_vec.iter().map(|&b| b as u8).collect();
165
+ let inputs_dev = self
166
+ .sp_gpu
167
+ .dev_ref()
168
+ .htod_sync_copy(&sdr_u8_all)
169
+ .map_err(|e| format!("H2D inputs: {e:?}"))?;
170
+
171
+ // 2. Allocate output buffers on device.
172
+ let mut cols_dev = self.sp_gpu.dev_ref()
173
+ .alloc_zeros::<u8>(t * n_cols)
174
+ .map_err(|e| format!("alloc cols: {e:?}"))?;
175
+ let mut anom_dev = self.sp_gpu.dev_ref()
176
+ .alloc_zeros::<f32>(t)
177
+ .map_err(|e| format!("alloc anom: {e:?}"))?;
178
+
179
+ // 3. Run T steps of SP + TM on GPU with NO per-step host sync.
180
+ self.sp_gpu.step_batch_with_tm(
181
+ &inputs_dev,
182
+ t,
183
+ self.input_bits,
184
+ learn,
185
+ &mut cols_dev,
186
+ &mut anom_dev,
187
+ &mut self.tm_gpu,
188
+ ).map_err(|e| format!("step_batch_with_tm: {e:?}"))?;
189
+
190
+ // 4. ONE D2H for the whole run (T * n_cols bytes + T floats).
191
+ let cols_host: Vec<u8> = self.sp_gpu.dev_ref()
192
+ .dtoh_sync_copy(&cols_dev)
193
+ .map_err(|e| format!("D2H cols: {e:?}"))?;
194
+ let anom_host: Vec<f32> = self.sp_gpu.dev_ref()
195
+ .dtoh_sync_copy(&anom_dev)
196
+ .map_err(|e| format!("D2H anom: {e:?}"))?;
197
+
198
+ Ok((cols_host, anom_host))
199
+ });
200
+
201
+ let (cols_u8, anom) = result.map_err(pyo3::exceptions::PyRuntimeError::new_err)?;
202
+
203
+ let cols_f32: Vec<f32> = cols_u8.iter().map(|&b| b as f32).collect();
204
+ let cols_arr = numpy::PyArray1::from_vec_bound(py, cols_f32)
205
+ .reshape([t, n_cols])
206
+ .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(format!("{e}")))?;
207
+ let anom_arr = numpy::PyArray1::from_vec_bound(py, anom);
208
+ Ok((cols_arr, anom_arr))
209
+ }
210
+
211
+ /// Zero-copy CUDA path: accept torch tensors via __cuda_array_interface__,
212
+ /// write outputs directly into caller-allocated torch tensors. Skips the
213
+ /// host round-trip that `step_many_gpu` pays on every call (sdr.cpu() +
214
+ /// two D2H copies at the end). This is the hot path for `train.py`.
215
+ ///
216
+ /// Contract:
217
+ /// sdr_cai.shape == (T, input_bits), dtype u8 (0/1 mask)
218
+ /// cols_cai.shape == (T, n_columns), dtype u8 (written)
219
+ /// anom_cai.shape == (T,), dtype f32 (written)
220
+ /// All three tensors must live on the SAME CUDA device as this region.
221
+ ///
222
+ /// The torch tensors still own their memory β€” this method only wraps
223
+ /// them as borrowed CudaSlice views (via ManuallyDrop) so cudarc's Drop
224
+ /// impl can't free pytorch's allocator.
225
+ #[pyo3(signature = (sdr_cai, cols_cai, anom_cai, learn=true))]
226
+ fn step_many_cuda(
227
+ &mut self,
228
+ py: Python<'_>,
229
+ sdr_cai: &Bound<'_, PyDict>,
230
+ cols_cai: &Bound<'_, PyDict>,
231
+ anom_cai: &Bound<'_, PyDict>,
232
+ learn: bool,
233
+ ) -> PyResult<()> {
234
+ let (sdr_ptr, sdr_shape, sdr_type) = cai_parse(sdr_cai)?;
235
+ let (cols_ptr, cols_shape, cols_type) = cai_parse(cols_cai)?;
236
+ let (anom_ptr, anom_shape, anom_type) = cai_parse(anom_cai)?;
237
+
238
+ // typestr sanity. numpy u1 is what torch.uint8 exports.
239
+ if sdr_type != "|u1" {
240
+ return Err(pyo3::exceptions::PyValueError::new_err(format!(
241
+ "sdr_cai typestr must be '|u1' (uint8), got {sdr_type}",
242
+ )));
243
+ }
244
+ if cols_type != "|u1" {
245
+ return Err(pyo3::exceptions::PyValueError::new_err(format!(
246
+ "cols_cai typestr must be '|u1' (uint8), got {cols_type}",
247
+ )));
248
+ }
249
+ if anom_type != "<f4" && anom_type != "=f4" {
250
+ return Err(pyo3::exceptions::PyValueError::new_err(format!(
251
+ "anom_cai typestr must be '<f4' (float32), got {anom_type}",
252
+ )));
253
+ }
254
+
255
+ // Shape validation.
256
+ if sdr_shape.len() != 2 || sdr_shape[1] != self.input_bits {
257
+ return Err(pyo3::exceptions::PyValueError::new_err(format!(
258
+ "sdr_cai shape {sdr_shape:?} != (T, {})",
259
+ self.input_bits,
260
+ )));
261
+ }
262
+ let t = sdr_shape[0];
263
+ if cols_shape != [t, self.n_columns] {
264
+ return Err(pyo3::exceptions::PyValueError::new_err(format!(
265
+ "cols_cai shape {cols_shape:?} != ({t}, {})",
266
+ self.n_columns,
267
+ )));
268
+ }
269
+ if anom_shape != [t] {
270
+ return Err(pyo3::exceptions::PyValueError::new_err(format!(
271
+ "anom_cai shape {anom_shape:?} != ({t},)",
272
+ )));
273
+ }
274
+
275
+ let dev = self.sp_gpu.dev_ref().clone();
276
+ let n_cols = self.n_columns;
277
+ let input_bits = self.input_bits;
278
+
279
+ let result = py.allow_threads(|| -> Result<(), String> {
280
+ // SAFETY:
281
+ // - ptrs came from torch CUDA tensors validated non-null by the
282
+ // __cuda_array_interface__ contract.
283
+ // - lens computed from validated shapes.
284
+ // - We wrap the returned CudaSlice in ManuallyDrop so cudarc's
285
+ // Drop (which calls cuMemFree) never runs against torch memory.
286
+ // The underlying allocation is owned+freed by torch.
287
+ // - The slices are used only for the duration of this call;
288
+ // torch guarantees the backing tensors are live across it
289
+ // (Python holds refs on the wrapping tensors).
290
+ let inputs_dev = ManuallyDrop::new(unsafe {
291
+ dev.upgrade_device_ptr::<u8>(sdr_ptr, t * input_bits)
292
+ });
293
+ let mut cols_dev = ManuallyDrop::new(unsafe {
294
+ dev.upgrade_device_ptr::<u8>(cols_ptr, t * n_cols)
295
+ });
296
+ let mut anom_dev = ManuallyDrop::new(unsafe {
297
+ dev.upgrade_device_ptr::<f32>(anom_ptr, t)
298
+ });
299
+
300
+ self.sp_gpu.step_batch_with_tm(
301
+ &inputs_dev,
302
+ t,
303
+ input_bits,
304
+ learn,
305
+ &mut cols_dev,
306
+ &mut anom_dev,
307
+ &mut self.tm_gpu,
308
+ ).map_err(|e| format!("step_batch_with_tm: {e:?}"))?;
309
+
310
+ // Synchronize: kernel writes must be visible to the next torch
311
+ // op that reads cols/anom. Pytorch's default stream is stream 0,
312
+ // and cudarc launches on its own stream β€” a full device sync
313
+ // is the simplest correct barrier. (Could narrow to a stream
314
+ // wait event in PR 2.)
315
+ // No dev.synchronize() here: caller must explicitly sync via the
316
+ // `device_sync()` method (or PyTorch auto-syncs when the output
317
+ // tensor is next consumed). Removing the per-launch barrier lets
318
+ // subsequent GPU work (mamba3 fwd, etc.) overlap in time.
319
+ Ok(())
320
+ });
321
+
322
+ result.map_err(pyo3::exceptions::PyRuntimeError::new_err)?;
323
+ Ok(())
324
+ }
325
+
326
+ /// Clear TM state on the GPU.
327
+ fn reset(&mut self) -> PyResult<()> {
328
+ self.tm_gpu.reset().map_err(|e| {
329
+ pyo3::exceptions::PyRuntimeError::new_err(format!("GPU TM reset: {e:?}"))
330
+ })?;
331
+ self.fused_state.reset().map_err(|e| {
332
+ pyo3::exceptions::PyRuntimeError::new_err(format!("GPU fused reset: {e:?}"))
333
+ })
334
+ }
335
+
336
+ /// FUSED MEGAKERNEL PATH: single CUDA launch for the entire T-step
337
+ /// forward (SP + TM all in one). Accepts torch CUDA tensors via
338
+ /// `__cuda_array_interface__` (zero-copy). Writes active-column mask +
339
+ /// anomaly directly into caller-allocated torch tensors.
340
+ ///
341
+ /// Semantics diverge from `step_many_cuda` in one important way: column
342
+ /// activation uses per-column threshold inhibition instead of global
343
+ /// top-K. The threshold is EMA-adapted per column toward the sparsity
344
+ /// target. See `docs/GPU_HTM.md` Β§Fused Kernel.
345
+ #[pyo3(signature = (sdr_cai, cols_cai, anom_cai, learn=true))]
346
+ fn step_many_fused_cuda(
347
+ &mut self,
348
+ py: Python<'_>,
349
+ sdr_cai: &Bound<'_, PyDict>,
350
+ cols_cai: &Bound<'_, PyDict>,
351
+ anom_cai: &Bound<'_, PyDict>,
352
+ learn: bool,
353
+ ) -> PyResult<()> {
354
+ let (sdr_ptr, sdr_shape, sdr_type) = cai_parse(sdr_cai)?;
355
+ let (cols_ptr, cols_shape, cols_type) = cai_parse(cols_cai)?;
356
+ let (anom_ptr, anom_shape, anom_type) = cai_parse(anom_cai)?;
357
+
358
+ if sdr_type != "|u1" {
359
+ return Err(pyo3::exceptions::PyValueError::new_err(format!(
360
+ "sdr_cai typestr must be '|u1' (uint8), got {sdr_type}",
361
+ )));
362
+ }
363
+ if cols_type != "|u1" {
364
+ return Err(pyo3::exceptions::PyValueError::new_err(format!(
365
+ "cols_cai typestr must be '|u1' (uint8), got {cols_type}",
366
+ )));
367
+ }
368
+ if anom_type != "<f4" && anom_type != "=f4" {
369
+ return Err(pyo3::exceptions::PyValueError::new_err(format!(
370
+ "anom_cai typestr must be '<f4' (float32), got {anom_type}",
371
+ )));
372
+ }
373
+
374
+ if sdr_shape.len() != 2 || sdr_shape[1] != self.input_bits {
375
+ return Err(pyo3::exceptions::PyValueError::new_err(format!(
376
+ "sdr_cai shape {sdr_shape:?} != (T, {})",
377
+ self.input_bits,
378
+ )));
379
+ }
380
+ let t = sdr_shape[0];
381
+ if cols_shape != [t, self.n_columns] {
382
+ return Err(pyo3::exceptions::PyValueError::new_err(format!(
383
+ "cols_cai shape {cols_shape:?} != ({t}, {})",
384
+ self.n_columns,
385
+ )));
386
+ }
387
+ if anom_shape != [t] {
388
+ return Err(pyo3::exceptions::PyValueError::new_err(format!(
389
+ "anom_cai shape {anom_shape:?} != ({t},)",
390
+ )));
391
+ }
392
+
393
+ let dev = self.sp_gpu.dev_ref().clone();
394
+ let n_cols = self.n_columns;
395
+ let input_bits = self.input_bits;
396
+
397
+ let result = py.allow_threads(|| -> Result<(), String> {
398
+ let inputs_dev = ManuallyDrop::new(unsafe {
399
+ dev.upgrade_device_ptr::<u8>(sdr_ptr, t * input_bits)
400
+ });
401
+ let mut cols_dev = ManuallyDrop::new(unsafe {
402
+ dev.upgrade_device_ptr::<u8>(cols_ptr, t * n_cols)
403
+ });
404
+ let mut anom_dev = ManuallyDrop::new(unsafe {
405
+ dev.upgrade_device_ptr::<f32>(anom_ptr, t)
406
+ });
407
+
408
+ fused::launch_fused(
409
+ &mut self.sp_gpu,
410
+ &mut self.tm_gpu,
411
+ &mut self.fused_state,
412
+ &inputs_dev,
413
+ &mut cols_dev,
414
+ &mut anom_dev,
415
+ t,
416
+ input_bits,
417
+ learn,
418
+ ).map_err(|e| format!("launch_fused: {e:?}"))?;
419
+
420
+ // No dev.synchronize() here: caller must explicitly sync via the
421
+ // `device_sync()` method (or PyTorch auto-syncs when the output
422
+ // tensor is next consumed). Removing the per-launch barrier lets
423
+ // subsequent GPU work (mamba3 fwd, etc.) overlap in time.
424
+ Ok(())
425
+ });
426
+
427
+ result.map_err(pyo3::exceptions::PyRuntimeError::new_err)?;
428
+ Ok(())
429
+ }
430
+
431
+ /// Explicit device synchronization β€” the caller must invoke this after
432
+ /// all batched `step_many_*_cuda` calls complete, before reading the
433
+ /// output tensors from a different CUDA stream. Equivalent to the old
434
+ /// per-call `dev.synchronize()` that was removed for overlap.
435
+ fn device_sync(&self) -> PyResult<()> {
436
+ let dev = self.sp_gpu.dev_ref();
437
+ dev.synchronize()
438
+ .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(format!("sync: {e:?}")))?;
439
+ Ok(())
440
+ }
441
+ }
442
+
443
+ /// Batch B regions into ONE cooperative kernel launch. Breaks through the
444
+ /// CUDA cooperative-kernel device-level serialization: a single cooperative
445
+ /// launch with grid.y=B processes all regions concurrently β€” ~BΓ— speedup
446
+ /// over B sequential launches.
447
+ ///
448
+ /// All regions must have the same config (input_bits, n_columns,
449
+ /// cells_per_column). Each region keeps its independent GPU state.
450
+ /// Does NOT sync; caller must invoke `device_sync()` on any region
451
+ /// afterwards (or rely on a downstream torch op to auto-sync).
452
+ #[pyfunction]
453
+ #[pyo3(signature = (regions, sdr_cais, cols_cais, anom_cais, learn=true))]
454
+ fn step_batch_fused_cuda(
455
+ py: Python<'_>,
456
+ regions: Vec<Py<HTMRegionGpu>>,
457
+ sdr_cais: Vec<Bound<'_, PyDict>>,
458
+ cols_cais: Vec<Bound<'_, PyDict>>,
459
+ anom_cais: Vec<Bound<'_, PyDict>>,
460
+ learn: bool,
461
+ ) -> PyResult<()> {
462
+ let b = regions.len();
463
+ if b == 0 {
464
+ return Err(pyo3::exceptions::PyValueError::new_err("regions is empty"));
465
+ }
466
+ if sdr_cais.len() != b || cols_cais.len() != b || anom_cais.len() != b {
467
+ return Err(pyo3::exceptions::PyValueError::new_err(
468
+ "sdr_cais / cols_cais / anom_cais length must match regions",
469
+ ));
470
+ }
471
+
472
+ // Parse all CAI dicts; collect device pointers. Validate shapes/dtypes.
473
+ let mut sdr_ptrs = Vec::with_capacity(b);
474
+ let mut cols_ptrs = Vec::with_capacity(b);
475
+ let mut anom_ptrs = Vec::with_capacity(b);
476
+ let (input_bits, n_columns, t) = {
477
+ let r0 = regions[0].bind(py).borrow();
478
+ (r0.input_bits, r0.n_columns, {
479
+ let (_p, sh, _ty) = cai_parse(&sdr_cais[0])?;
480
+ if sh.len() != 2 {
481
+ return Err(pyo3::exceptions::PyValueError::new_err(
482
+ format!("sdr_cai must be 2-D (T, input_bits), got {sh:?}"),
483
+ ));
484
+ }
485
+ sh[0]
486
+ })
487
+ };
488
+
489
+ for i in 0..b {
490
+ let (sdr_ptr, sdr_shape, sdr_type) = cai_parse(&sdr_cais[i])?;
491
+ let (cols_ptr, cols_shape, cols_type) = cai_parse(&cols_cais[i])?;
492
+ let (anom_ptr, anom_shape, anom_type) = cai_parse(&anom_cais[i])?;
493
+ if sdr_type != "|u1" || cols_type != "|u1" {
494
+ return Err(pyo3::exceptions::PyValueError::new_err(
495
+ "sdr/cols typestr must be '|u1' (uint8)",
496
+ ));
497
+ }
498
+ if anom_type != "<f4" && anom_type != "=f4" {
499
+ return Err(pyo3::exceptions::PyValueError::new_err(
500
+ "anom typestr must be '<f4' (float32)",
501
+ ));
502
+ }
503
+ if sdr_shape != [t, input_bits] {
504
+ return Err(pyo3::exceptions::PyValueError::new_err(format!(
505
+ "sdr[{i}] shape {sdr_shape:?} != ({t}, {input_bits})"
506
+ )));
507
+ }
508
+ if cols_shape != [t, n_columns] {
509
+ return Err(pyo3::exceptions::PyValueError::new_err(format!(
510
+ "cols[{i}] shape {cols_shape:?} != ({t}, {n_columns})"
511
+ )));
512
+ }
513
+ if anom_shape != [t] {
514
+ return Err(pyo3::exceptions::PyValueError::new_err(format!(
515
+ "anom[{i}] shape {anom_shape:?} != ({t},)"
516
+ )));
517
+ }
518
+ sdr_ptrs.push(sdr_ptr);
519
+ cols_ptrs.push(cols_ptr);
520
+ anom_ptrs.push(anom_ptr);
521
+ }
522
+
523
+ // Exclusively borrow each region. PyRefMut guarantees uniqueness.
524
+ let mut region_refs: Vec<pyo3::PyRefMut<HTMRegionGpu>> =
525
+ regions.iter().map(|p| p.bind(py).borrow_mut()).collect();
526
+ // Collect raw mutable pointers β€” each PyRefMut exclusively borrows its
527
+ // region for the lifetime of this call, so pointers stay valid and
528
+ // unique. launch_fused_batched_raw only dereferences one region at a
529
+ // time, not constructing an aliased slice.
530
+ let raw_ptrs: Vec<*mut HTMRegionGpu> = region_refs
531
+ .iter_mut()
532
+ .map(|r| &mut **r as *mut HTMRegionGpu)
533
+ .collect();
534
+
535
+ // No allow_threads: raw pointers aren't Send. The launch is GPU-queued
536
+ // and sync'd downstream; holding the GIL for the duration is cheap.
537
+ fused::launch_fused_batched_raw(
538
+ &raw_ptrs, &sdr_ptrs, &cols_ptrs, &anom_ptrs,
539
+ t, input_bits, learn,
540
+ )
541
+ .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(format!("launch_fused_batched: {e:?}")))?;
542
+ Ok(())
543
+ }
544
+
545
+ pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> {
546
+ m.add_class::<HTMRegionGpu>()?;
547
+ m.add_function(pyo3::wrap_pyfunction!(step_batch_fused_cuda, m)?)?;
548
+ Ok(())
549
+ }
overlay/htm_rust/src/gpu/sp_gpu.rs CHANGED
@@ -1,796 +1,796 @@
1
- //! GPU implementation of the Spatial Pooler.
2
- //!
3
- //! One `SpatialPoolerGpu` owns a set of persistent device buffers + 4 PTX
4
- //! kernels. `compute(input, learn)` performs one SP step and returns the
5
- //! sorted active-column indices (host `Vec<u32>`) β€” this is what the CPU
6
- //! TemporalMemory consumes.
7
- //!
8
- //! Persistent state on device (per region):
9
- //! syn_bit : u32 [n_columns Γ— S] (constant after init)
10
- //! syn_perm : f32 [n_columns Γ— S] (updated by sp_learn)
11
- //! boost : f32 [n_columns]
12
- //! active_duty : f32 [n_columns]
13
- //! overlap_duty: f32 [n_columns]
14
- //!
15
- //! Per-step transient state:
16
- //! inp_dev : u8 [input_bits] (H2D copy each step)
17
- //! raw : u32 [n_columns]
18
- //! boosted : f32 [n_columns]
19
- //! active_mask : u8 [n_columns] (topk output, D2H at the end)
20
-
21
- use std::sync::Arc;
22
-
23
- use cudarc::driver::{CudaDevice, CudaSlice, DeviceSlice, DriverError, LaunchAsync, LaunchConfig};
24
- use cudarc::nvrtc::Ptx;
25
-
26
- use crate::sp::SpatialPooler;
27
-
28
- // Embed PTX at compile time. OUT_DIR is set by build.rs.
29
- const PTX_SP_OVERLAP: &str =
30
- include_str!(concat!(env!("HTM_GPU_PTX_DIR"), "/sp_overlap.ptx"));
31
- const PTX_SP_TOPK: &str =
32
- include_str!(concat!(env!("HTM_GPU_PTX_DIR"), "/sp_topk.ptx"));
33
- const PTX_SP_LEARN: &str =
34
- include_str!(concat!(env!("HTM_GPU_PTX_DIR"), "/sp_learn.ptx"));
35
- const PTX_SP_DUTY: &str =
36
- include_str!(concat!(env!("HTM_GPU_PTX_DIR"), "/sp_duty.ptx"));
37
- const PTX_SP_BOOST_FUSED: &str =
38
- include_str!(concat!(env!("HTM_GPU_PTX_DIR"), "/sp_boost_fused.ptx"));
39
-
40
- pub struct SpatialPoolerGpu {
41
- dev: Arc<CudaDevice>,
42
-
43
- // Config mirror (we don't touch CPU SpatialPooler after init).
44
- input_bits: usize,
45
- n_columns: usize,
46
- synapses_per_col: usize,
47
- conn_thr: f32,
48
- inc: f32,
49
- dec: f32,
50
- sparsity: f32,
51
- duty_period: f32,
52
- boost_strength: f32,
53
-
54
- // Persistent device state.
55
- syn_bit: CudaSlice<u32>,
56
- syn_perm: CudaSlice<f32>,
57
- boost: CudaSlice<f32>,
58
- active_duty: CudaSlice<f32>,
59
- overlap_duty: CudaSlice<f32>,
60
-
61
- // Transient scratch (reused each step).
62
- inp_dev: CudaSlice<u8>,
63
- raw: CudaSlice<u32>,
64
- boosted: CudaSlice<f32>,
65
- active_mask: CudaSlice<u8>,
66
-
67
- // Reusable host buffer for D2H of active_mask.
68
- host_mask: Vec<u8>,
69
-
70
- /// Strict bit-parity with CPU reference. Enabled for tests.
71
- /// Forces host-side boost/exp computation and the overlap-duty bump check
72
- /// every step. Default false for max throughput.
73
- strict_parity: bool,
74
- }
75
-
76
- impl SpatialPoolerGpu {
77
- /// Copy CPU SpatialPooler state onto the device. This preserves the
78
- /// exact seeded proximal synapse layout + initial permanences, so the
79
- /// GPU SP is a bit-identical parallel implementation of the CPU SP.
80
- pub fn from_cpu(cpu: &SpatialPooler) -> Result<Self, DriverError> {
81
- let dev = CudaDevice::new(0)?;
82
- let cfg = &cpu.cfg;
83
- let n = cfg.n_columns;
84
- let s = cfg.potential_synapses;
85
-
86
- // Flatten proximal dendrites into column-major arrays.
87
- let mut syn_bit_h: Vec<u32> = Vec::with_capacity(n * s);
88
- let mut syn_perm_h: Vec<f32> = Vec::with_capacity(n * s);
89
- for col in &cpu.columns {
90
- debug_assert_eq!(col.inputs.len(), s);
91
- debug_assert_eq!(col.perms.len(), s);
92
- syn_bit_h.extend_from_slice(&col.inputs);
93
- syn_perm_h.extend_from_slice(&col.perms);
94
- }
95
-
96
- let syn_bit = dev.htod_sync_copy(&syn_bit_h)?;
97
- let syn_perm = dev.htod_sync_copy(&syn_perm_h)?;
98
- let boost = dev.htod_sync_copy(&cpu.boost)?;
99
- let active_duty = dev.htod_sync_copy(&cpu.active_duty_cycle)?;
100
- let overlap_duty = dev.htod_sync_copy(&cpu.overlap_duty_cycle)?;
101
-
102
- let inp_dev: CudaSlice<u8> = dev.alloc_zeros(cfg.input_bits)?;
103
- let raw: CudaSlice<u32> = dev.alloc_zeros(n)?;
104
- let boosted: CudaSlice<f32> = dev.alloc_zeros(n)?;
105
- let active_mask: CudaSlice<u8> = dev.alloc_zeros(n)?;
106
-
107
- // Load PTX modules. Each .ptx is a module containing one `extern "C"`
108
- // function; we tag them by unique module names so multiple SP instances
109
- // don't collide (cudarc uses the (module, func) pair).
110
- // Actually: CudaDevice::load_ptx stores under the given module name
111
- // globally on the device, so we use a deterministic naming scheme.
112
- let modules = [
113
- ("htm_sp_overlap", PTX_SP_OVERLAP, "sp_overlap"),
114
- ("htm_sp_topk", PTX_SP_TOPK, "sp_topk_select"),
115
- ("htm_sp_learn", PTX_SP_LEARN, "sp_learn"),
116
- ("htm_sp_duty", PTX_SP_DUTY, "sp_duty_update"),
117
- ("htm_sp_boost_fused", PTX_SP_BOOST_FUSED, "sp_boost_from_duty"),
118
- ];
119
- for (modname, ptx, fnname) in modules {
120
- // load_ptx is NOT idempotent β€” calling twice errors. For multi-region
121
- // support we check-then-load.
122
- if dev.get_func(modname, fnname).is_none() {
123
- dev.load_ptx(Ptx::from_src(ptx), modname, &[fnname])?;
124
- }
125
- }
126
-
127
- Ok(Self {
128
- dev,
129
- input_bits: cfg.input_bits,
130
- n_columns: n,
131
- synapses_per_col: s,
132
- conn_thr: cfg.connected_threshold,
133
- inc: cfg.syn_perm_active_inc,
134
- dec: cfg.syn_perm_inactive_dec,
135
- sparsity: cfg.sparsity,
136
- duty_period: cfg.duty_cycle_period,
137
- boost_strength: cfg.boost_strength,
138
- syn_bit,
139
- syn_perm,
140
- boost,
141
- active_duty,
142
- overlap_duty,
143
- inp_dev,
144
- raw,
145
- boosted,
146
- active_mask,
147
- host_mask: vec![0u8; n],
148
- strict_parity: false,
149
- })
150
- }
151
-
152
- /// Enable strict bit-parity mode. Parity tests use this.
153
- pub fn set_strict_parity(&mut self, strict: bool) {
154
- self.strict_parity = strict;
155
- }
156
-
157
- /// Access to the underlying CudaDevice for host-side orchestration.
158
- pub fn dev_ref(&self) -> &Arc<CudaDevice> {
159
- &self.dev
160
- }
161
-
162
- // --- Fused-path accessors (immutable state reads + pointer-grabs). ---
163
- pub fn n_columns_accessor(&self) -> usize { self.n_columns }
164
- #[allow(dead_code)]
165
- pub fn input_bits_accessor(&self) -> usize { self.input_bits }
166
- pub fn synapses_per_col_accessor(&self) -> usize { self.synapses_per_col }
167
- pub fn conn_thr_accessor(&self) -> f32 { self.conn_thr }
168
- pub fn inc_accessor(&self) -> f32 { self.inc }
169
- pub fn dec_accessor(&self) -> f32 { self.dec }
170
- pub fn sparsity_accessor(&self) -> f32 { self.sparsity }
171
- pub fn duty_period_accessor(&self) -> f32 { self.duty_period }
172
- #[allow(dead_code)]
173
- pub fn boost_strength_accessor(&self) -> f32 { self.boost_strength }
174
-
175
- pub fn syn_bit_accessor(&self) -> &CudaSlice<u32> { &self.syn_bit }
176
- pub fn syn_perm_accessor(&self) -> &CudaSlice<f32> { &self.syn_perm }
177
- pub fn boost_accessor(&self) -> &CudaSlice<f32> { &self.boost }
178
- pub fn active_duty_accessor(&self) -> &CudaSlice<f32> { &self.active_duty }
179
-
180
- /// Compute the 95th-percentile-like initial threshold from raw overlaps
181
- /// after a short warmup pass. Used to seed `inhibition_threshold` such
182
- /// that activation rate starts near the sparsity target.
183
- /// Placeholder (returns a conservative constant); real warmup pass
184
- /// happens on the Rust orchestrator side.
185
- pub fn initial_threshold_estimate(&self) -> f32 {
186
- // With conn_thr=0.5, init_perm around 0.5Β±0.1, S=40, sparse SDR at 2%:
187
- // expected overlap ~ 40 * 0.02 = 0.8 connected hits β†’ boosted ~ 0.8.
188
- // Top-K selects top 2%, so threshold for top 2% is roughly the
189
- // 98th-percentile of boosted. Conservative start: 2.0.
190
- // The per-column adaptation will quickly steer each column's thr.
191
- 2.0f32
192
- }
193
-
194
- /// Batched multi-step SP on the GPU. Processes T timesteps from a
195
- /// pre-uploaded device input buffer. Emits `(T, n_cols)` u8 active-column
196
- /// mask to `cols_dev_out` and `(T,)` active column index list (in a
197
- /// per-step window of size k, padded with u32::MAX).
198
- ///
199
- /// For each step, this runs the same 5-kernel pipeline as `compute`, but
200
- /// skips the per-step boost/duty D2H→exp→H2D round-trip: instead it
201
- /// accumulates to a host scratch once every `boost_interval` steps.
202
- ///
203
- /// This is the fast path used by `HTMRegionGpu.step_many_gpu`.
204
- #[allow(clippy::too_many_arguments)]
205
- pub fn step_batch(
206
- &mut self,
207
- inputs_flat_dev: &CudaSlice<u8>,
208
- t: usize,
209
- input_bits: usize,
210
- learn: bool,
211
- cols_out: &mut [u8],
212
- active_indices_host: &mut Vec<u32>,
213
- ) -> Result<(), DriverError> {
214
- let n = self.n_columns;
215
- let k = ((self.sparsity * n as f32).round() as usize).max(1);
216
- debug_assert_eq!(cols_out.len(), t * n);
217
-
218
- let overlap_fn = self.dev.get_func("htm_sp_overlap", "sp_overlap").unwrap();
219
- let topk_fn = self.dev.get_func("htm_sp_topk", "sp_topk_select").unwrap();
220
- let learn_fn = self.dev.get_func("htm_sp_learn", "sp_learn").unwrap();
221
- let duty_fn = self.dev.get_func("htm_sp_duty", "sp_duty_update").unwrap();
222
-
223
- let overlap_cfg = LaunchConfig {
224
- grid_dim: (n as u32, 1, 1),
225
- block_dim: (128, 1, 1),
226
- shared_mem_bytes: 0,
227
- };
228
- let topk_cfg = LaunchConfig {
229
- grid_dim: (1, 1, 1),
230
- block_dim: (256, 1, 1),
231
- shared_mem_bytes: (n * std::mem::size_of::<f32>()) as u32,
232
- };
233
- let learn_cfg = overlap_cfg;
234
- let duty_cfg = LaunchConfig {
235
- grid_dim: ((n as u32 + 255) / 256, 1, 1),
236
- block_dim: (256, 1, 1),
237
- shared_mem_bytes: 0,
238
- };
239
- let alpha = 1.0f32 / self.duty_period.max(1.0);
240
-
241
- // Reusable host buffer for the per-step active_mask D2H.
242
- self.host_mask.resize(n, 0);
243
-
244
- active_indices_host.clear();
245
-
246
- for ti in 0..t {
247
- // Point overlap kernel at the ti-th slice of the pre-uploaded input.
248
- // cudarc CudaSlice doesn't have a "view" per se, so we must copy the
249
- // slice into the reusable inp_dev buffer. This is a D2D copy β€” much
250
- // faster than H2D.
251
- // (Alternative: rewrite kernel to accept an offset; deferred.)
252
- let in_off = ti * input_bits;
253
- // Use dtod_copy via raw slice indexing: cudarc exposes slice() for this.
254
- let sub = inputs_flat_dev.slice(in_off..in_off + input_bits);
255
- self.dev.dtod_copy(&sub, &mut self.inp_dev)?;
256
-
257
- // 1. sp_overlap
258
- unsafe {
259
- overlap_fn.clone().launch(
260
- overlap_cfg,
261
- (
262
- &self.inp_dev,
263
- &self.syn_bit,
264
- &self.syn_perm,
265
- &self.boost,
266
- self.conn_thr,
267
- self.synapses_per_col as u32,
268
- n as u32,
269
- &mut self.raw,
270
- &mut self.boosted,
271
- ),
272
- )?;
273
- }
274
-
275
- // 2. Clear active_mask, then sp_topk
276
- self.dev.memset_zeros(&mut self.active_mask)?;
277
- unsafe {
278
- topk_fn.clone().launch(
279
- topk_cfg,
280
- (&self.boosted, n as u32, k as u32, &mut self.active_mask),
281
- )?;
282
- }
283
-
284
- // 3. sp_learn
285
- if learn {
286
- unsafe {
287
- learn_fn.clone().launch(
288
- learn_cfg,
289
- (
290
- &self.active_mask,
291
- &self.inp_dev,
292
- &self.syn_bit,
293
- &mut self.syn_perm,
294
- self.inc,
295
- self.dec,
296
- self.synapses_per_col as u32,
297
- n as u32,
298
- ),
299
- )?;
300
- }
301
- }
302
-
303
- // 4. duty update (device)
304
- unsafe {
305
- duty_fn.clone().launch(
306
- duty_cfg,
307
- (
308
- &self.active_mask,
309
- &self.raw,
310
- &mut self.active_duty,
311
- &mut self.overlap_duty,
312
- &mut self.boost,
313
- alpha,
314
- 1.0f32,
315
- 0.0f32,
316
- 0.0f32,
317
- 0u32,
318
- n as u32,
319
- ),
320
- )?;
321
- }
322
-
323
- // 5. Boost update. Two modes:
324
- // * strict_parity (tests): host-side exp for bit-exact match.
325
- // * default (production): GPU expf is close enough and ~10x faster
326
- // since we skip the D2H/H2D round-trip.
327
- if learn && self.boost_strength > 0.0 {
328
- if self.strict_parity {
329
- let mut duty_host = vec![0f32; n];
330
- self.dev
331
- .dtoh_sync_copy_into(&self.active_duty, &mut duty_host)?;
332
- let sum: f32 = duty_host.iter().sum();
333
- let mean = sum / (n as f32);
334
- let mut boost_host = vec![0f32; n];
335
- for i in 0..n {
336
- boost_host[i] =
337
- (-self.boost_strength * (duty_host[i] - mean)).exp();
338
- }
339
- self.dev.htod_sync_copy_into(&boost_host, &mut self.boost)?;
340
-
341
- // Permanence bump (rare). Only evaluated in strict mode.
342
- let mut ov_host = vec![0f32; n];
343
- self.dev
344
- .dtoh_sync_copy_into(&self.overlap_duty, &mut ov_host)?;
345
- let max_ov = ov_host.iter().cloned().fold(0f32, f32::max);
346
- if max_ov > 0.0 {
347
- let thr = 0.001f32 * max_ov;
348
- let bump = self.inc * 0.1f32;
349
- let bump_cols: Vec<u32> = ov_host
350
- .iter()
351
- .enumerate()
352
- .filter_map(|(i, &o)| {
353
- if o < thr { Some(i as u32) } else { None }
354
- })
355
- .collect();
356
- if !bump_cols.is_empty() {
357
- let s = self.synapses_per_col;
358
- let mut perm_host = vec![0f32; n * s];
359
- self.dev
360
- .dtoh_sync_copy_into(&self.syn_perm, &mut perm_host)?;
361
- for &c in &bump_cols {
362
- let base = (c as usize) * s;
363
- for p in &mut perm_host[base..base + s] {
364
- *p = (*p + bump).min(1.0);
365
- }
366
- }
367
- self.dev.htod_sync_copy_into(&perm_host, &mut self.syn_perm)?;
368
- }
369
- }
370
- } else {
371
- // Fast path: fused mean + boost = expf(-strength*(ad-mean))
372
- // in a single GPU block. Zero D2H, zero H2D β€” fully async.
373
- let boost_fn = self
374
- .dev
375
- .get_func("htm_sp_boost_fused", "sp_boost_from_duty")
376
- .expect("sp_boost_fused not loaded");
377
- let boost_cfg = LaunchConfig {
378
- grid_dim: (1, 1, 1),
379
- block_dim: (1024, 1, 1),
380
- shared_mem_bytes: 32 * std::mem::size_of::<f32>() as u32,
381
- };
382
- unsafe {
383
- boost_fn.launch(
384
- boost_cfg,
385
- (
386
- &self.active_duty,
387
- &mut self.boost,
388
- self.boost_strength,
389
- n as u32,
390
- ),
391
- )?;
392
- }
393
- }
394
- }
395
-
396
- // D2H the active_mask for this step. This is the single
397
- // unavoidable sync point per step β€” CPU TM needs the active
398
- // indices for its next state update. At 2048 bytes / step this
399
- // is tiny in bandwidth but costs a full syncronize (~5-10ΞΌs).
400
- self.dev
401
- .dtoh_sync_copy_into(&self.active_mask, &mut self.host_mask)?;
402
- let co = ti * n;
403
- cols_out[co..co + n].copy_from_slice(&self.host_mask);
404
- // Extract active indices.
405
- for (i, &b) in self.host_mask.iter().enumerate() {
406
- if b != 0 {
407
- active_indices_host.push(i as u32);
408
- }
409
- }
410
- // Insert separator (u32::MAX) between steps to demarcate step boundaries.
411
- active_indices_host.push(u32::MAX);
412
- }
413
-
414
- Ok(())
415
- }
416
-
417
- /// Fully-on-GPU batched SP + TM. Zero per-step host sync.
418
- ///
419
- /// Inputs:
420
- /// inputs_flat_dev : (T * input_bits) u8 already uploaded
421
- /// cols_dev : (T * n_cols) u8 output β€” active-column mask per step
422
- /// anom_dev : (T,) f32 output β€” anomaly score per step
423
- /// tm : persistent GPU TemporalMemory for this region
424
- #[allow(clippy::too_many_arguments)]
425
- pub fn step_batch_with_tm(
426
- &mut self,
427
- inputs_flat_dev: &CudaSlice<u8>,
428
- t: usize,
429
- input_bits: usize,
430
- learn: bool,
431
- cols_dev: &mut CudaSlice<u8>,
432
- anom_dev: &mut CudaSlice<f32>,
433
- tm: &mut crate::gpu::tm_gpu::TemporalMemoryGpu,
434
- ) -> Result<(), DriverError> {
435
- let n = self.n_columns;
436
- let k = ((self.sparsity * n as f32).round() as usize).max(1);
437
- debug_assert_eq!(cols_dev.len(), t * n);
438
- debug_assert_eq!(anom_dev.len(), t);
439
-
440
- let overlap_fn = self.dev.get_func("htm_sp_overlap", "sp_overlap").unwrap();
441
- let topk_fn = self.dev.get_func("htm_sp_topk", "sp_topk_select").unwrap();
442
- let learn_fn = self.dev.get_func("htm_sp_learn", "sp_learn").unwrap();
443
- let duty_fn = self.dev.get_func("htm_sp_duty", "sp_duty_update").unwrap();
444
-
445
- let overlap_cfg = LaunchConfig {
446
- grid_dim: (n as u32, 1, 1),
447
- block_dim: (128, 1, 1),
448
- shared_mem_bytes: 0,
449
- };
450
- let topk_cfg = LaunchConfig {
451
- grid_dim: (1, 1, 1),
452
- block_dim: (256, 1, 1),
453
- shared_mem_bytes: (n * std::mem::size_of::<f32>()) as u32,
454
- };
455
- let learn_cfg = overlap_cfg;
456
- let duty_cfg = LaunchConfig {
457
- grid_dim: ((n as u32 + 255) / 256, 1, 1),
458
- block_dim: (256, 1, 1),
459
- shared_mem_bytes: 0,
460
- };
461
- let alpha = 1.0f32 / self.duty_period.max(1.0);
462
-
463
- for ti in 0..t {
464
- let in_off = ti * input_bits;
465
- let sub = inputs_flat_dev.slice(in_off..in_off + input_bits);
466
- self.dev.dtod_copy(&sub, &mut self.inp_dev)?;
467
-
468
- // 1. sp_overlap
469
- unsafe {
470
- overlap_fn.clone().launch(
471
- overlap_cfg,
472
- (
473
- &self.inp_dev,
474
- &self.syn_bit,
475
- &self.syn_perm,
476
- &self.boost,
477
- self.conn_thr,
478
- self.synapses_per_col as u32,
479
- n as u32,
480
- &mut self.raw,
481
- &mut self.boosted,
482
- ),
483
- )?;
484
- }
485
-
486
- // 2. clear + sp_topk
487
- self.dev.memset_zeros(&mut self.active_mask)?;
488
- unsafe {
489
- topk_fn.clone().launch(
490
- topk_cfg,
491
- (&self.boosted, n as u32, k as u32, &mut self.active_mask),
492
- )?;
493
- }
494
-
495
- // 3. sp_learn
496
- if learn {
497
- unsafe {
498
- learn_fn.clone().launch(
499
- learn_cfg,
500
- (
501
- &self.active_mask,
502
- &self.inp_dev,
503
- &self.syn_bit,
504
- &mut self.syn_perm,
505
- self.inc,
506
- self.dec,
507
- self.synapses_per_col as u32,
508
- n as u32,
509
- ),
510
- )?;
511
- }
512
- }
513
-
514
- // 4. duty update (stage 1: no-boost write)
515
- unsafe {
516
- duty_fn.clone().launch(
517
- duty_cfg,
518
- (
519
- &self.active_mask,
520
- &self.raw,
521
- &mut self.active_duty,
522
- &mut self.overlap_duty,
523
- &mut self.boost,
524
- alpha,
525
- 1.0f32,
526
- 0.0f32,
527
- 0.0f32,
528
- 0u32,
529
- n as u32,
530
- ),
531
- )?;
532
- }
533
-
534
- // 5. Boost update: fused GPU kernel (no D2H).
535
- if learn && self.boost_strength > 0.0 {
536
- let boost_fn = self.dev
537
- .get_func("htm_sp_boost_fused", "sp_boost_from_duty")
538
- .expect("sp_boost_fused not loaded");
539
- let boost_cfg = LaunchConfig {
540
- grid_dim: (1, 1, 1),
541
- block_dim: (1024, 1, 1),
542
- shared_mem_bytes: 32 * std::mem::size_of::<f32>() as u32,
543
- };
544
- unsafe {
545
- boost_fn.launch(
546
- boost_cfg,
547
- (
548
- &self.active_duty,
549
- &mut self.boost,
550
- self.boost_strength,
551
- n as u32,
552
- ),
553
- )?;
554
- }
555
- }
556
-
557
- // 6. Copy active_mask slice into cols_dev[ti*n .. (ti+1)*n].
558
- let mut dst_slice = cols_dev.slice_mut(ti * n..(ti + 1) * n);
559
- self.dev.dtod_copy(&self.active_mask, &mut dst_slice)?;
560
-
561
- // 7. GPU TM step: predict + activate + anomaly + learn, all on device.
562
- tm.step(&self.active_mask, anom_dev, ti as u32, learn)?;
563
- }
564
-
565
- Ok(())
566
- }
567
-
568
- /// One SP step on the GPU. Returns sorted active-column indices.
569
- pub fn compute(&mut self, input: &[u8], learn: bool) -> Result<Vec<u32>, DriverError> {
570
- debug_assert_eq!(input.len(), self.input_bits);
571
- let n = self.n_columns;
572
- let k = ((self.sparsity * n as f32).round() as usize).max(1);
573
-
574
- // 1. H2D input SDR.
575
- self.dev.htod_sync_copy_into(input, &mut self.inp_dev)?;
576
-
577
- // 2. Launch sp_overlap: grid=n_columns, block=128.
578
- let overlap_fn = self
579
- .dev
580
- .get_func("htm_sp_overlap", "sp_overlap")
581
- .expect("sp_overlap not loaded");
582
- let overlap_cfg = LaunchConfig {
583
- grid_dim: (n as u32, 1, 1),
584
- block_dim: (128, 1, 1),
585
- shared_mem_bytes: 0,
586
- };
587
- unsafe {
588
- overlap_fn.launch(
589
- overlap_cfg,
590
- (
591
- &self.inp_dev,
592
- &self.syn_bit,
593
- &self.syn_perm,
594
- &self.boost,
595
- self.conn_thr,
596
- self.synapses_per_col as u32,
597
- n as u32,
598
- &mut self.raw,
599
- &mut self.boosted,
600
- ),
601
- )?;
602
- }
603
-
604
- // 3. Launch sp_topk: single block, shared mem = n_columns * f32.
605
- let topk_fn = self
606
- .dev
607
- .get_func("htm_sp_topk", "sp_topk_select")
608
- .expect("sp_topk not loaded");
609
- let topk_cfg = LaunchConfig {
610
- grid_dim: (1, 1, 1),
611
- block_dim: (256, 1, 1),
612
- shared_mem_bytes: (n * std::mem::size_of::<f32>()) as u32,
613
- };
614
- // Clear active_mask first. memset_zeros avoids an H2D of a host
615
- // zeroes vector every step.
616
- self.dev.memset_zeros(&mut self.active_mask)?;
617
- unsafe {
618
- topk_fn.launch(
619
- topk_cfg,
620
- (
621
- &self.boosted,
622
- n as u32,
623
- k as u32,
624
- &mut self.active_mask,
625
- ),
626
- )?;
627
- }
628
-
629
- // 4. Optional: sp_learn on active columns.
630
- if learn {
631
- let learn_fn = self
632
- .dev
633
- .get_func("htm_sp_learn", "sp_learn")
634
- .expect("sp_learn not loaded");
635
- let learn_cfg = LaunchConfig {
636
- grid_dim: (n as u32, 1, 1),
637
- block_dim: (128, 1, 1),
638
- shared_mem_bytes: 0,
639
- };
640
- unsafe {
641
- learn_fn.launch(
642
- learn_cfg,
643
- (
644
- &self.active_mask,
645
- &self.inp_dev,
646
- &self.syn_bit,
647
- &mut self.syn_perm,
648
- self.inc,
649
- self.dec,
650
- self.synapses_per_col as u32,
651
- n as u32,
652
- ),
653
- )?;
654
- }
655
- }
656
-
657
- // 5. Duty cycle + boost update. Always runs (matches CPU).
658
- // We need mean_duty on the host β€” compute BEFORE the update (matches
659
- // CPU sp.rs line 200-205 where mean is computed then written).
660
- // Actually CPU computes mean of the PRE-update duty cycles too? Re-read:
661
- // sp.rs lines 186-196 update duty cycles (pre-mean).
662
- // Line 202: mean = sum(active_duty_cycle) / n ← after update.
663
- // Line 204: boost[i] = exp(-strength*(active_duty[i] - mean)).
664
- // So mean is on POST-update values.
665
- // Easiest: 1) run duty update with boost_strength=0 (skip boost calc),
666
- // 2) D2H active_duty, compute mean, 3) run a boost-only kernel
667
- // OR inline the exp() in a second launch with mean passed.
668
- //
669
- // For simplicity and correctness we fuse: run the duty kernel with
670
- // mean=0 and boost_strength=0 (disables boost write), then D2H to
671
- // compute mean, then re-launch with the true mean. Two launches, one
672
- // tiny D2H (n Γ— f32). At n=2048 this is 8KB per step β€” negligible.
673
- let alpha = 1.0f32 / self.duty_period.max(1.0);
674
- let duty_fn = self
675
- .dev
676
- .get_func("htm_sp_duty", "sp_duty_update")
677
- .expect("sp_duty not loaded");
678
- let duty_cfg = LaunchConfig {
679
- grid_dim: ((n as u32 + 255) / 256, 1, 1),
680
- block_dim: (256, 1, 1),
681
- shared_mem_bytes: 0,
682
- };
683
- // Stage 1: update duty cycles (boost_strength=0 -> no write).
684
- unsafe {
685
- duty_fn.launch(
686
- duty_cfg,
687
- (
688
- &self.active_mask,
689
- &self.raw,
690
- &mut self.active_duty,
691
- &mut self.overlap_duty,
692
- &mut self.boost,
693
- alpha,
694
- 1.0f32, // stim_thr
695
- 0.0f32, // boost_strength = 0 -> skip write
696
- 0.0f32, // mean_duty (unused)
697
- 0u32, // learn_flag = 0
698
- n as u32,
699
- ),
700
- )?;
701
- }
702
-
703
- if learn && self.boost_strength > 0.0 && self.strict_parity {
704
- // Boost update must bit-match CPU `f32::exp`, so we compute it on
705
- // the host and copy back. Cost per step: 8KB D2H + 8KB H2D at n=2048.
706
- // Critical for learning parity β€” CUDA expf (even without fast-math)
707
- // uses different rounding for some inputs than host libm.
708
- let mut duty_host = vec![0f32; n];
709
- self.dev
710
- .dtoh_sync_copy_into(&self.active_duty, &mut duty_host)?;
711
- let sum: f32 = duty_host.iter().sum();
712
- let mean = sum / (n as f32);
713
- let mut boost_host = vec![0f32; n];
714
- for i in 0..n {
715
- boost_host[i] = (-self.boost_strength * (duty_host[i] - mean)).exp();
716
- }
717
- self.dev.htod_sync_copy_into(&boost_host, &mut self.boost)?;
718
-
719
- // CPU sp.rs 210-226: permanence bump for chronically under-stimulated
720
- // columns. If overlap_duty_cycle[i] < 0.001 * max(overlap_duty_cycle),
721
- // add inc*0.1 to every synapse of column i (clamped to 1.0).
722
- // This runs only once per step and only for the rare cases, but we
723
- // need it for bit-exact parity with CPU learn.
724
- let mut ov_host = vec![0f32; n];
725
- self.dev
726
- .dtoh_sync_copy_into(&self.overlap_duty, &mut ov_host)?;
727
- let max_ov = ov_host.iter().cloned().fold(0f32, f32::max);
728
- if max_ov > 0.0 {
729
- let thr = 0.001f32 * max_ov;
730
- let bump = self.inc * 0.1f32;
731
- // Find columns needing a bump. Usually empty. Rare β†’ D2H/H2D
732
- // of syn_perm is cheap (n*S*4 = 320KB at n=2048,S=40).
733
- let bump_cols: Vec<u32> = ov_host
734
- .iter()
735
- .enumerate()
736
- .filter_map(|(i, &o)| if o < thr { Some(i as u32) } else { None })
737
- .collect();
738
- if !bump_cols.is_empty() {
739
- // Download, bump, upload. (Keeps implementation simple and
740
- // bit-exact. Could kernelize later.)
741
- let s = self.synapses_per_col;
742
- let mut perm_host = vec![0f32; n * s];
743
- self.dev.dtoh_sync_copy_into(&self.syn_perm, &mut perm_host)?;
744
- for &c in &bump_cols {
745
- let base = (c as usize) * s;
746
- for p in &mut perm_host[base..base + s] {
747
- *p = (*p + bump).min(1.0);
748
- }
749
- }
750
- self.dev.htod_sync_copy_into(&perm_host, &mut self.syn_perm)?;
751
- }
752
- }
753
- } else if learn && self.boost_strength > 0.0 {
754
- // Fast path: GPU-side boost using the already-loaded duty kernel.
755
- let mut duty_host = vec![0f32; n];
756
- self.dev
757
- .dtoh_sync_copy_into(&self.active_duty, &mut duty_host)?;
758
- let sum: f32 = duty_host.iter().sum();
759
- let mean = sum / (n as f32);
760
- let boost_fn = self
761
- .dev
762
- .get_func("htm_sp_duty", "sp_duty_update")
763
- .expect("sp_duty not loaded");
764
- unsafe {
765
- boost_fn.launch(
766
- duty_cfg,
767
- (
768
- &self.active_mask,
769
- &self.raw,
770
- &mut self.active_duty,
771
- &mut self.overlap_duty,
772
- &mut self.boost,
773
- 0.0f32,
774
- 1.0f32,
775
- self.boost_strength,
776
- mean,
777
- 1u32,
778
- n as u32,
779
- ),
780
- )?;
781
- }
782
- }
783
-
784
- // 6. D2H active_mask and convert to sorted index list.
785
- self.dev
786
- .dtoh_sync_copy_into(&self.active_mask, &mut self.host_mask)?;
787
- let mut active: Vec<u32> = Vec::with_capacity(k);
788
- for (i, &b) in self.host_mask.iter().enumerate() {
789
- if b != 0 {
790
- active.push(i as u32);
791
- }
792
- }
793
- debug_assert_eq!(active.len(), k, "SP must emit exactly k winners");
794
- Ok(active)
795
- }
796
- }
 
1
+ //! GPU implementation of the Spatial Pooler.
2
+ //!
3
+ //! One `SpatialPoolerGpu` owns a set of persistent device buffers + 4 PTX
4
+ //! kernels. `compute(input, learn)` performs one SP step and returns the
5
+ //! sorted active-column indices (host `Vec<u32>`) β€” this is what the CPU
6
+ //! TemporalMemory consumes.
7
+ //!
8
+ //! Persistent state on device (per region):
9
+ //! syn_bit : u32 [n_columns Γ— S] (constant after init)
10
+ //! syn_perm : f32 [n_columns Γ— S] (updated by sp_learn)
11
+ //! boost : f32 [n_columns]
12
+ //! active_duty : f32 [n_columns]
13
+ //! overlap_duty: f32 [n_columns]
14
+ //!
15
+ //! Per-step transient state:
16
+ //! inp_dev : u8 [input_bits] (H2D copy each step)
17
+ //! raw : u32 [n_columns]
18
+ //! boosted : f32 [n_columns]
19
+ //! active_mask : u8 [n_columns] (topk output, D2H at the end)
20
+
21
+ use std::sync::Arc;
22
+
23
+ use cudarc::driver::{CudaDevice, CudaSlice, DeviceSlice, DriverError, LaunchAsync, LaunchConfig};
24
+ use cudarc::nvrtc::Ptx;
25
+
26
+ use crate::sp::SpatialPooler;
27
+
28
+ // Embed PTX at compile time. OUT_DIR is set by build.rs.
29
+ const PTX_SP_OVERLAP: &str =
30
+ include_str!(concat!(env!("HTM_GPU_PTX_DIR"), "/sp_overlap.ptx"));
31
+ const PTX_SP_TOPK: &str =
32
+ include_str!(concat!(env!("HTM_GPU_PTX_DIR"), "/sp_topk.ptx"));
33
+ const PTX_SP_LEARN: &str =
34
+ include_str!(concat!(env!("HTM_GPU_PTX_DIR"), "/sp_learn.ptx"));
35
+ const PTX_SP_DUTY: &str =
36
+ include_str!(concat!(env!("HTM_GPU_PTX_DIR"), "/sp_duty.ptx"));
37
+ const PTX_SP_BOOST_FUSED: &str =
38
+ include_str!(concat!(env!("HTM_GPU_PTX_DIR"), "/sp_boost_fused.ptx"));
39
+
40
+ pub struct SpatialPoolerGpu {
41
+ dev: Arc<CudaDevice>,
42
+
43
+ // Config mirror (we don't touch CPU SpatialPooler after init).
44
+ input_bits: usize,
45
+ n_columns: usize,
46
+ synapses_per_col: usize,
47
+ conn_thr: f32,
48
+ inc: f32,
49
+ dec: f32,
50
+ sparsity: f32,
51
+ duty_period: f32,
52
+ boost_strength: f32,
53
+
54
+ // Persistent device state.
55
+ syn_bit: CudaSlice<u32>,
56
+ syn_perm: CudaSlice<f32>,
57
+ boost: CudaSlice<f32>,
58
+ active_duty: CudaSlice<f32>,
59
+ overlap_duty: CudaSlice<f32>,
60
+
61
+ // Transient scratch (reused each step).
62
+ inp_dev: CudaSlice<u8>,
63
+ raw: CudaSlice<u32>,
64
+ boosted: CudaSlice<f32>,
65
+ active_mask: CudaSlice<u8>,
66
+
67
+ // Reusable host buffer for D2H of active_mask.
68
+ host_mask: Vec<u8>,
69
+
70
+ /// Strict bit-parity with CPU reference. Enabled for tests.
71
+ /// Forces host-side boost/exp computation and the overlap-duty bump check
72
+ /// every step. Default false for max throughput.
73
+ strict_parity: bool,
74
+ }
75
+
76
+ impl SpatialPoolerGpu {
77
+ /// Copy CPU SpatialPooler state onto the device. This preserves the
78
+ /// exact seeded proximal synapse layout + initial permanences, so the
79
+ /// GPU SP is a bit-identical parallel implementation of the CPU SP.
80
+ pub fn from_cpu(cpu: &SpatialPooler) -> Result<Self, DriverError> {
81
+ let dev = CudaDevice::new(0)?;
82
+ let cfg = &cpu.cfg;
83
+ let n = cfg.n_columns;
84
+ let s = cfg.potential_synapses;
85
+
86
+ // Flatten proximal dendrites into column-major arrays.
87
+ let mut syn_bit_h: Vec<u32> = Vec::with_capacity(n * s);
88
+ let mut syn_perm_h: Vec<f32> = Vec::with_capacity(n * s);
89
+ for col in &cpu.columns {
90
+ debug_assert_eq!(col.inputs.len(), s);
91
+ debug_assert_eq!(col.perms.len(), s);
92
+ syn_bit_h.extend_from_slice(&col.inputs);
93
+ syn_perm_h.extend_from_slice(&col.perms);
94
+ }
95
+
96
+ let syn_bit = dev.htod_sync_copy(&syn_bit_h)?;
97
+ let syn_perm = dev.htod_sync_copy(&syn_perm_h)?;
98
+ let boost = dev.htod_sync_copy(&cpu.boost)?;
99
+ let active_duty = dev.htod_sync_copy(&cpu.active_duty_cycle)?;
100
+ let overlap_duty = dev.htod_sync_copy(&cpu.overlap_duty_cycle)?;
101
+
102
+ let inp_dev: CudaSlice<u8> = dev.alloc_zeros(cfg.input_bits)?;
103
+ let raw: CudaSlice<u32> = dev.alloc_zeros(n)?;
104
+ let boosted: CudaSlice<f32> = dev.alloc_zeros(n)?;
105
+ let active_mask: CudaSlice<u8> = dev.alloc_zeros(n)?;
106
+
107
+ // Load PTX modules. Each .ptx is a module containing one `extern "C"`
108
+ // function; we tag them by unique module names so multiple SP instances
109
+ // don't collide (cudarc uses the (module, func) pair).
110
+ // Actually: CudaDevice::load_ptx stores under the given module name
111
+ // globally on the device, so we use a deterministic naming scheme.
112
+ let modules = [
113
+ ("htm_sp_overlap", PTX_SP_OVERLAP, "sp_overlap"),
114
+ ("htm_sp_topk", PTX_SP_TOPK, "sp_topk_select"),
115
+ ("htm_sp_learn", PTX_SP_LEARN, "sp_learn"),
116
+ ("htm_sp_duty", PTX_SP_DUTY, "sp_duty_update"),
117
+ ("htm_sp_boost_fused", PTX_SP_BOOST_FUSED, "sp_boost_from_duty"),
118
+ ];
119
+ for (modname, ptx, fnname) in modules {
120
+ // load_ptx is NOT idempotent β€” calling twice errors. For multi-region
121
+ // support we check-then-load.
122
+ if dev.get_func(modname, fnname).is_none() {
123
+ dev.load_ptx(Ptx::from_src(ptx), modname, &[fnname])?;
124
+ }
125
+ }
126
+
127
+ Ok(Self {
128
+ dev,
129
+ input_bits: cfg.input_bits,
130
+ n_columns: n,
131
+ synapses_per_col: s,
132
+ conn_thr: cfg.connected_threshold,
133
+ inc: cfg.syn_perm_active_inc,
134
+ dec: cfg.syn_perm_inactive_dec,
135
+ sparsity: cfg.sparsity,
136
+ duty_period: cfg.duty_cycle_period,
137
+ boost_strength: cfg.boost_strength,
138
+ syn_bit,
139
+ syn_perm,
140
+ boost,
141
+ active_duty,
142
+ overlap_duty,
143
+ inp_dev,
144
+ raw,
145
+ boosted,
146
+ active_mask,
147
+ host_mask: vec![0u8; n],
148
+ strict_parity: false,
149
+ })
150
+ }
151
+
152
+ /// Enable strict bit-parity mode. Parity tests use this.
153
+ pub fn set_strict_parity(&mut self, strict: bool) {
154
+ self.strict_parity = strict;
155
+ }
156
+
157
+ /// Access to the underlying CudaDevice for host-side orchestration.
158
+ pub fn dev_ref(&self) -> &Arc<CudaDevice> {
159
+ &self.dev
160
+ }
161
+
162
+ // --- Fused-path accessors (immutable state reads + pointer-grabs). ---
163
+ pub fn n_columns_accessor(&self) -> usize { self.n_columns }
164
+ #[allow(dead_code)]
165
+ pub fn input_bits_accessor(&self) -> usize { self.input_bits }
166
+ pub fn synapses_per_col_accessor(&self) -> usize { self.synapses_per_col }
167
+ pub fn conn_thr_accessor(&self) -> f32 { self.conn_thr }
168
+ pub fn inc_accessor(&self) -> f32 { self.inc }
169
+ pub fn dec_accessor(&self) -> f32 { self.dec }
170
+ pub fn sparsity_accessor(&self) -> f32 { self.sparsity }
171
+ pub fn duty_period_accessor(&self) -> f32 { self.duty_period }
172
+ #[allow(dead_code)]
173
+ pub fn boost_strength_accessor(&self) -> f32 { self.boost_strength }
174
+
175
+ pub fn syn_bit_accessor(&self) -> &CudaSlice<u32> { &self.syn_bit }
176
+ pub fn syn_perm_accessor(&self) -> &CudaSlice<f32> { &self.syn_perm }
177
+ pub fn boost_accessor(&self) -> &CudaSlice<f32> { &self.boost }
178
+ pub fn active_duty_accessor(&self) -> &CudaSlice<f32> { &self.active_duty }
179
+
180
+ /// Compute the 95th-percentile-like initial threshold from raw overlaps
181
+ /// after a short warmup pass. Used to seed `inhibition_threshold` such
182
+ /// that activation rate starts near the sparsity target.
183
+ /// Placeholder (returns a conservative constant); real warmup pass
184
+ /// happens on the Rust orchestrator side.
185
+ pub fn initial_threshold_estimate(&self) -> f32 {
186
+ // With conn_thr=0.5, init_perm around 0.5Β±0.1, S=40, sparse SDR at 2%:
187
+ // expected overlap ~ 40 * 0.02 = 0.8 connected hits β†’ boosted ~ 0.8.
188
+ // Top-K selects top 2%, so threshold for top 2% is roughly the
189
+ // 98th-percentile of boosted. Conservative start: 2.0.
190
+ // The per-column adaptation will quickly steer each column's thr.
191
+ 2.0f32
192
+ }
193
+
194
+ /// Batched multi-step SP on the GPU. Processes T timesteps from a
195
+ /// pre-uploaded device input buffer. Emits `(T, n_cols)` u8 active-column
196
+ /// mask to `cols_dev_out` and `(T,)` active column index list (in a
197
+ /// per-step window of size k, padded with u32::MAX).
198
+ ///
199
+ /// For each step, this runs the same 5-kernel pipeline as `compute`, but
200
+ /// skips the per-step boost/duty D2H→exp→H2D round-trip: instead it
201
+ /// accumulates to a host scratch once every `boost_interval` steps.
202
+ ///
203
+ /// This is the fast path used by `HTMRegionGpu.step_many_gpu`.
204
+ #[allow(clippy::too_many_arguments)]
205
+ pub fn step_batch(
206
+ &mut self,
207
+ inputs_flat_dev: &CudaSlice<u8>,
208
+ t: usize,
209
+ input_bits: usize,
210
+ learn: bool,
211
+ cols_out: &mut [u8],
212
+ active_indices_host: &mut Vec<u32>,
213
+ ) -> Result<(), DriverError> {
214
+ let n = self.n_columns;
215
+ let k = ((self.sparsity * n as f32).round() as usize).max(1);
216
+ debug_assert_eq!(cols_out.len(), t * n);
217
+
218
+ let overlap_fn = self.dev.get_func("htm_sp_overlap", "sp_overlap").unwrap();
219
+ let topk_fn = self.dev.get_func("htm_sp_topk", "sp_topk_select").unwrap();
220
+ let learn_fn = self.dev.get_func("htm_sp_learn", "sp_learn").unwrap();
221
+ let duty_fn = self.dev.get_func("htm_sp_duty", "sp_duty_update").unwrap();
222
+
223
+ let overlap_cfg = LaunchConfig {
224
+ grid_dim: (n as u32, 1, 1),
225
+ block_dim: (128, 1, 1),
226
+ shared_mem_bytes: 0,
227
+ };
228
+ let topk_cfg = LaunchConfig {
229
+ grid_dim: (1, 1, 1),
230
+ block_dim: (256, 1, 1),
231
+ shared_mem_bytes: (n * std::mem::size_of::<f32>()) as u32,
232
+ };
233
+ let learn_cfg = overlap_cfg;
234
+ let duty_cfg = LaunchConfig {
235
+ grid_dim: ((n as u32 + 255) / 256, 1, 1),
236
+ block_dim: (256, 1, 1),
237
+ shared_mem_bytes: 0,
238
+ };
239
+ let alpha = 1.0f32 / self.duty_period.max(1.0);
240
+
241
+ // Reusable host buffer for the per-step active_mask D2H.
242
+ self.host_mask.resize(n, 0);
243
+
244
+ active_indices_host.clear();
245
+
246
+ for ti in 0..t {
247
+ // Point overlap kernel at the ti-th slice of the pre-uploaded input.
248
+ // cudarc CudaSlice doesn't have a "view" per se, so we must copy the
249
+ // slice into the reusable inp_dev buffer. This is a D2D copy β€” much
250
+ // faster than H2D.
251
+ // (Alternative: rewrite kernel to accept an offset; deferred.)
252
+ let in_off = ti * input_bits;
253
+ // Use dtod_copy via raw slice indexing: cudarc exposes slice() for this.
254
+ let sub = inputs_flat_dev.slice(in_off..in_off + input_bits);
255
+ self.dev.dtod_copy(&sub, &mut self.inp_dev)?;
256
+
257
+ // 1. sp_overlap
258
+ unsafe {
259
+ overlap_fn.clone().launch(
260
+ overlap_cfg,
261
+ (
262
+ &self.inp_dev,
263
+ &self.syn_bit,
264
+ &self.syn_perm,
265
+ &self.boost,
266
+ self.conn_thr,
267
+ self.synapses_per_col as u32,
268
+ n as u32,
269
+ &mut self.raw,
270
+ &mut self.boosted,
271
+ ),
272
+ )?;
273
+ }
274
+
275
+ // 2. Clear active_mask, then sp_topk
276
+ self.dev.memset_zeros(&mut self.active_mask)?;
277
+ unsafe {
278
+ topk_fn.clone().launch(
279
+ topk_cfg,
280
+ (&self.boosted, n as u32, k as u32, &mut self.active_mask),
281
+ )?;
282
+ }
283
+
284
+ // 3. sp_learn
285
+ if learn {
286
+ unsafe {
287
+ learn_fn.clone().launch(
288
+ learn_cfg,
289
+ (
290
+ &self.active_mask,
291
+ &self.inp_dev,
292
+ &self.syn_bit,
293
+ &mut self.syn_perm,
294
+ self.inc,
295
+ self.dec,
296
+ self.synapses_per_col as u32,
297
+ n as u32,
298
+ ),
299
+ )?;
300
+ }
301
+ }
302
+
303
+ // 4. duty update (device)
304
+ unsafe {
305
+ duty_fn.clone().launch(
306
+ duty_cfg,
307
+ (
308
+ &self.active_mask,
309
+ &self.raw,
310
+ &mut self.active_duty,
311
+ &mut self.overlap_duty,
312
+ &mut self.boost,
313
+ alpha,
314
+ 1.0f32,
315
+ 0.0f32,
316
+ 0.0f32,
317
+ 0u32,
318
+ n as u32,
319
+ ),
320
+ )?;
321
+ }
322
+
323
+ // 5. Boost update. Two modes:
324
+ // * strict_parity (tests): host-side exp for bit-exact match.
325
+ // * default (production): GPU expf is close enough and ~10x faster
326
+ // since we skip the D2H/H2D round-trip.
327
+ if learn && self.boost_strength > 0.0 {
328
+ if self.strict_parity {
329
+ let mut duty_host = vec![0f32; n];
330
+ self.dev
331
+ .dtoh_sync_copy_into(&self.active_duty, &mut duty_host)?;
332
+ let sum: f32 = duty_host.iter().sum();
333
+ let mean = sum / (n as f32);
334
+ let mut boost_host = vec![0f32; n];
335
+ for i in 0..n {
336
+ boost_host[i] =
337
+ (-self.boost_strength * (duty_host[i] - mean)).exp();
338
+ }
339
+ self.dev.htod_sync_copy_into(&boost_host, &mut self.boost)?;
340
+
341
+ // Permanence bump (rare). Only evaluated in strict mode.
342
+ let mut ov_host = vec![0f32; n];
343
+ self.dev
344
+ .dtoh_sync_copy_into(&self.overlap_duty, &mut ov_host)?;
345
+ let max_ov = ov_host.iter().cloned().fold(0f32, f32::max);
346
+ if max_ov > 0.0 {
347
+ let thr = 0.001f32 * max_ov;
348
+ let bump = self.inc * 0.1f32;
349
+ let bump_cols: Vec<u32> = ov_host
350
+ .iter()
351
+ .enumerate()
352
+ .filter_map(|(i, &o)| {
353
+ if o < thr { Some(i as u32) } else { None }
354
+ })
355
+ .collect();
356
+ if !bump_cols.is_empty() {
357
+ let s = self.synapses_per_col;
358
+ let mut perm_host = vec![0f32; n * s];
359
+ self.dev
360
+ .dtoh_sync_copy_into(&self.syn_perm, &mut perm_host)?;
361
+ for &c in &bump_cols {
362
+ let base = (c as usize) * s;
363
+ for p in &mut perm_host[base..base + s] {
364
+ *p = (*p + bump).min(1.0);
365
+ }
366
+ }
367
+ self.dev.htod_sync_copy_into(&perm_host, &mut self.syn_perm)?;
368
+ }
369
+ }
370
+ } else {
371
+ // Fast path: fused mean + boost = expf(-strength*(ad-mean))
372
+ // in a single GPU block. Zero D2H, zero H2D β€” fully async.
373
+ let boost_fn = self
374
+ .dev
375
+ .get_func("htm_sp_boost_fused", "sp_boost_from_duty")
376
+ .expect("sp_boost_fused not loaded");
377
+ let boost_cfg = LaunchConfig {
378
+ grid_dim: (1, 1, 1),
379
+ block_dim: (1024, 1, 1),
380
+ shared_mem_bytes: 32 * std::mem::size_of::<f32>() as u32,
381
+ };
382
+ unsafe {
383
+ boost_fn.launch(
384
+ boost_cfg,
385
+ (
386
+ &self.active_duty,
387
+ &mut self.boost,
388
+ self.boost_strength,
389
+ n as u32,
390
+ ),
391
+ )?;
392
+ }
393
+ }
394
+ }
395
+
396
+ // D2H the active_mask for this step. This is the single
397
+ // unavoidable sync point per step β€” CPU TM needs the active
398
+ // indices for its next state update. At 2048 bytes / step this
399
+ // is tiny in bandwidth but costs a full syncronize (~5-10ΞΌs).
400
+ self.dev
401
+ .dtoh_sync_copy_into(&self.active_mask, &mut self.host_mask)?;
402
+ let co = ti * n;
403
+ cols_out[co..co + n].copy_from_slice(&self.host_mask);
404
+ // Extract active indices.
405
+ for (i, &b) in self.host_mask.iter().enumerate() {
406
+ if b != 0 {
407
+ active_indices_host.push(i as u32);
408
+ }
409
+ }
410
+ // Insert separator (u32::MAX) between steps to demarcate step boundaries.
411
+ active_indices_host.push(u32::MAX);
412
+ }
413
+
414
+ Ok(())
415
+ }
416
+
417
+ /// Fully-on-GPU batched SP + TM. Zero per-step host sync.
418
+ ///
419
+ /// Inputs:
420
+ /// inputs_flat_dev : (T * input_bits) u8 already uploaded
421
+ /// cols_dev : (T * n_cols) u8 output β€” active-column mask per step
422
+ /// anom_dev : (T,) f32 output β€” anomaly score per step
423
+ /// tm : persistent GPU TemporalMemory for this region
424
+ #[allow(clippy::too_many_arguments)]
425
+ pub fn step_batch_with_tm(
426
+ &mut self,
427
+ inputs_flat_dev: &CudaSlice<u8>,
428
+ t: usize,
429
+ input_bits: usize,
430
+ learn: bool,
431
+ cols_dev: &mut CudaSlice<u8>,
432
+ anom_dev: &mut CudaSlice<f32>,
433
+ tm: &mut crate::gpu::tm_gpu::TemporalMemoryGpu,
434
+ ) -> Result<(), DriverError> {
435
+ let n = self.n_columns;
436
+ let k = ((self.sparsity * n as f32).round() as usize).max(1);
437
+ debug_assert_eq!(cols_dev.len(), t * n);
438
+ debug_assert_eq!(anom_dev.len(), t);
439
+
440
+ let overlap_fn = self.dev.get_func("htm_sp_overlap", "sp_overlap").unwrap();
441
+ let topk_fn = self.dev.get_func("htm_sp_topk", "sp_topk_select").unwrap();
442
+ let learn_fn = self.dev.get_func("htm_sp_learn", "sp_learn").unwrap();
443
+ let duty_fn = self.dev.get_func("htm_sp_duty", "sp_duty_update").unwrap();
444
+
445
+ let overlap_cfg = LaunchConfig {
446
+ grid_dim: (n as u32, 1, 1),
447
+ block_dim: (128, 1, 1),
448
+ shared_mem_bytes: 0,
449
+ };
450
+ let topk_cfg = LaunchConfig {
451
+ grid_dim: (1, 1, 1),
452
+ block_dim: (256, 1, 1),
453
+ shared_mem_bytes: (n * std::mem::size_of::<f32>()) as u32,
454
+ };
455
+ let learn_cfg = overlap_cfg;
456
+ let duty_cfg = LaunchConfig {
457
+ grid_dim: ((n as u32 + 255) / 256, 1, 1),
458
+ block_dim: (256, 1, 1),
459
+ shared_mem_bytes: 0,
460
+ };
461
+ let alpha = 1.0f32 / self.duty_period.max(1.0);
462
+
463
+ for ti in 0..t {
464
+ let in_off = ti * input_bits;
465
+ let sub = inputs_flat_dev.slice(in_off..in_off + input_bits);
466
+ self.dev.dtod_copy(&sub, &mut self.inp_dev)?;
467
+
468
+ // 1. sp_overlap
469
+ unsafe {
470
+ overlap_fn.clone().launch(
471
+ overlap_cfg,
472
+ (
473
+ &self.inp_dev,
474
+ &self.syn_bit,
475
+ &self.syn_perm,
476
+ &self.boost,
477
+ self.conn_thr,
478
+ self.synapses_per_col as u32,
479
+ n as u32,
480
+ &mut self.raw,
481
+ &mut self.boosted,
482
+ ),
483
+ )?;
484
+ }
485
+
486
+ // 2. clear + sp_topk
487
+ self.dev.memset_zeros(&mut self.active_mask)?;
488
+ unsafe {
489
+ topk_fn.clone().launch(
490
+ topk_cfg,
491
+ (&self.boosted, n as u32, k as u32, &mut self.active_mask),
492
+ )?;
493
+ }
494
+
495
+ // 3. sp_learn
496
+ if learn {
497
+ unsafe {
498
+ learn_fn.clone().launch(
499
+ learn_cfg,
500
+ (
501
+ &self.active_mask,
502
+ &self.inp_dev,
503
+ &self.syn_bit,
504
+ &mut self.syn_perm,
505
+ self.inc,
506
+ self.dec,
507
+ self.synapses_per_col as u32,
508
+ n as u32,
509
+ ),
510
+ )?;
511
+ }
512
+ }
513
+
514
+ // 4. duty update (stage 1: no-boost write)
515
+ unsafe {
516
+ duty_fn.clone().launch(
517
+ duty_cfg,
518
+ (
519
+ &self.active_mask,
520
+ &self.raw,
521
+ &mut self.active_duty,
522
+ &mut self.overlap_duty,
523
+ &mut self.boost,
524
+ alpha,
525
+ 1.0f32,
526
+ 0.0f32,
527
+ 0.0f32,
528
+ 0u32,
529
+ n as u32,
530
+ ),
531
+ )?;
532
+ }
533
+
534
+ // 5. Boost update: fused GPU kernel (no D2H).
535
+ if learn && self.boost_strength > 0.0 {
536
+ let boost_fn = self.dev
537
+ .get_func("htm_sp_boost_fused", "sp_boost_from_duty")
538
+ .expect("sp_boost_fused not loaded");
539
+ let boost_cfg = LaunchConfig {
540
+ grid_dim: (1, 1, 1),
541
+ block_dim: (1024, 1, 1),
542
+ shared_mem_bytes: 32 * std::mem::size_of::<f32>() as u32,
543
+ };
544
+ unsafe {
545
+ boost_fn.launch(
546
+ boost_cfg,
547
+ (
548
+ &self.active_duty,
549
+ &mut self.boost,
550
+ self.boost_strength,
551
+ n as u32,
552
+ ),
553
+ )?;
554
+ }
555
+ }
556
+
557
+ // 6. Copy active_mask slice into cols_dev[ti*n .. (ti+1)*n].
558
+ let mut dst_slice = cols_dev.slice_mut(ti * n..(ti + 1) * n);
559
+ self.dev.dtod_copy(&self.active_mask, &mut dst_slice)?;
560
+
561
+ // 7. GPU TM step: predict + activate + anomaly + learn, all on device.
562
+ tm.step(&self.active_mask, anom_dev, ti as u32, learn)?;
563
+ }
564
+
565
+ Ok(())
566
+ }
567
+
568
+ /// One SP step on the GPU. Returns sorted active-column indices.
569
+ pub fn compute(&mut self, input: &[u8], learn: bool) -> Result<Vec<u32>, DriverError> {
570
+ debug_assert_eq!(input.len(), self.input_bits);
571
+ let n = self.n_columns;
572
+ let k = ((self.sparsity * n as f32).round() as usize).max(1);
573
+
574
+ // 1. H2D input SDR.
575
+ self.dev.htod_sync_copy_into(input, &mut self.inp_dev)?;
576
+
577
+ // 2. Launch sp_overlap: grid=n_columns, block=128.
578
+ let overlap_fn = self
579
+ .dev
580
+ .get_func("htm_sp_overlap", "sp_overlap")
581
+ .expect("sp_overlap not loaded");
582
+ let overlap_cfg = LaunchConfig {
583
+ grid_dim: (n as u32, 1, 1),
584
+ block_dim: (128, 1, 1),
585
+ shared_mem_bytes: 0,
586
+ };
587
+ unsafe {
588
+ overlap_fn.launch(
589
+ overlap_cfg,
590
+ (
591
+ &self.inp_dev,
592
+ &self.syn_bit,
593
+ &self.syn_perm,
594
+ &self.boost,
595
+ self.conn_thr,
596
+ self.synapses_per_col as u32,
597
+ n as u32,
598
+ &mut self.raw,
599
+ &mut self.boosted,
600
+ ),
601
+ )?;
602
+ }
603
+
604
+ // 3. Launch sp_topk: single block, shared mem = n_columns * f32.
605
+ let topk_fn = self
606
+ .dev
607
+ .get_func("htm_sp_topk", "sp_topk_select")
608
+ .expect("sp_topk not loaded");
609
+ let topk_cfg = LaunchConfig {
610
+ grid_dim: (1, 1, 1),
611
+ block_dim: (256, 1, 1),
612
+ shared_mem_bytes: (n * std::mem::size_of::<f32>()) as u32,
613
+ };
614
+ // Clear active_mask first. memset_zeros avoids an H2D of a host
615
+ // zeroes vector every step.
616
+ self.dev.memset_zeros(&mut self.active_mask)?;
617
+ unsafe {
618
+ topk_fn.launch(
619
+ topk_cfg,
620
+ (
621
+ &self.boosted,
622
+ n as u32,
623
+ k as u32,
624
+ &mut self.active_mask,
625
+ ),
626
+ )?;
627
+ }
628
+
629
+ // 4. Optional: sp_learn on active columns.
630
+ if learn {
631
+ let learn_fn = self
632
+ .dev
633
+ .get_func("htm_sp_learn", "sp_learn")
634
+ .expect("sp_learn not loaded");
635
+ let learn_cfg = LaunchConfig {
636
+ grid_dim: (n as u32, 1, 1),
637
+ block_dim: (128, 1, 1),
638
+ shared_mem_bytes: 0,
639
+ };
640
+ unsafe {
641
+ learn_fn.launch(
642
+ learn_cfg,
643
+ (
644
+ &self.active_mask,
645
+ &self.inp_dev,
646
+ &self.syn_bit,
647
+ &mut self.syn_perm,
648
+ self.inc,
649
+ self.dec,
650
+ self.synapses_per_col as u32,
651
+ n as u32,
652
+ ),
653
+ )?;
654
+ }
655
+ }
656
+
657
+ // 5. Duty cycle + boost update. Always runs (matches CPU).
658
+ // We need mean_duty on the host β€” compute BEFORE the update (matches
659
+ // CPU sp.rs line 200-205 where mean is computed then written).
660
+ // Actually CPU computes mean of the PRE-update duty cycles too? Re-read:
661
+ // sp.rs lines 186-196 update duty cycles (pre-mean).
662
+ // Line 202: mean = sum(active_duty_cycle) / n ← after update.
663
+ // Line 204: boost[i] = exp(-strength*(active_duty[i] - mean)).
664
+ // So mean is on POST-update values.
665
+ // Easiest: 1) run duty update with boost_strength=0 (skip boost calc),
666
+ // 2) D2H active_duty, compute mean, 3) run a boost-only kernel
667
+ // OR inline the exp() in a second launch with mean passed.
668
+ //
669
+ // For simplicity and correctness we fuse: run the duty kernel with
670
+ // mean=0 and boost_strength=0 (disables boost write), then D2H to
671
+ // compute mean, then re-launch with the true mean. Two launches, one
672
+ // tiny D2H (n Γ— f32). At n=2048 this is 8KB per step β€” negligible.
673
+ let alpha = 1.0f32 / self.duty_period.max(1.0);
674
+ let duty_fn = self
675
+ .dev
676
+ .get_func("htm_sp_duty", "sp_duty_update")
677
+ .expect("sp_duty not loaded");
678
+ let duty_cfg = LaunchConfig {
679
+ grid_dim: ((n as u32 + 255) / 256, 1, 1),
680
+ block_dim: (256, 1, 1),
681
+ shared_mem_bytes: 0,
682
+ };
683
+ // Stage 1: update duty cycles (boost_strength=0 -> no write).
684
+ unsafe {
685
+ duty_fn.launch(
686
+ duty_cfg,
687
+ (
688
+ &self.active_mask,
689
+ &self.raw,
690
+ &mut self.active_duty,
691
+ &mut self.overlap_duty,
692
+ &mut self.boost,
693
+ alpha,
694
+ 1.0f32, // stim_thr
695
+ 0.0f32, // boost_strength = 0 -> skip write
696
+ 0.0f32, // mean_duty (unused)
697
+ 0u32, // learn_flag = 0
698
+ n as u32,
699
+ ),
700
+ )?;
701
+ }
702
+
703
+ if learn && self.boost_strength > 0.0 && self.strict_parity {
704
+ // Boost update must bit-match CPU `f32::exp`, so we compute it on
705
+ // the host and copy back. Cost per step: 8KB D2H + 8KB H2D at n=2048.
706
+ // Critical for learning parity β€” CUDA expf (even without fast-math)
707
+ // uses different rounding for some inputs than host libm.
708
+ let mut duty_host = vec![0f32; n];
709
+ self.dev
710
+ .dtoh_sync_copy_into(&self.active_duty, &mut duty_host)?;
711
+ let sum: f32 = duty_host.iter().sum();
712
+ let mean = sum / (n as f32);
713
+ let mut boost_host = vec![0f32; n];
714
+ for i in 0..n {
715
+ boost_host[i] = (-self.boost_strength * (duty_host[i] - mean)).exp();
716
+ }
717
+ self.dev.htod_sync_copy_into(&boost_host, &mut self.boost)?;
718
+
719
+ // CPU sp.rs 210-226: permanence bump for chronically under-stimulated
720
+ // columns. If overlap_duty_cycle[i] < 0.001 * max(overlap_duty_cycle),
721
+ // add inc*0.1 to every synapse of column i (clamped to 1.0).
722
+ // This runs only once per step and only for the rare cases, but we
723
+ // need it for bit-exact parity with CPU learn.
724
+ let mut ov_host = vec![0f32; n];
725
+ self.dev
726
+ .dtoh_sync_copy_into(&self.overlap_duty, &mut ov_host)?;
727
+ let max_ov = ov_host.iter().cloned().fold(0f32, f32::max);
728
+ if max_ov > 0.0 {
729
+ let thr = 0.001f32 * max_ov;
730
+ let bump = self.inc * 0.1f32;
731
+ // Find columns needing a bump. Usually empty. Rare β†’ D2H/H2D
732
+ // of syn_perm is cheap (n*S*4 = 320KB at n=2048,S=40).
733
+ let bump_cols: Vec<u32> = ov_host
734
+ .iter()
735
+ .enumerate()
736
+ .filter_map(|(i, &o)| if o < thr { Some(i as u32) } else { None })
737
+ .collect();
738
+ if !bump_cols.is_empty() {
739
+ // Download, bump, upload. (Keeps implementation simple and
740
+ // bit-exact. Could kernelize later.)
741
+ let s = self.synapses_per_col;
742
+ let mut perm_host = vec![0f32; n * s];
743
+ self.dev.dtoh_sync_copy_into(&self.syn_perm, &mut perm_host)?;
744
+ for &c in &bump_cols {
745
+ let base = (c as usize) * s;
746
+ for p in &mut perm_host[base..base + s] {
747
+ *p = (*p + bump).min(1.0);
748
+ }
749
+ }
750
+ self.dev.htod_sync_copy_into(&perm_host, &mut self.syn_perm)?;
751
+ }
752
+ }
753
+ } else if learn && self.boost_strength > 0.0 {
754
+ // Fast path: GPU-side boost using the already-loaded duty kernel.
755
+ let mut duty_host = vec![0f32; n];
756
+ self.dev
757
+ .dtoh_sync_copy_into(&self.active_duty, &mut duty_host)?;
758
+ let sum: f32 = duty_host.iter().sum();
759
+ let mean = sum / (n as f32);
760
+ let boost_fn = self
761
+ .dev
762
+ .get_func("htm_sp_duty", "sp_duty_update")
763
+ .expect("sp_duty not loaded");
764
+ unsafe {
765
+ boost_fn.launch(
766
+ duty_cfg,
767
+ (
768
+ &self.active_mask,
769
+ &self.raw,
770
+ &mut self.active_duty,
771
+ &mut self.overlap_duty,
772
+ &mut self.boost,
773
+ 0.0f32,
774
+ 1.0f32,
775
+ self.boost_strength,
776
+ mean,
777
+ 1u32,
778
+ n as u32,
779
+ ),
780
+ )?;
781
+ }
782
+ }
783
+
784
+ // 6. D2H active_mask and convert to sorted index list.
785
+ self.dev
786
+ .dtoh_sync_copy_into(&self.active_mask, &mut self.host_mask)?;
787
+ let mut active: Vec<u32> = Vec::with_capacity(k);
788
+ for (i, &b) in self.host_mask.iter().enumerate() {
789
+ if b != 0 {
790
+ active.push(i as u32);
791
+ }
792
+ }
793
+ debug_assert_eq!(active.len(), k, "SP must emit exactly k winners");
794
+ Ok(active)
795
+ }
796
+ }
overlay/htm_rust/src/gpu/tests.rs CHANGED
@@ -1,663 +1,663 @@
1
- //! Parity tests: GPU SP vs CPU SP reference.
2
- //!
3
- //! With matching seeds the two should produce bit-identical active-column sets
4
- //! when `learn=false`, and remain bit-identical over repeated `learn=true`
5
- //! steps because the Hebbian update is deterministic (no RNG once initialised).
6
- //!
7
- //! Run with: cargo test --release --features gpu
8
-
9
- #![cfg(test)]
10
- #![cfg(feature = "gpu")]
11
-
12
- use crate::sp::{SpatialPooler, SpatialPoolerConfig};
13
- use crate::gpu::sp_gpu::SpatialPoolerGpu;
14
- use crate::gpu::tm_gpu::TemporalMemoryGpu;
15
- use crate::gpu::fused::{
16
- launch_fused, plan_batched_grid_dim, plan_fused_launch, FusedState,
17
- };
18
- use cudarc::driver::CudaSlice;
19
- use rand::{Rng, SeedableRng};
20
- use rand_xoshiro::Xoshiro256PlusPlus;
21
-
22
- fn make_sdr(rng: &mut Xoshiro256PlusPlus, bits: usize, sparsity: f32) -> Vec<u8> {
23
- let on = ((sparsity * bits as f32) as usize).max(1);
24
- let mut v = vec![0u8; bits];
25
- let mut placed = 0;
26
- while placed < on {
27
- let i = rng.gen_range(0..bits);
28
- if v[i] == 0 {
29
- v[i] = 1;
30
- placed += 1;
31
- }
32
- }
33
- v
34
- }
35
-
36
- #[test]
37
- fn gpu_sp_matches_cpu_no_learn() {
38
- let cfg = SpatialPoolerConfig::default();
39
- let bits = cfg.input_bits;
40
- let mut cpu = SpatialPooler::new(
41
- SpatialPoolerConfig { ..SpatialPoolerConfig::default() },
42
- 1234,
43
- );
44
- let cpu_for_gpu = SpatialPooler::new(
45
- SpatialPoolerConfig { ..SpatialPoolerConfig::default() },
46
- 1234,
47
- );
48
- let mut gpu = SpatialPoolerGpu::from_cpu(&cpu_for_gpu)
49
- .expect("gpu init (CUDA device available)");
50
- gpu.set_strict_parity(true);
51
-
52
- let mut rng = Xoshiro256PlusPlus::seed_from_u64(99);
53
- for step in 0..20 {
54
- let sdr_u8 = make_sdr(&mut rng, bits, 0.02);
55
- let sdr_bool: Vec<bool> = sdr_u8.iter().map(|&x| x != 0).collect();
56
-
57
- let cpu_active: Vec<u32> = cpu.compute(&sdr_bool, false);
58
- let gpu_active: Vec<u32> = gpu.compute(&sdr_u8, false).expect("gpu compute");
59
-
60
- assert_eq!(
61
- cpu_active, gpu_active,
62
- "mismatch at step {step}: len cpu={} gpu={}",
63
- cpu_active.len(), gpu_active.len()
64
- );
65
- }
66
- }
67
-
68
- #[test]
69
- fn gpu_sp_matches_cpu_with_learn() {
70
- let cfg = SpatialPoolerConfig::default();
71
- let bits = cfg.input_bits;
72
- let mut cpu = SpatialPooler::new(
73
- SpatialPoolerConfig { ..SpatialPoolerConfig::default() },
74
- 5678,
75
- );
76
- let cpu_for_gpu = SpatialPooler::new(
77
- SpatialPoolerConfig { ..SpatialPoolerConfig::default() },
78
- 5678,
79
- );
80
- let mut gpu = SpatialPoolerGpu::from_cpu(&cpu_for_gpu).expect("gpu init");
81
- gpu.set_strict_parity(true);
82
-
83
- let mut rng = Xoshiro256PlusPlus::seed_from_u64(42);
84
- for step in 0..50 {
85
- let sdr_u8 = make_sdr(&mut rng, bits, 0.02);
86
- let sdr_bool: Vec<bool> = sdr_u8.iter().map(|&x| x != 0).collect();
87
-
88
- let cpu_active = cpu.compute(&sdr_bool, true);
89
- let gpu_active = gpu.compute(&sdr_u8, true).expect("gpu compute");
90
-
91
- assert_eq!(
92
- cpu_active, gpu_active,
93
- "mismatch at step {step} with learning"
94
- );
95
- }
96
- }
97
-
98
- #[test]
99
- fn gpu_tm_anomaly_decays_on_repeating_sequence() {
100
- // End-to-end GPU pipeline: SP feeds TM; repeating SDR sequence should drive
101
- // anomaly down over time.
102
- use crate::gpu::HTMRegionGpu; // not pyclass methods; use internal constructor via Rust
103
- // Easier: replicate the pipeline directly with SP + TM.
104
-
105
- let cfg = SpatialPoolerConfig::default();
106
- let bits = cfg.input_bits;
107
- let n_cols = cfg.n_columns;
108
- let cells_per_col = 32usize;
109
-
110
- let cpu_for_gpu = SpatialPooler::new(SpatialPoolerConfig::default(), 314);
111
- let mut sp = SpatialPoolerGpu::from_cpu(&cpu_for_gpu).expect("gpu init");
112
- let dev = sp.dev_ref().clone();
113
- let mut tm = TemporalMemoryGpu::new(dev.clone(), n_cols, cells_per_col)
114
- .expect("gpu tm init");
115
- tm.reset().expect("tm reset");
116
-
117
- // Build 3 fixed SDRs, feed them in a repeating sequence.
118
- let mut rng = Xoshiro256PlusPlus::seed_from_u64(7);
119
- let make = |rng: &mut Xoshiro256PlusPlus| make_sdr(rng, bits, 0.02);
120
- let seqs = [make(&mut rng), make(&mut rng), make(&mut rng)];
121
-
122
- // Warm up SP so columns are stable per symbol.
123
- for _ in 0..100 {
124
- for s in &seqs {
125
- let _ = sp.compute(s, true).expect("sp compute");
126
- }
127
- }
128
-
129
- // Build a long input buffer: 100 repetitions of [A,B,C] = 300 steps.
130
- let repeats = 100usize;
131
- let t = repeats * 3;
132
- let mut inputs_flat = vec![0u8; t * bits];
133
- for r in 0..repeats {
134
- for (i, s) in seqs.iter().enumerate() {
135
- let off = (r * 3 + i) * bits;
136
- inputs_flat[off..off + bits].copy_from_slice(s);
137
- }
138
- }
139
- let inputs_dev: CudaSlice<u8> = dev.htod_sync_copy(&inputs_flat).expect("htod");
140
-
141
- let mut cols_dev = dev.alloc_zeros::<u8>(t * n_cols).expect("alloc cols");
142
- let mut anom_dev = dev.alloc_zeros::<f32>(t).expect("alloc anom");
143
-
144
- sp.step_batch_with_tm(
145
- &inputs_dev,
146
- t,
147
- bits,
148
- true,
149
- &mut cols_dev,
150
- &mut anom_dev,
151
- &mut tm,
152
- ).expect("step_batch_with_tm");
153
-
154
- let anom: Vec<f32> = dev.dtoh_sync_copy(&anom_dev).expect("d2h anom");
155
- let cols: Vec<u8> = dev.dtoh_sync_copy(&cols_dev).expect("d2h cols");
156
-
157
- // Active column count per step must equal k for every step.
158
- let k = ((cfg.sparsity * n_cols as f32).round() as usize).max(1);
159
- for ti in 0..t {
160
- let step_slice = &cols[ti * n_cols..(ti + 1) * n_cols];
161
- let n_on = step_slice.iter().filter(|&&b| b != 0).count();
162
- assert_eq!(n_on, k, "step {ti} has {n_on} active cols, expected {k}");
163
- }
164
-
165
- // First repetition: anomaly should be near 1.0 (nothing predicted).
166
- let early_avg: f32 = anom[3..9].iter().sum::<f32>() / 6.0;
167
- // Last repetitions: anomaly should be noticeably lower.
168
- let late_avg: f32 = anom[(t - 9)..t].iter().sum::<f32>() / 9.0;
169
- eprintln!("gpu tm: early anomaly = {early_avg:.3}, late = {late_avg:.3}");
170
- assert!(
171
- late_avg < early_avg,
172
- "GPU TM should reduce anomaly on repeating sequence: early={early_avg:.3}, late={late_avg:.3}"
173
- );
174
- }
175
-
176
- /// Cluster-sync smoke test: verifies that the fused megakernel (which relies on
177
- /// hardware `cluster::sync()` / grid-barrier on H100/H200 Hopper) completes
178
- /// without deadlock when called with real HTM state, and that output shapes are
179
- /// sane (no NaN / Inf in anomaly scores, active-column count in plausible range).
180
- ///
181
- /// This is an *integration* test, not a synthetic micro-benchmark: it exercises
182
- /// exactly the same `launch_fused` code path used in production, so any
183
- /// deadlock in the cooperative-grid or DLB barrier would surface here.
184
- ///
185
- /// Skips gracefully (with an eprintln) when no GPU is available β€” the test
186
- /// binary returns exit-code 0 in that case so CI still passes.
187
- #[test]
188
- fn cluster_sync_smoke_test() {
189
- // Build a tiny HTM region (1024 inputs, 256 columns, 4 cells/column).
190
- // This keeps VRAM usage minimal while still exercising all kernel paths.
191
- let input_bits = 1024usize;
192
- let n_columns = 256usize;
193
- let cells_per_col = 4usize;
194
-
195
- // Probe cooperative launch attribute before doing any real work.
196
- // CU_DEVICE_ATTRIBUTE_CLUSTER_LAUNCH = 223 (added in CUDA 11.8 for Hopper).
197
- // cudarc exposes raw attribute querying; we check cooperative launch (98)
198
- // as the guard β€” cluster launch is a superset and not separately probed
199
- // here since cudarc doesn't expose attribute 223 symbolically yet.
200
- // On pre-Hopper hardware the DLB barrier path is used instead and the
201
- // test still validates no deadlock on that path.
202
-
203
- let make_cfg = || SpatialPoolerConfig {
204
- input_bits,
205
- n_columns,
206
- sparsity: 0.04, // ~10 active cols out of 256
207
- ..SpatialPoolerConfig::default()
208
- };
209
-
210
- let cpu_ref = SpatialPooler::new(make_cfg(), 42);
211
-
212
- let mut sp = match SpatialPoolerGpu::from_cpu(&cpu_ref) {
213
- Ok(sp) => sp,
214
- Err(e) => {
215
- eprintln!("[cluster_sync_smoke_test] No GPU available ({e:?}) β€” skipping");
216
- return;
217
- }
218
- };
219
-
220
- let dev = sp.dev_ref().clone();
221
-
222
- // Check cooperative launch support; skip with a clear message if absent.
223
- let cooperative_ok = matches!(
224
- dev.attribute(cudarc::driver::sys::CUdevice_attribute::CU_DEVICE_ATTRIBUTE_COOPERATIVE_LAUNCH),
225
- Ok(v) if v > 0
226
- );
227
- if !cooperative_ok {
228
- eprintln!("[cluster_sync_smoke_test] CU_DEVICE_ATTRIBUTE_COOPERATIVE_LAUNCH=0 β€” DLB path only, still running test");
229
- // We continue β€” the DLB path is the production fallback and must not deadlock either.
230
- }
231
-
232
- let mut tm = match TemporalMemoryGpu::new(dev.clone(), n_columns, cells_per_col) {
233
- Ok(tm) => tm,
234
- Err(e) => {
235
- eprintln!("[cluster_sync_smoke_test] TemporalMemoryGpu::new failed ({e:?}) β€” skipping");
236
- return;
237
- }
238
- };
239
- tm.reset().expect("tm reset");
240
-
241
- let mut fused_st: FusedState = match FusedState::new(
242
- dev.clone(),
243
- n_columns,
244
- cells_per_col,
245
- sp.initial_threshold_estimate(),
246
- ) {
247
- Ok(f) => f,
248
- Err(e) => {
249
- eprintln!("[cluster_sync_smoke_test] FusedState::new failed ({e:?}) β€” skipping");
250
- return;
251
- }
252
- };
253
- fused_st.reset().expect("fused reset");
254
-
255
- // Build T=4 timesteps of all-zero input SDRs.
256
- let t = 4usize;
257
- let inputs_flat = vec![0u8; t * input_bits];
258
- let inputs_dev: CudaSlice<u8> = dev.htod_sync_copy(&inputs_flat).expect("htod inputs");
259
-
260
- let mut cols_dev = dev.alloc_zeros::<u8>(t * n_columns).expect("alloc cols");
261
- let mut anom_dev = dev.alloc_zeros::<f32>(t).expect("alloc anom");
262
-
263
- // Execute with a 2-second timeout guard via a thread. If the kernel
264
- // deadlocks, the parent test process times out and the CI job reports
265
- // failure β€” we can't cancel a live CUDA kernel from Rust, but the
266
- // launch_fused call itself must return within this window on any sane GPU.
267
- //
268
- // We run the kernel inline (not in a separate thread) because CUDA contexts
269
- // are not safely shareable across threads without explicit multi-threading
270
- // setup. The 2-second bound is enforced implicitly: if the kernel deadlocks,
271
- // the test binary will hang and the CI timeout (typically 5 min) will kill it.
272
- // For local dev, the deadlock would be immediately obvious.
273
-
274
- launch_fused(
275
- &mut sp,
276
- &mut tm,
277
- &mut fused_st,
278
- &inputs_dev,
279
- &mut cols_dev,
280
- &mut anom_dev,
281
- t,
282
- input_bits,
283
- false, // learn=false for determinism
284
- ).expect("launch_fused (cluster_sync_smoke_test): deadlock or CUDA error");
285
-
286
- dev.synchronize().expect("device sync after launch_fused");
287
-
288
- // --- Correctness assertions ---
289
-
290
- let cols_host: Vec<u8> = dev.dtoh_sync_copy(&cols_dev).expect("d2h cols");
291
- let anom_host: Vec<f32> = dev.dtoh_sync_copy(&anom_dev).expect("d2h anom");
292
-
293
- // Output buffers must be exactly the right size.
294
- assert_eq!(cols_host.len(), t * n_columns, "cols buffer size mismatch");
295
- assert_eq!(anom_host.len(), t, "anom buffer size mismatch");
296
-
297
- // Anomaly scores must be finite (NaN/Inf indicates numerical blow-up).
298
- for (i, &a) in anom_host.iter().enumerate() {
299
- assert!(a.is_finite(), "anomaly[{i}] is not finite: {a}");
300
- assert!(a >= 0.0 && a <= 1.0, "anomaly[{i}] out of [0,1]: {a}");
301
- }
302
-
303
- // Active-column count per step: threshold-based inhibition, so 0 is
304
- // possible on cold start (before thresholds calibrate), but we assert
305
- // <= n_columns to catch buffer overruns or completely wrong output.
306
- for ti in 0..t {
307
- let n_on = cols_host[ti * n_columns..(ti + 1) * n_columns]
308
- .iter()
309
- .filter(|&&b| b != 0)
310
- .count();
311
- assert!(
312
- n_on <= n_columns,
313
- "step {ti}: active columns {n_on} > n_columns {n_columns} (buffer overrun?)"
314
- );
315
- }
316
-
317
- eprintln!(
318
- "[cluster_sync_smoke_test] PASSED: T={t}, n_cols={n_columns}, \
319
- input_bits={input_bits}, cooperative_supported={cooperative_ok}, \
320
- anom={anom_host:?}"
321
- );
322
- }
323
-
324
- /// Parity check: the CAI zero-copy path (`step_many_cuda`) must produce
325
- /// bit-identical outputs to the numpy H2D/D2H path (`step_batch_with_tm`),
326
- /// since the kernel pipeline is the same β€” only the I/O wrapping changes.
327
- /// We skip the PyO3 CAI dict plumbing here and test the underlying
328
- /// ManuallyDrop + upgrade_device_ptr pattern directly.
329
- #[test]
330
- fn gpu_cuda_vs_numpy_parity() {
331
- use std::mem::ManuallyDrop;
332
-
333
- let cfg = SpatialPoolerConfig::default();
334
- let bits = cfg.input_bits;
335
- let n_cols = cfg.n_columns;
336
- let cells_per_col = 32usize;
337
-
338
- // Build two identical (SP, TM) pairs from the same seed.
339
- let build = || -> (SpatialPoolerGpu, TemporalMemoryGpu) {
340
- let cpu_ref = SpatialPooler::new(SpatialPoolerConfig::default(), 271828);
341
- let sp = SpatialPoolerGpu::from_cpu(&cpu_ref).expect("gpu init");
342
- let dev = sp.dev_ref().clone();
343
- let mut tm = TemporalMemoryGpu::new(dev, n_cols, cells_per_col).expect("tm init");
344
- tm.reset().expect("tm reset");
345
- (sp, tm)
346
- };
347
-
348
- // Deterministic SDR sequence.
349
- let mut rng = Xoshiro256PlusPlus::seed_from_u64(31337);
350
- let t = 32usize;
351
- let mut inputs_flat = vec![0u8; t * bits];
352
- for i in 0..t {
353
- let sdr = make_sdr(&mut rng, bits, 0.02);
354
- inputs_flat[i * bits..(i + 1) * bits].copy_from_slice(&sdr);
355
- }
356
-
357
- // ---- Path A: owned CudaSlice (numpy-equivalent path) ----
358
- let (mut sp_a, mut tm_a) = build();
359
- let dev_a = sp_a.dev_ref().clone();
360
- let inputs_a: CudaSlice<u8> = dev_a.htod_sync_copy(&inputs_flat).expect("htod");
361
- let mut cols_a = dev_a.alloc_zeros::<u8>(t * n_cols).expect("alloc cols_a");
362
- let mut anom_a = dev_a.alloc_zeros::<f32>(t).expect("alloc anom_a");
363
- sp_a.step_batch_with_tm(&inputs_a, t, bits, false, &mut cols_a, &mut anom_a, &mut tm_a)
364
- .expect("owned step_batch_with_tm");
365
- dev_a.synchronize().expect("sync a");
366
- let cols_a_host: Vec<u8> = dev_a.dtoh_sync_copy(&cols_a).expect("d2h cols_a");
367
- let anom_a_host: Vec<f32> = dev_a.dtoh_sync_copy(&anom_a).expect("d2h anom_a");
368
-
369
- // ---- Path B: borrowed device pointers via upgrade_device_ptr ----
370
- // We allocate fresh owned CudaSlices on a fresh device, then take their
371
- // raw ptrs and re-wrap as ManuallyDrop borrowed views β€” mimicking what
372
- // `step_many_cuda` does with torch-owned CUDA memory.
373
- let (mut sp_b, mut tm_b) = build();
374
- let dev_b = sp_b.dev_ref().clone();
375
- let inputs_b_owned: CudaSlice<u8> = dev_b.htod_sync_copy(&inputs_flat).expect("htod");
376
- let cols_b_owned = dev_b.alloc_zeros::<u8>(t * n_cols).expect("alloc cols_b");
377
- let anom_b_owned = dev_b.alloc_zeros::<f32>(t).expect("alloc anom_b");
378
-
379
- // Extract raw CUdeviceptrs (and leak the owners so their Drop doesn't free).
380
- let inputs_ptr = inputs_b_owned.leak();
381
- let cols_ptr = cols_b_owned.leak();
382
- let anom_ptr = anom_b_owned.leak();
383
-
384
- // Re-wrap as borrowed views.
385
- let inputs_b = ManuallyDrop::new(unsafe { dev_b.upgrade_device_ptr::<u8>(inputs_ptr, t * bits) });
386
- let mut cols_b = ManuallyDrop::new(unsafe { dev_b.upgrade_device_ptr::<u8>(cols_ptr, t * n_cols) });
387
- let mut anom_b = ManuallyDrop::new(unsafe { dev_b.upgrade_device_ptr::<f32>(anom_ptr, t) });
388
-
389
- sp_b.step_batch_with_tm(&inputs_b, t, bits, false, &mut cols_b, &mut anom_b, &mut tm_b)
390
- .expect("borrowed step_batch_with_tm");
391
- dev_b.synchronize().expect("sync b");
392
- // `ManuallyDrop` doesn't auto-coerce to `&CudaSlice<T>` for the DevicePtr
393
- // trait bound on `dtoh_sync_copy`; explicit deref.
394
- let cols_b_host: Vec<u8> = dev_b.dtoh_sync_copy(&*cols_b).expect("d2h cols_b");
395
- let anom_b_host: Vec<f32> = dev_b.dtoh_sync_copy(&*anom_b).expect("d2h anom_b");
396
-
397
- // Re-own so Drop actually frees (we leaked above).
398
- let _inputs_owned_again = unsafe { dev_b.upgrade_device_ptr::<u8>(inputs_ptr, t * bits) };
399
- let _cols_owned_again = unsafe { dev_b.upgrade_device_ptr::<u8>(cols_ptr, t * n_cols) };
400
- let _anom_owned_again = unsafe { dev_b.upgrade_device_ptr::<f32>(anom_ptr, t) };
401
-
402
- assert_eq!(cols_a_host, cols_b_host, "active-column mask diverges between numpy and CAI paths");
403
- assert_eq!(anom_a_host.len(), anom_b_host.len());
404
- for (i, (a, b)) in anom_a_host.iter().zip(anom_b_host.iter()).enumerate() {
405
- // Anomaly is a pure division of integer counts β€” bit-exact expected.
406
- assert!((a - b).abs() < 1e-7, "anomaly mismatch at step {i}: a={a} b={b}");
407
- }
408
- }
409
-
410
- /// Fused kernel: threshold activation should converge to near target sparsity
411
- /// after a short warmup. Acceptance: mean activation rate per step lands in
412
- /// [0.3*target, 2.5*target] after 500-step warmup. Because the threshold
413
- /// starts conservative (=2.0) and the per-column adaptation rate is slow
414
- /// (0.001), we allow a generous band β€” the test asserts directional
415
- /// convergence toward the target, not tight matching.
416
- #[test]
417
- fn gpu_threshold_converges_to_sparsity() {
418
- let cfg = SpatialPoolerConfig::default();
419
- let bits = cfg.input_bits;
420
- let n_cols = cfg.n_columns;
421
- let cells_per_col = 32usize;
422
- let target = cfg.sparsity; // 0.02 = 40 cols expected
423
-
424
- let cpu_ref = SpatialPooler::new(SpatialPoolerConfig::default(), 111);
425
- let mut sp = SpatialPoolerGpu::from_cpu(&cpu_ref).expect("gpu sp init");
426
- let dev = sp.dev_ref().clone();
427
- let mut tm = TemporalMemoryGpu::new(dev.clone(), n_cols, cells_per_col).expect("tm init");
428
- let mut fused = FusedState::new(
429
- dev.clone(),
430
- n_cols,
431
- cells_per_col,
432
- sp.initial_threshold_estimate(),
433
- ).expect("fused init");
434
- tm.reset().expect("tm reset");
435
- fused.reset().expect("fused reset");
436
-
437
- // Warmup: 1000 random 2%-sparse SDRs.
438
- let mut rng = Xoshiro256PlusPlus::seed_from_u64(31337);
439
- let t_warm = 1000usize;
440
- let mut inputs = vec![0u8; t_warm * bits];
441
- for ti in 0..t_warm {
442
- let sdr = make_sdr(&mut rng, bits, 0.02);
443
- inputs[ti*bits..(ti+1)*bits].copy_from_slice(&sdr);
444
- }
445
- let inputs_dev: CudaSlice<u8> = dev.htod_sync_copy(&inputs).expect("htod");
446
- let mut cols_dev = dev.alloc_zeros::<u8>(t_warm * n_cols).expect("alloc cols");
447
- let mut anom_dev = dev.alloc_zeros::<f32>(t_warm).expect("alloc anom");
448
- launch_fused(
449
- &mut sp, &mut tm, &mut fused,
450
- &inputs_dev, &mut cols_dev, &mut anom_dev,
451
- t_warm, bits, true,
452
- ).expect("warmup launch");
453
- dev.synchronize().expect("sync");
454
-
455
- // Measurement pass: another 200 steps, measure mean activation.
456
- let t_meas = 200usize;
457
- let mut meas_inputs = vec![0u8; t_meas * bits];
458
- for ti in 0..t_meas {
459
- let sdr = make_sdr(&mut rng, bits, 0.02);
460
- meas_inputs[ti*bits..(ti+1)*bits].copy_from_slice(&sdr);
461
- }
462
- let meas_dev: CudaSlice<u8> = dev.htod_sync_copy(&meas_inputs).expect("htod meas");
463
- let mut meas_cols = dev.alloc_zeros::<u8>(t_meas * n_cols).expect("alloc meas cols");
464
- let mut meas_anom = dev.alloc_zeros::<f32>(t_meas).expect("alloc meas anom");
465
- launch_fused(
466
- &mut sp, &mut tm, &mut fused,
467
- &meas_dev, &mut meas_cols, &mut meas_anom,
468
- t_meas, bits, true,
469
- ).expect("meas launch");
470
- dev.synchronize().expect("sync meas");
471
-
472
- let cols_host: Vec<u8> = dev.dtoh_sync_copy(&meas_cols).expect("d2h");
473
- let mut step_counts = Vec::with_capacity(t_meas);
474
- for ti in 0..t_meas {
475
- let n_on = cols_host[ti*n_cols..(ti+1)*n_cols]
476
- .iter().filter(|&&b| b != 0).count();
477
- step_counts.push(n_on);
478
- }
479
- let mean_active: f64 = step_counts.iter().map(|&c| c as f64).sum::<f64>()
480
- / (t_meas as f64);
481
- let target_active = target as f64 * n_cols as f64;
482
- eprintln!(
483
- "threshold-activation convergence: mean_active/step = {mean_active:.1} \
484
- (target = {target_active:.1})"
485
- );
486
- // Very generous band β€” we just want to confirm the threshold loop is
487
- // functioning (not diverged to 0 or to all-active).
488
- assert!(
489
- mean_active >= 0.25 * target_active && mean_active <= 4.0 * target_active,
490
- "mean active {mean_active:.1} outside [0.25x, 4x] of target {target_active:.1}"
491
- );
492
- }
493
-
494
- /// Fused kernel: TM should learn a repeating sequence β€” anomaly decays.
495
- #[test]
496
- fn gpu_fused_tm_anomaly_decays_on_repeating_sequence() {
497
- let cfg = SpatialPoolerConfig::default();
498
- let bits = cfg.input_bits;
499
- let n_cols = cfg.n_columns;
500
- let cells_per_col = 32usize;
501
-
502
- let cpu_ref = SpatialPooler::new(SpatialPoolerConfig::default(), 271);
503
- let mut sp = SpatialPoolerGpu::from_cpu(&cpu_ref).expect("gpu sp init");
504
- let dev = sp.dev_ref().clone();
505
- let mut tm = TemporalMemoryGpu::new(dev.clone(), n_cols, cells_per_col).expect("tm init");
506
- let mut fused = FusedState::new(
507
- dev.clone(),
508
- n_cols,
509
- cells_per_col,
510
- sp.initial_threshold_estimate(),
511
- ).expect("fused init");
512
- tm.reset().expect("tm reset");
513
- fused.reset().expect("fused reset");
514
-
515
- let mut rng = Xoshiro256PlusPlus::seed_from_u64(7);
516
- let make = |rng: &mut Xoshiro256PlusPlus| make_sdr(rng, bits, 0.02);
517
- let seqs = [make(&mut rng), make(&mut rng), make(&mut rng)];
518
-
519
- // Warmup SP threshold calibration with random SDRs first.
520
- let warm = 300usize;
521
- let mut warm_inputs = vec![0u8; warm * bits];
522
- for ti in 0..warm {
523
- let sdr = make_sdr(&mut rng, bits, 0.02);
524
- warm_inputs[ti*bits..(ti+1)*bits].copy_from_slice(&sdr);
525
- }
526
- let warm_dev: CudaSlice<u8> = dev.htod_sync_copy(&warm_inputs).expect("htod warm");
527
- let mut warm_cols = dev.alloc_zeros::<u8>(warm * n_cols).expect("alloc warm cols");
528
- let mut warm_anom = dev.alloc_zeros::<f32>(warm).expect("alloc warm anom");
529
- launch_fused(
530
- &mut sp, &mut tm, &mut fused,
531
- &warm_dev, &mut warm_cols, &mut warm_anom,
532
- warm, bits, true,
533
- ).expect("warm launch");
534
- dev.synchronize().expect("sync warm");
535
-
536
- // Feed repeating A,B,C sequence for 100 reps.
537
- let repeats = 100usize;
538
- let t = repeats * 3;
539
- let mut inputs = vec![0u8; t * bits];
540
- for r in 0..repeats {
541
- for (i, s) in seqs.iter().enumerate() {
542
- let off = (r*3 + i) * bits;
543
- inputs[off..off+bits].copy_from_slice(s);
544
- }
545
- }
546
- let inputs_dev: CudaSlice<u8> = dev.htod_sync_copy(&inputs).expect("htod rep");
547
- let mut cols_dev = dev.alloc_zeros::<u8>(t * n_cols).expect("alloc rep cols");
548
- let mut anom_dev = dev.alloc_zeros::<f32>(t).expect("alloc rep anom");
549
- launch_fused(
550
- &mut sp, &mut tm, &mut fused,
551
- &inputs_dev, &mut cols_dev, &mut anom_dev,
552
- t, bits, true,
553
- ).expect("rep launch");
554
- dev.synchronize().expect("sync rep");
555
-
556
- let anom: Vec<f32> = dev.dtoh_sync_copy(&anom_dev).expect("d2h anom");
557
- let early_avg: f32 = anom[3..12].iter().sum::<f32>() / 9.0;
558
- let late_avg: f32 = anom[(t-9)..t].iter().sum::<f32>() / 9.0;
559
- eprintln!("fused TM anomaly: early={early_avg:.3} late={late_avg:.3}");
560
- assert!(
561
- late_avg < early_avg,
562
- "anomaly must decay: early={early_avg:.3} late={late_avg:.3}"
563
- );
564
- assert!(
565
- late_avg < 0.5,
566
- "late anomaly must be < 0.5 (got {late_avg:.3})"
567
- );
568
- }
569
-
570
- #[test]
571
- fn gpu_sp_yields_k_winners() {
572
- let cfg = SpatialPoolerConfig::default();
573
- let bits = cfg.input_bits;
574
- let n = cfg.n_columns;
575
- let expected_k = ((cfg.sparsity * n as f32).round() as usize).max(1);
576
- let cpu = SpatialPooler::new(SpatialPoolerConfig::default(), 7);
577
- let mut gpu = SpatialPoolerGpu::from_cpu(&cpu).expect("gpu init");
578
-
579
- let mut rng = Xoshiro256PlusPlus::seed_from_u64(1);
580
- for _ in 0..10 {
581
- let sdr_u8 = make_sdr(&mut rng, bits, 0.02);
582
- let active = gpu.compute(&sdr_u8, false).expect("gpu compute");
583
- assert_eq!(active.len(), expected_k);
584
- // Ensure sorted + unique.
585
- for w in active.windows(2) {
586
- assert!(w[0] < w[1], "duplicate or out-of-order winner indices");
587
- }
588
- }
589
- }
590
-
591
- #[test]
592
- fn fused_launch_plan_uses_cooperative_grid_sync() {
593
- let plan = plan_fused_launch(30, true, 30, None).expect("cooperative supported");
594
- assert_eq!(plan.grid_dim_x, 16);
595
- assert_eq!(plan.cooperative_grid_limit, 30);
596
- }
597
-
598
- #[test]
599
- fn fused_launch_plan_scales_to_big_gpu() {
600
- // H200-like: 132 SMs, high cooperative_grid_limit. Cap still applies.
601
- let plan = plan_fused_launch(132, true, 1000, None).expect("cooperative supported");
602
- assert_eq!(plan.grid_dim_x, 16); // capped by default override
603
- let plan = plan_fused_launch(132, true, 1000, Some(64)).expect("cooperative supported");
604
- assert_eq!(plan.grid_dim_x, 64); // override raises the cap
605
- }
606
-
607
- #[test]
608
- fn fused_launch_plan_refuses_non_cooperative_devices() {
609
- // The slow path was removed. Devices without cooperative launch fail fast.
610
- let err = plan_fused_launch(30, false, 0, None).unwrap_err();
611
- assert!(err.contains("cooperative launch"));
612
- }
613
-
614
- #[test]
615
- fn fused_grid_cap_env_override_is_honored() {
616
- let cfg = SpatialPoolerConfig::default();
617
- let cpu_ref = SpatialPooler::new(SpatialPoolerConfig::default(), 5252);
618
- let sp = SpatialPoolerGpu::from_cpu(&cpu_ref).expect("gpu sp init");
619
- let dev = sp.dev_ref().clone();
620
-
621
- unsafe { std::env::set_var("HTM_FUSED_GRID_CAP", "12"); }
622
- let fused = FusedState::new(
623
- dev.clone(),
624
- cfg.n_columns,
625
- 32usize,
626
- sp.initial_threshold_estimate(),
627
- ).expect("fused init");
628
- unsafe { std::env::remove_var("HTM_FUSED_GRID_CAP"); }
629
-
630
- let sm_count = match dev.attribute(
631
- cudarc::driver::sys::CUdevice_attribute::CU_DEVICE_ATTRIBUTE_MULTIPROCESSOR_COUNT,
632
- ) {
633
- Ok(v) => v as u32,
634
- Err(_) => 16u32,
635
- };
636
- let expected = sm_count.max(1).min(12);
637
- assert_eq!(
638
- fused.grid_dim_x,
639
- expected,
640
- "fused grid cap env override ignored: expected min(sm_count, 12) = {expected}, got {}",
641
- fused.grid_dim_x,
642
- );
643
- }
644
-
645
- #[test]
646
- fn batched_grid_plan_clamps_a10g_batch32_under_cooperative_limit() {
647
- // A10G observed in HF Jobs: cooperative_grid_limit=400, B=32.
648
- // grid_x=16 requests 512 cooperative blocks and fails; clamp to 12.
649
- let grid_x = plan_batched_grid_dim(16, 400, 32, false).expect("fits after clamp");
650
- assert_eq!(grid_x, 12);
651
- }
652
-
653
- #[test]
654
- fn batched_grid_plan_reports_oversized_batch() {
655
- let err = plan_batched_grid_dim(16, 31, 32, false).unwrap_err();
656
- assert!(err.contains("COOPERATIVE_LAUNCH_TOO_LARGE"));
657
- }
658
-
659
- #[test]
660
- fn batched_grid_plan_does_not_clamp_cluster_launches() {
661
- let grid_x = plan_batched_grid_dim(16, 31, 32, true).expect("cluster path bypasses cooperative limit");
662
- assert_eq!(grid_x, 16);
663
- }
 
1
+ //! Parity tests: GPU SP vs CPU SP reference.
2
+ //!
3
+ //! With matching seeds the two should produce bit-identical active-column sets
4
+ //! when `learn=false`, and remain bit-identical over repeated `learn=true`
5
+ //! steps because the Hebbian update is deterministic (no RNG once initialised).
6
+ //!
7
+ //! Run with: cargo test --release --features gpu
8
+
9
+ #![cfg(test)]
10
+ #![cfg(feature = "gpu")]
11
+
12
+ use crate::sp::{SpatialPooler, SpatialPoolerConfig};
13
+ use crate::gpu::sp_gpu::SpatialPoolerGpu;
14
+ use crate::gpu::tm_gpu::TemporalMemoryGpu;
15
+ use crate::gpu::fused::{
16
+ launch_fused, plan_batched_grid_dim, plan_fused_launch, FusedState,
17
+ };
18
+ use cudarc::driver::CudaSlice;
19
+ use rand::{Rng, SeedableRng};
20
+ use rand_xoshiro::Xoshiro256PlusPlus;
21
+
22
+ fn make_sdr(rng: &mut Xoshiro256PlusPlus, bits: usize, sparsity: f32) -> Vec<u8> {
23
+ let on = ((sparsity * bits as f32) as usize).max(1);
24
+ let mut v = vec![0u8; bits];
25
+ let mut placed = 0;
26
+ while placed < on {
27
+ let i = rng.gen_range(0..bits);
28
+ if v[i] == 0 {
29
+ v[i] = 1;
30
+ placed += 1;
31
+ }
32
+ }
33
+ v
34
+ }
35
+
36
+ #[test]
37
+ fn gpu_sp_matches_cpu_no_learn() {
38
+ let cfg = SpatialPoolerConfig::default();
39
+ let bits = cfg.input_bits;
40
+ let mut cpu = SpatialPooler::new(
41
+ SpatialPoolerConfig { ..SpatialPoolerConfig::default() },
42
+ 1234,
43
+ );
44
+ let cpu_for_gpu = SpatialPooler::new(
45
+ SpatialPoolerConfig { ..SpatialPoolerConfig::default() },
46
+ 1234,
47
+ );
48
+ let mut gpu = SpatialPoolerGpu::from_cpu(&cpu_for_gpu)
49
+ .expect("gpu init (CUDA device available)");
50
+ gpu.set_strict_parity(true);
51
+
52
+ let mut rng = Xoshiro256PlusPlus::seed_from_u64(99);
53
+ for step in 0..20 {
54
+ let sdr_u8 = make_sdr(&mut rng, bits, 0.02);
55
+ let sdr_bool: Vec<bool> = sdr_u8.iter().map(|&x| x != 0).collect();
56
+
57
+ let cpu_active: Vec<u32> = cpu.compute(&sdr_bool, false);
58
+ let gpu_active: Vec<u32> = gpu.compute(&sdr_u8, false).expect("gpu compute");
59
+
60
+ assert_eq!(
61
+ cpu_active, gpu_active,
62
+ "mismatch at step {step}: len cpu={} gpu={}",
63
+ cpu_active.len(), gpu_active.len()
64
+ );
65
+ }
66
+ }
67
+
68
+ #[test]
69
+ fn gpu_sp_matches_cpu_with_learn() {
70
+ let cfg = SpatialPoolerConfig::default();
71
+ let bits = cfg.input_bits;
72
+ let mut cpu = SpatialPooler::new(
73
+ SpatialPoolerConfig { ..SpatialPoolerConfig::default() },
74
+ 5678,
75
+ );
76
+ let cpu_for_gpu = SpatialPooler::new(
77
+ SpatialPoolerConfig { ..SpatialPoolerConfig::default() },
78
+ 5678,
79
+ );
80
+ let mut gpu = SpatialPoolerGpu::from_cpu(&cpu_for_gpu).expect("gpu init");
81
+ gpu.set_strict_parity(true);
82
+
83
+ let mut rng = Xoshiro256PlusPlus::seed_from_u64(42);
84
+ for step in 0..50 {
85
+ let sdr_u8 = make_sdr(&mut rng, bits, 0.02);
86
+ let sdr_bool: Vec<bool> = sdr_u8.iter().map(|&x| x != 0).collect();
87
+
88
+ let cpu_active = cpu.compute(&sdr_bool, true);
89
+ let gpu_active = gpu.compute(&sdr_u8, true).expect("gpu compute");
90
+
91
+ assert_eq!(
92
+ cpu_active, gpu_active,
93
+ "mismatch at step {step} with learning"
94
+ );
95
+ }
96
+ }
97
+
98
+ #[test]
99
+ fn gpu_tm_anomaly_decays_on_repeating_sequence() {
100
+ // End-to-end GPU pipeline: SP feeds TM; repeating SDR sequence should drive
101
+ // anomaly down over time.
102
+ use crate::gpu::HTMRegionGpu; // not pyclass methods; use internal constructor via Rust
103
+ // Easier: replicate the pipeline directly with SP + TM.
104
+
105
+ let cfg = SpatialPoolerConfig::default();
106
+ let bits = cfg.input_bits;
107
+ let n_cols = cfg.n_columns;
108
+ let cells_per_col = 32usize;
109
+
110
+ let cpu_for_gpu = SpatialPooler::new(SpatialPoolerConfig::default(), 314);
111
+ let mut sp = SpatialPoolerGpu::from_cpu(&cpu_for_gpu).expect("gpu init");
112
+ let dev = sp.dev_ref().clone();
113
+ let mut tm = TemporalMemoryGpu::new(dev.clone(), n_cols, cells_per_col)
114
+ .expect("gpu tm init");
115
+ tm.reset().expect("tm reset");
116
+
117
+ // Build 3 fixed SDRs, feed them in a repeating sequence.
118
+ let mut rng = Xoshiro256PlusPlus::seed_from_u64(7);
119
+ let make = |rng: &mut Xoshiro256PlusPlus| make_sdr(rng, bits, 0.02);
120
+ let seqs = [make(&mut rng), make(&mut rng), make(&mut rng)];
121
+
122
+ // Warm up SP so columns are stable per symbol.
123
+ for _ in 0..100 {
124
+ for s in &seqs {
125
+ let _ = sp.compute(s, true).expect("sp compute");
126
+ }
127
+ }
128
+
129
+ // Build a long input buffer: 100 repetitions of [A,B,C] = 300 steps.
130
+ let repeats = 100usize;
131
+ let t = repeats * 3;
132
+ let mut inputs_flat = vec![0u8; t * bits];
133
+ for r in 0..repeats {
134
+ for (i, s) in seqs.iter().enumerate() {
135
+ let off = (r * 3 + i) * bits;
136
+ inputs_flat[off..off + bits].copy_from_slice(s);
137
+ }
138
+ }
139
+ let inputs_dev: CudaSlice<u8> = dev.htod_sync_copy(&inputs_flat).expect("htod");
140
+
141
+ let mut cols_dev = dev.alloc_zeros::<u8>(t * n_cols).expect("alloc cols");
142
+ let mut anom_dev = dev.alloc_zeros::<f32>(t).expect("alloc anom");
143
+
144
+ sp.step_batch_with_tm(
145
+ &inputs_dev,
146
+ t,
147
+ bits,
148
+ true,
149
+ &mut cols_dev,
150
+ &mut anom_dev,
151
+ &mut tm,
152
+ ).expect("step_batch_with_tm");
153
+
154
+ let anom: Vec<f32> = dev.dtoh_sync_copy(&anom_dev).expect("d2h anom");
155
+ let cols: Vec<u8> = dev.dtoh_sync_copy(&cols_dev).expect("d2h cols");
156
+
157
+ // Active column count per step must equal k for every step.
158
+ let k = ((cfg.sparsity * n_cols as f32).round() as usize).max(1);
159
+ for ti in 0..t {
160
+ let step_slice = &cols[ti * n_cols..(ti + 1) * n_cols];
161
+ let n_on = step_slice.iter().filter(|&&b| b != 0).count();
162
+ assert_eq!(n_on, k, "step {ti} has {n_on} active cols, expected {k}");
163
+ }
164
+
165
+ // First repetition: anomaly should be near 1.0 (nothing predicted).
166
+ let early_avg: f32 = anom[3..9].iter().sum::<f32>() / 6.0;
167
+ // Last repetitions: anomaly should be noticeably lower.
168
+ let late_avg: f32 = anom[(t - 9)..t].iter().sum::<f32>() / 9.0;
169
+ eprintln!("gpu tm: early anomaly = {early_avg:.3}, late = {late_avg:.3}");
170
+ assert!(
171
+ late_avg < early_avg,
172
+ "GPU TM should reduce anomaly on repeating sequence: early={early_avg:.3}, late={late_avg:.3}"
173
+ );
174
+ }
175
+
176
+ /// Cluster-sync smoke test: verifies that the fused megakernel (which relies on
177
+ /// hardware `cluster::sync()` / grid-barrier on H100/H200 Hopper) completes
178
+ /// without deadlock when called with real HTM state, and that output shapes are
179
+ /// sane (no NaN / Inf in anomaly scores, active-column count in plausible range).
180
+ ///
181
+ /// This is an *integration* test, not a synthetic micro-benchmark: it exercises
182
+ /// exactly the same `launch_fused` code path used in production, so any
183
+ /// deadlock in the cooperative-grid or DLB barrier would surface here.
184
+ ///
185
+ /// Skips gracefully (with an eprintln) when no GPU is available β€” the test
186
+ /// binary returns exit-code 0 in that case so CI still passes.
187
+ #[test]
188
+ fn cluster_sync_smoke_test() {
189
+ // Build a tiny HTM region (1024 inputs, 256 columns, 4 cells/column).
190
+ // This keeps VRAM usage minimal while still exercising all kernel paths.
191
+ let input_bits = 1024usize;
192
+ let n_columns = 256usize;
193
+ let cells_per_col = 4usize;
194
+
195
+ // Probe cooperative launch attribute before doing any real work.
196
+ // CU_DEVICE_ATTRIBUTE_CLUSTER_LAUNCH = 223 (added in CUDA 11.8 for Hopper).
197
+ // cudarc exposes raw attribute querying; we check cooperative launch (98)
198
+ // as the guard β€” cluster launch is a superset and not separately probed
199
+ // here since cudarc doesn't expose attribute 223 symbolically yet.
200
+ // On pre-Hopper hardware the DLB barrier path is used instead and the
201
+ // test still validates no deadlock on that path.
202
+
203
+ let make_cfg = || SpatialPoolerConfig {
204
+ input_bits,
205
+ n_columns,
206
+ sparsity: 0.04, // ~10 active cols out of 256
207
+ ..SpatialPoolerConfig::default()
208
+ };
209
+
210
+ let cpu_ref = SpatialPooler::new(make_cfg(), 42);
211
+
212
+ let mut sp = match SpatialPoolerGpu::from_cpu(&cpu_ref) {
213
+ Ok(sp) => sp,
214
+ Err(e) => {
215
+ eprintln!("[cluster_sync_smoke_test] No GPU available ({e:?}) β€” skipping");
216
+ return;
217
+ }
218
+ };
219
+
220
+ let dev = sp.dev_ref().clone();
221
+
222
+ // Check cooperative launch support; skip with a clear message if absent.
223
+ let cooperative_ok = matches!(
224
+ dev.attribute(cudarc::driver::sys::CUdevice_attribute::CU_DEVICE_ATTRIBUTE_COOPERATIVE_LAUNCH),
225
+ Ok(v) if v > 0
226
+ );
227
+ if !cooperative_ok {
228
+ eprintln!("[cluster_sync_smoke_test] CU_DEVICE_ATTRIBUTE_COOPERATIVE_LAUNCH=0 β€” DLB path only, still running test");
229
+ // We continue β€” the DLB path is the production fallback and must not deadlock either.
230
+ }
231
+
232
+ let mut tm = match TemporalMemoryGpu::new(dev.clone(), n_columns, cells_per_col) {
233
+ Ok(tm) => tm,
234
+ Err(e) => {
235
+ eprintln!("[cluster_sync_smoke_test] TemporalMemoryGpu::new failed ({e:?}) β€” skipping");
236
+ return;
237
+ }
238
+ };
239
+ tm.reset().expect("tm reset");
240
+
241
+ let mut fused_st: FusedState = match FusedState::new(
242
+ dev.clone(),
243
+ n_columns,
244
+ cells_per_col,
245
+ sp.initial_threshold_estimate(),
246
+ ) {
247
+ Ok(f) => f,
248
+ Err(e) => {
249
+ eprintln!("[cluster_sync_smoke_test] FusedState::new failed ({e:?}) β€” skipping");
250
+ return;
251
+ }
252
+ };
253
+ fused_st.reset().expect("fused reset");
254
+
255
+ // Build T=4 timesteps of all-zero input SDRs.
256
+ let t = 4usize;
257
+ let inputs_flat = vec![0u8; t * input_bits];
258
+ let inputs_dev: CudaSlice<u8> = dev.htod_sync_copy(&inputs_flat).expect("htod inputs");
259
+
260
+ let mut cols_dev = dev.alloc_zeros::<u8>(t * n_columns).expect("alloc cols");
261
+ let mut anom_dev = dev.alloc_zeros::<f32>(t).expect("alloc anom");
262
+
263
+ // Execute with a 2-second timeout guard via a thread. If the kernel
264
+ // deadlocks, the parent test process times out and the CI job reports
265
+ // failure β€” we can't cancel a live CUDA kernel from Rust, but the
266
+ // launch_fused call itself must return within this window on any sane GPU.
267
+ //
268
+ // We run the kernel inline (not in a separate thread) because CUDA contexts
269
+ // are not safely shareable across threads without explicit multi-threading
270
+ // setup. The 2-second bound is enforced implicitly: if the kernel deadlocks,
271
+ // the test binary will hang and the CI timeout (typically 5 min) will kill it.
272
+ // For local dev, the deadlock would be immediately obvious.
273
+
274
+ launch_fused(
275
+ &mut sp,
276
+ &mut tm,
277
+ &mut fused_st,
278
+ &inputs_dev,
279
+ &mut cols_dev,
280
+ &mut anom_dev,
281
+ t,
282
+ input_bits,
283
+ false, // learn=false for determinism
284
+ ).expect("launch_fused (cluster_sync_smoke_test): deadlock or CUDA error");
285
+
286
+ dev.synchronize().expect("device sync after launch_fused");
287
+
288
+ // --- Correctness assertions ---
289
+
290
+ let cols_host: Vec<u8> = dev.dtoh_sync_copy(&cols_dev).expect("d2h cols");
291
+ let anom_host: Vec<f32> = dev.dtoh_sync_copy(&anom_dev).expect("d2h anom");
292
+
293
+ // Output buffers must be exactly the right size.
294
+ assert_eq!(cols_host.len(), t * n_columns, "cols buffer size mismatch");
295
+ assert_eq!(anom_host.len(), t, "anom buffer size mismatch");
296
+
297
+ // Anomaly scores must be finite (NaN/Inf indicates numerical blow-up).
298
+ for (i, &a) in anom_host.iter().enumerate() {
299
+ assert!(a.is_finite(), "anomaly[{i}] is not finite: {a}");
300
+ assert!(a >= 0.0 && a <= 1.0, "anomaly[{i}] out of [0,1]: {a}");
301
+ }
302
+
303
+ // Active-column count per step: threshold-based inhibition, so 0 is
304
+ // possible on cold start (before thresholds calibrate), but we assert
305
+ // <= n_columns to catch buffer overruns or completely wrong output.
306
+ for ti in 0..t {
307
+ let n_on = cols_host[ti * n_columns..(ti + 1) * n_columns]
308
+ .iter()
309
+ .filter(|&&b| b != 0)
310
+ .count();
311
+ assert!(
312
+ n_on <= n_columns,
313
+ "step {ti}: active columns {n_on} > n_columns {n_columns} (buffer overrun?)"
314
+ );
315
+ }
316
+
317
+ eprintln!(
318
+ "[cluster_sync_smoke_test] PASSED: T={t}, n_cols={n_columns}, \
319
+ input_bits={input_bits}, cooperative_supported={cooperative_ok}, \
320
+ anom={anom_host:?}"
321
+ );
322
+ }
323
+
324
+ /// Parity check: the CAI zero-copy path (`step_many_cuda`) must produce
325
+ /// bit-identical outputs to the numpy H2D/D2H path (`step_batch_with_tm`),
326
+ /// since the kernel pipeline is the same β€” only the I/O wrapping changes.
327
+ /// We skip the PyO3 CAI dict plumbing here and test the underlying
328
+ /// ManuallyDrop + upgrade_device_ptr pattern directly.
329
+ #[test]
330
+ fn gpu_cuda_vs_numpy_parity() {
331
+ use std::mem::ManuallyDrop;
332
+
333
+ let cfg = SpatialPoolerConfig::default();
334
+ let bits = cfg.input_bits;
335
+ let n_cols = cfg.n_columns;
336
+ let cells_per_col = 32usize;
337
+
338
+ // Build two identical (SP, TM) pairs from the same seed.
339
+ let build = || -> (SpatialPoolerGpu, TemporalMemoryGpu) {
340
+ let cpu_ref = SpatialPooler::new(SpatialPoolerConfig::default(), 271828);
341
+ let sp = SpatialPoolerGpu::from_cpu(&cpu_ref).expect("gpu init");
342
+ let dev = sp.dev_ref().clone();
343
+ let mut tm = TemporalMemoryGpu::new(dev, n_cols, cells_per_col).expect("tm init");
344
+ tm.reset().expect("tm reset");
345
+ (sp, tm)
346
+ };
347
+
348
+ // Deterministic SDR sequence.
349
+ let mut rng = Xoshiro256PlusPlus::seed_from_u64(31337);
350
+ let t = 32usize;
351
+ let mut inputs_flat = vec![0u8; t * bits];
352
+ for i in 0..t {
353
+ let sdr = make_sdr(&mut rng, bits, 0.02);
354
+ inputs_flat[i * bits..(i + 1) * bits].copy_from_slice(&sdr);
355
+ }
356
+
357
+ // ---- Path A: owned CudaSlice (numpy-equivalent path) ----
358
+ let (mut sp_a, mut tm_a) = build();
359
+ let dev_a = sp_a.dev_ref().clone();
360
+ let inputs_a: CudaSlice<u8> = dev_a.htod_sync_copy(&inputs_flat).expect("htod");
361
+ let mut cols_a = dev_a.alloc_zeros::<u8>(t * n_cols).expect("alloc cols_a");
362
+ let mut anom_a = dev_a.alloc_zeros::<f32>(t).expect("alloc anom_a");
363
+ sp_a.step_batch_with_tm(&inputs_a, t, bits, false, &mut cols_a, &mut anom_a, &mut tm_a)
364
+ .expect("owned step_batch_with_tm");
365
+ dev_a.synchronize().expect("sync a");
366
+ let cols_a_host: Vec<u8> = dev_a.dtoh_sync_copy(&cols_a).expect("d2h cols_a");
367
+ let anom_a_host: Vec<f32> = dev_a.dtoh_sync_copy(&anom_a).expect("d2h anom_a");
368
+
369
+ // ---- Path B: borrowed device pointers via upgrade_device_ptr ----
370
+ // We allocate fresh owned CudaSlices on a fresh device, then take their
371
+ // raw ptrs and re-wrap as ManuallyDrop borrowed views β€” mimicking what
372
+ // `step_many_cuda` does with torch-owned CUDA memory.
373
+ let (mut sp_b, mut tm_b) = build();
374
+ let dev_b = sp_b.dev_ref().clone();
375
+ let inputs_b_owned: CudaSlice<u8> = dev_b.htod_sync_copy(&inputs_flat).expect("htod");
376
+ let cols_b_owned = dev_b.alloc_zeros::<u8>(t * n_cols).expect("alloc cols_b");
377
+ let anom_b_owned = dev_b.alloc_zeros::<f32>(t).expect("alloc anom_b");
378
+
379
+ // Extract raw CUdeviceptrs (and leak the owners so their Drop doesn't free).
380
+ let inputs_ptr = inputs_b_owned.leak();
381
+ let cols_ptr = cols_b_owned.leak();
382
+ let anom_ptr = anom_b_owned.leak();
383
+
384
+ // Re-wrap as borrowed views.
385
+ let inputs_b = ManuallyDrop::new(unsafe { dev_b.upgrade_device_ptr::<u8>(inputs_ptr, t * bits) });
386
+ let mut cols_b = ManuallyDrop::new(unsafe { dev_b.upgrade_device_ptr::<u8>(cols_ptr, t * n_cols) });
387
+ let mut anom_b = ManuallyDrop::new(unsafe { dev_b.upgrade_device_ptr::<f32>(anom_ptr, t) });
388
+
389
+ sp_b.step_batch_with_tm(&inputs_b, t, bits, false, &mut cols_b, &mut anom_b, &mut tm_b)
390
+ .expect("borrowed step_batch_with_tm");
391
+ dev_b.synchronize().expect("sync b");
392
+ // `ManuallyDrop` doesn't auto-coerce to `&CudaSlice<T>` for the DevicePtr
393
+ // trait bound on `dtoh_sync_copy`; explicit deref.
394
+ let cols_b_host: Vec<u8> = dev_b.dtoh_sync_copy(&*cols_b).expect("d2h cols_b");
395
+ let anom_b_host: Vec<f32> = dev_b.dtoh_sync_copy(&*anom_b).expect("d2h anom_b");
396
+
397
+ // Re-own so Drop actually frees (we leaked above).
398
+ let _inputs_owned_again = unsafe { dev_b.upgrade_device_ptr::<u8>(inputs_ptr, t * bits) };
399
+ let _cols_owned_again = unsafe { dev_b.upgrade_device_ptr::<u8>(cols_ptr, t * n_cols) };
400
+ let _anom_owned_again = unsafe { dev_b.upgrade_device_ptr::<f32>(anom_ptr, t) };
401
+
402
+ assert_eq!(cols_a_host, cols_b_host, "active-column mask diverges between numpy and CAI paths");
403
+ assert_eq!(anom_a_host.len(), anom_b_host.len());
404
+ for (i, (a, b)) in anom_a_host.iter().zip(anom_b_host.iter()).enumerate() {
405
+ // Anomaly is a pure division of integer counts β€” bit-exact expected.
406
+ assert!((a - b).abs() < 1e-7, "anomaly mismatch at step {i}: a={a} b={b}");
407
+ }
408
+ }
409
+
410
+ /// Fused kernel: threshold activation should converge to near target sparsity
411
+ /// after a short warmup. Acceptance: mean activation rate per step lands in
412
+ /// [0.3*target, 2.5*target] after 500-step warmup. Because the threshold
413
+ /// starts conservative (=2.0) and the per-column adaptation rate is slow
414
+ /// (0.001), we allow a generous band β€” the test asserts directional
415
+ /// convergence toward the target, not tight matching.
416
+ #[test]
417
+ fn gpu_threshold_converges_to_sparsity() {
418
+ let cfg = SpatialPoolerConfig::default();
419
+ let bits = cfg.input_bits;
420
+ let n_cols = cfg.n_columns;
421
+ let cells_per_col = 32usize;
422
+ let target = cfg.sparsity; // 0.02 = 40 cols expected
423
+
424
+ let cpu_ref = SpatialPooler::new(SpatialPoolerConfig::default(), 111);
425
+ let mut sp = SpatialPoolerGpu::from_cpu(&cpu_ref).expect("gpu sp init");
426
+ let dev = sp.dev_ref().clone();
427
+ let mut tm = TemporalMemoryGpu::new(dev.clone(), n_cols, cells_per_col).expect("tm init");
428
+ let mut fused = FusedState::new(
429
+ dev.clone(),
430
+ n_cols,
431
+ cells_per_col,
432
+ sp.initial_threshold_estimate(),
433
+ ).expect("fused init");
434
+ tm.reset().expect("tm reset");
435
+ fused.reset().expect("fused reset");
436
+
437
+ // Warmup: 1000 random 2%-sparse SDRs.
438
+ let mut rng = Xoshiro256PlusPlus::seed_from_u64(31337);
439
+ let t_warm = 1000usize;
440
+ let mut inputs = vec![0u8; t_warm * bits];
441
+ for ti in 0..t_warm {
442
+ let sdr = make_sdr(&mut rng, bits, 0.02);
443
+ inputs[ti*bits..(ti+1)*bits].copy_from_slice(&sdr);
444
+ }
445
+ let inputs_dev: CudaSlice<u8> = dev.htod_sync_copy(&inputs).expect("htod");
446
+ let mut cols_dev = dev.alloc_zeros::<u8>(t_warm * n_cols).expect("alloc cols");
447
+ let mut anom_dev = dev.alloc_zeros::<f32>(t_warm).expect("alloc anom");
448
+ launch_fused(
449
+ &mut sp, &mut tm, &mut fused,
450
+ &inputs_dev, &mut cols_dev, &mut anom_dev,
451
+ t_warm, bits, true,
452
+ ).expect("warmup launch");
453
+ dev.synchronize().expect("sync");
454
+
455
+ // Measurement pass: another 200 steps, measure mean activation.
456
+ let t_meas = 200usize;
457
+ let mut meas_inputs = vec![0u8; t_meas * bits];
458
+ for ti in 0..t_meas {
459
+ let sdr = make_sdr(&mut rng, bits, 0.02);
460
+ meas_inputs[ti*bits..(ti+1)*bits].copy_from_slice(&sdr);
461
+ }
462
+ let meas_dev: CudaSlice<u8> = dev.htod_sync_copy(&meas_inputs).expect("htod meas");
463
+ let mut meas_cols = dev.alloc_zeros::<u8>(t_meas * n_cols).expect("alloc meas cols");
464
+ let mut meas_anom = dev.alloc_zeros::<f32>(t_meas).expect("alloc meas anom");
465
+ launch_fused(
466
+ &mut sp, &mut tm, &mut fused,
467
+ &meas_dev, &mut meas_cols, &mut meas_anom,
468
+ t_meas, bits, true,
469
+ ).expect("meas launch");
470
+ dev.synchronize().expect("sync meas");
471
+
472
+ let cols_host: Vec<u8> = dev.dtoh_sync_copy(&meas_cols).expect("d2h");
473
+ let mut step_counts = Vec::with_capacity(t_meas);
474
+ for ti in 0..t_meas {
475
+ let n_on = cols_host[ti*n_cols..(ti+1)*n_cols]
476
+ .iter().filter(|&&b| b != 0).count();
477
+ step_counts.push(n_on);
478
+ }
479
+ let mean_active: f64 = step_counts.iter().map(|&c| c as f64).sum::<f64>()
480
+ / (t_meas as f64);
481
+ let target_active = target as f64 * n_cols as f64;
482
+ eprintln!(
483
+ "threshold-activation convergence: mean_active/step = {mean_active:.1} \
484
+ (target = {target_active:.1})"
485
+ );
486
+ // Very generous band β€” we just want to confirm the threshold loop is
487
+ // functioning (not diverged to 0 or to all-active).
488
+ assert!(
489
+ mean_active >= 0.25 * target_active && mean_active <= 4.0 * target_active,
490
+ "mean active {mean_active:.1} outside [0.25x, 4x] of target {target_active:.1}"
491
+ );
492
+ }
493
+
494
+ /// Fused kernel: TM should learn a repeating sequence β€” anomaly decays.
495
+ #[test]
496
+ fn gpu_fused_tm_anomaly_decays_on_repeating_sequence() {
497
+ let cfg = SpatialPoolerConfig::default();
498
+ let bits = cfg.input_bits;
499
+ let n_cols = cfg.n_columns;
500
+ let cells_per_col = 32usize;
501
+
502
+ let cpu_ref = SpatialPooler::new(SpatialPoolerConfig::default(), 271);
503
+ let mut sp = SpatialPoolerGpu::from_cpu(&cpu_ref).expect("gpu sp init");
504
+ let dev = sp.dev_ref().clone();
505
+ let mut tm = TemporalMemoryGpu::new(dev.clone(), n_cols, cells_per_col).expect("tm init");
506
+ let mut fused = FusedState::new(
507
+ dev.clone(),
508
+ n_cols,
509
+ cells_per_col,
510
+ sp.initial_threshold_estimate(),
511
+ ).expect("fused init");
512
+ tm.reset().expect("tm reset");
513
+ fused.reset().expect("fused reset");
514
+
515
+ let mut rng = Xoshiro256PlusPlus::seed_from_u64(7);
516
+ let make = |rng: &mut Xoshiro256PlusPlus| make_sdr(rng, bits, 0.02);
517
+ let seqs = [make(&mut rng), make(&mut rng), make(&mut rng)];
518
+
519
+ // Warmup SP threshold calibration with random SDRs first.
520
+ let warm = 300usize;
521
+ let mut warm_inputs = vec![0u8; warm * bits];
522
+ for ti in 0..warm {
523
+ let sdr = make_sdr(&mut rng, bits, 0.02);
524
+ warm_inputs[ti*bits..(ti+1)*bits].copy_from_slice(&sdr);
525
+ }
526
+ let warm_dev: CudaSlice<u8> = dev.htod_sync_copy(&warm_inputs).expect("htod warm");
527
+ let mut warm_cols = dev.alloc_zeros::<u8>(warm * n_cols).expect("alloc warm cols");
528
+ let mut warm_anom = dev.alloc_zeros::<f32>(warm).expect("alloc warm anom");
529
+ launch_fused(
530
+ &mut sp, &mut tm, &mut fused,
531
+ &warm_dev, &mut warm_cols, &mut warm_anom,
532
+ warm, bits, true,
533
+ ).expect("warm launch");
534
+ dev.synchronize().expect("sync warm");
535
+
536
+ // Feed repeating A,B,C sequence for 100 reps.
537
+ let repeats = 100usize;
538
+ let t = repeats * 3;
539
+ let mut inputs = vec![0u8; t * bits];
540
+ for r in 0..repeats {
541
+ for (i, s) in seqs.iter().enumerate() {
542
+ let off = (r*3 + i) * bits;
543
+ inputs[off..off+bits].copy_from_slice(s);
544
+ }
545
+ }
546
+ let inputs_dev: CudaSlice<u8> = dev.htod_sync_copy(&inputs).expect("htod rep");
547
+ let mut cols_dev = dev.alloc_zeros::<u8>(t * n_cols).expect("alloc rep cols");
548
+ let mut anom_dev = dev.alloc_zeros::<f32>(t).expect("alloc rep anom");
549
+ launch_fused(
550
+ &mut sp, &mut tm, &mut fused,
551
+ &inputs_dev, &mut cols_dev, &mut anom_dev,
552
+ t, bits, true,
553
+ ).expect("rep launch");
554
+ dev.synchronize().expect("sync rep");
555
+
556
+ let anom: Vec<f32> = dev.dtoh_sync_copy(&anom_dev).expect("d2h anom");
557
+ let early_avg: f32 = anom[3..12].iter().sum::<f32>() / 9.0;
558
+ let late_avg: f32 = anom[(t-9)..t].iter().sum::<f32>() / 9.0;
559
+ eprintln!("fused TM anomaly: early={early_avg:.3} late={late_avg:.3}");
560
+ assert!(
561
+ late_avg < early_avg,
562
+ "anomaly must decay: early={early_avg:.3} late={late_avg:.3}"
563
+ );
564
+ assert!(
565
+ late_avg < 0.5,
566
+ "late anomaly must be < 0.5 (got {late_avg:.3})"
567
+ );
568
+ }
569
+
570
+ #[test]
571
+ fn gpu_sp_yields_k_winners() {
572
+ let cfg = SpatialPoolerConfig::default();
573
+ let bits = cfg.input_bits;
574
+ let n = cfg.n_columns;
575
+ let expected_k = ((cfg.sparsity * n as f32).round() as usize).max(1);
576
+ let cpu = SpatialPooler::new(SpatialPoolerConfig::default(), 7);
577
+ let mut gpu = SpatialPoolerGpu::from_cpu(&cpu).expect("gpu init");
578
+
579
+ let mut rng = Xoshiro256PlusPlus::seed_from_u64(1);
580
+ for _ in 0..10 {
581
+ let sdr_u8 = make_sdr(&mut rng, bits, 0.02);
582
+ let active = gpu.compute(&sdr_u8, false).expect("gpu compute");
583
+ assert_eq!(active.len(), expected_k);
584
+ // Ensure sorted + unique.
585
+ for w in active.windows(2) {
586
+ assert!(w[0] < w[1], "duplicate or out-of-order winner indices");
587
+ }
588
+ }
589
+ }
590
+
591
+ #[test]
592
+ fn fused_launch_plan_uses_cooperative_grid_sync() {
593
+ let plan = plan_fused_launch(30, true, 30, None).expect("cooperative supported");
594
+ assert_eq!(plan.grid_dim_x, 16);
595
+ assert_eq!(plan.cooperative_grid_limit, 30);
596
+ }
597
+
598
+ #[test]
599
+ fn fused_launch_plan_scales_to_big_gpu() {
600
+ // H200-like: 132 SMs, high cooperative_grid_limit. Cap still applies.
601
+ let plan = plan_fused_launch(132, true, 1000, None).expect("cooperative supported");
602
+ assert_eq!(plan.grid_dim_x, 16); // capped by default override
603
+ let plan = plan_fused_launch(132, true, 1000, Some(64)).expect("cooperative supported");
604
+ assert_eq!(plan.grid_dim_x, 64); // override raises the cap
605
+ }
606
+
607
+ #[test]
608
+ fn fused_launch_plan_refuses_non_cooperative_devices() {
609
+ // The slow path was removed. Devices without cooperative launch fail fast.
610
+ let err = plan_fused_launch(30, false, 0, None).unwrap_err();
611
+ assert!(err.contains("cooperative launch"));
612
+ }
613
+
614
+ #[test]
615
+ fn fused_grid_cap_env_override_is_honored() {
616
+ let cfg = SpatialPoolerConfig::default();
617
+ let cpu_ref = SpatialPooler::new(SpatialPoolerConfig::default(), 5252);
618
+ let sp = SpatialPoolerGpu::from_cpu(&cpu_ref).expect("gpu sp init");
619
+ let dev = sp.dev_ref().clone();
620
+
621
+ unsafe { std::env::set_var("HTM_FUSED_GRID_CAP", "12"); }
622
+ let fused = FusedState::new(
623
+ dev.clone(),
624
+ cfg.n_columns,
625
+ 32usize,
626
+ sp.initial_threshold_estimate(),
627
+ ).expect("fused init");
628
+ unsafe { std::env::remove_var("HTM_FUSED_GRID_CAP"); }
629
+
630
+ let sm_count = match dev.attribute(
631
+ cudarc::driver::sys::CUdevice_attribute::CU_DEVICE_ATTRIBUTE_MULTIPROCESSOR_COUNT,
632
+ ) {
633
+ Ok(v) => v as u32,
634
+ Err(_) => 16u32,
635
+ };
636
+ let expected = sm_count.max(1).min(12);
637
+ assert_eq!(
638
+ fused.grid_dim_x,
639
+ expected,
640
+ "fused grid cap env override ignored: expected min(sm_count, 12) = {expected}, got {}",
641
+ fused.grid_dim_x,
642
+ );
643
+ }
644
+
645
+ #[test]
646
+ fn batched_grid_plan_clamps_a10g_batch32_under_cooperative_limit() {
647
+ // A10G observed in HF Jobs: cooperative_grid_limit=400, B=32.
648
+ // grid_x=16 requests 512 cooperative blocks and fails; clamp to 12.
649
+ let grid_x = plan_batched_grid_dim(16, 400, 32, false).expect("fits after clamp");
650
+ assert_eq!(grid_x, 12);
651
+ }
652
+
653
+ #[test]
654
+ fn batched_grid_plan_reports_oversized_batch() {
655
+ let err = plan_batched_grid_dim(16, 31, 32, false).unwrap_err();
656
+ assert!(err.contains("COOPERATIVE_LAUNCH_TOO_LARGE"));
657
+ }
658
+
659
+ #[test]
660
+ fn batched_grid_plan_does_not_clamp_cluster_launches() {
661
+ let grid_x = plan_batched_grid_dim(16, 31, 32, true).expect("cluster path bypasses cooperative limit");
662
+ assert_eq!(grid_x, 16);
663
+ }
overlay/htm_rust/src/gpu/tm_gpu.rs CHANGED
@@ -1,460 +1,460 @@
1
- //! GPU Temporal Memory.
2
- //!
3
- //! Flat device storage. Pre-allocated segment slab:
4
- //! n_cells = n_columns * cells_per_column
5
- //! n_segments_max = n_cells * MAX_SEGMENTS_PER_CELL
6
- //! n_synapses_max = n_segments_max * MAX_SYN_PER_SEGMENT
7
- //!
8
- //! Defaults (CPU parity targets relaxed on GPU to keep memory tractable):
9
- //! MAX_SEGMENTS_PER_CELL = 16
10
- //! MAX_SYN_PER_SEGMENT = 32
11
- //!
12
- //! At n_cells = 65536:
13
- //! n_segments_max = 1_048_576 (~1M)
14
- //! n_synapses_max = 33_554_432 (~33M)
15
- //! Storage:
16
- //! syn_presyn : u32 Γ— 33M = 128 MB
17
- //! syn_perm : i16 Γ— 33M = 64 MB
18
- //! seg_cell : u32 Γ— 1M = 4 MB
19
- //! seg_syn_n : u32 Γ— 1M = 4 MB
20
- //! misc bitsets etc ~ <1 MB
21
- //! -------------------------------
22
- //! Total per region ~200 MB
23
- //!
24
- //! Permanences are stored as i16 scaled by 32767 (β†’ [0, 32767] represents
25
- //! [0.0, 1.0]). inc/dec are provided pre-scaled.
26
-
27
- use std::sync::Arc;
28
-
29
- use cudarc::driver::{CudaDevice, CudaSlice, DriverError, DeviceRepr, LaunchAsync, LaunchConfig};
30
- use cudarc::nvrtc::Ptx;
31
-
32
- /// Packed config struct passed by value to TM kernels to stay under
33
- /// cudarc's 12-tuple launch limit. Layout must match the C-side
34
- /// `TmConfig` struct declared in each kernel.
35
- #[repr(C)]
36
- #[derive(Clone, Copy)]
37
- pub struct TmConfig {
38
- pub activation_threshold: u32,
39
- pub learning_threshold: u32,
40
- pub cells_per_column: u32,
41
- pub synapses_per_segment: u32,
42
- pub n_segments: u32,
43
- pub n_cells: u32,
44
- pub max_segments_per_cell: u32,
45
- pub max_new_synapses: u32,
46
- pub conn_thr_i16: i32, // i16 widened to i32 for alignment
47
- pub perm_inc_i16: i32,
48
- pub perm_dec_i16: i32,
49
- pub predicted_seg_dec_i16: i32,
50
- pub initial_perm_i16: i32,
51
- pub iter_seed: u32,
52
- pub n_cols: u32,
53
- pub bits_words: u32,
54
- }
55
-
56
- unsafe impl DeviceRepr for TmConfig {}
57
-
58
- // Embedded PTX.
59
- const PTX_TM_PREDICT: &str = include_str!(concat!(env!("HTM_GPU_PTX_DIR"), "/tm_predict.ptx"));
60
- const PTX_TM_ACTIVATE: &str = include_str!(concat!(env!("HTM_GPU_PTX_DIR"), "/tm_activate.ptx"));
61
- const PTX_TM_LEARN: &str = include_str!(concat!(env!("HTM_GPU_PTX_DIR"), "/tm_learn.ptx"));
62
- const PTX_TM_PUNISH: &str = include_str!(concat!(env!("HTM_GPU_PTX_DIR"), "/tm_punish.ptx"));
63
- const PTX_TM_GROW: &str = include_str!(concat!(env!("HTM_GPU_PTX_DIR"), "/tm_grow.ptx"));
64
- const PTX_TM_ANOMALY: &str = include_str!(concat!(env!("HTM_GPU_PTX_DIR"), "/tm_anomaly.ptx"));
65
- const PTX_TM_RESET: &str = include_str!(concat!(env!("HTM_GPU_PTX_DIR"), "/tm_reset.ptx"));
66
-
67
- /// Capacity trade-offs for 6 GB VRAM (RTX 3060) shared with the model:
68
- /// n_cells = 2048 Γ— 32 = 65_536
69
- /// n_segments_max = n_cells Γ— MAX_SEGMENTS_PER_CELL
70
- /// n_synapses_max = n_segments_max Γ— MAX_SYN_PER_SEGMENT
71
- ///
72
- /// At 4/20 these are 262_144 segments and ~5.2M synapses (~50 MB per region).
73
- /// The training loop runs with `reset_each_forward=True`, so segment counts
74
- /// per window stay well below 32K (typical: ~n_cols new segs per step until
75
- /// the first matching segment is reused; in a 2048-step window that plateaus
76
- /// around ~5K total live segments). The 262K ceiling is generous headroom.
77
- pub const MAX_SEGMENTS_PER_CELL: usize = 4;
78
- pub const MAX_SYN_PER_SEGMENT: usize = 20;
79
-
80
- const PERM_SCALE: f32 = 32767.0;
81
-
82
- fn perm_f32_to_i16(x: f32) -> i16 {
83
- let clamped = x.clamp(0.0, 1.0);
84
- (clamped * PERM_SCALE).round() as i16
85
- }
86
-
87
- pub struct TemporalMemoryGpu {
88
- dev: Arc<CudaDevice>,
89
-
90
- // Config mirror
91
- pub n_columns: usize,
92
- pub cells_per_column: usize,
93
- pub activation_threshold: u32,
94
- pub learning_threshold: u32,
95
- pub initial_perm_i16: i16,
96
- pub conn_thr_i16: i16,
97
- pub perm_inc_i16: i16,
98
- pub perm_dec_i16: i16,
99
- pub predicted_seg_dec_i16: i16,
100
- pub max_new_synapse_count: u32,
101
-
102
- // Sizes
103
- pub n_cells: usize,
104
- pub n_segments_max: usize,
105
- pub bits_words: usize, // n_cells / 32
106
-
107
- // Persistent device buffers
108
- seg_cell_id: CudaSlice<u32>,
109
- seg_syn_count: CudaSlice<u32>,
110
- syn_presyn: CudaSlice<u32>,
111
- syn_perm: CudaSlice<i16>,
112
- cell_seg_count: CudaSlice<u32>,
113
-
114
- cell_active_bits: CudaSlice<u32>,
115
- cell_winner_bits: CudaSlice<u32>,
116
- cell_predictive_bits: CudaSlice<u32>,
117
- prev_active_bits: CudaSlice<u32>,
118
- prev_winner_bits: CudaSlice<u32>,
119
-
120
- col_predicted: CudaSlice<u8>,
121
- seg_num_active_conn: CudaSlice<u32>,
122
- seg_num_active_pot: CudaSlice<u32>,
123
- unpredicted_count: CudaSlice<u32>,
124
- burst_cols_flat: CudaSlice<u32>,
125
- burst_cols_count: CudaSlice<u32>,
126
- col_best_match: CudaSlice<u32>,
127
-
128
- iter_counter: u32,
129
- }
130
-
131
- impl TemporalMemoryGpu {
132
- pub fn new(
133
- dev: Arc<CudaDevice>,
134
- n_columns: usize,
135
- cells_per_column: usize,
136
- ) -> Result<Self, DriverError> {
137
- let n_cells = n_columns * cells_per_column;
138
- assert!(n_cells % 32 == 0, "n_cells must be divisible by 32 for bitsets");
139
- let n_segments_max = n_cells * MAX_SEGMENTS_PER_CELL;
140
- let bits_words = n_cells / 32;
141
-
142
- // Numenta defaults.
143
- let activation_threshold = 15u32;
144
- let learning_threshold = 13u32;
145
- let initial_perm_i16 = perm_f32_to_i16(0.21);
146
- let conn_thr_i16 = perm_f32_to_i16(0.50);
147
- let perm_inc_i16 = perm_f32_to_i16(0.10);
148
- let perm_dec_i16 = perm_f32_to_i16(0.10);
149
- let predicted_seg_dec_i16 = perm_f32_to_i16(0.10);
150
- let max_new_synapse_count = 20u32;
151
-
152
- // Allocate buffers.
153
- let seg_cell_id_host: Vec<u32> = vec![u32::MAX; n_segments_max];
154
- let seg_cell_id = dev.htod_sync_copy(&seg_cell_id_host)?;
155
- let seg_syn_count = dev.alloc_zeros::<u32>(n_segments_max)?;
156
- let syn_presyn = dev.alloc_zeros::<u32>(n_segments_max * MAX_SYN_PER_SEGMENT)?;
157
- let syn_perm = dev.alloc_zeros::<i16>(n_segments_max * MAX_SYN_PER_SEGMENT)?;
158
- let cell_seg_count = dev.alloc_zeros::<u32>(n_cells)?;
159
-
160
- let cell_active_bits = dev.alloc_zeros::<u32>(bits_words)?;
161
- let cell_winner_bits = dev.alloc_zeros::<u32>(bits_words)?;
162
- let cell_predictive_bits = dev.alloc_zeros::<u32>(bits_words)?;
163
- let prev_active_bits = dev.alloc_zeros::<u32>(bits_words)?;
164
- let prev_winner_bits = dev.alloc_zeros::<u32>(bits_words)?;
165
-
166
- let col_predicted = dev.alloc_zeros::<u8>(n_columns)?;
167
- let seg_num_active_conn = dev.alloc_zeros::<u32>(n_segments_max)?;
168
- let seg_num_active_pot = dev.alloc_zeros::<u32>(n_segments_max)?;
169
- let unpredicted_count = dev.alloc_zeros::<u32>(1)?;
170
- // Bursting columns for one step bounded by n_columns.
171
- let burst_cols_flat = dev.alloc_zeros::<u32>(n_columns)?;
172
- let burst_cols_count = dev.alloc_zeros::<u32>(1)?;
173
- let col_best_match = dev.alloc_zeros::<u32>(n_columns)?;
174
-
175
- // Load PTX modules.
176
- let modules = [
177
- ("htm_tm_predict", PTX_TM_PREDICT, "tm_predict"),
178
- ("htm_tm_activate", PTX_TM_ACTIVATE, "tm_activate"),
179
- ("htm_tm_learn", PTX_TM_LEARN, "tm_learn_reinforce"),
180
- ("htm_tm_punish", PTX_TM_PUNISH, "tm_punish"),
181
- ("htm_tm_grow", PTX_TM_GROW, "tm_grow"),
182
- ("htm_tm_anomaly", PTX_TM_ANOMALY, "tm_anomaly"),
183
- ("htm_tm_reset", PTX_TM_RESET, "tm_reset_step"),
184
- ];
185
- for (modname, ptx, fnname) in modules {
186
- if dev.get_func(modname, fnname).is_none() {
187
- dev.load_ptx(Ptx::from_src(ptx), modname, &[fnname])?;
188
- }
189
- }
190
-
191
- Ok(Self {
192
- dev,
193
- n_columns,
194
- cells_per_column,
195
- activation_threshold,
196
- learning_threshold,
197
- initial_perm_i16,
198
- conn_thr_i16,
199
- perm_inc_i16,
200
- perm_dec_i16,
201
- predicted_seg_dec_i16,
202
- max_new_synapse_count,
203
- n_cells,
204
- n_segments_max,
205
- bits_words,
206
- seg_cell_id,
207
- seg_syn_count,
208
- syn_presyn,
209
- syn_perm,
210
- cell_seg_count,
211
- cell_active_bits,
212
- cell_winner_bits,
213
- cell_predictive_bits,
214
- prev_active_bits,
215
- prev_winner_bits,
216
- col_predicted,
217
- seg_num_active_conn,
218
- seg_num_active_pot,
219
- unpredicted_count,
220
- burst_cols_flat,
221
- burst_cols_count,
222
- col_best_match,
223
- iter_counter: 0,
224
- })
225
- }
226
-
227
- // --- Fused-path accessors ---
228
- pub fn seg_cell_id_accessor(&self) -> &CudaSlice<u32> { &self.seg_cell_id }
229
- pub fn seg_syn_count_accessor(&self) -> &CudaSlice<u32> { &self.seg_syn_count }
230
- pub fn syn_presyn_accessor(&self) -> &CudaSlice<u32> { &self.syn_presyn }
231
- pub fn syn_perm_accessor(&self) -> &CudaSlice<i16> { &self.syn_perm }
232
- pub fn cell_seg_count_accessor(&self) -> &CudaSlice<u32> { &self.cell_seg_count }
233
-
234
- /// Hard reset β€” clear everything (predictive + active + segments).
235
- pub fn reset(&mut self) -> Result<(), DriverError> {
236
- // Restore "unused" sentinel in seg_cell_id.
237
- let unused_host: Vec<u32> = vec![u32::MAX; self.n_segments_max];
238
- self.dev.htod_sync_copy_into(&unused_host, &mut self.seg_cell_id)?;
239
- self.dev.memset_zeros(&mut self.seg_syn_count)?;
240
- self.dev.memset_zeros(&mut self.cell_seg_count)?;
241
- self.dev.memset_zeros(&mut self.cell_active_bits)?;
242
- self.dev.memset_zeros(&mut self.cell_winner_bits)?;
243
- self.dev.memset_zeros(&mut self.cell_predictive_bits)?;
244
- self.dev.memset_zeros(&mut self.prev_active_bits)?;
245
- self.dev.memset_zeros(&mut self.prev_winner_bits)?;
246
- self.dev.memset_zeros(&mut self.col_best_match)?;
247
- self.iter_counter = 0;
248
- Ok(())
249
- }
250
-
251
- fn build_cfg(&self) -> TmConfig {
252
- TmConfig {
253
- activation_threshold: self.activation_threshold,
254
- learning_threshold: self.learning_threshold,
255
- cells_per_column: self.cells_per_column as u32,
256
- synapses_per_segment: MAX_SYN_PER_SEGMENT as u32,
257
- n_segments: self.n_segments_max as u32,
258
- n_cells: self.n_cells as u32,
259
- max_segments_per_cell: MAX_SEGMENTS_PER_CELL as u32,
260
- max_new_synapses: self.max_new_synapse_count,
261
- conn_thr_i16: self.conn_thr_i16 as i32,
262
- perm_inc_i16: self.perm_inc_i16 as i32,
263
- perm_dec_i16: self.perm_dec_i16 as i32,
264
- predicted_seg_dec_i16: self.predicted_seg_dec_i16 as i32,
265
- initial_perm_i16: self.initial_perm_i16 as i32,
266
- iter_seed: self.iter_counter,
267
- n_cols: self.n_columns as u32,
268
- bits_words: self.bits_words as u32,
269
- }
270
- }
271
-
272
- /// Run one TM step on the GPU. Takes the SP active-column mask (u8, already
273
- /// on device) and writes `anomaly_out[t_slot]`.
274
- pub fn step(
275
- &mut self,
276
- sp_active_mask: &CudaSlice<u8>,
277
- anomaly_out: &mut CudaSlice<f32>,
278
- t_slot: u32,
279
- learn: bool,
280
- ) -> Result<(), DriverError> {
281
- let n_cells = self.n_cells;
282
- let n_cols = self.n_columns;
283
-
284
- let predict_fn = self.dev.get_func("htm_tm_predict", "tm_predict").unwrap();
285
- let activate_fn = self.dev.get_func("htm_tm_activate", "tm_activate").unwrap();
286
- let learn_fn = self.dev.get_func("htm_tm_learn", "tm_learn_reinforce").unwrap();
287
- let punish_fn = self.dev.get_func("htm_tm_punish", "tm_punish").unwrap();
288
- let grow_fn = self.dev.get_func("htm_tm_grow", "tm_grow").unwrap();
289
- let anom_fn = self.dev.get_func("htm_tm_anomaly", "tm_anomaly").unwrap();
290
- let reset_fn = self.dev.get_func("htm_tm_reset", "tm_reset_step").unwrap();
291
-
292
- self.iter_counter = self.iter_counter.wrapping_add(1);
293
- let cfg_val = self.build_cfg();
294
-
295
- // 0. Per-step reset.
296
- let reset_words = self.bits_words.max(n_cols);
297
- let reset_cfg = LaunchConfig {
298
- grid_dim: (((reset_words + 255) / 256) as u32, 1, 1),
299
- block_dim: (256, 1, 1),
300
- shared_mem_bytes: 0,
301
- };
302
- unsafe {
303
- reset_fn.clone().launch(
304
- reset_cfg,
305
- (
306
- &mut self.cell_active_bits,
307
- &mut self.cell_winner_bits,
308
- &mut self.cell_predictive_bits,
309
- &mut self.prev_active_bits,
310
- &mut self.prev_winner_bits,
311
- &mut self.col_predicted,
312
- &mut self.unpredicted_count,
313
- &mut self.burst_cols_count,
314
- &mut self.col_best_match,
315
- self.bits_words as u32,
316
- n_cols as u32,
317
- ),
318
- )?;
319
- }
320
-
321
- // 1. Predict (grid = n_cells; each block iterates its cell's segments).
322
- let predict_cfg = LaunchConfig {
323
- grid_dim: (n_cells as u32, 1, 1),
324
- block_dim: (32, 1, 1),
325
- shared_mem_bytes: 0,
326
- };
327
- unsafe {
328
- predict_fn.clone().launch(
329
- predict_cfg,
330
- (
331
- &self.seg_cell_id,
332
- &self.seg_syn_count,
333
- &self.syn_presyn,
334
- &self.syn_perm,
335
- &self.prev_active_bits,
336
- &mut self.cell_predictive_bits,
337
- &mut self.col_predicted,
338
- &mut self.seg_num_active_conn,
339
- &mut self.seg_num_active_pot,
340
- &mut self.col_best_match,
341
- &self.cell_seg_count,
342
- cfg_val,
343
- ),
344
- )?;
345
- }
346
-
347
- // 2. Activate.
348
- let activate_cfg = LaunchConfig {
349
- grid_dim: (((n_cols + 255) / 256) as u32, 1, 1),
350
- block_dim: (256, 1, 1),
351
- shared_mem_bytes: 0,
352
- };
353
- unsafe {
354
- activate_fn.clone().launch(
355
- activate_cfg,
356
- (
357
- sp_active_mask,
358
- &self.col_predicted,
359
- &self.cell_predictive_bits,
360
- &mut self.cell_active_bits,
361
- &mut self.cell_winner_bits,
362
- &mut self.unpredicted_count,
363
- &mut self.burst_cols_flat,
364
- &mut self.burst_cols_count,
365
- cfg_val,
366
- ),
367
- )?;
368
- }
369
-
370
- // 3. Anomaly.
371
- let anom_cfg = LaunchConfig {
372
- grid_dim: (1, 1, 1),
373
- block_dim: (256, 1, 1),
374
- shared_mem_bytes: 0,
375
- };
376
- unsafe {
377
- anom_fn.clone().launch(
378
- anom_cfg,
379
- (
380
- sp_active_mask,
381
- &self.unpredicted_count,
382
- anomaly_out,
383
- t_slot,
384
- n_cols as u32,
385
- ),
386
- )?;
387
- }
388
-
389
- if learn {
390
- // 4. Reinforce (grid = n_cells).
391
- let learn_cfg = LaunchConfig {
392
- grid_dim: (n_cells as u32, 1, 1),
393
- block_dim: (32, 1, 1),
394
- shared_mem_bytes: 0,
395
- };
396
- unsafe {
397
- learn_fn.clone().launch(
398
- learn_cfg,
399
- (
400
- &self.seg_cell_id,
401
- &self.seg_syn_count,
402
- &self.syn_presyn,
403
- &mut self.syn_perm,
404
- &self.seg_num_active_conn,
405
- &self.prev_active_bits,
406
- sp_active_mask,
407
- &self.col_predicted,
408
- &self.cell_seg_count,
409
- cfg_val,
410
- ),
411
- )?;
412
- }
413
-
414
- // 5. Punish.
415
- unsafe {
416
- punish_fn.clone().launch(
417
- learn_cfg,
418
- (
419
- &self.seg_cell_id,
420
- &self.seg_syn_count,
421
- &self.syn_presyn,
422
- &mut self.syn_perm,
423
- &self.seg_num_active_pot,
424
- &self.prev_active_bits,
425
- sp_active_mask,
426
- &self.cell_seg_count,
427
- cfg_val,
428
- ),
429
- )?;
430
- }
431
-
432
- // 6. Grow.
433
- let grow_cfg = LaunchConfig {
434
- grid_dim: (n_cols as u32, 1, 1),
435
- block_dim: (32, 1, 1),
436
- shared_mem_bytes: 0,
437
- };
438
- unsafe {
439
- grow_fn.clone().launch(
440
- grow_cfg,
441
- (
442
- &mut self.seg_cell_id,
443
- &mut self.seg_syn_count,
444
- &mut self.syn_presyn,
445
- &mut self.syn_perm,
446
- &mut self.cell_seg_count,
447
- &self.burst_cols_flat,
448
- &self.burst_cols_count,
449
- &self.prev_winner_bits,
450
- &self.prev_active_bits,
451
- &self.col_best_match,
452
- cfg_val,
453
- ),
454
- )?;
455
- }
456
- }
457
-
458
- Ok(())
459
- }
460
- }
 
1
+ //! GPU Temporal Memory.
2
+ //!
3
+ //! Flat device storage. Pre-allocated segment slab:
4
+ //! n_cells = n_columns * cells_per_column
5
+ //! n_segments_max = n_cells * MAX_SEGMENTS_PER_CELL
6
+ //! n_synapses_max = n_segments_max * MAX_SYN_PER_SEGMENT
7
+ //!
8
+ //! Defaults (CPU parity targets relaxed on GPU to keep memory tractable):
9
+ //! MAX_SEGMENTS_PER_CELL = 16
10
+ //! MAX_SYN_PER_SEGMENT = 32
11
+ //!
12
+ //! At n_cells = 65536:
13
+ //! n_segments_max = 1_048_576 (~1M)
14
+ //! n_synapses_max = 33_554_432 (~33M)
15
+ //! Storage:
16
+ //! syn_presyn : u32 Γ— 33M = 128 MB
17
+ //! syn_perm : i16 Γ— 33M = 64 MB
18
+ //! seg_cell : u32 Γ— 1M = 4 MB
19
+ //! seg_syn_n : u32 Γ— 1M = 4 MB
20
+ //! misc bitsets etc ~ <1 MB
21
+ //! -------------------------------
22
+ //! Total per region ~200 MB
23
+ //!
24
+ //! Permanences are stored as i16 scaled by 32767 (β†’ [0, 32767] represents
25
+ //! [0.0, 1.0]). inc/dec are provided pre-scaled.
26
+
27
+ use std::sync::Arc;
28
+
29
+ use cudarc::driver::{CudaDevice, CudaSlice, DriverError, DeviceRepr, LaunchAsync, LaunchConfig};
30
+ use cudarc::nvrtc::Ptx;
31
+
32
+ /// Packed config struct passed by value to TM kernels to stay under
33
+ /// cudarc's 12-tuple launch limit. Layout must match the C-side
34
+ /// `TmConfig` struct declared in each kernel.
35
+ #[repr(C)]
36
+ #[derive(Clone, Copy)]
37
+ pub struct TmConfig {
38
+ pub activation_threshold: u32,
39
+ pub learning_threshold: u32,
40
+ pub cells_per_column: u32,
41
+ pub synapses_per_segment: u32,
42
+ pub n_segments: u32,
43
+ pub n_cells: u32,
44
+ pub max_segments_per_cell: u32,
45
+ pub max_new_synapses: u32,
46
+ pub conn_thr_i16: i32, // i16 widened to i32 for alignment
47
+ pub perm_inc_i16: i32,
48
+ pub perm_dec_i16: i32,
49
+ pub predicted_seg_dec_i16: i32,
50
+ pub initial_perm_i16: i32,
51
+ pub iter_seed: u32,
52
+ pub n_cols: u32,
53
+ pub bits_words: u32,
54
+ }
55
+
56
+ unsafe impl DeviceRepr for TmConfig {}
57
+
58
+ // Embedded PTX.
59
+ const PTX_TM_PREDICT: &str = include_str!(concat!(env!("HTM_GPU_PTX_DIR"), "/tm_predict.ptx"));
60
+ const PTX_TM_ACTIVATE: &str = include_str!(concat!(env!("HTM_GPU_PTX_DIR"), "/tm_activate.ptx"));
61
+ const PTX_TM_LEARN: &str = include_str!(concat!(env!("HTM_GPU_PTX_DIR"), "/tm_learn.ptx"));
62
+ const PTX_TM_PUNISH: &str = include_str!(concat!(env!("HTM_GPU_PTX_DIR"), "/tm_punish.ptx"));
63
+ const PTX_TM_GROW: &str = include_str!(concat!(env!("HTM_GPU_PTX_DIR"), "/tm_grow.ptx"));
64
+ const PTX_TM_ANOMALY: &str = include_str!(concat!(env!("HTM_GPU_PTX_DIR"), "/tm_anomaly.ptx"));
65
+ const PTX_TM_RESET: &str = include_str!(concat!(env!("HTM_GPU_PTX_DIR"), "/tm_reset.ptx"));
66
+
67
+ /// Capacity trade-offs for 6 GB VRAM (RTX 3060) shared with the model:
68
+ /// n_cells = 2048 Γ— 32 = 65_536
69
+ /// n_segments_max = n_cells Γ— MAX_SEGMENTS_PER_CELL
70
+ /// n_synapses_max = n_segments_max Γ— MAX_SYN_PER_SEGMENT
71
+ ///
72
+ /// At 4/20 these are 262_144 segments and ~5.2M synapses (~50 MB per region).
73
+ /// The training loop runs with `reset_each_forward=True`, so segment counts
74
+ /// per window stay well below 32K (typical: ~n_cols new segs per step until
75
+ /// the first matching segment is reused; in a 2048-step window that plateaus
76
+ /// around ~5K total live segments). The 262K ceiling is generous headroom.
77
+ pub const MAX_SEGMENTS_PER_CELL: usize = 4;
78
+ pub const MAX_SYN_PER_SEGMENT: usize = 20;
79
+
80
+ const PERM_SCALE: f32 = 32767.0;
81
+
82
+ fn perm_f32_to_i16(x: f32) -> i16 {
83
+ let clamped = x.clamp(0.0, 1.0);
84
+ (clamped * PERM_SCALE).round() as i16
85
+ }
86
+
87
+ pub struct TemporalMemoryGpu {
88
+ dev: Arc<CudaDevice>,
89
+
90
+ // Config mirror
91
+ pub n_columns: usize,
92
+ pub cells_per_column: usize,
93
+ pub activation_threshold: u32,
94
+ pub learning_threshold: u32,
95
+ pub initial_perm_i16: i16,
96
+ pub conn_thr_i16: i16,
97
+ pub perm_inc_i16: i16,
98
+ pub perm_dec_i16: i16,
99
+ pub predicted_seg_dec_i16: i16,
100
+ pub max_new_synapse_count: u32,
101
+
102
+ // Sizes
103
+ pub n_cells: usize,
104
+ pub n_segments_max: usize,
105
+ pub bits_words: usize, // n_cells / 32
106
+
107
+ // Persistent device buffers
108
+ seg_cell_id: CudaSlice<u32>,
109
+ seg_syn_count: CudaSlice<u32>,
110
+ syn_presyn: CudaSlice<u32>,
111
+ syn_perm: CudaSlice<i16>,
112
+ cell_seg_count: CudaSlice<u32>,
113
+
114
+ cell_active_bits: CudaSlice<u32>,
115
+ cell_winner_bits: CudaSlice<u32>,
116
+ cell_predictive_bits: CudaSlice<u32>,
117
+ prev_active_bits: CudaSlice<u32>,
118
+ prev_winner_bits: CudaSlice<u32>,
119
+
120
+ col_predicted: CudaSlice<u8>,
121
+ seg_num_active_conn: CudaSlice<u32>,
122
+ seg_num_active_pot: CudaSlice<u32>,
123
+ unpredicted_count: CudaSlice<u32>,
124
+ burst_cols_flat: CudaSlice<u32>,
125
+ burst_cols_count: CudaSlice<u32>,
126
+ col_best_match: CudaSlice<u32>,
127
+
128
+ iter_counter: u32,
129
+ }
130
+
131
+ impl TemporalMemoryGpu {
132
+ pub fn new(
133
+ dev: Arc<CudaDevice>,
134
+ n_columns: usize,
135
+ cells_per_column: usize,
136
+ ) -> Result<Self, DriverError> {
137
+ let n_cells = n_columns * cells_per_column;
138
+ assert!(n_cells % 32 == 0, "n_cells must be divisible by 32 for bitsets");
139
+ let n_segments_max = n_cells * MAX_SEGMENTS_PER_CELL;
140
+ let bits_words = n_cells / 32;
141
+
142
+ // Numenta defaults.
143
+ let activation_threshold = 15u32;
144
+ let learning_threshold = 13u32;
145
+ let initial_perm_i16 = perm_f32_to_i16(0.21);
146
+ let conn_thr_i16 = perm_f32_to_i16(0.50);
147
+ let perm_inc_i16 = perm_f32_to_i16(0.10);
148
+ let perm_dec_i16 = perm_f32_to_i16(0.10);
149
+ let predicted_seg_dec_i16 = perm_f32_to_i16(0.10);
150
+ let max_new_synapse_count = 20u32;
151
+
152
+ // Allocate buffers.
153
+ let seg_cell_id_host: Vec<u32> = vec![u32::MAX; n_segments_max];
154
+ let seg_cell_id = dev.htod_sync_copy(&seg_cell_id_host)?;
155
+ let seg_syn_count = dev.alloc_zeros::<u32>(n_segments_max)?;
156
+ let syn_presyn = dev.alloc_zeros::<u32>(n_segments_max * MAX_SYN_PER_SEGMENT)?;
157
+ let syn_perm = dev.alloc_zeros::<i16>(n_segments_max * MAX_SYN_PER_SEGMENT)?;
158
+ let cell_seg_count = dev.alloc_zeros::<u32>(n_cells)?;
159
+
160
+ let cell_active_bits = dev.alloc_zeros::<u32>(bits_words)?;
161
+ let cell_winner_bits = dev.alloc_zeros::<u32>(bits_words)?;
162
+ let cell_predictive_bits = dev.alloc_zeros::<u32>(bits_words)?;
163
+ let prev_active_bits = dev.alloc_zeros::<u32>(bits_words)?;
164
+ let prev_winner_bits = dev.alloc_zeros::<u32>(bits_words)?;
165
+
166
+ let col_predicted = dev.alloc_zeros::<u8>(n_columns)?;
167
+ let seg_num_active_conn = dev.alloc_zeros::<u32>(n_segments_max)?;
168
+ let seg_num_active_pot = dev.alloc_zeros::<u32>(n_segments_max)?;
169
+ let unpredicted_count = dev.alloc_zeros::<u32>(1)?;
170
+ // Bursting columns for one step bounded by n_columns.
171
+ let burst_cols_flat = dev.alloc_zeros::<u32>(n_columns)?;
172
+ let burst_cols_count = dev.alloc_zeros::<u32>(1)?;
173
+ let col_best_match = dev.alloc_zeros::<u32>(n_columns)?;
174
+
175
+ // Load PTX modules.
176
+ let modules = [
177
+ ("htm_tm_predict", PTX_TM_PREDICT, "tm_predict"),
178
+ ("htm_tm_activate", PTX_TM_ACTIVATE, "tm_activate"),
179
+ ("htm_tm_learn", PTX_TM_LEARN, "tm_learn_reinforce"),
180
+ ("htm_tm_punish", PTX_TM_PUNISH, "tm_punish"),
181
+ ("htm_tm_grow", PTX_TM_GROW, "tm_grow"),
182
+ ("htm_tm_anomaly", PTX_TM_ANOMALY, "tm_anomaly"),
183
+ ("htm_tm_reset", PTX_TM_RESET, "tm_reset_step"),
184
+ ];
185
+ for (modname, ptx, fnname) in modules {
186
+ if dev.get_func(modname, fnname).is_none() {
187
+ dev.load_ptx(Ptx::from_src(ptx), modname, &[fnname])?;
188
+ }
189
+ }
190
+
191
+ Ok(Self {
192
+ dev,
193
+ n_columns,
194
+ cells_per_column,
195
+ activation_threshold,
196
+ learning_threshold,
197
+ initial_perm_i16,
198
+ conn_thr_i16,
199
+ perm_inc_i16,
200
+ perm_dec_i16,
201
+ predicted_seg_dec_i16,
202
+ max_new_synapse_count,
203
+ n_cells,
204
+ n_segments_max,
205
+ bits_words,
206
+ seg_cell_id,
207
+ seg_syn_count,
208
+ syn_presyn,
209
+ syn_perm,
210
+ cell_seg_count,
211
+ cell_active_bits,
212
+ cell_winner_bits,
213
+ cell_predictive_bits,
214
+ prev_active_bits,
215
+ prev_winner_bits,
216
+ col_predicted,
217
+ seg_num_active_conn,
218
+ seg_num_active_pot,
219
+ unpredicted_count,
220
+ burst_cols_flat,
221
+ burst_cols_count,
222
+ col_best_match,
223
+ iter_counter: 0,
224
+ })
225
+ }
226
+
227
+ // --- Fused-path accessors ---
228
+ pub fn seg_cell_id_accessor(&self) -> &CudaSlice<u32> { &self.seg_cell_id }
229
+ pub fn seg_syn_count_accessor(&self) -> &CudaSlice<u32> { &self.seg_syn_count }
230
+ pub fn syn_presyn_accessor(&self) -> &CudaSlice<u32> { &self.syn_presyn }
231
+ pub fn syn_perm_accessor(&self) -> &CudaSlice<i16> { &self.syn_perm }
232
+ pub fn cell_seg_count_accessor(&self) -> &CudaSlice<u32> { &self.cell_seg_count }
233
+
234
+ /// Hard reset β€” clear everything (predictive + active + segments).
235
+ pub fn reset(&mut self) -> Result<(), DriverError> {
236
+ // Restore "unused" sentinel in seg_cell_id.
237
+ let unused_host: Vec<u32> = vec![u32::MAX; self.n_segments_max];
238
+ self.dev.htod_sync_copy_into(&unused_host, &mut self.seg_cell_id)?;
239
+ self.dev.memset_zeros(&mut self.seg_syn_count)?;
240
+ self.dev.memset_zeros(&mut self.cell_seg_count)?;
241
+ self.dev.memset_zeros(&mut self.cell_active_bits)?;
242
+ self.dev.memset_zeros(&mut self.cell_winner_bits)?;
243
+ self.dev.memset_zeros(&mut self.cell_predictive_bits)?;
244
+ self.dev.memset_zeros(&mut self.prev_active_bits)?;
245
+ self.dev.memset_zeros(&mut self.prev_winner_bits)?;
246
+ self.dev.memset_zeros(&mut self.col_best_match)?;
247
+ self.iter_counter = 0;
248
+ Ok(())
249
+ }
250
+
251
+ fn build_cfg(&self) -> TmConfig {
252
+ TmConfig {
253
+ activation_threshold: self.activation_threshold,
254
+ learning_threshold: self.learning_threshold,
255
+ cells_per_column: self.cells_per_column as u32,
256
+ synapses_per_segment: MAX_SYN_PER_SEGMENT as u32,
257
+ n_segments: self.n_segments_max as u32,
258
+ n_cells: self.n_cells as u32,
259
+ max_segments_per_cell: MAX_SEGMENTS_PER_CELL as u32,
260
+ max_new_synapses: self.max_new_synapse_count,
261
+ conn_thr_i16: self.conn_thr_i16 as i32,
262
+ perm_inc_i16: self.perm_inc_i16 as i32,
263
+ perm_dec_i16: self.perm_dec_i16 as i32,
264
+ predicted_seg_dec_i16: self.predicted_seg_dec_i16 as i32,
265
+ initial_perm_i16: self.initial_perm_i16 as i32,
266
+ iter_seed: self.iter_counter,
267
+ n_cols: self.n_columns as u32,
268
+ bits_words: self.bits_words as u32,
269
+ }
270
+ }
271
+
272
+ /// Run one TM step on the GPU. Takes the SP active-column mask (u8, already
273
+ /// on device) and writes `anomaly_out[t_slot]`.
274
+ pub fn step(
275
+ &mut self,
276
+ sp_active_mask: &CudaSlice<u8>,
277
+ anomaly_out: &mut CudaSlice<f32>,
278
+ t_slot: u32,
279
+ learn: bool,
280
+ ) -> Result<(), DriverError> {
281
+ let n_cells = self.n_cells;
282
+ let n_cols = self.n_columns;
283
+
284
+ let predict_fn = self.dev.get_func("htm_tm_predict", "tm_predict").unwrap();
285
+ let activate_fn = self.dev.get_func("htm_tm_activate", "tm_activate").unwrap();
286
+ let learn_fn = self.dev.get_func("htm_tm_learn", "tm_learn_reinforce").unwrap();
287
+ let punish_fn = self.dev.get_func("htm_tm_punish", "tm_punish").unwrap();
288
+ let grow_fn = self.dev.get_func("htm_tm_grow", "tm_grow").unwrap();
289
+ let anom_fn = self.dev.get_func("htm_tm_anomaly", "tm_anomaly").unwrap();
290
+ let reset_fn = self.dev.get_func("htm_tm_reset", "tm_reset_step").unwrap();
291
+
292
+ self.iter_counter = self.iter_counter.wrapping_add(1);
293
+ let cfg_val = self.build_cfg();
294
+
295
+ // 0. Per-step reset.
296
+ let reset_words = self.bits_words.max(n_cols);
297
+ let reset_cfg = LaunchConfig {
298
+ grid_dim: (((reset_words + 255) / 256) as u32, 1, 1),
299
+ block_dim: (256, 1, 1),
300
+ shared_mem_bytes: 0,
301
+ };
302
+ unsafe {
303
+ reset_fn.clone().launch(
304
+ reset_cfg,
305
+ (
306
+ &mut self.cell_active_bits,
307
+ &mut self.cell_winner_bits,
308
+ &mut self.cell_predictive_bits,
309
+ &mut self.prev_active_bits,
310
+ &mut self.prev_winner_bits,
311
+ &mut self.col_predicted,
312
+ &mut self.unpredicted_count,
313
+ &mut self.burst_cols_count,
314
+ &mut self.col_best_match,
315
+ self.bits_words as u32,
316
+ n_cols as u32,
317
+ ),
318
+ )?;
319
+ }
320
+
321
+ // 1. Predict (grid = n_cells; each block iterates its cell's segments).
322
+ let predict_cfg = LaunchConfig {
323
+ grid_dim: (n_cells as u32, 1, 1),
324
+ block_dim: (32, 1, 1),
325
+ shared_mem_bytes: 0,
326
+ };
327
+ unsafe {
328
+ predict_fn.clone().launch(
329
+ predict_cfg,
330
+ (
331
+ &self.seg_cell_id,
332
+ &self.seg_syn_count,
333
+ &self.syn_presyn,
334
+ &self.syn_perm,
335
+ &self.prev_active_bits,
336
+ &mut self.cell_predictive_bits,
337
+ &mut self.col_predicted,
338
+ &mut self.seg_num_active_conn,
339
+ &mut self.seg_num_active_pot,
340
+ &mut self.col_best_match,
341
+ &self.cell_seg_count,
342
+ cfg_val,
343
+ ),
344
+ )?;
345
+ }
346
+
347
+ // 2. Activate.
348
+ let activate_cfg = LaunchConfig {
349
+ grid_dim: (((n_cols + 255) / 256) as u32, 1, 1),
350
+ block_dim: (256, 1, 1),
351
+ shared_mem_bytes: 0,
352
+ };
353
+ unsafe {
354
+ activate_fn.clone().launch(
355
+ activate_cfg,
356
+ (
357
+ sp_active_mask,
358
+ &self.col_predicted,
359
+ &self.cell_predictive_bits,
360
+ &mut self.cell_active_bits,
361
+ &mut self.cell_winner_bits,
362
+ &mut self.unpredicted_count,
363
+ &mut self.burst_cols_flat,
364
+ &mut self.burst_cols_count,
365
+ cfg_val,
366
+ ),
367
+ )?;
368
+ }
369
+
370
+ // 3. Anomaly.
371
+ let anom_cfg = LaunchConfig {
372
+ grid_dim: (1, 1, 1),
373
+ block_dim: (256, 1, 1),
374
+ shared_mem_bytes: 0,
375
+ };
376
+ unsafe {
377
+ anom_fn.clone().launch(
378
+ anom_cfg,
379
+ (
380
+ sp_active_mask,
381
+ &self.unpredicted_count,
382
+ anomaly_out,
383
+ t_slot,
384
+ n_cols as u32,
385
+ ),
386
+ )?;
387
+ }
388
+
389
+ if learn {
390
+ // 4. Reinforce (grid = n_cells).
391
+ let learn_cfg = LaunchConfig {
392
+ grid_dim: (n_cells as u32, 1, 1),
393
+ block_dim: (32, 1, 1),
394
+ shared_mem_bytes: 0,
395
+ };
396
+ unsafe {
397
+ learn_fn.clone().launch(
398
+ learn_cfg,
399
+ (
400
+ &self.seg_cell_id,
401
+ &self.seg_syn_count,
402
+ &self.syn_presyn,
403
+ &mut self.syn_perm,
404
+ &self.seg_num_active_conn,
405
+ &self.prev_active_bits,
406
+ sp_active_mask,
407
+ &self.col_predicted,
408
+ &self.cell_seg_count,
409
+ cfg_val,
410
+ ),
411
+ )?;
412
+ }
413
+
414
+ // 5. Punish.
415
+ unsafe {
416
+ punish_fn.clone().launch(
417
+ learn_cfg,
418
+ (
419
+ &self.seg_cell_id,
420
+ &self.seg_syn_count,
421
+ &self.syn_presyn,
422
+ &mut self.syn_perm,
423
+ &self.seg_num_active_pot,
424
+ &self.prev_active_bits,
425
+ sp_active_mask,
426
+ &self.cell_seg_count,
427
+ cfg_val,
428
+ ),
429
+ )?;
430
+ }
431
+
432
+ // 6. Grow.
433
+ let grow_cfg = LaunchConfig {
434
+ grid_dim: (n_cols as u32, 1, 1),
435
+ block_dim: (32, 1, 1),
436
+ shared_mem_bytes: 0,
437
+ };
438
+ unsafe {
439
+ grow_fn.clone().launch(
440
+ grow_cfg,
441
+ (
442
+ &mut self.seg_cell_id,
443
+ &mut self.seg_syn_count,
444
+ &mut self.syn_presyn,
445
+ &mut self.syn_perm,
446
+ &mut self.cell_seg_count,
447
+ &self.burst_cols_flat,
448
+ &self.burst_cols_count,
449
+ &self.prev_winner_bits,
450
+ &self.prev_active_bits,
451
+ &self.col_best_match,
452
+ cfg_val,
453
+ ),
454
+ )?;
455
+ }
456
+ }
457
+
458
+ Ok(())
459
+ }
460
+ }
overlay/htm_rust/src/lib.rs CHANGED
@@ -1,198 +1,198 @@
1
- //! pyo3 bindings for HTMRegion (Numenta BAMI-spec HTM).
2
- //!
3
- //! Exposed class:
4
- //! HTMRegion(input_bits, n_columns, cells_per_column, seed) -> HTMRegion
5
- //! .step(input_sdr: np.ndarray[bool; input_bits], learn: bool = True)
6
- //! -> (active_columns: np.ndarray[bool; n_columns],
7
- //! active_cells: np.ndarray[bool; n_columns*cells_per_column],
8
- //! predicted_cells:np.ndarray[bool; n_columns*cells_per_column],
9
- //! anomaly: float)
10
- //! .reset()
11
- //! .n_columns -> int
12
- //! .cells_per_column -> int
13
- //! .input_bits -> int
14
- //!
15
- //! GIL is dropped during the heavy compute via `py.allow_threads(...)` so the
16
- //! region is effectively `Send` for Python-side threading.
17
-
18
- // pyo3 0.22 `#[pymethods]` expansion inserts an implicit `.into()` on the
19
- // returned `Result` to normalise the error type, which clippy reports as
20
- // `useless_conversion` when our methods already return `PyErr`. The emitted
21
- // code sits outside the user-written impl, so item-level allows don't reach
22
- // it; the module-wide allow is the documented workaround.
23
- #![allow(clippy::useless_conversion)]
24
-
25
- mod region;
26
- mod sp;
27
- mod tm;
28
-
29
- #[cfg(feature = "gpu")]
30
- mod gpu;
31
-
32
- use numpy::{
33
- IntoPyArray, PyArray1, PyArray2, PyArrayMethods, PyReadonlyArray1, PyReadonlyArray2,
34
- PyUntypedArrayMethods,
35
- };
36
- use pyo3::prelude::*;
37
-
38
- use crate::region::HTMRegionCore;
39
-
40
- /// Result of one HTM step: (active_columns, active_cells, predicted_cells, anomaly).
41
- type StepOutput<'py> = (
42
- Bound<'py, PyArray1<bool>>,
43
- Bound<'py, PyArray1<bool>>,
44
- Bound<'py, PyArray1<bool>>,
45
- f32,
46
- );
47
-
48
- #[pyclass(module = "htm_rust")]
49
- pub struct HTMRegion {
50
- core: HTMRegionCore,
51
- }
52
-
53
- #[pymethods]
54
- impl HTMRegion {
55
- /// Create a new HTM region.
56
- ///
57
- /// Args:
58
- /// input_bits: length of binary input SDR
59
- /// n_columns: number of mini-columns in the SP (e.g. 2048)
60
- /// cells_per_column: cells per column in the TM (e.g. 32)
61
- /// seed: RNG seed for reproducibility
62
- #[new]
63
- #[pyo3(signature = (input_bits, n_columns, cells_per_column, seed=42))]
64
- fn new(
65
- input_bits: usize,
66
- n_columns: usize,
67
- cells_per_column: usize,
68
- seed: u64,
69
- ) -> PyResult<Self> {
70
- if input_bits == 0 {
71
- return Err(pyo3::exceptions::PyValueError::new_err(
72
- "input_bits must be > 0",
73
- ));
74
- }
75
- if n_columns == 0 {
76
- return Err(pyo3::exceptions::PyValueError::new_err(
77
- "n_columns must be > 0",
78
- ));
79
- }
80
- if cells_per_column == 0 {
81
- return Err(pyo3::exceptions::PyValueError::new_err(
82
- "cells_per_column must be > 0",
83
- ));
84
- }
85
- Ok(Self {
86
- core: HTMRegionCore::new(input_bits, n_columns, cells_per_column, seed),
87
- })
88
- }
89
-
90
- #[getter]
91
- fn input_bits(&self) -> usize { self.core.sp.cfg.input_bits }
92
-
93
- #[getter]
94
- fn n_columns(&self) -> usize { self.core.sp.cfg.n_columns }
95
-
96
- #[getter]
97
- fn cells_per_column(&self) -> usize { self.core.tm.cfg.cells_per_column }
98
-
99
- /// Process one timestep.
100
- ///
101
- /// Args:
102
- /// input_sdr: 1-D numpy boolean array of length `input_bits`.
103
- /// learn: if True, update SP permanences and TM synapses.
104
- ///
105
- /// Returns:
106
- /// (active_columns, active_cells, predicted_cells, anomaly)
107
- #[pyo3(signature = (input_sdr, learn=true))]
108
- fn step<'py>(
109
- &mut self,
110
- py: Python<'py>,
111
- input_sdr: PyReadonlyArray1<'py, bool>,
112
- learn: bool,
113
- ) -> PyResult<StepOutput<'py>> {
114
- let expected = self.core.sp.cfg.input_bits;
115
- let slice = input_sdr.as_slice()?;
116
- let got = slice.len();
117
- if got != expected {
118
- return Err(pyo3::exceptions::PyValueError::new_err(format!(
119
- "input_sdr length {got} != expected input_bits {expected}",
120
- )));
121
- }
122
-
123
- // Copy input to an owned Vec so we can drop the GIL.
124
- let input_vec: Vec<bool> = slice.to_vec();
125
-
126
- let (active_cols, active_cells, predicted_cells, anomaly) =
127
- py.allow_threads(|| self.core.step(&input_vec, learn));
128
-
129
- let a: Bound<'py, PyArray1<bool>> = active_cols.into_pyarray_bound(py);
130
- let c: Bound<'py, PyArray1<bool>> = active_cells.into_pyarray_bound(py);
131
- let p: Bound<'py, PyArray1<bool>> = predicted_cells.into_pyarray_bound(py);
132
- Ok((a, c, p, anomaly))
133
- }
134
-
135
- /// Clear TM predictive state. Does NOT unlearn synapses.
136
- fn reset(&mut self) { self.core.reset(); }
137
-
138
- /// Process T timesteps from a `(T, input_bits)` bool ndarray.
139
- ///
140
- /// Returns:
141
- /// cols: (T, n_columns) float32 0/1 active-column mask
142
- /// anom: (T,) float32 anomaly scores
143
- ///
144
- /// Single GIL release for the whole pass, avoiding T Γ— Python-call overhead.
145
- #[pyo3(signature = (inputs, learn=true))]
146
- fn step_many<'py>(
147
- &mut self,
148
- py: Python<'py>,
149
- inputs: PyReadonlyArray2<'py, bool>,
150
- learn: bool,
151
- ) -> PyResult<(Bound<'py, PyArray2<f32>>, Bound<'py, PyArray1<f32>>)> {
152
- let shape = inputs.shape();
153
- if shape.len() != 2 {
154
- return Err(pyo3::exceptions::PyValueError::new_err(
155
- "inputs must be 2-D (T, input_bits)",
156
- ));
157
- }
158
- let t = shape[0];
159
- let bits = shape[1];
160
- let expected = self.core.sp.cfg.input_bits;
161
- if bits != expected {
162
- return Err(pyo3::exceptions::PyValueError::new_err(format!(
163
- "inputs last dim {bits} != expected input_bits {expected}",
164
- )));
165
- }
166
- let slice = inputs.as_slice()?;
167
- let n_cols = self.core.sp.cfg.n_columns;
168
-
169
- // Own the input buffer so we can drop the GIL.
170
- let input_vec: Vec<bool> = slice.to_vec();
171
-
172
- let (cols_u8, anom) =
173
- py.allow_threads(|| self.core.step_many(&input_vec, bits, t, learn));
174
-
175
- // Convert u8 mask to f32 for direct numpy consumption.
176
- let cols_f32: Vec<f32> = cols_u8.iter().map(|&b| b as f32).collect();
177
-
178
- // Build (T, n_cols) and (T,) arrays.
179
- let cols_arr =
180
- numpy::PyArray1::from_vec_bound(py, cols_f32)
181
- .reshape([t, n_cols])
182
- .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(format!("{e}")))?;
183
- let anom_arr = numpy::PyArray1::from_vec_bound(py, anom);
184
- Ok((cols_arr, anom_arr))
185
- }
186
- }
187
-
188
- /// Python module entry point.
189
- #[pymodule]
190
- fn htm_rust(m: &Bound<'_, PyModule>) -> PyResult<()> {
191
- m.add_class::<HTMRegion>()?;
192
- #[cfg(feature = "gpu")]
193
- {
194
- gpu::register(m)?;
195
- }
196
- m.add("__version__", env!("CARGO_PKG_VERSION"))?;
197
- Ok(())
198
- }
 
1
+ //! pyo3 bindings for HTMRegion (Numenta BAMI-spec HTM).
2
+ //!
3
+ //! Exposed class:
4
+ //! HTMRegion(input_bits, n_columns, cells_per_column, seed) -> HTMRegion
5
+ //! .step(input_sdr: np.ndarray[bool; input_bits], learn: bool = True)
6
+ //! -> (active_columns: np.ndarray[bool; n_columns],
7
+ //! active_cells: np.ndarray[bool; n_columns*cells_per_column],
8
+ //! predicted_cells:np.ndarray[bool; n_columns*cells_per_column],
9
+ //! anomaly: float)
10
+ //! .reset()
11
+ //! .n_columns -> int
12
+ //! .cells_per_column -> int
13
+ //! .input_bits -> int
14
+ //!
15
+ //! GIL is dropped during the heavy compute via `py.allow_threads(...)` so the
16
+ //! region is effectively `Send` for Python-side threading.
17
+
18
+ // pyo3 0.22 `#[pymethods]` expansion inserts an implicit `.into()` on the
19
+ // returned `Result` to normalise the error type, which clippy reports as
20
+ // `useless_conversion` when our methods already return `PyErr`. The emitted
21
+ // code sits outside the user-written impl, so item-level allows don't reach
22
+ // it; the module-wide allow is the documented workaround.
23
+ #![allow(clippy::useless_conversion)]
24
+
25
+ mod region;
26
+ mod sp;
27
+ mod tm;
28
+
29
+ #[cfg(feature = "gpu")]
30
+ mod gpu;
31
+
32
+ use numpy::{
33
+ IntoPyArray, PyArray1, PyArray2, PyArrayMethods, PyReadonlyArray1, PyReadonlyArray2,
34
+ PyUntypedArrayMethods,
35
+ };
36
+ use pyo3::prelude::*;
37
+
38
+ use crate::region::HTMRegionCore;
39
+
40
+ /// Result of one HTM step: (active_columns, active_cells, predicted_cells, anomaly).
41
+ type StepOutput<'py> = (
42
+ Bound<'py, PyArray1<bool>>,
43
+ Bound<'py, PyArray1<bool>>,
44
+ Bound<'py, PyArray1<bool>>,
45
+ f32,
46
+ );
47
+
48
+ #[pyclass(module = "htm_rust")]
49
+ pub struct HTMRegion {
50
+ core: HTMRegionCore,
51
+ }
52
+
53
+ #[pymethods]
54
+ impl HTMRegion {
55
+ /// Create a new HTM region.
56
+ ///
57
+ /// Args:
58
+ /// input_bits: length of binary input SDR
59
+ /// n_columns: number of mini-columns in the SP (e.g. 2048)
60
+ /// cells_per_column: cells per column in the TM (e.g. 32)
61
+ /// seed: RNG seed for reproducibility
62
+ #[new]
63
+ #[pyo3(signature = (input_bits, n_columns, cells_per_column, seed=42))]
64
+ fn new(
65
+ input_bits: usize,
66
+ n_columns: usize,
67
+ cells_per_column: usize,
68
+ seed: u64,
69
+ ) -> PyResult<Self> {
70
+ if input_bits == 0 {
71
+ return Err(pyo3::exceptions::PyValueError::new_err(
72
+ "input_bits must be > 0",
73
+ ));
74
+ }
75
+ if n_columns == 0 {
76
+ return Err(pyo3::exceptions::PyValueError::new_err(
77
+ "n_columns must be > 0",
78
+ ));
79
+ }
80
+ if cells_per_column == 0 {
81
+ return Err(pyo3::exceptions::PyValueError::new_err(
82
+ "cells_per_column must be > 0",
83
+ ));
84
+ }
85
+ Ok(Self {
86
+ core: HTMRegionCore::new(input_bits, n_columns, cells_per_column, seed),
87
+ })
88
+ }
89
+
90
+ #[getter]
91
+ fn input_bits(&self) -> usize { self.core.sp.cfg.input_bits }
92
+
93
+ #[getter]
94
+ fn n_columns(&self) -> usize { self.core.sp.cfg.n_columns }
95
+
96
+ #[getter]
97
+ fn cells_per_column(&self) -> usize { self.core.tm.cfg.cells_per_column }
98
+
99
+ /// Process one timestep.
100
+ ///
101
+ /// Args:
102
+ /// input_sdr: 1-D numpy boolean array of length `input_bits`.
103
+ /// learn: if True, update SP permanences and TM synapses.
104
+ ///
105
+ /// Returns:
106
+ /// (active_columns, active_cells, predicted_cells, anomaly)
107
+ #[pyo3(signature = (input_sdr, learn=true))]
108
+ fn step<'py>(
109
+ &mut self,
110
+ py: Python<'py>,
111
+ input_sdr: PyReadonlyArray1<'py, bool>,
112
+ learn: bool,
113
+ ) -> PyResult<StepOutput<'py>> {
114
+ let expected = self.core.sp.cfg.input_bits;
115
+ let slice = input_sdr.as_slice()?;
116
+ let got = slice.len();
117
+ if got != expected {
118
+ return Err(pyo3::exceptions::PyValueError::new_err(format!(
119
+ "input_sdr length {got} != expected input_bits {expected}",
120
+ )));
121
+ }
122
+
123
+ // Copy input to an owned Vec so we can drop the GIL.
124
+ let input_vec: Vec<bool> = slice.to_vec();
125
+
126
+ let (active_cols, active_cells, predicted_cells, anomaly) =
127
+ py.allow_threads(|| self.core.step(&input_vec, learn));
128
+
129
+ let a: Bound<'py, PyArray1<bool>> = active_cols.into_pyarray_bound(py);
130
+ let c: Bound<'py, PyArray1<bool>> = active_cells.into_pyarray_bound(py);
131
+ let p: Bound<'py, PyArray1<bool>> = predicted_cells.into_pyarray_bound(py);
132
+ Ok((a, c, p, anomaly))
133
+ }
134
+
135
+ /// Clear TM predictive state. Does NOT unlearn synapses.
136
+ fn reset(&mut self) { self.core.reset(); }
137
+
138
+ /// Process T timesteps from a `(T, input_bits)` bool ndarray.
139
+ ///
140
+ /// Returns:
141
+ /// cols: (T, n_columns) float32 0/1 active-column mask
142
+ /// anom: (T,) float32 anomaly scores
143
+ ///
144
+ /// Single GIL release for the whole pass, avoiding T Γ— Python-call overhead.
145
+ #[pyo3(signature = (inputs, learn=true))]
146
+ fn step_many<'py>(
147
+ &mut self,
148
+ py: Python<'py>,
149
+ inputs: PyReadonlyArray2<'py, bool>,
150
+ learn: bool,
151
+ ) -> PyResult<(Bound<'py, PyArray2<f32>>, Bound<'py, PyArray1<f32>>)> {
152
+ let shape = inputs.shape();
153
+ if shape.len() != 2 {
154
+ return Err(pyo3::exceptions::PyValueError::new_err(
155
+ "inputs must be 2-D (T, input_bits)",
156
+ ));
157
+ }
158
+ let t = shape[0];
159
+ let bits = shape[1];
160
+ let expected = self.core.sp.cfg.input_bits;
161
+ if bits != expected {
162
+ return Err(pyo3::exceptions::PyValueError::new_err(format!(
163
+ "inputs last dim {bits} != expected input_bits {expected}",
164
+ )));
165
+ }
166
+ let slice = inputs.as_slice()?;
167
+ let n_cols = self.core.sp.cfg.n_columns;
168
+
169
+ // Own the input buffer so we can drop the GIL.
170
+ let input_vec: Vec<bool> = slice.to_vec();
171
+
172
+ let (cols_u8, anom) =
173
+ py.allow_threads(|| self.core.step_many(&input_vec, bits, t, learn));
174
+
175
+ // Convert u8 mask to f32 for direct numpy consumption.
176
+ let cols_f32: Vec<f32> = cols_u8.iter().map(|&b| b as f32).collect();
177
+
178
+ // Build (T, n_cols) and (T,) arrays.
179
+ let cols_arr =
180
+ numpy::PyArray1::from_vec_bound(py, cols_f32)
181
+ .reshape([t, n_cols])
182
+ .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(format!("{e}")))?;
183
+ let anom_arr = numpy::PyArray1::from_vec_bound(py, anom);
184
+ Ok((cols_arr, anom_arr))
185
+ }
186
+ }
187
+
188
+ /// Python module entry point.
189
+ #[pymodule]
190
+ fn htm_rust(m: &Bound<'_, PyModule>) -> PyResult<()> {
191
+ m.add_class::<HTMRegion>()?;
192
+ #[cfg(feature = "gpu")]
193
+ {
194
+ gpu::register(m)?;
195
+ }
196
+ m.add("__version__", env!("CARGO_PKG_VERSION"))?;
197
+ Ok(())
198
+ }
overlay/htm_rust/src/region.rs CHANGED
@@ -1,94 +1,94 @@
1
- //! HTMRegion: compose SpatialPooler + TemporalMemory into a single step().
2
-
3
- use crate::sp::{SpatialPooler, SpatialPoolerConfig};
4
- use crate::tm::{TemporalMemory, TemporalMemoryConfig};
5
-
6
- pub struct HTMRegionCore {
7
- pub sp: SpatialPooler,
8
- pub tm: TemporalMemory,
9
- }
10
-
11
- impl HTMRegionCore {
12
- pub fn new(
13
- input_bits: usize,
14
- n_columns: usize,
15
- cells_per_column: usize,
16
- seed: u64,
17
- ) -> Self {
18
- let defaults = SpatialPoolerConfig::default();
19
- let sp_cfg = SpatialPoolerConfig {
20
- input_bits,
21
- n_columns,
22
- // Scale potential_radius to at most the input size.
23
- potential_radius: defaults.potential_radius.min(input_bits),
24
- ..defaults
25
- };
26
-
27
- let tm_cfg = TemporalMemoryConfig {
28
- n_columns,
29
- cells_per_column,
30
- ..TemporalMemoryConfig::default()
31
- };
32
-
33
- Self {
34
- sp: SpatialPooler::new(sp_cfg, seed),
35
- tm: TemporalMemory::new(tm_cfg, seed.wrapping_add(0x9E3779B97F4A7C15)),
36
- }
37
- }
38
-
39
- /// Process one timestep. Returns (active_columns_mask,
40
- /// active_cells_mask, predicted_cells_mask, anomaly).
41
- pub fn step(
42
- &mut self,
43
- input_sdr: &[bool],
44
- learn: bool,
45
- ) -> (Vec<bool>, Vec<bool>, Vec<bool>, f32) {
46
- let active_cols = self.sp.compute(input_sdr, learn);
47
-
48
- let mut active_cols_mask = vec![false; self.sp.cfg.n_columns];
49
- for &c in &active_cols {
50
- active_cols_mask[c as usize] = true;
51
- }
52
-
53
- let anomaly = self.tm.compute(&active_cols, learn);
54
-
55
- // active_cells and predictive_cells are stored as Vec<bool> already.
56
- let active_cells_mask = self.tm.active_cells.clone();
57
- let predicted_cells_mask = self.tm.predictive_cells.clone();
58
-
59
- (active_cols_mask, active_cells_mask, predicted_cells_mask, anomaly)
60
- }
61
-
62
- pub fn reset(&mut self) {
63
- self.tm.reset();
64
- }
65
-
66
- /// Process T timesteps in one call. Returns flat `(T*n_columns)` active-column
67
- /// mask (u8 0/1) and `(T,)` anomaly scores.
68
- ///
69
- /// Amortises the per-step Python round-trip for training: one GIL release,
70
- /// one copy-out. Used by `HTMLayer.step_many`.
71
- pub fn step_many(
72
- &mut self,
73
- inputs_flat: &[bool],
74
- input_bits: usize,
75
- t: usize,
76
- learn: bool,
77
- ) -> (Vec<u8>, Vec<f32>) {
78
- let n_cols = self.sp.cfg.n_columns;
79
- debug_assert_eq!(inputs_flat.len(), t * input_bits);
80
- let mut cols = vec![0u8; t * n_cols];
81
- let mut anom = vec![0f32; t];
82
- for ti in 0..t {
83
- let off = ti * input_bits;
84
- let input = &inputs_flat[off..off + input_bits];
85
- let active_cols = self.sp.compute(input, learn);
86
- let co = ti * n_cols;
87
- for &c in &active_cols {
88
- cols[co + c as usize] = 1;
89
- }
90
- anom[ti] = self.tm.compute(&active_cols, learn);
91
- }
92
- (cols, anom)
93
- }
94
- }
 
1
+ //! HTMRegion: compose SpatialPooler + TemporalMemory into a single step().
2
+
3
+ use crate::sp::{SpatialPooler, SpatialPoolerConfig};
4
+ use crate::tm::{TemporalMemory, TemporalMemoryConfig};
5
+
6
+ pub struct HTMRegionCore {
7
+ pub sp: SpatialPooler,
8
+ pub tm: TemporalMemory,
9
+ }
10
+
11
+ impl HTMRegionCore {
12
+ pub fn new(
13
+ input_bits: usize,
14
+ n_columns: usize,
15
+ cells_per_column: usize,
16
+ seed: u64,
17
+ ) -> Self {
18
+ let defaults = SpatialPoolerConfig::default();
19
+ let sp_cfg = SpatialPoolerConfig {
20
+ input_bits,
21
+ n_columns,
22
+ // Scale potential_radius to at most the input size.
23
+ potential_radius: defaults.potential_radius.min(input_bits),
24
+ ..defaults
25
+ };
26
+
27
+ let tm_cfg = TemporalMemoryConfig {
28
+ n_columns,
29
+ cells_per_column,
30
+ ..TemporalMemoryConfig::default()
31
+ };
32
+
33
+ Self {
34
+ sp: SpatialPooler::new(sp_cfg, seed),
35
+ tm: TemporalMemory::new(tm_cfg, seed.wrapping_add(0x9E3779B97F4A7C15)),
36
+ }
37
+ }
38
+
39
+ /// Process one timestep. Returns (active_columns_mask,
40
+ /// active_cells_mask, predicted_cells_mask, anomaly).
41
+ pub fn step(
42
+ &mut self,
43
+ input_sdr: &[bool],
44
+ learn: bool,
45
+ ) -> (Vec<bool>, Vec<bool>, Vec<bool>, f32) {
46
+ let active_cols = self.sp.compute(input_sdr, learn);
47
+
48
+ let mut active_cols_mask = vec![false; self.sp.cfg.n_columns];
49
+ for &c in &active_cols {
50
+ active_cols_mask[c as usize] = true;
51
+ }
52
+
53
+ let anomaly = self.tm.compute(&active_cols, learn);
54
+
55
+ // active_cells and predictive_cells are stored as Vec<bool> already.
56
+ let active_cells_mask = self.tm.active_cells.clone();
57
+ let predicted_cells_mask = self.tm.predictive_cells.clone();
58
+
59
+ (active_cols_mask, active_cells_mask, predicted_cells_mask, anomaly)
60
+ }
61
+
62
+ pub fn reset(&mut self) {
63
+ self.tm.reset();
64
+ }
65
+
66
+ /// Process T timesteps in one call. Returns flat `(T*n_columns)` active-column
67
+ /// mask (u8 0/1) and `(T,)` anomaly scores.
68
+ ///
69
+ /// Amortises the per-step Python round-trip for training: one GIL release,
70
+ /// one copy-out. Used by `HTMLayer.step_many`.
71
+ pub fn step_many(
72
+ &mut self,
73
+ inputs_flat: &[bool],
74
+ input_bits: usize,
75
+ t: usize,
76
+ learn: bool,
77
+ ) -> (Vec<u8>, Vec<f32>) {
78
+ let n_cols = self.sp.cfg.n_columns;
79
+ debug_assert_eq!(inputs_flat.len(), t * input_bits);
80
+ let mut cols = vec![0u8; t * n_cols];
81
+ let mut anom = vec![0f32; t];
82
+ for ti in 0..t {
83
+ let off = ti * input_bits;
84
+ let input = &inputs_flat[off..off + input_bits];
85
+ let active_cols = self.sp.compute(input, learn);
86
+ let co = ti * n_cols;
87
+ for &c in &active_cols {
88
+ cols[co + c as usize] = 1;
89
+ }
90
+ anom[ti] = self.tm.compute(&active_cols, learn);
91
+ }
92
+ (cols, anom)
93
+ }
94
+ }
overlay/htm_rust/src/sp.rs CHANGED
@@ -1,302 +1,302 @@
1
- //! Numenta BAMI-spec Spatial Pooler.
2
- //!
3
- //! Implements:
4
- //! - 2048 (configurable) mini-columns with proximal dendrites
5
- //! - `potential_synapses` (default 40) synapses per column sampled from
6
- //! `potential_radius` (default 1024) random input bits
7
- //! - Permanence in [0.0, 1.0] (f32), connected_threshold = 0.5
8
- //! - syn_perm_active_inc = +0.04, syn_perm_inactive_dec = -0.008
9
- //! - Global k-WTA inhibition (top `sparsity` fraction of columns)
10
- //! - Boost factor with exponential duty-cycle tracking (Numenta formula)
11
- //!
12
- //! Reference: BAMI "Spatial Pooling Algorithm Details" (Numenta, 2017).
13
-
14
- use rand::Rng;
15
- use rand::SeedableRng;
16
- use rand::seq::SliceRandom;
17
- use rand_xoshiro::Xoshiro256PlusPlus;
18
-
19
- /// A single proximal dendrite: a sparse set of potential synapses onto
20
- /// specific input bit indices, with per-synapse permanence values.
21
- #[derive(Clone)]
22
- pub struct ProximalDendrite {
23
- /// Indices into the input SDR. Length == potential_synapses.
24
- pub inputs: Vec<u32>,
25
- /// Permanence for each potential synapse (same length as `inputs`).
26
- pub perms: Vec<f32>,
27
- }
28
-
29
- pub struct SpatialPoolerConfig {
30
- pub input_bits: usize,
31
- pub n_columns: usize,
32
- /// Size of the random input sample per column.
33
- pub potential_radius: usize,
34
- /// Number of potential synapses per column's proximal dendrite.
35
- pub potential_synapses: usize,
36
- pub connected_threshold: f32,
37
- pub syn_perm_active_inc: f32,
38
- pub syn_perm_inactive_dec: f32,
39
- /// Target fraction of columns active per step (e.g. 0.02 for 2%).
40
- pub sparsity: f32,
41
- /// Duty cycle EMA period.
42
- pub duty_cycle_period: f32,
43
- /// Boost strength. Set to 0.0 to disable boosting.
44
- pub boost_strength: f32,
45
- /// Initial permanence span around the connected threshold.
46
- pub init_perm_span: f32,
47
- }
48
-
49
- impl Default for SpatialPoolerConfig {
50
- fn default() -> Self {
51
- Self {
52
- input_bits: 16384,
53
- n_columns: 2048,
54
- potential_radius: 1024,
55
- potential_synapses: 40,
56
- connected_threshold: 0.5,
57
- syn_perm_active_inc: 0.04,
58
- syn_perm_inactive_dec: 0.008,
59
- sparsity: 0.02,
60
- duty_cycle_period: 1000.0,
61
- boost_strength: 1.0,
62
- init_perm_span: 0.1,
63
- }
64
- }
65
- }
66
-
67
- pub struct SpatialPooler {
68
- pub cfg: SpatialPoolerConfig,
69
- pub columns: Vec<ProximalDendrite>,
70
- /// Exponential moving average of "column was active" per step.
71
- pub active_duty_cycle: Vec<f32>,
72
- /// Exponential moving average of "overlap exceeded threshold" per step.
73
- pub overlap_duty_cycle: Vec<f32>,
74
- /// Boost factor per column.
75
- pub boost: Vec<f32>,
76
- rng: Xoshiro256PlusPlus,
77
- iter_count: u64,
78
- }
79
-
80
- impl SpatialPooler {
81
- pub fn new(cfg: SpatialPoolerConfig, seed: u64) -> Self {
82
- assert!(cfg.input_bits >= cfg.potential_radius,
83
- "input_bits ({}) must be >= potential_radius ({})",
84
- cfg.input_bits, cfg.potential_radius);
85
- assert!(cfg.potential_radius >= cfg.potential_synapses,
86
- "potential_radius ({}) must be >= potential_synapses ({})",
87
- cfg.potential_radius, cfg.potential_synapses);
88
-
89
- let mut rng = Xoshiro256PlusPlus::seed_from_u64(seed);
90
-
91
- let mut columns = Vec::with_capacity(cfg.n_columns);
92
- for _ in 0..cfg.n_columns {
93
- // Sample `potential_radius` distinct input indices, then from those
94
- // pick `potential_synapses` as the actual proximal synapses.
95
- // Using partial Fisher-Yates via shuffle on a pool index range.
96
- let mut pool: Vec<u32> = (0..cfg.input_bits as u32).collect();
97
- // Efficient partial shuffle: swap the first `potential_radius`
98
- // items with random items from the rest (Durstenfeld step).
99
- for i in 0..cfg.potential_radius.min(pool.len()) {
100
- let j = rng.gen_range(i..pool.len());
101
- pool.swap(i, j);
102
- }
103
- let window = &mut pool[..cfg.potential_radius];
104
- window.shuffle(&mut rng);
105
- let mut inputs: Vec<u32> = window[..cfg.potential_synapses].to_vec();
106
- inputs.sort_unstable();
107
-
108
- let perms: Vec<f32> = (0..cfg.potential_synapses)
109
- .map(|_| {
110
- let delta: f32 = rng.gen_range(-cfg.init_perm_span..cfg.init_perm_span);
111
- (cfg.connected_threshold + delta).clamp(0.0, 1.0)
112
- })
113
- .collect();
114
-
115
- columns.push(ProximalDendrite { inputs, perms });
116
- }
117
-
118
- let n = cfg.n_columns;
119
- Self {
120
- cfg,
121
- columns,
122
- active_duty_cycle: vec![0.0; n],
123
- overlap_duty_cycle: vec![0.0; n],
124
- boost: vec![1.0; n],
125
- rng,
126
- iter_count: 0,
127
- }
128
- }
129
-
130
- /// Process one step: compute overlaps, inhibit, learn (if `learn`), update
131
- /// duty cycles and boosts. Returns the set of active column indices.
132
- pub fn compute(&mut self, input: &[bool], learn: bool) -> Vec<u32> {
133
- assert_eq!(input.len(), self.cfg.input_bits);
134
-
135
- // 1) Overlap score per column (sum of CONNECTED synapses onto active inputs).
136
- // Also track raw overlap for the overlap-duty-cycle.
137
- let n = self.cfg.n_columns;
138
- let mut overlaps: Vec<f32> = vec![0.0; n];
139
- let mut raw_overlaps: Vec<u32> = vec![0; n];
140
-
141
- for (ci, col) in self.columns.iter().enumerate() {
142
- let mut s: u32 = 0;
143
- for (syn_i, &inp) in col.inputs.iter().enumerate() {
144
- if input[inp as usize] && col.perms[syn_i] >= self.cfg.connected_threshold {
145
- s += 1;
146
- }
147
- }
148
- raw_overlaps[ci] = s;
149
- overlaps[ci] = (s as f32) * self.boost[ci];
150
- }
151
-
152
- // 2) Global k-WTA inhibition. Select top-k columns by boosted overlap.
153
- let k = ((self.cfg.sparsity * n as f32).round() as usize).max(1);
154
- let active: Vec<u32> = top_k(&overlaps, k);
155
-
156
- // 3) Hebbian learning on active columns.
157
- if learn {
158
- for &ci in &active {
159
- let col = &mut self.columns[ci as usize];
160
- for (syn_i, &inp) in col.inputs.iter().enumerate() {
161
- if input[inp as usize] {
162
- col.perms[syn_i] =
163
- (col.perms[syn_i] + self.cfg.syn_perm_active_inc).min(1.0);
164
- } else {
165
- col.perms[syn_i] =
166
- (col.perms[syn_i] - self.cfg.syn_perm_inactive_dec).max(0.0);
167
- }
168
- }
169
- }
170
- }
171
-
172
- // 4) Update duty cycles (EMA with period T -> alpha = 1/T).
173
- let period = self.cfg.duty_cycle_period.max(1.0);
174
- let alpha = 1.0 / period;
175
- // Column is "overlapping enough" if raw overlap >= stimulus_threshold.
176
- // Numenta uses min_overlap; we use 1 as a conservative floor.
177
- let stimulus_threshold = 1.0_f32;
178
-
179
- // Mark active columns.
180
- let mut active_mask = vec![false; n];
181
- for &ci in &active {
182
- active_mask[ci as usize] = true;
183
- }
184
-
185
- for i in 0..n {
186
- let active_sample = if active_mask[i] { 1.0 } else { 0.0 };
187
- let overlap_sample = if (raw_overlaps[i] as f32) >= stimulus_threshold {
188
- 1.0
189
- } else {
190
- 0.0
191
- };
192
- self.active_duty_cycle[i] =
193
- (1.0 - alpha) * self.active_duty_cycle[i] + alpha * active_sample;
194
- self.overlap_duty_cycle[i] =
195
- (1.0 - alpha) * self.overlap_duty_cycle[i] + alpha * overlap_sample;
196
- }
197
-
198
- // 5) Boost factor: b_i = exp(-boost_strength * (duty_i - mean_duty)).
199
- // Under-used columns (duty < mean) get boost > 1.
200
- if learn && self.cfg.boost_strength > 0.0 {
201
- let mean_duty: f32 =
202
- self.active_duty_cycle.iter().sum::<f32>() / (n as f32);
203
- for i in 0..n {
204
- self.boost[i] =
205
- (-self.cfg.boost_strength * (self.active_duty_cycle[i] - mean_duty)).exp();
206
- }
207
-
208
- // 6) Permanence bump for chronically under-stimulated columns.
209
- // If overlap_duty_cycle[i] < min_pct_overlap * max_duty_in_neighborhood,
210
- // bump all permanences by syn_perm_active_inc * 0.1.
211
- // With global inhibition, "neighborhood" = all columns.
212
- let max_overlap_duty = self
213
- .overlap_duty_cycle
214
- .iter()
215
- .cloned()
216
- .fold(0.0_f32, f32::max);
217
- let min_pct_overlap_duty = 0.001_f32 * max_overlap_duty;
218
- if max_overlap_duty > 0.0 {
219
- for i in 0..n {
220
- if self.overlap_duty_cycle[i] < min_pct_overlap_duty {
221
- for p in &mut self.columns[i].perms {
222
- *p = (*p + self.cfg.syn_perm_active_inc * 0.1).min(1.0);
223
- }
224
- }
225
- }
226
- }
227
- }
228
-
229
- self.iter_count = self.iter_count.wrapping_add(1);
230
- let _ = &mut self.rng; // suppress unused-mut when learn=false
231
- active
232
- }
233
- }
234
-
235
- /// Return the indices of the top-k values in `scores`.
236
- /// Ties broken by index order. Output is sorted ascending.
237
- fn top_k(scores: &[f32], k: usize) -> Vec<u32> {
238
- if k == 0 {
239
- return Vec::new();
240
- }
241
- let mut idx: Vec<u32> = (0..scores.len() as u32).collect();
242
- // Partial sort: put top-k at the front by descending score.
243
- // Use select_nth_unstable_by on (desc score, asc index).
244
- idx.select_nth_unstable_by(k - 1, |&a, &b| {
245
- let sa = scores[a as usize];
246
- let sb = scores[b as usize];
247
- // Reverse for descending.
248
- match sb.partial_cmp(&sa).unwrap_or(std::cmp::Ordering::Equal) {
249
- std::cmp::Ordering::Equal => a.cmp(&b),
250
- ord => ord,
251
- }
252
- });
253
- let mut winners: Vec<u32> = idx[..k].to_vec();
254
- winners.sort_unstable();
255
- winners
256
- }
257
-
258
- // ---------------------------------------------------------------------------
259
- // Tests
260
- // ---------------------------------------------------------------------------
261
-
262
- #[cfg(test)]
263
- mod tests {
264
- use super::*;
265
- use rand::Rng;
266
- use rand::SeedableRng;
267
- use rand_xoshiro::Xoshiro256PlusPlus;
268
-
269
- #[test]
270
- fn sp_sparsity_exact_2pct() {
271
- // BAMI says "top ~2%"; with 2048 columns that's round(0.02*2048) = 41.
272
- // The SP must produce *exactly* that count, no more, no less, and with
273
- // no duplicate indices.
274
- let cfg = SpatialPoolerConfig::default();
275
- let expected_k = (cfg.sparsity * cfg.n_columns as f32).round() as usize;
276
- assert!(expected_k > 0);
277
-
278
- let input_bits = cfg.input_bits;
279
- let mut sp = SpatialPooler::new(cfg, 42);
280
- let mut rng = Xoshiro256PlusPlus::seed_from_u64(7);
281
-
282
- for _ in 0..100 {
283
- // 2% sparse random input SDR.
284
- let on_bits = (0.02 * input_bits as f32) as usize;
285
- let mut sdr = vec![false; input_bits];
286
- for _ in 0..on_bits {
287
- let i = rng.gen_range(0..input_bits);
288
- sdr[i] = true;
289
- }
290
- let active = sp.compute(&sdr, true);
291
- assert_eq!(
292
- active.len(),
293
- expected_k,
294
- "SP must emit exactly {expected_k} active columns"
295
- );
296
- let mut a = active.clone();
297
- a.sort_unstable();
298
- a.dedup();
299
- assert_eq!(a.len(), expected_k);
300
- }
301
- }
302
- }
 
1
+ //! Numenta BAMI-spec Spatial Pooler.
2
+ //!
3
+ //! Implements:
4
+ //! - 2048 (configurable) mini-columns with proximal dendrites
5
+ //! - `potential_synapses` (default 40) synapses per column sampled from
6
+ //! `potential_radius` (default 1024) random input bits
7
+ //! - Permanence in [0.0, 1.0] (f32), connected_threshold = 0.5
8
+ //! - syn_perm_active_inc = +0.04, syn_perm_inactive_dec = -0.008
9
+ //! - Global k-WTA inhibition (top `sparsity` fraction of columns)
10
+ //! - Boost factor with exponential duty-cycle tracking (Numenta formula)
11
+ //!
12
+ //! Reference: BAMI "Spatial Pooling Algorithm Details" (Numenta, 2017).
13
+
14
+ use rand::Rng;
15
+ use rand::SeedableRng;
16
+ use rand::seq::SliceRandom;
17
+ use rand_xoshiro::Xoshiro256PlusPlus;
18
+
19
+ /// A single proximal dendrite: a sparse set of potential synapses onto
20
+ /// specific input bit indices, with per-synapse permanence values.
21
+ #[derive(Clone)]
22
+ pub struct ProximalDendrite {
23
+ /// Indices into the input SDR. Length == potential_synapses.
24
+ pub inputs: Vec<u32>,
25
+ /// Permanence for each potential synapse (same length as `inputs`).
26
+ pub perms: Vec<f32>,
27
+ }
28
+
29
+ pub struct SpatialPoolerConfig {
30
+ pub input_bits: usize,
31
+ pub n_columns: usize,
32
+ /// Size of the random input sample per column.
33
+ pub potential_radius: usize,
34
+ /// Number of potential synapses per column's proximal dendrite.
35
+ pub potential_synapses: usize,
36
+ pub connected_threshold: f32,
37
+ pub syn_perm_active_inc: f32,
38
+ pub syn_perm_inactive_dec: f32,
39
+ /// Target fraction of columns active per step (e.g. 0.02 for 2%).
40
+ pub sparsity: f32,
41
+ /// Duty cycle EMA period.
42
+ pub duty_cycle_period: f32,
43
+ /// Boost strength. Set to 0.0 to disable boosting.
44
+ pub boost_strength: f32,
45
+ /// Initial permanence span around the connected threshold.
46
+ pub init_perm_span: f32,
47
+ }
48
+
49
+ impl Default for SpatialPoolerConfig {
50
+ fn default() -> Self {
51
+ Self {
52
+ input_bits: 16384,
53
+ n_columns: 2048,
54
+ potential_radius: 1024,
55
+ potential_synapses: 40,
56
+ connected_threshold: 0.5,
57
+ syn_perm_active_inc: 0.04,
58
+ syn_perm_inactive_dec: 0.008,
59
+ sparsity: 0.02,
60
+ duty_cycle_period: 1000.0,
61
+ boost_strength: 1.0,
62
+ init_perm_span: 0.1,
63
+ }
64
+ }
65
+ }
66
+
67
+ pub struct SpatialPooler {
68
+ pub cfg: SpatialPoolerConfig,
69
+ pub columns: Vec<ProximalDendrite>,
70
+ /// Exponential moving average of "column was active" per step.
71
+ pub active_duty_cycle: Vec<f32>,
72
+ /// Exponential moving average of "overlap exceeded threshold" per step.
73
+ pub overlap_duty_cycle: Vec<f32>,
74
+ /// Boost factor per column.
75
+ pub boost: Vec<f32>,
76
+ rng: Xoshiro256PlusPlus,
77
+ iter_count: u64,
78
+ }
79
+
80
+ impl SpatialPooler {
81
+ pub fn new(cfg: SpatialPoolerConfig, seed: u64) -> Self {
82
+ assert!(cfg.input_bits >= cfg.potential_radius,
83
+ "input_bits ({}) must be >= potential_radius ({})",
84
+ cfg.input_bits, cfg.potential_radius);
85
+ assert!(cfg.potential_radius >= cfg.potential_synapses,
86
+ "potential_radius ({}) must be >= potential_synapses ({})",
87
+ cfg.potential_radius, cfg.potential_synapses);
88
+
89
+ let mut rng = Xoshiro256PlusPlus::seed_from_u64(seed);
90
+
91
+ let mut columns = Vec::with_capacity(cfg.n_columns);
92
+ for _ in 0..cfg.n_columns {
93
+ // Sample `potential_radius` distinct input indices, then from those
94
+ // pick `potential_synapses` as the actual proximal synapses.
95
+ // Using partial Fisher-Yates via shuffle on a pool index range.
96
+ let mut pool: Vec<u32> = (0..cfg.input_bits as u32).collect();
97
+ // Efficient partial shuffle: swap the first `potential_radius`
98
+ // items with random items from the rest (Durstenfeld step).
99
+ for i in 0..cfg.potential_radius.min(pool.len()) {
100
+ let j = rng.gen_range(i..pool.len());
101
+ pool.swap(i, j);
102
+ }
103
+ let window = &mut pool[..cfg.potential_radius];
104
+ window.shuffle(&mut rng);
105
+ let mut inputs: Vec<u32> = window[..cfg.potential_synapses].to_vec();
106
+ inputs.sort_unstable();
107
+
108
+ let perms: Vec<f32> = (0..cfg.potential_synapses)
109
+ .map(|_| {
110
+ let delta: f32 = rng.gen_range(-cfg.init_perm_span..cfg.init_perm_span);
111
+ (cfg.connected_threshold + delta).clamp(0.0, 1.0)
112
+ })
113
+ .collect();
114
+
115
+ columns.push(ProximalDendrite { inputs, perms });
116
+ }
117
+
118
+ let n = cfg.n_columns;
119
+ Self {
120
+ cfg,
121
+ columns,
122
+ active_duty_cycle: vec![0.0; n],
123
+ overlap_duty_cycle: vec![0.0; n],
124
+ boost: vec![1.0; n],
125
+ rng,
126
+ iter_count: 0,
127
+ }
128
+ }
129
+
130
+ /// Process one step: compute overlaps, inhibit, learn (if `learn`), update
131
+ /// duty cycles and boosts. Returns the set of active column indices.
132
+ pub fn compute(&mut self, input: &[bool], learn: bool) -> Vec<u32> {
133
+ assert_eq!(input.len(), self.cfg.input_bits);
134
+
135
+ // 1) Overlap score per column (sum of CONNECTED synapses onto active inputs).
136
+ // Also track raw overlap for the overlap-duty-cycle.
137
+ let n = self.cfg.n_columns;
138
+ let mut overlaps: Vec<f32> = vec![0.0; n];
139
+ let mut raw_overlaps: Vec<u32> = vec![0; n];
140
+
141
+ for (ci, col) in self.columns.iter().enumerate() {
142
+ let mut s: u32 = 0;
143
+ for (syn_i, &inp) in col.inputs.iter().enumerate() {
144
+ if input[inp as usize] && col.perms[syn_i] >= self.cfg.connected_threshold {
145
+ s += 1;
146
+ }
147
+ }
148
+ raw_overlaps[ci] = s;
149
+ overlaps[ci] = (s as f32) * self.boost[ci];
150
+ }
151
+
152
+ // 2) Global k-WTA inhibition. Select top-k columns by boosted overlap.
153
+ let k = ((self.cfg.sparsity * n as f32).round() as usize).max(1);
154
+ let active: Vec<u32> = top_k(&overlaps, k);
155
+
156
+ // 3) Hebbian learning on active columns.
157
+ if learn {
158
+ for &ci in &active {
159
+ let col = &mut self.columns[ci as usize];
160
+ for (syn_i, &inp) in col.inputs.iter().enumerate() {
161
+ if input[inp as usize] {
162
+ col.perms[syn_i] =
163
+ (col.perms[syn_i] + self.cfg.syn_perm_active_inc).min(1.0);
164
+ } else {
165
+ col.perms[syn_i] =
166
+ (col.perms[syn_i] - self.cfg.syn_perm_inactive_dec).max(0.0);
167
+ }
168
+ }
169
+ }
170
+ }
171
+
172
+ // 4) Update duty cycles (EMA with period T -> alpha = 1/T).
173
+ let period = self.cfg.duty_cycle_period.max(1.0);
174
+ let alpha = 1.0 / period;
175
+ // Column is "overlapping enough" if raw overlap >= stimulus_threshold.
176
+ // Numenta uses min_overlap; we use 1 as a conservative floor.
177
+ let stimulus_threshold = 1.0_f32;
178
+
179
+ // Mark active columns.
180
+ let mut active_mask = vec![false; n];
181
+ for &ci in &active {
182
+ active_mask[ci as usize] = true;
183
+ }
184
+
185
+ for i in 0..n {
186
+ let active_sample = if active_mask[i] { 1.0 } else { 0.0 };
187
+ let overlap_sample = if (raw_overlaps[i] as f32) >= stimulus_threshold {
188
+ 1.0
189
+ } else {
190
+ 0.0
191
+ };
192
+ self.active_duty_cycle[i] =
193
+ (1.0 - alpha) * self.active_duty_cycle[i] + alpha * active_sample;
194
+ self.overlap_duty_cycle[i] =
195
+ (1.0 - alpha) * self.overlap_duty_cycle[i] + alpha * overlap_sample;
196
+ }
197
+
198
+ // 5) Boost factor: b_i = exp(-boost_strength * (duty_i - mean_duty)).
199
+ // Under-used columns (duty < mean) get boost > 1.
200
+ if learn && self.cfg.boost_strength > 0.0 {
201
+ let mean_duty: f32 =
202
+ self.active_duty_cycle.iter().sum::<f32>() / (n as f32);
203
+ for i in 0..n {
204
+ self.boost[i] =
205
+ (-self.cfg.boost_strength * (self.active_duty_cycle[i] - mean_duty)).exp();
206
+ }
207
+
208
+ // 6) Permanence bump for chronically under-stimulated columns.
209
+ // If overlap_duty_cycle[i] < min_pct_overlap * max_duty_in_neighborhood,
210
+ // bump all permanences by syn_perm_active_inc * 0.1.
211
+ // With global inhibition, "neighborhood" = all columns.
212
+ let max_overlap_duty = self
213
+ .overlap_duty_cycle
214
+ .iter()
215
+ .cloned()
216
+ .fold(0.0_f32, f32::max);
217
+ let min_pct_overlap_duty = 0.001_f32 * max_overlap_duty;
218
+ if max_overlap_duty > 0.0 {
219
+ for i in 0..n {
220
+ if self.overlap_duty_cycle[i] < min_pct_overlap_duty {
221
+ for p in &mut self.columns[i].perms {
222
+ *p = (*p + self.cfg.syn_perm_active_inc * 0.1).min(1.0);
223
+ }
224
+ }
225
+ }
226
+ }
227
+ }
228
+
229
+ self.iter_count = self.iter_count.wrapping_add(1);
230
+ let _ = &mut self.rng; // suppress unused-mut when learn=false
231
+ active
232
+ }
233
+ }
234
+
235
+ /// Return the indices of the top-k values in `scores`.
236
+ /// Ties broken by index order. Output is sorted ascending.
237
+ fn top_k(scores: &[f32], k: usize) -> Vec<u32> {
238
+ if k == 0 {
239
+ return Vec::new();
240
+ }
241
+ let mut idx: Vec<u32> = (0..scores.len() as u32).collect();
242
+ // Partial sort: put top-k at the front by descending score.
243
+ // Use select_nth_unstable_by on (desc score, asc index).
244
+ idx.select_nth_unstable_by(k - 1, |&a, &b| {
245
+ let sa = scores[a as usize];
246
+ let sb = scores[b as usize];
247
+ // Reverse for descending.
248
+ match sb.partial_cmp(&sa).unwrap_or(std::cmp::Ordering::Equal) {
249
+ std::cmp::Ordering::Equal => a.cmp(&b),
250
+ ord => ord,
251
+ }
252
+ });
253
+ let mut winners: Vec<u32> = idx[..k].to_vec();
254
+ winners.sort_unstable();
255
+ winners
256
+ }
257
+
258
+ // ---------------------------------------------------------------------------
259
+ // Tests
260
+ // ---------------------------------------------------------------------------
261
+
262
+ #[cfg(test)]
263
+ mod tests {
264
+ use super::*;
265
+ use rand::Rng;
266
+ use rand::SeedableRng;
267
+ use rand_xoshiro::Xoshiro256PlusPlus;
268
+
269
+ #[test]
270
+ fn sp_sparsity_exact_2pct() {
271
+ // BAMI says "top ~2%"; with 2048 columns that's round(0.02*2048) = 41.
272
+ // The SP must produce *exactly* that count, no more, no less, and with
273
+ // no duplicate indices.
274
+ let cfg = SpatialPoolerConfig::default();
275
+ let expected_k = (cfg.sparsity * cfg.n_columns as f32).round() as usize;
276
+ assert!(expected_k > 0);
277
+
278
+ let input_bits = cfg.input_bits;
279
+ let mut sp = SpatialPooler::new(cfg, 42);
280
+ let mut rng = Xoshiro256PlusPlus::seed_from_u64(7);
281
+
282
+ for _ in 0..100 {
283
+ // 2% sparse random input SDR.
284
+ let on_bits = (0.02 * input_bits as f32) as usize;
285
+ let mut sdr = vec![false; input_bits];
286
+ for _ in 0..on_bits {
287
+ let i = rng.gen_range(0..input_bits);
288
+ sdr[i] = true;
289
+ }
290
+ let active = sp.compute(&sdr, true);
291
+ assert_eq!(
292
+ active.len(),
293
+ expected_k,
294
+ "SP must emit exactly {expected_k} active columns"
295
+ );
296
+ let mut a = active.clone();
297
+ a.sort_unstable();
298
+ a.dedup();
299
+ assert_eq!(a.len(), expected_k);
300
+ }
301
+ }
302
+ }
overlay/htm_rust/src/tm.rs CHANGED
@@ -1,545 +1,545 @@
1
- //! Numenta BAMI-spec Temporal Memory.
2
- //!
3
- //! Key parameters (Numenta defaults):
4
- //! - cells_per_column = 32
5
- //! - max_segments_per_cell = 255
6
- //! - max_synapses_per_segment = 32
7
- //! - activation_threshold = 15 (CONNECTED synapses onto active cells)
8
- //! - learning_threshold = 13 (POTENTIAL synapses onto active cells)
9
- //! (often called `minThreshold` / match threshold in BAMI)
10
- //! - initial_permanence = 0.21
11
- //! - connected_permanence = 0.50
12
- //! - permanence_increment = 0.10
13
- //! - permanence_decrement = 0.10
14
- //! - predicted_segment_decrement = 0.10 (decay for segments that predicted
15
- //! inactive columns; called `predictedSegmentDecrement` in BAMI)
16
- //! - max_new_synapse_count = 20 (max synapses to grow on a new/reinforced seg)
17
- //!
18
- //! Algorithm (one step):
19
- //! Given `active_columns` from the Spatial Pooler, and segment activity
20
- //! caches `active_segments` and `matching_segments` computed *at the end of
21
- //! the previous step*:
22
- //!
23
- //! 1. For each active column:
24
- //! - If it contains any predicted cell (any cell with an active segment
25
- //! from the previous depolarization), mark those cells active and
26
- //! learn on the segment that predicted it.
27
- //! - Else BURST the column: mark all cells in it active, and grow a new
28
- //! segment on the best-matching cell in the column (or, if none,
29
- //! on the cell with the fewest segments).
30
- //! 2. For every column that was predicted but did NOT become active
31
- //! (matching segments on inactive columns), apply the
32
- //! `predicted_segment_decrement` decay so spurious predictions fade.
33
- //! 3. Winner cells = active cells chosen for learning (1 per active column).
34
- //! 4. Compute segment activity for NEXT step:
35
- //! - A segment's CONNECTED activity = #synapses with perm >= connected_perm
36
- //! whose presynaptic cell is in `active_cells`. If >= activation_threshold
37
- //! -> segment is "active" -> its cell is "predicted".
38
- //! - A segment's POTENTIAL activity = #synapses whose presynaptic cell is
39
- //! in `active_cells` (regardless of permanence). If >= learning_threshold
40
- //! -> segment is "matching".
41
- //!
42
- //! Anomaly score = (active columns with no prior predicted cells)
43
- //! / (# active columns).
44
-
45
- use rand::Rng;
46
- use rand::SeedableRng;
47
- use rand_xoshiro::Xoshiro256PlusPlus;
48
-
49
- type CellIdx = u32;
50
- type SegmentIdx = u32;
51
-
52
- #[derive(Clone)]
53
- pub struct Synapse {
54
- pub presynaptic_cell: CellIdx,
55
- pub permanence: f32,
56
- }
57
-
58
- #[derive(Clone)]
59
- pub struct Segment {
60
- pub cell: CellIdx,
61
- pub synapses: Vec<Synapse>,
62
- /// Cached counters; recomputed each step.
63
- pub num_active_connected: u32,
64
- pub num_active_potential: u32,
65
- /// Simple "last iter touched" stat for least-used cell selection.
66
- pub last_used_iteration: u64,
67
- }
68
-
69
- pub struct TemporalMemoryConfig {
70
- pub n_columns: usize,
71
- pub cells_per_column: usize,
72
- pub activation_threshold: u32,
73
- pub learning_threshold: u32,
74
- pub initial_permanence: f32,
75
- pub connected_permanence: f32,
76
- pub permanence_increment: f32,
77
- pub permanence_decrement: f32,
78
- pub predicted_segment_decrement: f32,
79
- pub max_segments_per_cell: usize,
80
- pub max_synapses_per_segment: usize,
81
- pub max_new_synapse_count: usize,
82
- }
83
-
84
- impl Default for TemporalMemoryConfig {
85
- fn default() -> Self {
86
- Self {
87
- n_columns: 2048,
88
- cells_per_column: 32,
89
- activation_threshold: 15,
90
- learning_threshold: 13,
91
- initial_permanence: 0.21,
92
- connected_permanence: 0.50,
93
- permanence_increment: 0.10,
94
- permanence_decrement: 0.10,
95
- predicted_segment_decrement: 0.10,
96
- max_segments_per_cell: 255,
97
- max_synapses_per_segment: 32,
98
- max_new_synapse_count: 20,
99
- }
100
- }
101
- }
102
-
103
- pub struct TemporalMemory {
104
- pub cfg: TemporalMemoryConfig,
105
- /// All segments in the region. Indexed by SegmentIdx.
106
- pub segments: Vec<Segment>,
107
- /// For each cell, the list of segments that belong to it.
108
- pub cell_segments: Vec<Vec<SegmentIdx>>,
109
- /// Active cells in the current step.
110
- pub active_cells: Vec<bool>,
111
- /// Winner cells (subset of active_cells, 1 per active column) for learning.
112
- pub winner_cells: Vec<bool>,
113
- /// Predictive cells for the current step = cells whose segment became
114
- /// active at the end of the previous step.
115
- pub predictive_cells: Vec<bool>,
116
- /// Cached list of segment indices that were "active" last compute().
117
- active_segments_prev: Vec<SegmentIdx>,
118
- /// Cached list of segment indices that were "matching" last compute().
119
- matching_segments_prev: Vec<SegmentIdx>,
120
- rng: Xoshiro256PlusPlus,
121
- iter_count: u64,
122
- }
123
-
124
- impl TemporalMemory {
125
- pub fn new(cfg: TemporalMemoryConfig, seed: u64) -> Self {
126
- let total = cfg.n_columns * cfg.cells_per_column;
127
- Self {
128
- cell_segments: vec![Vec::new(); total],
129
- active_cells: vec![false; total],
130
- winner_cells: vec![false; total],
131
- predictive_cells: vec![false; total],
132
- cfg,
133
- segments: Vec::new(),
134
- active_segments_prev: Vec::new(),
135
- matching_segments_prev: Vec::new(),
136
- rng: Xoshiro256PlusPlus::seed_from_u64(seed),
137
- iter_count: 0,
138
- }
139
- }
140
-
141
- pub fn reset(&mut self) {
142
- for v in self.active_cells.iter_mut() { *v = false; }
143
- for v in self.winner_cells.iter_mut() { *v = false; }
144
- for v in self.predictive_cells.iter_mut() { *v = false; }
145
- self.active_segments_prev.clear();
146
- self.matching_segments_prev.clear();
147
- }
148
-
149
- #[inline]
150
- fn col_of(&self, cell: CellIdx) -> usize {
151
- (cell as usize) / self.cfg.cells_per_column
152
- }
153
-
154
- #[inline]
155
- fn cells_in_col(&self, col: usize) -> std::ops::Range<CellIdx> {
156
- let base = (col * self.cfg.cells_per_column) as CellIdx;
157
- base..(base + self.cfg.cells_per_column as CellIdx)
158
- }
159
-
160
- /// Process one step.
161
- ///
162
- /// `active_columns` is the set of column indices activated by the Spatial
163
- /// Pooler this step. Returns the anomaly score in [0, 1].
164
- pub fn compute(&mut self, active_columns: &[u32], learn: bool) -> f32 {
165
- self.iter_count = self.iter_count.wrapping_add(1);
166
-
167
- // Snapshot previous-step cell activity (for learning on segments).
168
- let prev_active_cells = self.active_cells.clone();
169
- let prev_winner_cells = self.winner_cells.clone();
170
-
171
- // Move current "predictive" (computed at the end of the last step)
172
- // into local variables; we'll overwrite predictive_cells later.
173
- let predictive_prev = self.predictive_cells.clone();
174
-
175
- // Group active segments and matching segments by column of their
176
- // owning cell, for the columns that are active this step.
177
- let n_cols = self.cfg.n_columns;
178
-
179
- // active_segs_by_col[col] = segment indices whose cell is in col and
180
- // which were "active" in the previous depolarization.
181
- // matching_segs_by_col[col] = similarly for "matching".
182
- let mut active_segs_by_col: Vec<Vec<SegmentIdx>> = vec![Vec::new(); n_cols];
183
- let mut matching_segs_by_col: Vec<Vec<SegmentIdx>> = vec![Vec::new(); n_cols];
184
- for &seg in &self.active_segments_prev {
185
- let col = self.col_of(self.segments[seg as usize].cell);
186
- active_segs_by_col[col].push(seg);
187
- }
188
- for &seg in &self.matching_segments_prev {
189
- let col = self.col_of(self.segments[seg as usize].cell);
190
- matching_segs_by_col[col].push(seg);
191
- }
192
-
193
- // Columns that are active this step (for O(1) lookup).
194
- let mut active_col_mask = vec![false; n_cols];
195
- for &c in active_columns { active_col_mask[c as usize] = true; }
196
-
197
- // Zero out current cell activations.
198
- for v in self.active_cells.iter_mut() { *v = false; }
199
- for v in self.winner_cells.iter_mut() { *v = false; }
200
-
201
- // Track anomaly.
202
- let mut unpredicted_cols = 0u32;
203
-
204
- // We'll collect (segment, learn_mode) pairs for segment reinforcement
205
- // so we can batch-apply permanence adjustments using prev_active_cells.
206
- // learn_mode: "reinforce_correctly_predicted", "punish_incorrectly_matched"
207
- enum LearnOp {
208
- Reinforce(SegmentIdx), // correctly predicted
209
- Grow { // bursting column: grow on chosen segment
210
- segment: SegmentIdx,
211
- #[allow(dead_code)]
212
- winner_cell: CellIdx,
213
- },
214
- Punish(SegmentIdx), // matching segment on inactive column
215
- }
216
- let mut ops: Vec<LearnOp> = Vec::new();
217
-
218
- // ---- 1) Process active columns ----
219
- for &col in active_columns {
220
- let col = col as usize;
221
- let active_segs = &active_segs_by_col[col];
222
- if !active_segs.is_empty() {
223
- // "Activate predicted column": each cell with an active segment
224
- // becomes active and is a winner; reinforce that segment.
225
- let mut seen_cells: Vec<CellIdx> = Vec::new();
226
- for &seg_i in active_segs {
227
- let seg = &self.segments[seg_i as usize];
228
- let cell = seg.cell;
229
- if !seen_cells.contains(&cell) {
230
- self.active_cells[cell as usize] = true;
231
- self.winner_cells[cell as usize] = true;
232
- seen_cells.push(cell);
233
- }
234
- if learn {
235
- ops.push(LearnOp::Reinforce(seg_i));
236
- }
237
- }
238
- } else {
239
- // ----- BURST -----
240
- unpredicted_cols += 1;
241
- for c in self.cells_in_col(col) {
242
- self.active_cells[c as usize] = true;
243
- }
244
- // Pick a winner cell + segment for learning.
245
- if learn {
246
- let matching = &matching_segs_by_col[col];
247
- let (winner_cell, target_segment) = if !matching.is_empty() {
248
- // Best-matching segment = highest num_active_potential.
249
- let mut best = matching[0];
250
- let mut best_score = self.segments[best as usize].num_active_potential;
251
- for &s in &matching[1..] {
252
- let score = self.segments[s as usize].num_active_potential;
253
- if score > best_score {
254
- best_score = score;
255
- best = s;
256
- }
257
- }
258
- let wc = self.segments[best as usize].cell;
259
- (wc, Some(best))
260
- } else {
261
- // Least-used cell in column, then grow a new segment.
262
- let winner = self.least_used_cell(col);
263
- (winner, None)
264
- };
265
- self.winner_cells[winner_cell as usize] = true;
266
- let segment_id = match target_segment {
267
- Some(s) => s,
268
- None => {
269
- // Create a fresh empty segment on winner cell.
270
- self.create_segment(winner_cell)
271
- }
272
- };
273
- ops.push(LearnOp::Grow { segment: segment_id, winner_cell });
274
- } else {
275
- // No learning: still pick some winner cell (arbitrary)
276
- // so downstream code that inspects winner_cells isn't empty.
277
- let matching = &matching_segs_by_col[col];
278
- let winner_cell = if !matching.is_empty() {
279
- self.segments[matching[0] as usize].cell
280
- } else {
281
- self.least_used_cell(col)
282
- };
283
- self.winner_cells[winner_cell as usize] = true;
284
- }
285
- }
286
- }
287
-
288
- // ---- 2) Punish matching segments on INACTIVE columns ----
289
- if learn && self.cfg.predicted_segment_decrement > 0.0 {
290
- for &seg_i in &self.matching_segments_prev {
291
- let col = self.col_of(self.segments[seg_i as usize].cell);
292
- if !active_col_mask[col] {
293
- ops.push(LearnOp::Punish(seg_i));
294
- }
295
- }
296
- }
297
-
298
- // ---- 3) Apply learning ----
299
- if learn {
300
- for op in ops {
301
- match op {
302
- LearnOp::Reinforce(seg_i) => {
303
- self.reinforce_segment(seg_i, &prev_active_cells);
304
- // Optionally grow up to N new synapses to winner cells
305
- // of the previous step.
306
- self.grow_synapses_on_segment(seg_i, &prev_winner_cells);
307
- }
308
- LearnOp::Grow { segment, winner_cell: _ } => {
309
- self.reinforce_segment(segment, &prev_active_cells);
310
- self.grow_synapses_on_segment(segment, &prev_winner_cells);
311
- }
312
- LearnOp::Punish(seg_i) => {
313
- let dec = self.cfg.predicted_segment_decrement;
314
- for syn in &mut self.segments[seg_i as usize].synapses {
315
- if prev_active_cells[syn.presynaptic_cell as usize] {
316
- syn.permanence = (syn.permanence - dec).max(0.0);
317
- }
318
- }
319
- }
320
- }
321
- }
322
- }
323
-
324
- // ---- 4) Compute segment activity & predictive cells for NEXT step ----
325
- // We have to use the *current* active_cells (just set above).
326
- let mut next_active_segs: Vec<SegmentIdx> = Vec::new();
327
- let mut next_matching_segs: Vec<SegmentIdx> = Vec::new();
328
- for v in self.predictive_cells.iter_mut() { *v = false; }
329
-
330
- let conn = self.cfg.connected_permanence;
331
- let act_thr = self.cfg.activation_threshold;
332
- let learn_thr = self.cfg.learning_threshold;
333
-
334
- for (seg_i, seg) in self.segments.iter_mut().enumerate() {
335
- let mut n_conn: u32 = 0;
336
- let mut n_pot: u32 = 0;
337
- for syn in &seg.synapses {
338
- if self.active_cells[syn.presynaptic_cell as usize] {
339
- n_pot += 1;
340
- if syn.permanence >= conn { n_conn += 1; }
341
- }
342
- }
343
- seg.num_active_connected = n_conn;
344
- seg.num_active_potential = n_pot;
345
- if n_conn >= act_thr {
346
- next_active_segs.push(seg_i as SegmentIdx);
347
- self.predictive_cells[seg.cell as usize] = true;
348
- }
349
- if n_pot >= learn_thr {
350
- next_matching_segs.push(seg_i as SegmentIdx);
351
- }
352
- }
353
- self.active_segments_prev = next_active_segs;
354
- self.matching_segments_prev = next_matching_segs;
355
-
356
- // Keep predictive_prev unused-guard; we no longer need it but
357
- // retained to document intent.
358
- let _ = predictive_prev;
359
-
360
- // Anomaly.
361
- if active_columns.is_empty() {
362
- 0.0
363
- } else {
364
- (unpredicted_cols as f32) / (active_columns.len() as f32)
365
- }
366
- }
367
-
368
- /// Reinforce synapses on `seg`: +inc if presynaptic is active last step,
369
- /// -dec otherwise.
370
- fn reinforce_segment(&mut self, seg_i: SegmentIdx, prev_active_cells: &[bool]) {
371
- let inc = self.cfg.permanence_increment;
372
- let dec = self.cfg.permanence_decrement;
373
- let seg = &mut self.segments[seg_i as usize];
374
- seg.last_used_iteration = self.iter_count;
375
- for syn in &mut seg.synapses {
376
- if prev_active_cells[syn.presynaptic_cell as usize] {
377
- syn.permanence = (syn.permanence + inc).min(1.0);
378
- } else {
379
- syn.permanence = (syn.permanence - dec).max(0.0);
380
- }
381
- }
382
- }
383
-
384
- /// Grow up to `max_new_synapse_count - current_potential` new synapses
385
- /// from previous winner cells that are not already connected to this seg.
386
- fn grow_synapses_on_segment(
387
- &mut self,
388
- seg_i: SegmentIdx,
389
- prev_winner_cells: &[bool],
390
- ) {
391
- let initial_perm = self.cfg.initial_permanence;
392
- let cap = self.cfg.max_synapses_per_segment;
393
- let max_new = self.cfg.max_new_synapse_count;
394
-
395
- // Gather candidate cells (prev winners not already presynaptic to this seg).
396
- let already: Vec<CellIdx> = self.segments[seg_i as usize]
397
- .synapses
398
- .iter()
399
- .map(|s| s.presynaptic_cell)
400
- .collect();
401
- let mut candidates: Vec<CellIdx> = Vec::new();
402
- for (cell_i, &b) in prev_winner_cells.iter().enumerate() {
403
- if b && !already.contains(&(cell_i as CellIdx)) {
404
- candidates.push(cell_i as CellIdx);
405
- }
406
- }
407
-
408
- // How many can we add?
409
- let current_len = self.segments[seg_i as usize].synapses.len();
410
- let room = cap.saturating_sub(current_len);
411
- let mut to_add = max_new.min(candidates.len()).min(room);
412
-
413
- // Random sample without replacement from candidates.
414
- while to_add > 0 {
415
- let idx = self.rng.gen_range(0..candidates.len());
416
- let pre = candidates.swap_remove(idx);
417
- self.segments[seg_i as usize].synapses.push(Synapse {
418
- presynaptic_cell: pre,
419
- permanence: initial_perm,
420
- });
421
- to_add -= 1;
422
- }
423
- }
424
-
425
- fn create_segment(&mut self, cell: CellIdx) -> SegmentIdx {
426
- // Enforce per-cell segment cap by evicting least-recently-used segment
427
- // if necessary.
428
- let cell_segs = &mut self.cell_segments[cell as usize];
429
- if cell_segs.len() >= self.cfg.max_segments_per_cell {
430
- // Find LRU segment.
431
- let (lru_pos, &lru_id) = cell_segs
432
- .iter()
433
- .enumerate()
434
- .min_by_key(|(_, &sid)| self.segments[sid as usize].last_used_iteration)
435
- .expect("cell_segs non-empty");
436
- // Clear that segment in place and reuse its index.
437
- self.segments[lru_id as usize].synapses.clear();
438
- self.segments[lru_id as usize].num_active_connected = 0;
439
- self.segments[lru_id as usize].num_active_potential = 0;
440
- self.segments[lru_id as usize].last_used_iteration = self.iter_count;
441
- // Keep at same position in cell_segs.
442
- let _ = lru_pos;
443
- return lru_id;
444
- }
445
-
446
- let new_id = self.segments.len() as SegmentIdx;
447
- self.segments.push(Segment {
448
- cell,
449
- synapses: Vec::with_capacity(self.cfg.max_new_synapse_count),
450
- num_active_connected: 0,
451
- num_active_potential: 0,
452
- last_used_iteration: self.iter_count,
453
- });
454
- cell_segs.push(new_id);
455
- new_id
456
- }
457
-
458
- fn least_used_cell(&mut self, col: usize) -> CellIdx {
459
- // Cell with the fewest segments; break ties randomly.
460
- let mut min_segs = usize::MAX;
461
- let mut candidates: Vec<CellIdx> = Vec::new();
462
- for c in self.cells_in_col(col) {
463
- let n = self.cell_segments[c as usize].len();
464
- if n < min_segs {
465
- min_segs = n;
466
- candidates.clear();
467
- candidates.push(c);
468
- } else if n == min_segs {
469
- candidates.push(c);
470
- }
471
- }
472
- let idx = self.rng.gen_range(0..candidates.len());
473
- candidates[idx]
474
- }
475
- }
476
-
477
- // ---------------------------------------------------------------------------
478
- // Tests
479
- // ---------------------------------------------------------------------------
480
-
481
- #[cfg(test)]
482
- mod tests {
483
- use super::*;
484
- use crate::sp::{SpatialPooler, SpatialPoolerConfig};
485
- use rand::Rng;
486
- use rand::SeedableRng;
487
- use rand_xoshiro::Xoshiro256PlusPlus;
488
-
489
- #[test]
490
- fn tm_learns_repeating_sequence() {
491
- // Sequence A -> B -> C -> A -> B -> C -> ... should drive anomaly down.
492
- let cfg = SpatialPoolerConfig::default();
493
- let mut sp = SpatialPooler::new(cfg, 123);
494
- let mut tm = TemporalMemory::new(TemporalMemoryConfig::default(), 456);
495
-
496
- // Build 3 fixed random SDRs of 2% sparsity.
497
- let mut rng = Xoshiro256PlusPlus::seed_from_u64(99);
498
- let input_bits = sp.cfg.input_bits;
499
- let make_sdr = |rng: &mut Xoshiro256PlusPlus| {
500
- let mut v = vec![false; input_bits];
501
- let on = (0.02 * input_bits as f32) as usize;
502
- let mut placed = 0;
503
- while placed < on {
504
- let i = rng.gen_range(0..input_bits);
505
- if !v[i] {
506
- v[i] = true;
507
- placed += 1;
508
- }
509
- }
510
- v
511
- };
512
- let seqs = [make_sdr(&mut rng), make_sdr(&mut rng), make_sdr(&mut rng)];
513
-
514
- // Warm up SP first so that columns are reliable for each symbol.
515
- for _ in 0..200 {
516
- for s in &seqs {
517
- sp.compute(s, true);
518
- }
519
- }
520
-
521
- // Reset TM so prediction state is clean.
522
- tm.reset();
523
-
524
- // Record anomaly over a window early and late.
525
- let mut early_anoms: Vec<f32> = Vec::new();
526
- let mut late_anoms: Vec<f32> = Vec::new();
527
- for iter in 0..250 {
528
- for s in &seqs {
529
- let active = sp.compute(s, false);
530
- let anomaly = tm.compute(&active, true);
531
- if iter == 10 { early_anoms.push(anomaly); }
532
- if iter == 249 { late_anoms.push(anomaly); }
533
- }
534
- }
535
-
536
- let mean = |v: &[f32]| v.iter().sum::<f32>() / (v.len() as f32);
537
- let early = mean(&early_anoms);
538
- let late = mean(&late_anoms);
539
- println!("early_anomaly={early}, late_anomaly={late}");
540
- assert!(
541
- late < 0.5 * early + 1e-6,
542
- "late anomaly ({late}) should be < 0.5 * early anomaly ({early})"
543
- );
544
- }
545
- }
 
1
+ //! Numenta BAMI-spec Temporal Memory.
2
+ //!
3
+ //! Key parameters (Numenta defaults):
4
+ //! - cells_per_column = 32
5
+ //! - max_segments_per_cell = 255
6
+ //! - max_synapses_per_segment = 32
7
+ //! - activation_threshold = 15 (CONNECTED synapses onto active cells)
8
+ //! - learning_threshold = 13 (POTENTIAL synapses onto active cells)
9
+ //! (often called `minThreshold` / match threshold in BAMI)
10
+ //! - initial_permanence = 0.21
11
+ //! - connected_permanence = 0.50
12
+ //! - permanence_increment = 0.10
13
+ //! - permanence_decrement = 0.10
14
+ //! - predicted_segment_decrement = 0.10 (decay for segments that predicted
15
+ //! inactive columns; called `predictedSegmentDecrement` in BAMI)
16
+ //! - max_new_synapse_count = 20 (max synapses to grow on a new/reinforced seg)
17
+ //!
18
+ //! Algorithm (one step):
19
+ //! Given `active_columns` from the Spatial Pooler, and segment activity
20
+ //! caches `active_segments` and `matching_segments` computed *at the end of
21
+ //! the previous step*:
22
+ //!
23
+ //! 1. For each active column:
24
+ //! - If it contains any predicted cell (any cell with an active segment
25
+ //! from the previous depolarization), mark those cells active and
26
+ //! learn on the segment that predicted it.
27
+ //! - Else BURST the column: mark all cells in it active, and grow a new
28
+ //! segment on the best-matching cell in the column (or, if none,
29
+ //! on the cell with the fewest segments).
30
+ //! 2. For every column that was predicted but did NOT become active
31
+ //! (matching segments on inactive columns), apply the
32
+ //! `predicted_segment_decrement` decay so spurious predictions fade.
33
+ //! 3. Winner cells = active cells chosen for learning (1 per active column).
34
+ //! 4. Compute segment activity for NEXT step:
35
+ //! - A segment's CONNECTED activity = #synapses with perm >= connected_perm
36
+ //! whose presynaptic cell is in `active_cells`. If >= activation_threshold
37
+ //! -> segment is "active" -> its cell is "predicted".
38
+ //! - A segment's POTENTIAL activity = #synapses whose presynaptic cell is
39
+ //! in `active_cells` (regardless of permanence). If >= learning_threshold
40
+ //! -> segment is "matching".
41
+ //!
42
+ //! Anomaly score = (active columns with no prior predicted cells)
43
+ //! / (# active columns).
44
+
45
+ use rand::Rng;
46
+ use rand::SeedableRng;
47
+ use rand_xoshiro::Xoshiro256PlusPlus;
48
+
49
+ type CellIdx = u32;
50
+ type SegmentIdx = u32;
51
+
52
+ #[derive(Clone)]
53
+ pub struct Synapse {
54
+ pub presynaptic_cell: CellIdx,
55
+ pub permanence: f32,
56
+ }
57
+
58
+ #[derive(Clone)]
59
+ pub struct Segment {
60
+ pub cell: CellIdx,
61
+ pub synapses: Vec<Synapse>,
62
+ /// Cached counters; recomputed each step.
63
+ pub num_active_connected: u32,
64
+ pub num_active_potential: u32,
65
+ /// Simple "last iter touched" stat for least-used cell selection.
66
+ pub last_used_iteration: u64,
67
+ }
68
+
69
+ pub struct TemporalMemoryConfig {
70
+ pub n_columns: usize,
71
+ pub cells_per_column: usize,
72
+ pub activation_threshold: u32,
73
+ pub learning_threshold: u32,
74
+ pub initial_permanence: f32,
75
+ pub connected_permanence: f32,
76
+ pub permanence_increment: f32,
77
+ pub permanence_decrement: f32,
78
+ pub predicted_segment_decrement: f32,
79
+ pub max_segments_per_cell: usize,
80
+ pub max_synapses_per_segment: usize,
81
+ pub max_new_synapse_count: usize,
82
+ }
83
+
84
+ impl Default for TemporalMemoryConfig {
85
+ fn default() -> Self {
86
+ Self {
87
+ n_columns: 2048,
88
+ cells_per_column: 32,
89
+ activation_threshold: 15,
90
+ learning_threshold: 13,
91
+ initial_permanence: 0.21,
92
+ connected_permanence: 0.50,
93
+ permanence_increment: 0.10,
94
+ permanence_decrement: 0.10,
95
+ predicted_segment_decrement: 0.10,
96
+ max_segments_per_cell: 255,
97
+ max_synapses_per_segment: 32,
98
+ max_new_synapse_count: 20,
99
+ }
100
+ }
101
+ }
102
+
103
+ pub struct TemporalMemory {
104
+ pub cfg: TemporalMemoryConfig,
105
+ /// All segments in the region. Indexed by SegmentIdx.
106
+ pub segments: Vec<Segment>,
107
+ /// For each cell, the list of segments that belong to it.
108
+ pub cell_segments: Vec<Vec<SegmentIdx>>,
109
+ /// Active cells in the current step.
110
+ pub active_cells: Vec<bool>,
111
+ /// Winner cells (subset of active_cells, 1 per active column) for learning.
112
+ pub winner_cells: Vec<bool>,
113
+ /// Predictive cells for the current step = cells whose segment became
114
+ /// active at the end of the previous step.
115
+ pub predictive_cells: Vec<bool>,
116
+ /// Cached list of segment indices that were "active" last compute().
117
+ active_segments_prev: Vec<SegmentIdx>,
118
+ /// Cached list of segment indices that were "matching" last compute().
119
+ matching_segments_prev: Vec<SegmentIdx>,
120
+ rng: Xoshiro256PlusPlus,
121
+ iter_count: u64,
122
+ }
123
+
124
+ impl TemporalMemory {
125
+ pub fn new(cfg: TemporalMemoryConfig, seed: u64) -> Self {
126
+ let total = cfg.n_columns * cfg.cells_per_column;
127
+ Self {
128
+ cell_segments: vec![Vec::new(); total],
129
+ active_cells: vec![false; total],
130
+ winner_cells: vec![false; total],
131
+ predictive_cells: vec![false; total],
132
+ cfg,
133
+ segments: Vec::new(),
134
+ active_segments_prev: Vec::new(),
135
+ matching_segments_prev: Vec::new(),
136
+ rng: Xoshiro256PlusPlus::seed_from_u64(seed),
137
+ iter_count: 0,
138
+ }
139
+ }
140
+
141
+ pub fn reset(&mut self) {
142
+ for v in self.active_cells.iter_mut() { *v = false; }
143
+ for v in self.winner_cells.iter_mut() { *v = false; }
144
+ for v in self.predictive_cells.iter_mut() { *v = false; }
145
+ self.active_segments_prev.clear();
146
+ self.matching_segments_prev.clear();
147
+ }
148
+
149
+ #[inline]
150
+ fn col_of(&self, cell: CellIdx) -> usize {
151
+ (cell as usize) / self.cfg.cells_per_column
152
+ }
153
+
154
+ #[inline]
155
+ fn cells_in_col(&self, col: usize) -> std::ops::Range<CellIdx> {
156
+ let base = (col * self.cfg.cells_per_column) as CellIdx;
157
+ base..(base + self.cfg.cells_per_column as CellIdx)
158
+ }
159
+
160
+ /// Process one step.
161
+ ///
162
+ /// `active_columns` is the set of column indices activated by the Spatial
163
+ /// Pooler this step. Returns the anomaly score in [0, 1].
164
+ pub fn compute(&mut self, active_columns: &[u32], learn: bool) -> f32 {
165
+ self.iter_count = self.iter_count.wrapping_add(1);
166
+
167
+ // Snapshot previous-step cell activity (for learning on segments).
168
+ let prev_active_cells = self.active_cells.clone();
169
+ let prev_winner_cells = self.winner_cells.clone();
170
+
171
+ // Move current "predictive" (computed at the end of the last step)
172
+ // into local variables; we'll overwrite predictive_cells later.
173
+ let predictive_prev = self.predictive_cells.clone();
174
+
175
+ // Group active segments and matching segments by column of their
176
+ // owning cell, for the columns that are active this step.
177
+ let n_cols = self.cfg.n_columns;
178
+
179
+ // active_segs_by_col[col] = segment indices whose cell is in col and
180
+ // which were "active" in the previous depolarization.
181
+ // matching_segs_by_col[col] = similarly for "matching".
182
+ let mut active_segs_by_col: Vec<Vec<SegmentIdx>> = vec![Vec::new(); n_cols];
183
+ let mut matching_segs_by_col: Vec<Vec<SegmentIdx>> = vec![Vec::new(); n_cols];
184
+ for &seg in &self.active_segments_prev {
185
+ let col = self.col_of(self.segments[seg as usize].cell);
186
+ active_segs_by_col[col].push(seg);
187
+ }
188
+ for &seg in &self.matching_segments_prev {
189
+ let col = self.col_of(self.segments[seg as usize].cell);
190
+ matching_segs_by_col[col].push(seg);
191
+ }
192
+
193
+ // Columns that are active this step (for O(1) lookup).
194
+ let mut active_col_mask = vec![false; n_cols];
195
+ for &c in active_columns { active_col_mask[c as usize] = true; }
196
+
197
+ // Zero out current cell activations.
198
+ for v in self.active_cells.iter_mut() { *v = false; }
199
+ for v in self.winner_cells.iter_mut() { *v = false; }
200
+
201
+ // Track anomaly.
202
+ let mut unpredicted_cols = 0u32;
203
+
204
+ // We'll collect (segment, learn_mode) pairs for segment reinforcement
205
+ // so we can batch-apply permanence adjustments using prev_active_cells.
206
+ // learn_mode: "reinforce_correctly_predicted", "punish_incorrectly_matched"
207
+ enum LearnOp {
208
+ Reinforce(SegmentIdx), // correctly predicted
209
+ Grow { // bursting column: grow on chosen segment
210
+ segment: SegmentIdx,
211
+ #[allow(dead_code)]
212
+ winner_cell: CellIdx,
213
+ },
214
+ Punish(SegmentIdx), // matching segment on inactive column
215
+ }
216
+ let mut ops: Vec<LearnOp> = Vec::new();
217
+
218
+ // ---- 1) Process active columns ----
219
+ for &col in active_columns {
220
+ let col = col as usize;
221
+ let active_segs = &active_segs_by_col[col];
222
+ if !active_segs.is_empty() {
223
+ // "Activate predicted column": each cell with an active segment
224
+ // becomes active and is a winner; reinforce that segment.
225
+ let mut seen_cells: Vec<CellIdx> = Vec::new();
226
+ for &seg_i in active_segs {
227
+ let seg = &self.segments[seg_i as usize];
228
+ let cell = seg.cell;
229
+ if !seen_cells.contains(&cell) {
230
+ self.active_cells[cell as usize] = true;
231
+ self.winner_cells[cell as usize] = true;
232
+ seen_cells.push(cell);
233
+ }
234
+ if learn {
235
+ ops.push(LearnOp::Reinforce(seg_i));
236
+ }
237
+ }
238
+ } else {
239
+ // ----- BURST -----
240
+ unpredicted_cols += 1;
241
+ for c in self.cells_in_col(col) {
242
+ self.active_cells[c as usize] = true;
243
+ }
244
+ // Pick a winner cell + segment for learning.
245
+ if learn {
246
+ let matching = &matching_segs_by_col[col];
247
+ let (winner_cell, target_segment) = if !matching.is_empty() {
248
+ // Best-matching segment = highest num_active_potential.
249
+ let mut best = matching[0];
250
+ let mut best_score = self.segments[best as usize].num_active_potential;
251
+ for &s in &matching[1..] {
252
+ let score = self.segments[s as usize].num_active_potential;
253
+ if score > best_score {
254
+ best_score = score;
255
+ best = s;
256
+ }
257
+ }
258
+ let wc = self.segments[best as usize].cell;
259
+ (wc, Some(best))
260
+ } else {
261
+ // Least-used cell in column, then grow a new segment.
262
+ let winner = self.least_used_cell(col);
263
+ (winner, None)
264
+ };
265
+ self.winner_cells[winner_cell as usize] = true;
266
+ let segment_id = match target_segment {
267
+ Some(s) => s,
268
+ None => {
269
+ // Create a fresh empty segment on winner cell.
270
+ self.create_segment(winner_cell)
271
+ }
272
+ };
273
+ ops.push(LearnOp::Grow { segment: segment_id, winner_cell });
274
+ } else {
275
+ // No learning: still pick some winner cell (arbitrary)
276
+ // so downstream code that inspects winner_cells isn't empty.
277
+ let matching = &matching_segs_by_col[col];
278
+ let winner_cell = if !matching.is_empty() {
279
+ self.segments[matching[0] as usize].cell
280
+ } else {
281
+ self.least_used_cell(col)
282
+ };
283
+ self.winner_cells[winner_cell as usize] = true;
284
+ }
285
+ }
286
+ }
287
+
288
+ // ---- 2) Punish matching segments on INACTIVE columns ----
289
+ if learn && self.cfg.predicted_segment_decrement > 0.0 {
290
+ for &seg_i in &self.matching_segments_prev {
291
+ let col = self.col_of(self.segments[seg_i as usize].cell);
292
+ if !active_col_mask[col] {
293
+ ops.push(LearnOp::Punish(seg_i));
294
+ }
295
+ }
296
+ }
297
+
298
+ // ---- 3) Apply learning ----
299
+ if learn {
300
+ for op in ops {
301
+ match op {
302
+ LearnOp::Reinforce(seg_i) => {
303
+ self.reinforce_segment(seg_i, &prev_active_cells);
304
+ // Optionally grow up to N new synapses to winner cells
305
+ // of the previous step.
306
+ self.grow_synapses_on_segment(seg_i, &prev_winner_cells);
307
+ }
308
+ LearnOp::Grow { segment, winner_cell: _ } => {
309
+ self.reinforce_segment(segment, &prev_active_cells);
310
+ self.grow_synapses_on_segment(segment, &prev_winner_cells);
311
+ }
312
+ LearnOp::Punish(seg_i) => {
313
+ let dec = self.cfg.predicted_segment_decrement;
314
+ for syn in &mut self.segments[seg_i as usize].synapses {
315
+ if prev_active_cells[syn.presynaptic_cell as usize] {
316
+ syn.permanence = (syn.permanence - dec).max(0.0);
317
+ }
318
+ }
319
+ }
320
+ }
321
+ }
322
+ }
323
+
324
+ // ---- 4) Compute segment activity & predictive cells for NEXT step ----
325
+ // We have to use the *current* active_cells (just set above).
326
+ let mut next_active_segs: Vec<SegmentIdx> = Vec::new();
327
+ let mut next_matching_segs: Vec<SegmentIdx> = Vec::new();
328
+ for v in self.predictive_cells.iter_mut() { *v = false; }
329
+
330
+ let conn = self.cfg.connected_permanence;
331
+ let act_thr = self.cfg.activation_threshold;
332
+ let learn_thr = self.cfg.learning_threshold;
333
+
334
+ for (seg_i, seg) in self.segments.iter_mut().enumerate() {
335
+ let mut n_conn: u32 = 0;
336
+ let mut n_pot: u32 = 0;
337
+ for syn in &seg.synapses {
338
+ if self.active_cells[syn.presynaptic_cell as usize] {
339
+ n_pot += 1;
340
+ if syn.permanence >= conn { n_conn += 1; }
341
+ }
342
+ }
343
+ seg.num_active_connected = n_conn;
344
+ seg.num_active_potential = n_pot;
345
+ if n_conn >= act_thr {
346
+ next_active_segs.push(seg_i as SegmentIdx);
347
+ self.predictive_cells[seg.cell as usize] = true;
348
+ }
349
+ if n_pot >= learn_thr {
350
+ next_matching_segs.push(seg_i as SegmentIdx);
351
+ }
352
+ }
353
+ self.active_segments_prev = next_active_segs;
354
+ self.matching_segments_prev = next_matching_segs;
355
+
356
+ // Keep predictive_prev unused-guard; we no longer need it but
357
+ // retained to document intent.
358
+ let _ = predictive_prev;
359
+
360
+ // Anomaly.
361
+ if active_columns.is_empty() {
362
+ 0.0
363
+ } else {
364
+ (unpredicted_cols as f32) / (active_columns.len() as f32)
365
+ }
366
+ }
367
+
368
+ /// Reinforce synapses on `seg`: +inc if presynaptic is active last step,
369
+ /// -dec otherwise.
370
+ fn reinforce_segment(&mut self, seg_i: SegmentIdx, prev_active_cells: &[bool]) {
371
+ let inc = self.cfg.permanence_increment;
372
+ let dec = self.cfg.permanence_decrement;
373
+ let seg = &mut self.segments[seg_i as usize];
374
+ seg.last_used_iteration = self.iter_count;
375
+ for syn in &mut seg.synapses {
376
+ if prev_active_cells[syn.presynaptic_cell as usize] {
377
+ syn.permanence = (syn.permanence + inc).min(1.0);
378
+ } else {
379
+ syn.permanence = (syn.permanence - dec).max(0.0);
380
+ }
381
+ }
382
+ }
383
+
384
+ /// Grow up to `max_new_synapse_count - current_potential` new synapses
385
+ /// from previous winner cells that are not already connected to this seg.
386
+ fn grow_synapses_on_segment(
387
+ &mut self,
388
+ seg_i: SegmentIdx,
389
+ prev_winner_cells: &[bool],
390
+ ) {
391
+ let initial_perm = self.cfg.initial_permanence;
392
+ let cap = self.cfg.max_synapses_per_segment;
393
+ let max_new = self.cfg.max_new_synapse_count;
394
+
395
+ // Gather candidate cells (prev winners not already presynaptic to this seg).
396
+ let already: Vec<CellIdx> = self.segments[seg_i as usize]
397
+ .synapses
398
+ .iter()
399
+ .map(|s| s.presynaptic_cell)
400
+ .collect();
401
+ let mut candidates: Vec<CellIdx> = Vec::new();
402
+ for (cell_i, &b) in prev_winner_cells.iter().enumerate() {
403
+ if b && !already.contains(&(cell_i as CellIdx)) {
404
+ candidates.push(cell_i as CellIdx);
405
+ }
406
+ }
407
+
408
+ // How many can we add?
409
+ let current_len = self.segments[seg_i as usize].synapses.len();
410
+ let room = cap.saturating_sub(current_len);
411
+ let mut to_add = max_new.min(candidates.len()).min(room);
412
+
413
+ // Random sample without replacement from candidates.
414
+ while to_add > 0 {
415
+ let idx = self.rng.gen_range(0..candidates.len());
416
+ let pre = candidates.swap_remove(idx);
417
+ self.segments[seg_i as usize].synapses.push(Synapse {
418
+ presynaptic_cell: pre,
419
+ permanence: initial_perm,
420
+ });
421
+ to_add -= 1;
422
+ }
423
+ }
424
+
425
+ fn create_segment(&mut self, cell: CellIdx) -> SegmentIdx {
426
+ // Enforce per-cell segment cap by evicting least-recently-used segment
427
+ // if necessary.
428
+ let cell_segs = &mut self.cell_segments[cell as usize];
429
+ if cell_segs.len() >= self.cfg.max_segments_per_cell {
430
+ // Find LRU segment.
431
+ let (lru_pos, &lru_id) = cell_segs
432
+ .iter()
433
+ .enumerate()
434
+ .min_by_key(|(_, &sid)| self.segments[sid as usize].last_used_iteration)
435
+ .expect("cell_segs non-empty");
436
+ // Clear that segment in place and reuse its index.
437
+ self.segments[lru_id as usize].synapses.clear();
438
+ self.segments[lru_id as usize].num_active_connected = 0;
439
+ self.segments[lru_id as usize].num_active_potential = 0;
440
+ self.segments[lru_id as usize].last_used_iteration = self.iter_count;
441
+ // Keep at same position in cell_segs.
442
+ let _ = lru_pos;
443
+ return lru_id;
444
+ }
445
+
446
+ let new_id = self.segments.len() as SegmentIdx;
447
+ self.segments.push(Segment {
448
+ cell,
449
+ synapses: Vec::with_capacity(self.cfg.max_new_synapse_count),
450
+ num_active_connected: 0,
451
+ num_active_potential: 0,
452
+ last_used_iteration: self.iter_count,
453
+ });
454
+ cell_segs.push(new_id);
455
+ new_id
456
+ }
457
+
458
+ fn least_used_cell(&mut self, col: usize) -> CellIdx {
459
+ // Cell with the fewest segments; break ties randomly.
460
+ let mut min_segs = usize::MAX;
461
+ let mut candidates: Vec<CellIdx> = Vec::new();
462
+ for c in self.cells_in_col(col) {
463
+ let n = self.cell_segments[c as usize].len();
464
+ if n < min_segs {
465
+ min_segs = n;
466
+ candidates.clear();
467
+ candidates.push(c);
468
+ } else if n == min_segs {
469
+ candidates.push(c);
470
+ }
471
+ }
472
+ let idx = self.rng.gen_range(0..candidates.len());
473
+ candidates[idx]
474
+ }
475
+ }
476
+
477
+ // ---------------------------------------------------------------------------
478
+ // Tests
479
+ // ---------------------------------------------------------------------------
480
+
481
+ #[cfg(test)]
482
+ mod tests {
483
+ use super::*;
484
+ use crate::sp::{SpatialPooler, SpatialPoolerConfig};
485
+ use rand::Rng;
486
+ use rand::SeedableRng;
487
+ use rand_xoshiro::Xoshiro256PlusPlus;
488
+
489
+ #[test]
490
+ fn tm_learns_repeating_sequence() {
491
+ // Sequence A -> B -> C -> A -> B -> C -> ... should drive anomaly down.
492
+ let cfg = SpatialPoolerConfig::default();
493
+ let mut sp = SpatialPooler::new(cfg, 123);
494
+ let mut tm = TemporalMemory::new(TemporalMemoryConfig::default(), 456);
495
+
496
+ // Build 3 fixed random SDRs of 2% sparsity.
497
+ let mut rng = Xoshiro256PlusPlus::seed_from_u64(99);
498
+ let input_bits = sp.cfg.input_bits;
499
+ let make_sdr = |rng: &mut Xoshiro256PlusPlus| {
500
+ let mut v = vec![false; input_bits];
501
+ let on = (0.02 * input_bits as f32) as usize;
502
+ let mut placed = 0;
503
+ while placed < on {
504
+ let i = rng.gen_range(0..input_bits);
505
+ if !v[i] {
506
+ v[i] = true;
507
+ placed += 1;
508
+ }
509
+ }
510
+ v
511
+ };
512
+ let seqs = [make_sdr(&mut rng), make_sdr(&mut rng), make_sdr(&mut rng)];
513
+
514
+ // Warm up SP first so that columns are reliable for each symbol.
515
+ for _ in 0..200 {
516
+ for s in &seqs {
517
+ sp.compute(s, true);
518
+ }
519
+ }
520
+
521
+ // Reset TM so prediction state is clean.
522
+ tm.reset();
523
+
524
+ // Record anomaly over a window early and late.
525
+ let mut early_anoms: Vec<f32> = Vec::new();
526
+ let mut late_anoms: Vec<f32> = Vec::new();
527
+ for iter in 0..250 {
528
+ for s in &seqs {
529
+ let active = sp.compute(s, false);
530
+ let anomaly = tm.compute(&active, true);
531
+ if iter == 10 { early_anoms.push(anomaly); }
532
+ if iter == 249 { late_anoms.push(anomaly); }
533
+ }
534
+ }
535
+
536
+ let mean = |v: &[f32]| v.iter().sum::<f32>() / (v.len() as f32);
537
+ let early = mean(&early_anoms);
538
+ let late = mean(&late_anoms);
539
+ println!("early_anomaly={early}, late_anomaly={late}");
540
+ assert!(
541
+ late < 0.5 * early + 1e-6,
542
+ "late anomaly ({late}) should be < 0.5 * early anomaly ({early})"
543
+ );
544
+ }
545
+ }
overlay/htm_rust/uv.lock CHANGED
@@ -1,8 +1,8 @@
1
- version = 1
2
- revision = 3
3
- requires-python = ">=3.11"
4
-
5
- [[package]]
6
- name = "htm-rust"
7
- version = "0.1.0"
8
- source = { editable = "." }
 
1
+ version = 1
2
+ revision = 3
3
+ requires-python = ">=3.11"
4
+
5
+ [[package]]
6
+ name = "htm-rust"
7
+ version = "0.1.0"
8
+ source = { editable = "." }
overlay/hydra/__init__.py CHANGED
@@ -1,31 +1,31 @@
1
- """HYDRA training package.
2
-
3
- Thin facade re-exporting the public API used by train.py, the test suite,
4
- and external research scripts. Imports are lazy where possible to keep
5
- `import hydra` cheap (prepare.py and mamba-ssm are the heavy deps).
6
- """
7
-
8
- from hydra.config import PostSemClawConfig
9
- from hydra.engram import GPUEngram
10
- from hydra.model import PostSemClawModel, norm
11
- from hydra.optimizer import MuonAdamW, adamw_step_fused, muon_step_fused
12
-
13
- # config_from_dict is imported lazily (via attribute access on hydra.training)
14
- # to keep `import hydra` cheap; re-export here for convenience.
15
- def __getattr__(name: str):
16
- if name == "config_from_dict":
17
- from hydra.training import config_from_dict as _cfd
18
- return _cfd
19
- raise AttributeError(name)
20
-
21
-
22
- __all__ = [
23
- "PostSemClawConfig",
24
- "GPUEngram",
25
- "PostSemClawModel",
26
- "norm",
27
- "MuonAdamW",
28
- "adamw_step_fused",
29
- "muon_step_fused",
30
- "config_from_dict",
31
- ]
 
1
+ """HYDRA training package.
2
+
3
+ Thin facade re-exporting the public API used by train.py, the test suite,
4
+ and external research scripts. Imports are lazy where possible to keep
5
+ `import hydra` cheap (prepare.py and mamba-ssm are the heavy deps).
6
+ """
7
+
8
+ from hydra.config import PostSemClawConfig
9
+ from hydra.engram import GPUEngram
10
+ from hydra.model import PostSemClawModel, norm
11
+ from hydra.optimizer import MuonAdamW, adamw_step_fused, muon_step_fused
12
+
13
+ # config_from_dict is imported lazily (via attribute access on hydra.training)
14
+ # to keep `import hydra` cheap; re-export here for convenience.
15
+ def __getattr__(name: str):
16
+ if name == "config_from_dict":
17
+ from hydra.training import config_from_dict as _cfd
18
+ return _cfd
19
+ raise AttributeError(name)
20
+
21
+
22
+ __all__ = [
23
+ "PostSemClawConfig",
24
+ "GPUEngram",
25
+ "PostSemClawModel",
26
+ "norm",
27
+ "MuonAdamW",
28
+ "adamw_step_fused",
29
+ "muon_step_fused",
30
+ "config_from_dict",
31
+ ]
overlay/hydra/config.py CHANGED
@@ -1,225 +1,225 @@
1
- """HYDRA training configuration β€” dataclass + env-var constants.
2
-
3
- Extracted from the monolithic train.py as part of W1 modularization. All
4
- env-var reads and the PostSemClawConfig dataclass live here. The training
5
- body imports these constants; zero behavior change from the extraction.
6
- """
7
-
8
- from __future__ import annotations
9
-
10
- import os
11
- from dataclasses import dataclass, field
12
-
13
-
14
- def _parse_hyena_layers_env() -> tuple[int, ...]:
15
- """Parse HYDRA_HYENA_LAYERS env var into a sorted tuple of layer indices.
16
-
17
- Used as the default_factory for PostSemClawConfig.hyena_layers so a fresh
18
- config construction reads the current env var, but once constructed the
19
- value is first-class and travels with checkpoints (see asdict(config) in
20
- save_ckpt). Ckpt-load sets the dataclass field explicitly, overriding the
21
- env-var default.
22
-
23
- Returns empty tuple when env var is unset/empty (byte-identical to
24
- pre-port behavior: no Hyena layers).
25
- """
26
- raw = os.environ.get("HYDRA_HYENA_LAYERS", "")
27
- if not raw:
28
- return ()
29
- return tuple(sorted({int(s.strip()) for s in raw.split(",") if s.strip()}))
30
-
31
-
32
- def _parse_gdn_layers_env() -> tuple[int, ...]:
33
- """Parse HYDRA_GDN_LAYERS env var into a sorted tuple of layer indices.
34
-
35
- Same contract as _parse_hyena_layers_env: layers whose index is listed
36
- here use GatedDeltaNet (fla.layers.GatedDeltaNet) as a drop-in
37
- replacement for Mamba3. Empty tuple = no GDN layers (byte-identical
38
- to baseline).
39
- """
40
- raw = os.environ.get("HYDRA_GDN_LAYERS", "")
41
- if not raw:
42
- return ()
43
- return tuple(sorted({int(s.strip()) for s in raw.split(",") if s.strip()}))
44
-
45
- # ---------------------------------------------------------------------------
46
- # CUDA env β€” set before importing torch in entry point. Kept here so any
47
- # module that `from hydra.config import ...` also benefits (import order is
48
- # top-down in Python, and train.py used to set these at module top).
49
- # ---------------------------------------------------------------------------
50
- os.environ.setdefault("CUDA_HOME", "/usr/local/cuda")
51
- if "/usr/local/cuda/bin" not in os.environ.get("PATH", ""):
52
- os.environ["PATH"] = "/usr/local/cuda/bin:" + os.environ.get("PATH", "")
53
- os.environ.setdefault("PYTORCH_ALLOC_CONF", "expandable_segments:True")
54
-
55
-
56
- # ---------------------------------------------------------------------------
57
- # Model Configuration
58
- # ---------------------------------------------------------------------------
59
-
60
- @dataclass
61
- class PostSemClawConfig:
62
- """Full-architecture model config. Defaults reflect Phase-1 baseline;
63
- the training entry overrides d_model/n_layer/etc. from env vars."""
64
- # Sequence
65
- sequence_len: int = 2048
66
- vocab_size: int = 8192 # Must match prepare.py VOCAB_SIZE
67
-
68
- # Mamba-3 SSM
69
- n_layer: int = 6
70
- d_model: int = 384
71
- d_state: int = 64 # SSM state dimension
72
- headdim: int = 48 # head dimension for SSM
73
- n_heads: int = 8 # d_model // headdim
74
- expand: int = 2 # inner_dim = expand * d_model
75
-
76
- # Engram (conditional memory with Hebbian writes)
77
- engram_n_columns: int = 4096
78
- engram_key_dim: int = 64
79
- engram_layer_idx: int = 1 # which layer gets engram (0-indexed, mid-layer)
80
-
81
- # SemanticFoldingSDR (offline retina with STE; no-bypass, runs every step)
82
- sdr_n_bits: int = 16384 # retina width
83
- # Default 327 = 2% sparsity (Webber/Numenta canonical). Override with
84
- # HYDRA_SDR_TARGET_ACTIVE env var; value MUST match subsystems/sdr_retina.py
85
- # TARGET_ACTIVE (same env var is read there, so just setting it once works).
86
- sdr_target_active: int = int(os.environ.get("HYDRA_SDR_TARGET_ACTIVE", "327"))
87
- sdr_delta_rank: int = 32 # low-rank STE delta rank
88
- sdr_som_warmup: int = 500
89
- sdr_som_interval: int = 100
90
-
91
- # HTMLayer (Rust-backed, Hebbian; no-bypass, runs every step)
92
- htm_n_columns: int = 2048
93
- htm_cells_per_column: int = 32
94
-
95
- # Hyena supplement layer indices (sorted tuple). Defaults to the
96
- # HYDRA_HYENA_LAYERS env var at config-construction time, but once
97
- # persisted in a checkpoint the value is first-class and survives even
98
- # when the env var is unset at resume time. This fixes the ckpt-reload
99
- # crash path where a model trained with `HYDRA_HYENA_LAYERS=3,7` saves
100
- # HyenaBlock params but a fresh process without the env var would try
101
- # to build a pure-Mamba3 architecture and reject the state_dict as
102
- # `Missing/Unexpected key(s)`.
103
- hyena_layers: tuple[int, ...] = field(default_factory=_parse_hyena_layers_env)
104
-
105
- # GatedDeltaNet supplement layer indices (sorted tuple). Same semantics
106
- # as hyena_layers β€” a layer index listed here uses GDNBlock (fla-backed
107
- # Gated DeltaNet) instead of Mamba3. Selections are mutually exclusive
108
- # with hyena_layers at construction time (hyena wins on overlap; the
109
- # model loop checks hyena first).
110
- gdn_layers: tuple[int, ...] = field(default_factory=_parse_gdn_layers_env)
111
-
112
- # Label smoothing + Z-loss
113
- label_smoothing: float = 0.0 # disabled: any smoothing hurts in 5-min budget
114
- z_loss_weight: float = 1e-4
115
-
116
-
117
- # ---------------------------------------------------------------------------
118
- # Hyperparameters (autoresearch agent modifies these via env vars)
119
- # ---------------------------------------------------------------------------
120
-
121
- # Model architecture
122
- D_MODEL = int(os.environ.get("HYDRA_D_MODEL", "256"))
123
- N_LAYER = int(os.environ.get("HYDRA_N_LAYER", "4"))
124
- D_STATE = int(os.environ.get("HYDRA_D_STATE", "64"))
125
- HEADDIM = int(os.environ.get("HYDRA_HEADDIM", "32"))
126
- N_HEADS = D_MODEL // HEADDIM
127
- EXPAND = int(os.environ.get("HYDRA_EXPAND", "2"))
128
-
129
- # Engram
130
- ENGRAM_N_COLUMNS = int(os.environ.get("HYDRA_ENGRAM_N_COLUMNS", "1024"))
131
- ENGRAM_KEY_DIM = 64
132
- ENGRAM_LAYER_IDX = int(os.environ.get("HYDRA_ENGRAM_LAYER_IDX", "1"))
133
-
134
- # Optimization
135
- DEVICE_BATCH_SIZE = int(os.environ.get("HYDRA_BATCH_SIZE", "1"))
136
- TOTAL_BATCH_SIZE = int(os.environ.get("HYDRA_TOTAL_BATCH", "32768"))
137
- MATRIX_LR = float(os.environ.get("HYDRA_MATRIX_LR", "0.12"))
138
- EMBEDDING_LR = float(os.environ.get("HYDRA_EMBED_LR", "1.0"))
139
- UNEMBEDDING_LR = float(os.environ.get("HYDRA_UNEMBED_LR", "0.005"))
140
- # Scalar/vector params include Hyena implicit-filter vectors, norms, gate/bias
141
- # terms, and SDR delta_u/delta_v. They are AdamW-scaled by d_model and can be
142
- # the hidden instability path when the high-throughput HF recipe pushes a large
143
- # device batch for hours. Keep the historical default, but make it controllable
144
- # from launch scripts so cloud jobs can cool scalars without editing code.
145
- SCALAR_LR = float(os.environ.get("HYDRA_SCALAR_LR", "0.5"))
146
- WEIGHT_DECAY = float(os.environ.get("HYDRA_WEIGHT_DECAY", "0.01"))
147
- ADAM_BETAS = (0.9, 0.95)
148
- WARMUP_RATIO = float(os.environ.get("HYDRA_WARMUP_RATIO", "0.0"))
149
- WARMDOWN_RATIO = 0.5
150
- FINAL_LR_FRAC = float(os.environ.get("HYDRA_LR_MIN_MULT", "0.0"))
151
-
152
- # Runtime
153
- SEED = int(os.environ.get("HYDRA_SEED", "42"))
154
- # BF16 TFLOPS peak (RTX 3060=25.5, A100 SXM4=312, H100 SXM5=989)
155
- GPU_BF16_PEAK_FLOPS = float(os.environ.get("HYDRA_GPU_BF16_TFLOPS", "25.5")) * 1e12
156
-
157
- # Loss / inference knobs read by the model
158
- CE_CHUNK = int(os.environ.get("HYDRA_CE_CHUNK", "1024"))
159
- DROPOUT = float(os.environ.get("HYDRA_DROPOUT", "0.2"))
160
- FUSED_ADAMW = os.environ.get("HYDRA_FUSED_ADAMW", "1") == "1"
161
-
162
- # ---------------------------------------------------------------------------
163
- # Learnability knobs (all OFF by default β€” zero behavior change unless set)
164
- # ---------------------------------------------------------------------------
165
- # 1) Multi-Token Prediction (Llama-3 style). K=1 disables (next-1 only). K=4
166
- # adds 3 extra weight-tied heads; loss = mean of K position-shifted CEs.
167
- MTP_K = int(os.environ.get("HYDRA_MTP_K", "1"))
168
- # 2) Exponential Moving Average of model weights (decay=0.999). Saves an
169
- # additional latest_ema.pt at the end of training.
170
- USE_EMA = os.environ.get("HYDRA_USE_EMA", "0") == "1"
171
- EMA_DECAY = float(os.environ.get("HYDRA_EMA_DECAY", "0.999"))
172
- # 3) Gradient checkpointing on Mamba3 block forward. Trades ~30% compute for
173
- # ~40% activation memory savings β€” lets you push B upward on a 3060.
174
- GRAD_CKPT = os.environ.get("HYDRA_GRAD_CKPT", "0") == "1"
175
- # 4) Doc-separator masking in packed sequences: at every packed-BOS position
176
- # in the targets tensor, mask the loss (ignore_index=-1) so the model is
177
- # not forced to predict doc B from doc A's context.
178
- DOC_SEP_MASK = os.environ.get("HYDRA_DOC_SEP_MASK", "0") == "1"
179
- # 5) Stop-gradient on HTM state (belt-and-braces: htm_rust already runs under
180
- # torch.no_grad() so the tensor returned has requires_grad=False; this
181
- # simply detaches explicitly to harden graph hygiene against future refactors).
182
- HTM_STOP_GRAD = os.environ.get("HYDRA_HTM_STOP_GRAD", "0") == "1"
183
- # 6) Output entropy penalty: loss += -lambda * H(softmax(logits)). Negative
184
- # entropy penalizes peaked distributions and breaks repetition loops.
185
- ENTROPY_PENALTY = float(os.environ.get("HYDRA_ENTROPY_PENALTY", "0.0"))
186
- # 7) Curriculum: first N optimizer steps use short seq_len, then switch to
187
- # full. 0 disables (no curriculum).
188
- CURRICULUM_SHORT_STEPS = int(os.environ.get("HYDRA_CURRICULUM_SHORT_STEPS", "0"))
189
- CURRICULUM_SHORT_SEQ_LEN = int(os.environ.get("HYDRA_CURRICULUM_SHORT_SEQ_LEN", "256"))
190
-
191
- # ---------------------------------------------------------------------------
192
- # Hyena supplement (additional block type for selected layer indices).
193
- # Hyena replaces Mamba3 at the specified layer indices while all other layers
194
- # remain Mamba3. Empty string (default) β†’ no Hyena layers, byte-identical to
195
- # pre-port behavior.
196
- # HYDRA_HYENA_LAYERS "3,7" β€” comma-separated 0-indexed layer ids
197
- # HYDRA_HYENA_ORDER 2 β€” Hyena recurrence order (>= 2)
198
- # HYDRA_HYENA_FILTER_DIM 64 β€” implicit-filter MLP hidden width
199
- # Hyena reference: https://arxiv.org/pdf/2302.10866.pdf (HazyResearch/safari).
200
- # ---------------------------------------------------------------------------
201
- HYENA_LAYERS = os.environ.get("HYDRA_HYENA_LAYERS", "")
202
- HYENA_ORDER = int(os.environ.get("HYDRA_HYENA_ORDER", "2"))
203
- HYENA_FILTER_DIM = int(os.environ.get("HYDRA_HYENA_FILTER_DIM", "64"))
204
- # Filter-rfft cache modes (see subsystems/hyena_pure.py):
205
- # HYDRA_HYENA_FILTER_CACHE=1 β€” eval-only cache. Safe under torch.no_grad()
206
- # where PyTorch never saves intermediate tensors. Off by default.
207
- # HYDRA_HYENA_TRAIN_CACHE=1 β€” training-safe cache using a deferred
208
- # gradient pattern. Cuts the implicit filter MLP forward to ONCE per
209
- # optimizer step regardless of grad-accumulation factor. Requires the
210
- # training loop (see hydra/lightning_module.py::optimizer_step) to
211
- # call `model.flush_hyena_pending_grads()` before optimizer.step().
212
- # Off by default.
213
- HYENA_FILTER_CACHE = os.environ.get("HYDRA_HYENA_FILTER_CACHE", "0") == "1"
214
- HYENA_TRAIN_CACHE = os.environ.get("HYDRA_HYENA_TRAIN_CACHE", "0") == "1"
215
-
216
- # Factual eval knobs
217
- FACTUAL_SAMPLES = int(os.environ.get("HYDRA_FACTUAL_SAMPLES", "3"))
218
- FACTUAL_BATCH = int(os.environ.get("HYDRA_FACTUAL_BATCH", "32"))
219
- # F6 (partial): Full incremental SSM decode integration deferred β€” would require
220
- # threading mamba_ssm InferenceParams through PostSemClawModel.forward and all
221
- # auxiliary subsystems (HTM, SDR, Engram) which currently run full-sequence each
222
- # call. As a stopgap we reduce default from 16 -> 4 so the per-prompt cost is
223
- # quartered (each gen-tok does a full re-encode of ctx+k tokens). Override with
224
- # HYDRA_FACTUAL_GEN_TOKENS to restore prior behavior. See docs/OPTIMIZATION_PLAN.md.
225
- FACTUAL_GEN_TOKENS = int(os.environ.get("HYDRA_FACTUAL_GEN_TOKENS", "2"))
 
1
+ """HYDRA training configuration β€” dataclass + env-var constants.
2
+
3
+ Extracted from the monolithic train.py as part of W1 modularization. All
4
+ env-var reads and the PostSemClawConfig dataclass live here. The training
5
+ body imports these constants; zero behavior change from the extraction.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ from dataclasses import dataclass, field
12
+
13
+
14
+ def _parse_hyena_layers_env() -> tuple[int, ...]:
15
+ """Parse HYDRA_HYENA_LAYERS env var into a sorted tuple of layer indices.
16
+
17
+ Used as the default_factory for PostSemClawConfig.hyena_layers so a fresh
18
+ config construction reads the current env var, but once constructed the
19
+ value is first-class and travels with checkpoints (see asdict(config) in
20
+ save_ckpt). Ckpt-load sets the dataclass field explicitly, overriding the
21
+ env-var default.
22
+
23
+ Returns empty tuple when env var is unset/empty (byte-identical to
24
+ pre-port behavior: no Hyena layers).
25
+ """
26
+ raw = os.environ.get("HYDRA_HYENA_LAYERS", "")
27
+ if not raw:
28
+ return ()
29
+ return tuple(sorted({int(s.strip()) for s in raw.split(",") if s.strip()}))
30
+
31
+
32
+ def _parse_gdn_layers_env() -> tuple[int, ...]:
33
+ """Parse HYDRA_GDN_LAYERS env var into a sorted tuple of layer indices.
34
+
35
+ Same contract as _parse_hyena_layers_env: layers whose index is listed
36
+ here use GatedDeltaNet (fla.layers.GatedDeltaNet) as a drop-in
37
+ replacement for Mamba3. Empty tuple = no GDN layers (byte-identical
38
+ to baseline).
39
+ """
40
+ raw = os.environ.get("HYDRA_GDN_LAYERS", "")
41
+ if not raw:
42
+ return ()
43
+ return tuple(sorted({int(s.strip()) for s in raw.split(",") if s.strip()}))
44
+
45
+ # ---------------------------------------------------------------------------
46
+ # CUDA env β€” set before importing torch in entry point. Kept here so any
47
+ # module that `from hydra.config import ...` also benefits (import order is
48
+ # top-down in Python, and train.py used to set these at module top).
49
+ # ---------------------------------------------------------------------------
50
+ os.environ.setdefault("CUDA_HOME", "/usr/local/cuda")
51
+ if "/usr/local/cuda/bin" not in os.environ.get("PATH", ""):
52
+ os.environ["PATH"] = "/usr/local/cuda/bin:" + os.environ.get("PATH", "")
53
+ os.environ.setdefault("PYTORCH_ALLOC_CONF", "expandable_segments:True")
54
+
55
+
56
+ # ---------------------------------------------------------------------------
57
+ # Model Configuration
58
+ # ---------------------------------------------------------------------------
59
+
60
+ @dataclass
61
+ class PostSemClawConfig:
62
+ """Full-architecture model config. Defaults reflect Phase-1 baseline;
63
+ the training entry overrides d_model/n_layer/etc. from env vars."""
64
+ # Sequence
65
+ sequence_len: int = 2048
66
+ vocab_size: int = 8192 # Must match prepare.py VOCAB_SIZE
67
+
68
+ # Mamba-3 SSM
69
+ n_layer: int = 6
70
+ d_model: int = 384
71
+ d_state: int = 64 # SSM state dimension
72
+ headdim: int = 48 # head dimension for SSM
73
+ n_heads: int = 8 # d_model // headdim
74
+ expand: int = 2 # inner_dim = expand * d_model
75
+
76
+ # Engram (conditional memory with Hebbian writes)
77
+ engram_n_columns: int = 4096
78
+ engram_key_dim: int = 64
79
+ engram_layer_idx: int = 1 # which layer gets engram (0-indexed, mid-layer)
80
+
81
+ # SemanticFoldingSDR (offline retina with STE; no-bypass, runs every step)
82
+ sdr_n_bits: int = 16384 # retina width
83
+ # Default 327 = 2% sparsity (Webber/Numenta canonical). Override with
84
+ # HYDRA_SDR_TARGET_ACTIVE env var; value MUST match subsystems/sdr_retina.py
85
+ # TARGET_ACTIVE (same env var is read there, so just setting it once works).
86
+ sdr_target_active: int = int(os.environ.get("HYDRA_SDR_TARGET_ACTIVE", "327"))
87
+ sdr_delta_rank: int = 32 # low-rank STE delta rank
88
+ sdr_som_warmup: int = 500
89
+ sdr_som_interval: int = 100
90
+
91
+ # HTMLayer (Rust-backed, Hebbian; no-bypass, runs every step)
92
+ htm_n_columns: int = 2048
93
+ htm_cells_per_column: int = 32
94
+
95
+ # Hyena supplement layer indices (sorted tuple). Defaults to the
96
+ # HYDRA_HYENA_LAYERS env var at config-construction time, but once
97
+ # persisted in a checkpoint the value is first-class and survives even
98
+ # when the env var is unset at resume time. This fixes the ckpt-reload
99
+ # crash path where a model trained with `HYDRA_HYENA_LAYERS=3,7` saves
100
+ # HyenaBlock params but a fresh process without the env var would try
101
+ # to build a pure-Mamba3 architecture and reject the state_dict as
102
+ # `Missing/Unexpected key(s)`.
103
+ hyena_layers: tuple[int, ...] = field(default_factory=_parse_hyena_layers_env)
104
+
105
+ # GatedDeltaNet supplement layer indices (sorted tuple). Same semantics
106
+ # as hyena_layers β€” a layer index listed here uses GDNBlock (fla-backed
107
+ # Gated DeltaNet) instead of Mamba3. Selections are mutually exclusive
108
+ # with hyena_layers at construction time (hyena wins on overlap; the
109
+ # model loop checks hyena first).
110
+ gdn_layers: tuple[int, ...] = field(default_factory=_parse_gdn_layers_env)
111
+
112
+ # Label smoothing + Z-loss
113
+ label_smoothing: float = 0.0 # disabled: any smoothing hurts in 5-min budget
114
+ z_loss_weight: float = 1e-4
115
+
116
+
117
+ # ---------------------------------------------------------------------------
118
+ # Hyperparameters (autoresearch agent modifies these via env vars)
119
+ # ---------------------------------------------------------------------------
120
+
121
+ # Model architecture
122
+ D_MODEL = int(os.environ.get("HYDRA_D_MODEL", "256"))
123
+ N_LAYER = int(os.environ.get("HYDRA_N_LAYER", "4"))
124
+ D_STATE = int(os.environ.get("HYDRA_D_STATE", "64"))
125
+ HEADDIM = int(os.environ.get("HYDRA_HEADDIM", "32"))
126
+ N_HEADS = D_MODEL // HEADDIM
127
+ EXPAND = int(os.environ.get("HYDRA_EXPAND", "2"))
128
+
129
+ # Engram
130
+ ENGRAM_N_COLUMNS = int(os.environ.get("HYDRA_ENGRAM_N_COLUMNS", "1024"))
131
+ ENGRAM_KEY_DIM = 64
132
+ ENGRAM_LAYER_IDX = int(os.environ.get("HYDRA_ENGRAM_LAYER_IDX", "1"))
133
+
134
+ # Optimization
135
+ DEVICE_BATCH_SIZE = int(os.environ.get("HYDRA_BATCH_SIZE", "1"))
136
+ TOTAL_BATCH_SIZE = int(os.environ.get("HYDRA_TOTAL_BATCH", "32768"))
137
+ MATRIX_LR = float(os.environ.get("HYDRA_MATRIX_LR", "0.12"))
138
+ EMBEDDING_LR = float(os.environ.get("HYDRA_EMBED_LR", "1.0"))
139
+ UNEMBEDDING_LR = float(os.environ.get("HYDRA_UNEMBED_LR", "0.005"))
140
+ # Scalar/vector params include Hyena implicit-filter vectors, norms, gate/bias
141
+ # terms, and SDR delta_u/delta_v. They are AdamW-scaled by d_model and can be
142
+ # the hidden instability path when the high-throughput HF recipe pushes a large
143
+ # device batch for hours. Keep the historical default, but make it controllable
144
+ # from launch scripts so cloud jobs can cool scalars without editing code.
145
+ SCALAR_LR = float(os.environ.get("HYDRA_SCALAR_LR", "0.5"))
146
+ WEIGHT_DECAY = float(os.environ.get("HYDRA_WEIGHT_DECAY", "0.01"))
147
+ ADAM_BETAS = (0.9, 0.95)
148
+ WARMUP_RATIO = float(os.environ.get("HYDRA_WARMUP_RATIO", "0.0"))
149
+ WARMDOWN_RATIO = 0.5
150
+ FINAL_LR_FRAC = float(os.environ.get("HYDRA_LR_MIN_MULT", "0.0"))
151
+
152
+ # Runtime
153
+ SEED = int(os.environ.get("HYDRA_SEED", "42"))
154
+ # BF16 TFLOPS peak (RTX 3060=25.5, A100 SXM4=312, H100 SXM5=989)
155
+ GPU_BF16_PEAK_FLOPS = float(os.environ.get("HYDRA_GPU_BF16_TFLOPS", "25.5")) * 1e12
156
+
157
+ # Loss / inference knobs read by the model
158
+ CE_CHUNK = int(os.environ.get("HYDRA_CE_CHUNK", "1024"))
159
+ DROPOUT = float(os.environ.get("HYDRA_DROPOUT", "0.2"))
160
+ FUSED_ADAMW = os.environ.get("HYDRA_FUSED_ADAMW", "1") == "1"
161
+
162
+ # ---------------------------------------------------------------------------
163
+ # Learnability knobs (all OFF by default β€” zero behavior change unless set)
164
+ # ---------------------------------------------------------------------------
165
+ # 1) Multi-Token Prediction (Llama-3 style). K=1 disables (next-1 only). K=4
166
+ # adds 3 extra weight-tied heads; loss = mean of K position-shifted CEs.
167
+ MTP_K = int(os.environ.get("HYDRA_MTP_K", "1"))
168
+ # 2) Exponential Moving Average of model weights (decay=0.999). Saves an
169
+ # additional latest_ema.pt at the end of training.
170
+ USE_EMA = os.environ.get("HYDRA_USE_EMA", "0") == "1"
171
+ EMA_DECAY = float(os.environ.get("HYDRA_EMA_DECAY", "0.999"))
172
+ # 3) Gradient checkpointing on Mamba3 block forward. Trades ~30% compute for
173
+ # ~40% activation memory savings β€” lets you push B upward on a 3060.
174
+ GRAD_CKPT = os.environ.get("HYDRA_GRAD_CKPT", "0") == "1"
175
+ # 4) Doc-separator masking in packed sequences: at every packed-BOS position
176
+ # in the targets tensor, mask the loss (ignore_index=-1) so the model is
177
+ # not forced to predict doc B from doc A's context.
178
+ DOC_SEP_MASK = os.environ.get("HYDRA_DOC_SEP_MASK", "0") == "1"
179
+ # 5) Stop-gradient on HTM state (belt-and-braces: htm_rust already runs under
180
+ # torch.no_grad() so the tensor returned has requires_grad=False; this
181
+ # simply detaches explicitly to harden graph hygiene against future refactors).
182
+ HTM_STOP_GRAD = os.environ.get("HYDRA_HTM_STOP_GRAD", "0") == "1"
183
+ # 6) Output entropy penalty: loss += -lambda * H(softmax(logits)). Negative
184
+ # entropy penalizes peaked distributions and breaks repetition loops.
185
+ ENTROPY_PENALTY = float(os.environ.get("HYDRA_ENTROPY_PENALTY", "0.0"))
186
+ # 7) Curriculum: first N optimizer steps use short seq_len, then switch to
187
+ # full. 0 disables (no curriculum).
188
+ CURRICULUM_SHORT_STEPS = int(os.environ.get("HYDRA_CURRICULUM_SHORT_STEPS", "0"))
189
+ CURRICULUM_SHORT_SEQ_LEN = int(os.environ.get("HYDRA_CURRICULUM_SHORT_SEQ_LEN", "256"))
190
+
191
+ # ---------------------------------------------------------------------------
192
+ # Hyena supplement (additional block type for selected layer indices).
193
+ # Hyena replaces Mamba3 at the specified layer indices while all other layers
194
+ # remain Mamba3. Empty string (default) β†’ no Hyena layers, byte-identical to
195
+ # pre-port behavior.
196
+ # HYDRA_HYENA_LAYERS "3,7" β€” comma-separated 0-indexed layer ids
197
+ # HYDRA_HYENA_ORDER 2 β€” Hyena recurrence order (>= 2)
198
+ # HYDRA_HYENA_FILTER_DIM 64 β€” implicit-filter MLP hidden width
199
+ # Hyena reference: https://arxiv.org/pdf/2302.10866.pdf (HazyResearch/safari).
200
+ # ---------------------------------------------------------------------------
201
+ HYENA_LAYERS = os.environ.get("HYDRA_HYENA_LAYERS", "")
202
+ HYENA_ORDER = int(os.environ.get("HYDRA_HYENA_ORDER", "2"))
203
+ HYENA_FILTER_DIM = int(os.environ.get("HYDRA_HYENA_FILTER_DIM", "64"))
204
+ # Filter-rfft cache modes (see subsystems/hyena_pure.py):
205
+ # HYDRA_HYENA_FILTER_CACHE=1 β€” eval-only cache. Safe under torch.no_grad()
206
+ # where PyTorch never saves intermediate tensors. Off by default.
207
+ # HYDRA_HYENA_TRAIN_CACHE=1 β€” training-safe cache using a deferred
208
+ # gradient pattern. Cuts the implicit filter MLP forward to ONCE per
209
+ # optimizer step regardless of grad-accumulation factor. Requires the
210
+ # training loop (see hydra/lightning_module.py::optimizer_step) to
211
+ # call `model.flush_hyena_pending_grads()` before optimizer.step().
212
+ # Off by default.
213
+ HYENA_FILTER_CACHE = os.environ.get("HYDRA_HYENA_FILTER_CACHE", "0") == "1"
214
+ HYENA_TRAIN_CACHE = os.environ.get("HYDRA_HYENA_TRAIN_CACHE", "0") == "1"
215
+
216
+ # Factual eval knobs
217
+ FACTUAL_SAMPLES = int(os.environ.get("HYDRA_FACTUAL_SAMPLES", "3"))
218
+ FACTUAL_BATCH = int(os.environ.get("HYDRA_FACTUAL_BATCH", "32"))
219
+ # F6 (partial): Full incremental SSM decode integration deferred β€” would require
220
+ # threading mamba_ssm InferenceParams through PostSemClawModel.forward and all
221
+ # auxiliary subsystems (HTM, SDR, Engram) which currently run full-sequence each
222
+ # call. As a stopgap we reduce default from 16 -> 4 so the per-prompt cost is
223
+ # quartered (each gen-tok does a full re-encode of ctx+k tokens). Override with
224
+ # HYDRA_FACTUAL_GEN_TOKENS to restore prior behavior. See docs/OPTIMIZATION_PLAN.md.
225
+ FACTUAL_GEN_TOKENS = int(os.environ.get("HYDRA_FACTUAL_GEN_TOKENS", "2"))
overlay/hydra/data_module.py CHANGED
@@ -1,288 +1,288 @@
1
- """Lightning DataModule + IterableDataset for HYDRA pretraining.
2
-
3
- Replaces the custom threading/queue pipeline in prepare_nemotron.make_dataloader
4
- with a standard multiprocessing DataLoader approach.
5
-
6
- Design:
7
- β€’ IterableStreamDataset: each worker opens its own HF streams for the 7-way
8
- blend, tokenizes with rustbpe, packs into (T+1,) rows via best-fit, and
9
- yields one row per __next__.
10
- β€’ HydraDataModule: wraps the dataset with a standard DataLoader using
11
- num_workers>=1, prefetch_factor=4, pin_memory=True. Lightning handles
12
- device transfer.
13
- β€’ Val stream: deterministic seed 12345, weights match training blend.
14
-
15
- The worker RNG is seeded per-worker so the weighted-sampling schedule is
16
- independent across workers (else all workers request the same config at
17
- the same step and prefetching serializes).
18
-
19
- Env vars (all preserved from prepare_nemotron):
20
- HYDRA_SEQ_LEN β€” sequence length T (default 512)
21
- HYDRA_BATCH_SIZE β€” batch size B (default 1) β€” passed through
22
- to DataLoader
23
- HYDRA_STREAM_SHUFFLE_BUFFER β€” HF shuffle buffer (default 2048)
24
- HYDRA_USE_FULL_BLEND β€” 7-way blend vs 5-way Nemotron phase
25
- HYDRA_USE_NEMOTRON β€” enables streaming path (else shard path)
26
- HYDRA_FACTUAL_INJECT_RATE β€” factual doc injection cadence
27
- HYDRA_NEMOTRON_PHASE β€” phase1|phase2 (when not full blend)
28
- HYDRA_DATA_NUM_WORKERS β€” DataLoader num_workers (default 2)
29
- HYDRA_DATA_PREFETCH β€” DataLoader prefetch_factor (default 4)
30
- HYDRA_DATA_BUFFER β€” doc_buffer size for best-fit packing
31
- (default 1000)
32
- """
33
- from __future__ import annotations
34
-
35
- import os
36
- import random
37
- from typing import Iterator
38
-
39
- import numpy as np
40
- import torch
41
- import lightning as L
42
- from torch.utils.data import DataLoader, IterableDataset, get_worker_info
43
-
44
- import prepare as _prepare
45
- import prepare_nemotron as _p_nemo
46
- from prepare_nemotron import (
47
- FULL_BLEND_WEIGHTS,
48
- PHASE1_WEIGHTS,
49
- PHASE2_WEIGHTS,
50
- _BLEND_REGISTRY,
51
- _extract_text,
52
- _open_stream,
53
- )
54
-
55
-
56
- # ---------------------------------------------------------------------------
57
- # Worker-local weighted stream. A stripped version of prepare_nemotron's
58
- # _WeightedStream that is constructed inside each worker. Adds worker sharding:
59
- # when num_workers > 1 the RNG is seeded per-worker, so different workers
60
- # sample different config sequences and pull disjoint shard assignments from
61
- # HF's shuffle buffer.
62
- # ---------------------------------------------------------------------------
63
-
64
-
65
- class _WorkerWeightedStream:
66
- def __init__(self, weights: dict[str, float], base_seed: int, worker_id: int):
67
- self.configs = list(weights.keys())
68
- self.weights = [weights[c] for c in self.configs]
69
- self.base_seed = base_seed
70
- self.worker_id = worker_id
71
- # Each worker opens its own HF streams. _open_stream returns an iter()
72
- # over a streaming dataset, with an internal shuffle buffer.
73
- self.streams = {c: _open_stream(c, "train") for c in self.configs}
74
- # Per-worker RNG so the config-choice trajectory is independent.
75
- self.rng = random.Random(base_seed + worker_id * 7919)
76
- self.epoch = 1
77
-
78
- # Lazy-init factual docs (once per worker). The main-process version
79
- # in prepare_nemotron._WeightedStream reads these on first __next__.
80
- self._factual_docs: list[str] | None = None
81
- self._factual_idx = 0
82
- self._inject_counter = 0
83
- inject_rate = int(os.environ.get("HYDRA_FACTUAL_INJECT_RATE", "50"))
84
- self._inject_rate = inject_rate
85
- if inject_rate > 0:
86
- factual_path = os.path.join(
87
- os.path.dirname(os.path.abspath(_p_nemo.__file__)),
88
- "data", "factual", "facts.txt",
89
- )
90
- if os.path.exists(factual_path):
91
- with open(factual_path) as fh:
92
- self._factual_docs = fh.read().strip().split("\n")
93
-
94
- def _reopen(self, config: str) -> None:
95
- self.streams[config] = _open_stream(config, "train")
96
- self.epoch += 1
97
-
98
- def __iter__(self):
99
- return self
100
-
101
- def __next__(self) -> tuple[str, int]:
102
- # Factual injection (preserves prepare_nemotron cadence).
103
- if self._inject_rate > 0 and self._factual_docs:
104
- self._inject_counter += 1
105
- if self._inject_counter >= self._inject_rate:
106
- self._inject_counter = 0
107
- doc = self._factual_docs[self._factual_idx % len(self._factual_docs)]
108
- self._factual_idx += 1
109
- return doc, self.epoch
110
-
111
- config = self.rng.choices(self.configs, weights=self.weights, k=1)[0]
112
- try:
113
- row = next(self.streams[config])
114
- except StopIteration:
115
- self._reopen(config)
116
- row = next(self.streams[config])
117
- return _extract_text(row), self.epoch
118
-
119
-
120
- # ---------------------------------------------------------------------------
121
- # IterableStreamDataset β€” yields (T+1,) packed rows. No threads. No queues.
122
- # Lives inside each DataLoader worker. DataLoader's own multiprocessing stacks
123
- # rows into batches of shape (B, T+1) and sends them to the main process.
124
- # ---------------------------------------------------------------------------
125
-
126
-
127
- class IterableStreamDataset(IterableDataset):
128
- """Streams docs, tokenizes, packs into (T+1,) rows via best-fit.
129
-
130
- Each worker gets its own instance (via fork/spawn) and initializes its
131
- own HF streams + rustbpe tokenizer + factual injector. The tokenizer
132
- pickled blob is small (~1 MB) and thread-safe per tiktoken docs.
133
- """
134
-
135
- def __init__(
136
- self,
137
- split: str,
138
- seq_len: int,
139
- *,
140
- base_seed: int = 0,
141
- doc_buffer_size: int = 1000,
142
- tokenizer_batch: int = 128,
143
- ):
144
- super().__init__()
145
- assert split in ("train", "val"), split
146
- self.split = split
147
- self.seq_len = seq_len
148
- self.row_capacity = seq_len + 1
149
- self.base_seed = base_seed
150
- self.doc_buffer_size = doc_buffer_size
151
- self.tokenizer_batch = tokenizer_batch
152
-
153
- def _pick_weights(self) -> dict[str, float]:
154
- if self.split == "val":
155
- if os.environ.get("HYDRA_USE_FULL_BLEND", "0") == "1":
156
- return FULL_BLEND_WEIGHTS
157
- return {"Nemotron-Pretraining-Multiple-Choice": 1.0}
158
- if os.environ.get("HYDRA_USE_FULL_BLEND", "0") == "1":
159
- return FULL_BLEND_WEIGHTS
160
- phase = os.environ.get("HYDRA_NEMOTRON_PHASE", "phase1").strip().lower()
161
- return PHASE2_WEIGHTS if phase == "phase2" else PHASE1_WEIGHTS
162
-
163
- def __iter__(self) -> Iterator[torch.Tensor]:
164
- info = get_worker_info()
165
- worker_id = 0 if info is None else info.id
166
-
167
- # Each worker builds its own tokenizer instance. tiktoken's Encoding
168
- # object is pickleable and the underlying C++ BPE is thread-safe;
169
- # per-worker instantiation avoids cross-process sharing headaches.
170
- tokenizer = _prepare.Tokenizer.from_directory()
171
- bos = tokenizer.get_bos_token_id()
172
-
173
- # Each worker gets its own weighted HF stream. Seed offset ensures
174
- # disjoint config-choice trajectories; HF's own shuffle buffer handles
175
- # shard randomization.
176
- val_seed = 12345 # deterministic val
177
- seed = val_seed if self.split == "val" else self.base_seed
178
- stream = _WorkerWeightedStream(
179
- self._pick_weights(), base_seed=seed, worker_id=worker_id,
180
- )
181
-
182
- row_capacity = self.row_capacity
183
- doc_buffer: list[list[int]] = []
184
- doc_batch_size = self.tokenizer_batch
185
-
186
- def refill_buffer() -> None:
187
- # Collect doc_batch_size text strings, then batch-tokenize.
188
- texts: list[str] = []
189
- for _ in range(doc_batch_size):
190
- text, _epoch = next(stream)
191
- if text:
192
- texts.append(text)
193
- if texts:
194
- token_lists = tokenizer.encode(texts, prepend=bos)
195
- doc_buffer.extend(token_lists)
196
-
197
- while True:
198
- pos = 0
199
- row = torch.empty(row_capacity, dtype=torch.long)
200
- while pos < row_capacity:
201
- while len(doc_buffer) < self.doc_buffer_size:
202
- refill_buffer()
203
-
204
- remaining = row_capacity - pos
205
-
206
- # Best-fit packing: largest doc that fully fits.
207
- best_idx = -1
208
- best_len = 0
209
- for i, doc in enumerate(doc_buffer):
210
- dlen = len(doc)
211
- if dlen <= remaining and dlen > best_len:
212
- best_idx = i
213
- best_len = dlen
214
-
215
- if best_idx >= 0:
216
- doc = doc_buffer.pop(best_idx)
217
- row[pos : pos + len(doc)] = torch.tensor(doc, dtype=torch.long)
218
- pos += len(doc)
219
- else:
220
- # No doc fits remaining space β€” crop shortest to fill.
221
- shortest_idx = min(
222
- range(len(doc_buffer)),
223
- key=lambda i: len(doc_buffer[i]),
224
- )
225
- doc = doc_buffer.pop(shortest_idx)
226
- row[pos : pos + remaining] = torch.tensor(
227
- doc[:remaining], dtype=torch.long,
228
- )
229
- pos += remaining
230
-
231
- yield row
232
-
233
-
234
- # ---------------------------------------------------------------------------
235
- # LightningDataModule
236
- # ---------------------------------------------------------------------------
237
-
238
-
239
- class HydraDataModule(L.LightningDataModule):
240
- def __init__(
241
- self,
242
- batch_size: int | None = None,
243
- seq_len: int | None = None,
244
- num_workers: int | None = None,
245
- prefetch_factor: int | None = None,
246
- ):
247
- super().__init__()
248
- self.batch_size = batch_size or int(os.environ.get("HYDRA_BATCH_SIZE", "1"))
249
- self.seq_len = seq_len or int(os.environ.get("HYDRA_SEQ_LEN", "512"))
250
- self.num_workers = (
251
- num_workers
252
- if num_workers is not None
253
- else int(os.environ.get("HYDRA_DATA_NUM_WORKERS", "2"))
254
- )
255
- self.prefetch_factor = (
256
- prefetch_factor
257
- if prefetch_factor is not None
258
- else int(os.environ.get("HYDRA_DATA_PREFETCH", "4"))
259
- )
260
- self.doc_buffer = int(os.environ.get("HYDRA_DATA_BUFFER", "1000"))
261
-
262
- def _make_loader(self, split: str, seed: int) -> DataLoader:
263
- dataset = IterableStreamDataset(
264
- split=split,
265
- seq_len=self.seq_len,
266
- base_seed=seed,
267
- doc_buffer_size=self.doc_buffer,
268
- )
269
- # num_workers=0 β†’ main-process iteration (useful for debugging). With
270
- # IterableDataset the DataLoader batches the rows into (B, T+1) via
271
- # default torch.stack-collate.
272
- kw: dict = dict(
273
- dataset=dataset,
274
- batch_size=self.batch_size,
275
- num_workers=self.num_workers,
276
- pin_memory=True,
277
- drop_last=True,
278
- )
279
- if self.num_workers > 0:
280
- kw["prefetch_factor"] = self.prefetch_factor
281
- kw["persistent_workers"] = True
282
- return DataLoader(**kw)
283
-
284
- def train_dataloader(self) -> DataLoader:
285
- return self._make_loader("train", seed=0)
286
-
287
- def val_dataloader(self) -> DataLoader:
288
- return self._make_loader("val", seed=12345)
 
1
+ """Lightning DataModule + IterableDataset for HYDRA pretraining.
2
+
3
+ Replaces the custom threading/queue pipeline in prepare_nemotron.make_dataloader
4
+ with a standard multiprocessing DataLoader approach.
5
+
6
+ Design:
7
+ β€’ IterableStreamDataset: each worker opens its own HF streams for the 7-way
8
+ blend, tokenizes with rustbpe, packs into (T+1,) rows via best-fit, and
9
+ yields one row per __next__.
10
+ β€’ HydraDataModule: wraps the dataset with a standard DataLoader using
11
+ num_workers>=1, prefetch_factor=4, pin_memory=True. Lightning handles
12
+ device transfer.
13
+ β€’ Val stream: deterministic seed 12345, weights match training blend.
14
+
15
+ The worker RNG is seeded per-worker so the weighted-sampling schedule is
16
+ independent across workers (else all workers request the same config at
17
+ the same step and prefetching serializes).
18
+
19
+ Env vars (all preserved from prepare_nemotron):
20
+ HYDRA_SEQ_LEN β€” sequence length T (default 512)
21
+ HYDRA_BATCH_SIZE β€” batch size B (default 1) β€” passed through
22
+ to DataLoader
23
+ HYDRA_STREAM_SHUFFLE_BUFFER β€” HF shuffle buffer (default 2048)
24
+ HYDRA_USE_FULL_BLEND β€” 7-way blend vs 5-way Nemotron phase
25
+ HYDRA_USE_NEMOTRON β€” enables streaming path (else shard path)
26
+ HYDRA_FACTUAL_INJECT_RATE β€” factual doc injection cadence
27
+ HYDRA_NEMOTRON_PHASE β€” phase1|phase2 (when not full blend)
28
+ HYDRA_DATA_NUM_WORKERS β€” DataLoader num_workers (default 2)
29
+ HYDRA_DATA_PREFETCH β€” DataLoader prefetch_factor (default 4)
30
+ HYDRA_DATA_BUFFER β€” doc_buffer size for best-fit packing
31
+ (default 1000)
32
+ """
33
+ from __future__ import annotations
34
+
35
+ import os
36
+ import random
37
+ from typing import Iterator
38
+
39
+ import numpy as np
40
+ import torch
41
+ import lightning as L
42
+ from torch.utils.data import DataLoader, IterableDataset, get_worker_info
43
+
44
+ import prepare as _prepare
45
+ import prepare_nemotron as _p_nemo
46
+ from prepare_nemotron import (
47
+ FULL_BLEND_WEIGHTS,
48
+ PHASE1_WEIGHTS,
49
+ PHASE2_WEIGHTS,
50
+ _BLEND_REGISTRY,
51
+ _extract_text,
52
+ _open_stream,
53
+ )
54
+
55
+
56
+ # ---------------------------------------------------------------------------
57
+ # Worker-local weighted stream. A stripped version of prepare_nemotron's
58
+ # _WeightedStream that is constructed inside each worker. Adds worker sharding:
59
+ # when num_workers > 1 the RNG is seeded per-worker, so different workers
60
+ # sample different config sequences and pull disjoint shard assignments from
61
+ # HF's shuffle buffer.
62
+ # ---------------------------------------------------------------------------
63
+
64
+
65
+ class _WorkerWeightedStream:
66
+ def __init__(self, weights: dict[str, float], base_seed: int, worker_id: int):
67
+ self.configs = list(weights.keys())
68
+ self.weights = [weights[c] for c in self.configs]
69
+ self.base_seed = base_seed
70
+ self.worker_id = worker_id
71
+ # Each worker opens its own HF streams. _open_stream returns an iter()
72
+ # over a streaming dataset, with an internal shuffle buffer.
73
+ self.streams = {c: _open_stream(c, "train") for c in self.configs}
74
+ # Per-worker RNG so the config-choice trajectory is independent.
75
+ self.rng = random.Random(base_seed + worker_id * 7919)
76
+ self.epoch = 1
77
+
78
+ # Lazy-init factual docs (once per worker). The main-process version
79
+ # in prepare_nemotron._WeightedStream reads these on first __next__.
80
+ self._factual_docs: list[str] | None = None
81
+ self._factual_idx = 0
82
+ self._inject_counter = 0
83
+ inject_rate = int(os.environ.get("HYDRA_FACTUAL_INJECT_RATE", "50"))
84
+ self._inject_rate = inject_rate
85
+ if inject_rate > 0:
86
+ factual_path = os.path.join(
87
+ os.path.dirname(os.path.abspath(_p_nemo.__file__)),
88
+ "data", "factual", "facts.txt",
89
+ )
90
+ if os.path.exists(factual_path):
91
+ with open(factual_path) as fh:
92
+ self._factual_docs = fh.read().strip().split("\n")
93
+
94
+ def _reopen(self, config: str) -> None:
95
+ self.streams[config] = _open_stream(config, "train")
96
+ self.epoch += 1
97
+
98
+ def __iter__(self):
99
+ return self
100
+
101
+ def __next__(self) -> tuple[str, int]:
102
+ # Factual injection (preserves prepare_nemotron cadence).
103
+ if self._inject_rate > 0 and self._factual_docs:
104
+ self._inject_counter += 1
105
+ if self._inject_counter >= self._inject_rate:
106
+ self._inject_counter = 0
107
+ doc = self._factual_docs[self._factual_idx % len(self._factual_docs)]
108
+ self._factual_idx += 1
109
+ return doc, self.epoch
110
+
111
+ config = self.rng.choices(self.configs, weights=self.weights, k=1)[0]
112
+ try:
113
+ row = next(self.streams[config])
114
+ except StopIteration:
115
+ self._reopen(config)
116
+ row = next(self.streams[config])
117
+ return _extract_text(row), self.epoch
118
+
119
+
120
+ # ---------------------------------------------------------------------------
121
+ # IterableStreamDataset β€” yields (T+1,) packed rows. No threads. No queues.
122
+ # Lives inside each DataLoader worker. DataLoader's own multiprocessing stacks
123
+ # rows into batches of shape (B, T+1) and sends them to the main process.
124
+ # ---------------------------------------------------------------------------
125
+
126
+
127
+ class IterableStreamDataset(IterableDataset):
128
+ """Streams docs, tokenizes, packs into (T+1,) rows via best-fit.
129
+
130
+ Each worker gets its own instance (via fork/spawn) and initializes its
131
+ own HF streams + rustbpe tokenizer + factual injector. The tokenizer
132
+ pickled blob is small (~1 MB) and thread-safe per tiktoken docs.
133
+ """
134
+
135
+ def __init__(
136
+ self,
137
+ split: str,
138
+ seq_len: int,
139
+ *,
140
+ base_seed: int = 0,
141
+ doc_buffer_size: int = 1000,
142
+ tokenizer_batch: int = 128,
143
+ ):
144
+ super().__init__()
145
+ assert split in ("train", "val"), split
146
+ self.split = split
147
+ self.seq_len = seq_len
148
+ self.row_capacity = seq_len + 1
149
+ self.base_seed = base_seed
150
+ self.doc_buffer_size = doc_buffer_size
151
+ self.tokenizer_batch = tokenizer_batch
152
+
153
+ def _pick_weights(self) -> dict[str, float]:
154
+ if self.split == "val":
155
+ if os.environ.get("HYDRA_USE_FULL_BLEND", "0") == "1":
156
+ return FULL_BLEND_WEIGHTS
157
+ return {"Nemotron-Pretraining-Multiple-Choice": 1.0}
158
+ if os.environ.get("HYDRA_USE_FULL_BLEND", "0") == "1":
159
+ return FULL_BLEND_WEIGHTS
160
+ phase = os.environ.get("HYDRA_NEMOTRON_PHASE", "phase1").strip().lower()
161
+ return PHASE2_WEIGHTS if phase == "phase2" else PHASE1_WEIGHTS
162
+
163
+ def __iter__(self) -> Iterator[torch.Tensor]:
164
+ info = get_worker_info()
165
+ worker_id = 0 if info is None else info.id
166
+
167
+ # Each worker builds its own tokenizer instance. tiktoken's Encoding
168
+ # object is pickleable and the underlying C++ BPE is thread-safe;
169
+ # per-worker instantiation avoids cross-process sharing headaches.
170
+ tokenizer = _prepare.Tokenizer.from_directory()
171
+ bos = tokenizer.get_bos_token_id()
172
+
173
+ # Each worker gets its own weighted HF stream. Seed offset ensures
174
+ # disjoint config-choice trajectories; HF's own shuffle buffer handles
175
+ # shard randomization.
176
+ val_seed = 12345 # deterministic val
177
+ seed = val_seed if self.split == "val" else self.base_seed
178
+ stream = _WorkerWeightedStream(
179
+ self._pick_weights(), base_seed=seed, worker_id=worker_id,
180
+ )
181
+
182
+ row_capacity = self.row_capacity
183
+ doc_buffer: list[list[int]] = []
184
+ doc_batch_size = self.tokenizer_batch
185
+
186
+ def refill_buffer() -> None:
187
+ # Collect doc_batch_size text strings, then batch-tokenize.
188
+ texts: list[str] = []
189
+ for _ in range(doc_batch_size):
190
+ text, _epoch = next(stream)
191
+ if text:
192
+ texts.append(text)
193
+ if texts:
194
+ token_lists = tokenizer.encode(texts, prepend=bos)
195
+ doc_buffer.extend(token_lists)
196
+
197
+ while True:
198
+ pos = 0
199
+ row = torch.empty(row_capacity, dtype=torch.long)
200
+ while pos < row_capacity:
201
+ while len(doc_buffer) < self.doc_buffer_size:
202
+ refill_buffer()
203
+
204
+ remaining = row_capacity - pos
205
+
206
+ # Best-fit packing: largest doc that fully fits.
207
+ best_idx = -1
208
+ best_len = 0
209
+ for i, doc in enumerate(doc_buffer):
210
+ dlen = len(doc)
211
+ if dlen <= remaining and dlen > best_len:
212
+ best_idx = i
213
+ best_len = dlen
214
+
215
+ if best_idx >= 0:
216
+ doc = doc_buffer.pop(best_idx)
217
+ row[pos : pos + len(doc)] = torch.tensor(doc, dtype=torch.long)
218
+ pos += len(doc)
219
+ else:
220
+ # No doc fits remaining space β€” crop shortest to fill.
221
+ shortest_idx = min(
222
+ range(len(doc_buffer)),
223
+ key=lambda i: len(doc_buffer[i]),
224
+ )
225
+ doc = doc_buffer.pop(shortest_idx)
226
+ row[pos : pos + remaining] = torch.tensor(
227
+ doc[:remaining], dtype=torch.long,
228
+ )
229
+ pos += remaining
230
+
231
+ yield row
232
+
233
+
234
+ # ---------------------------------------------------------------------------
235
+ # LightningDataModule
236
+ # ---------------------------------------------------------------------------
237
+
238
+
239
+ class HydraDataModule(L.LightningDataModule):
240
+ def __init__(
241
+ self,
242
+ batch_size: int | None = None,
243
+ seq_len: int | None = None,
244
+ num_workers: int | None = None,
245
+ prefetch_factor: int | None = None,
246
+ ):
247
+ super().__init__()
248
+ self.batch_size = batch_size or int(os.environ.get("HYDRA_BATCH_SIZE", "1"))
249
+ self.seq_len = seq_len or int(os.environ.get("HYDRA_SEQ_LEN", "512"))
250
+ self.num_workers = (
251
+ num_workers
252
+ if num_workers is not None
253
+ else int(os.environ.get("HYDRA_DATA_NUM_WORKERS", "2"))
254
+ )
255
+ self.prefetch_factor = (
256
+ prefetch_factor
257
+ if prefetch_factor is not None
258
+ else int(os.environ.get("HYDRA_DATA_PREFETCH", "4"))
259
+ )
260
+ self.doc_buffer = int(os.environ.get("HYDRA_DATA_BUFFER", "1000"))
261
+
262
+ def _make_loader(self, split: str, seed: int) -> DataLoader:
263
+ dataset = IterableStreamDataset(
264
+ split=split,
265
+ seq_len=self.seq_len,
266
+ base_seed=seed,
267
+ doc_buffer_size=self.doc_buffer,
268
+ )
269
+ # num_workers=0 β†’ main-process iteration (useful for debugging). With
270
+ # IterableDataset the DataLoader batches the rows into (B, T+1) via
271
+ # default torch.stack-collate.
272
+ kw: dict = dict(
273
+ dataset=dataset,
274
+ batch_size=self.batch_size,
275
+ num_workers=self.num_workers,
276
+ pin_memory=True,
277
+ drop_last=True,
278
+ )
279
+ if self.num_workers > 0:
280
+ kw["prefetch_factor"] = self.prefetch_factor
281
+ kw["persistent_workers"] = True
282
+ return DataLoader(**kw)
283
+
284
+ def train_dataloader(self) -> DataLoader:
285
+ return self._make_loader("train", seed=0)
286
+
287
+ def val_dataloader(self) -> DataLoader:
288
+ return self._make_loader("val", seed=12345)
overlay/hydra/diffusion_loss.py CHANGED
@@ -1,236 +1,236 @@
1
- """MDLM Rao-Blackwellized Masked Diffusion Loss.
2
-
3
- Implements the masked-diffusion ELBO from:
4
- Sahoo et al., "Simple and Effective Masked Diffusion Language Models" (MDLM),
5
- NeurIPS 2024, arXiv:2406.07524.
6
-
7
- Equations referenced:
8
- - Forward process: eq. 2 (per-token Bernoulli masking at rate 1 - alpha_t)
9
- - Log-linear schedule: alpha_t = 1 - t, t ~ Uniform(0, 1)
10
- - RB-ELBO: eq. 7-8 L_RB = E_t E_q [ (1/alpha_t) * CE(x_theta(x_t), x_0) ]
11
- where the expectation over masked positions.
12
-
13
- Key insight: the Rao-Blackwellized estimate replaces an average over all masks
14
- (exponential) by a closed-form weighted CE that applies weight 1/alpha_t only
15
- on the positions that were masked, and 0 on unmasked positions. This gives an
16
- unbiased estimator with lower variance than a naive Monte Carlo over mask
17
- patterns.
18
-
19
- Reference implementation cross-checked against:
20
- https://github.com/kuleshov-group/mdlm (diffusion.py::DiffusionModel._loss)
21
- """
22
-
23
- from __future__ import annotations
24
-
25
- from typing import Literal
26
-
27
- import torch
28
- import torch.nn.functional as F
29
-
30
-
31
- # Clamping weight keeps gradients finite while still up-weighting high-noise
32
- # positions. Historical value 1/eps=1000 blew up HYDRA training on a 12h v2
33
- # launch (2026-04-22): loss 26 β†’ 42 β†’ NaN in 13 steps under Muon lr=7e-3
34
- # because per-token CE Γ— 1000 saturated the 100-unit FAIL guard. The MDLM
35
- # paper reports stable training at Adam lr=1e-4; HYDRA uses Muon at 7e-3
36
- # (70Γ— larger), so the weight clamp needs to compensate.
37
- #
38
- # Tunable via HYDRA_MDLM_MAX_WEIGHT (default 5.0). Set =1.0 to disable
39
- # weighting entirely (flat masked-LM CE, no RB reweighting β€” simpler and
40
- # more stable, sacrifices the theoretical ELBO property).
41
- import os as _os
42
- _MAX_WEIGHT: float = float(_os.environ.get("HYDRA_MDLM_MAX_WEIGHT", "5.0"))
43
- _MIN_ALPHA: float = 1.0 / _MAX_WEIGHT # so clamp(alpha, min=_MIN_ALPHA) gives 1/alpha <= _MAX_WEIGHT
44
-
45
-
46
- # ---------------------------------------------------------------------------
47
- # Public API
48
- # ---------------------------------------------------------------------------
49
-
50
- def mdlm_masked_forward_process(
51
- targets: torch.Tensor,
52
- mask_token_id: int,
53
- t: torch.Tensor | None = None,
54
- alpha_schedule: Literal["linear", "loglinear"] = "loglinear",
55
- ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
56
- """MDLM forward (noising) process: mask tokens and compute RB weights.
57
-
58
- Args:
59
- targets: (B, T) int64 token ids β€” the clean sequence x_0.
60
- mask_token_id: The special token id used to represent a masked token.
61
- t: (B,) float in (0, 1). If None, samples Uniform(0, 1) per batch
62
- element. t=0 means fully clean; t=1 means fully masked.
63
- alpha_schedule: Noise schedule.
64
- "loglinear" (MDLM default): alpha_t = 1 - t
65
- "linear": identical formula β€” both are provided for completeness
66
- since the paper calls the 1-t schedule "log-linear" in the context
67
- of the ELBO derivation.
68
-
69
- Returns:
70
- x_t : (B, T) int64 β€” noised sequence; masked positions hold
71
- mask_token_id, unmasked positions equal targets.
72
- mask_positions: (B, T) bool β€” True where the token was masked.
73
- loss_weights : (B, T) float32 β€” RB weighting factor. On masked
74
- positions: 1/alpha_t (clamped to _MAX_WEIGHT). On
75
- unmasked positions: 0.0. Summing
76
- (CE * loss_weights * mask_positions).sum() / mask.sum()
77
- gives the per-sample RB-ELBO estimator.
78
- """
79
- B, T = targets.shape
80
- device = targets.device
81
- dtype = torch.float32
82
-
83
- # --- sample or validate t ---
84
- if t is None:
85
- # Uniform(0, 1) per batch element; avoid exactly 0 and 1.
86
- t = torch.rand(B, device=device, dtype=dtype)
87
- else:
88
- t = t.to(device=device, dtype=dtype)
89
- if t.shape != (B,):
90
- raise ValueError(f"t must be shape (B,)={(B,)}, got {t.shape}")
91
- if (t < 0).any() or (t > 1).any():
92
- raise ValueError("t must be in [0, 1]")
93
-
94
- # --- noise schedule: alpha_t = probability that a token is NOT masked ---
95
- # Both "linear" and "loglinear" in MDLM use alpha_t = 1 - t; the paper
96
- # refers to "log-linear" because the schedule is linear in the *log* domain
97
- # of the forward process probability. We expose both names for clarity.
98
- if alpha_schedule in ("linear", "loglinear"):
99
- alpha_t = 1.0 - t # (B,) float, in [0, 1]
100
- else:
101
- raise ValueError(f"Unknown alpha_schedule: {alpha_schedule!r}. Use 'linear' or 'loglinear'.")
102
-
103
- # --- per-token Bernoulli mask ---
104
- # alpha_t[:, None] broadcasts to (B, T).
105
- alpha_t_expanded = alpha_t[:, None] # (B, 1)
106
- # Bernoulli(1 - alpha_t) = 1 means "mask this token".
107
- # We sample independently per token, per batch element.
108
- rand = torch.rand(B, T, device=device, dtype=dtype)
109
- mask_positions = rand > alpha_t_expanded # (B, T) bool
110
- # True β†’ masked position
111
- # False β†’ unmasked (kept as original)
112
-
113
- # --- build x_t ---
114
- x_t = targets.clone()
115
- x_t = torch.where(mask_positions, torch.full_like(x_t, mask_token_id), x_t)
116
-
117
- # --- RB loss weights: 1/alpha_t on masked positions, 0 elsewhere ---
118
- # Clamp alpha_t so weights stay finite near t→1.
119
- safe_alpha = alpha_t.clamp(min=_MIN_ALPHA) # (B,)
120
- weight_per_sample = 1.0 / safe_alpha # (B,)
121
- # Broadcast to (B, T) and zero out unmasked positions.
122
- loss_weights = weight_per_sample[:, None].expand(B, T).to(dtype=dtype) # (B, T)
123
- loss_weights = loss_weights * mask_positions.float()
124
-
125
- return x_t, mask_positions, loss_weights
126
-
127
-
128
- def mdlm_rb_loss(
129
- logits: torch.Tensor,
130
- targets: torch.Tensor,
131
- mask_positions: torch.Tensor,
132
- loss_weights: torch.Tensor,
133
- ignore_index: int = -100,
134
- ) -> torch.Tensor:
135
- """Rao-Blackwellized negative ELBO.
136
-
137
- Applies the MDLM loss: cross-entropy on masked positions only, weighted
138
- per-token by loss_weights, averaged over the batch.
139
-
140
- The formula (eq. 7-8 of arXiv:2406.07524):
141
- L_RB = mean_B [ sum_T (weight_t * CE(logits_i, target_i) * mask_i)
142
- / max(sum_T(mask_i), 1) ]
143
-
144
- Args:
145
- logits : (B, T, V) raw logits. May be bf16; internally cast to
146
- float32 for CE computation.
147
- targets : (B, T) int64 true token ids (x_0).
148
- mask_positions: (B, T) bool β€” True = masked position.
149
- loss_weights : (B, T) float32 β€” 1/alpha_t on masked positions, 0 elsewhere.
150
- ignore_index : Passed to F.cross_entropy; positions with this label
151
- are excluded from the loss.
152
-
153
- Returns:
154
- Scalar float32 loss. Returns 0.0 tensor if no positions are masked.
155
- """
156
- B, T, V = logits.shape
157
-
158
- # Ensure float32 for numerical stability; F.cross_entropy accepts fp16/bf16
159
- # logits but accumulates in float internally anyway. Being explicit avoids
160
- # silent precision surprises.
161
- logits_f = logits.float() # (B, T, V)
162
-
163
- # Build targets with ignore_index on UNmasked positions so CE only fires
164
- # where mask_positions is True. We also honour any pre-existing -100 values
165
- # (e.g. doc-separator masking upstream).
166
- targets_masked = torch.where(
167
- mask_positions & (targets != ignore_index),
168
- targets,
169
- torch.full_like(targets, ignore_index),
170
- )
171
-
172
- # Per-token CE; shape (B, T). Positions with ignore_index β†’ 0 from CE.
173
- per_tok_ce = F.cross_entropy(
174
- logits_f.reshape(B * T, V),
175
- targets_masked.reshape(B * T),
176
- ignore_index=ignore_index,
177
- reduction="none",
178
- ).reshape(B, T) # (B, T) float32
179
-
180
- # Apply RB weight. loss_weights already has 0 on unmasked positions.
181
- weighted = per_tok_ce * loss_weights # (B, T)
182
-
183
- # Per-sample mean over masked positions, then average over batch.
184
- mask_f = mask_positions.float() # (B, T)
185
- per_sample_mask_count = mask_f.sum(dim=1).clamp(min=1) # (B,)
186
- per_sample_loss = weighted.sum(dim=1) / per_sample_mask_count # (B,)
187
-
188
- return per_sample_loss.mean() # scalar float32
189
-
190
-
191
- def mdlm_loss(
192
- logits: torch.Tensor,
193
- targets: torch.Tensor,
194
- mask_token_id: int,
195
- t: torch.Tensor | None = None,
196
- alpha_schedule: Literal["linear", "loglinear"] = "loglinear",
197
- ignore_index: int = -100,
198
- ) -> torch.Tensor:
199
- """Convenience wrapper: forward process + RB-ELBO in one call.
200
-
201
- Suitable for the common case where the caller has full-vocab logits and
202
- wants a drop-in replacement for a standard masked-LM CE loss.
203
-
204
- Args:
205
- logits : (B, T, V) raw logits.
206
- targets : (B, T) int64 clean token ids.
207
- mask_token_id : The MASK token id used to corrupt the input.
208
- t : Optional (B,) timestep in (0, 1). Sampled if None.
209
- alpha_schedule: "loglinear" (default) or "linear".
210
- ignore_index : Token id to ignore in the loss (e.g. padding).
211
-
212
- Returns:
213
- Scalar float32 MDLM RB-ELBO loss.
214
-
215
- Note on sampled-softmax / partial logits:
216
- If your model only computes logits for a subset of vocab positions
217
- (e.g. HYDRA's sampled-softmax head), call mdlm_masked_forward_process
218
- and mdlm_rb_loss separately. mdlm_rb_loss expects full-vocab logits.
219
- """
220
- x_t, mask_positions, loss_weights = mdlm_masked_forward_process(
221
- targets=targets,
222
- mask_token_id=mask_token_id,
223
- t=t,
224
- alpha_schedule=alpha_schedule,
225
- )
226
- # x_t is produced for the model's input (not used by this convenience
227
- # wrapper since logits are already provided by the caller). In a real
228
- # training loop the caller feeds x_t into the model to get logits, THEN
229
- # calls this function. See the orchestrator wiring note in training.py.
230
- return mdlm_rb_loss(
231
- logits=logits,
232
- targets=targets,
233
- mask_positions=mask_positions,
234
- loss_weights=loss_weights,
235
- ignore_index=ignore_index,
236
- )
 
1
+ """MDLM Rao-Blackwellized Masked Diffusion Loss.
2
+
3
+ Implements the masked-diffusion ELBO from:
4
+ Sahoo et al., "Simple and Effective Masked Diffusion Language Models" (MDLM),
5
+ NeurIPS 2024, arXiv:2406.07524.
6
+
7
+ Equations referenced:
8
+ - Forward process: eq. 2 (per-token Bernoulli masking at rate 1 - alpha_t)
9
+ - Log-linear schedule: alpha_t = 1 - t, t ~ Uniform(0, 1)
10
+ - RB-ELBO: eq. 7-8 L_RB = E_t E_q [ (1/alpha_t) * CE(x_theta(x_t), x_0) ]
11
+ where the expectation over masked positions.
12
+
13
+ Key insight: the Rao-Blackwellized estimate replaces an average over all masks
14
+ (exponential) by a closed-form weighted CE that applies weight 1/alpha_t only
15
+ on the positions that were masked, and 0 on unmasked positions. This gives an
16
+ unbiased estimator with lower variance than a naive Monte Carlo over mask
17
+ patterns.
18
+
19
+ Reference implementation cross-checked against:
20
+ https://github.com/kuleshov-group/mdlm (diffusion.py::DiffusionModel._loss)
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ from typing import Literal
26
+
27
+ import torch
28
+ import torch.nn.functional as F
29
+
30
+
31
+ # Clamping weight keeps gradients finite while still up-weighting high-noise
32
+ # positions. Historical value 1/eps=1000 blew up HYDRA training on a 12h v2
33
+ # launch (2026-04-22): loss 26 β†’ 42 β†’ NaN in 13 steps under Muon lr=7e-3
34
+ # because per-token CE Γ— 1000 saturated the 100-unit FAIL guard. The MDLM
35
+ # paper reports stable training at Adam lr=1e-4; HYDRA uses Muon at 7e-3
36
+ # (70Γ— larger), so the weight clamp needs to compensate.
37
+ #
38
+ # Tunable via HYDRA_MDLM_MAX_WEIGHT (default 5.0). Set =1.0 to disable
39
+ # weighting entirely (flat masked-LM CE, no RB reweighting β€” simpler and
40
+ # more stable, sacrifices the theoretical ELBO property).
41
+ import os as _os
42
+ _MAX_WEIGHT: float = float(_os.environ.get("HYDRA_MDLM_MAX_WEIGHT", "5.0"))
43
+ _MIN_ALPHA: float = 1.0 / _MAX_WEIGHT # so clamp(alpha, min=_MIN_ALPHA) gives 1/alpha <= _MAX_WEIGHT
44
+
45
+
46
+ # ---------------------------------------------------------------------------
47
+ # Public API
48
+ # ---------------------------------------------------------------------------
49
+
50
+ def mdlm_masked_forward_process(
51
+ targets: torch.Tensor,
52
+ mask_token_id: int,
53
+ t: torch.Tensor | None = None,
54
+ alpha_schedule: Literal["linear", "loglinear"] = "loglinear",
55
+ ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
56
+ """MDLM forward (noising) process: mask tokens and compute RB weights.
57
+
58
+ Args:
59
+ targets: (B, T) int64 token ids β€” the clean sequence x_0.
60
+ mask_token_id: The special token id used to represent a masked token.
61
+ t: (B,) float in (0, 1). If None, samples Uniform(0, 1) per batch
62
+ element. t=0 means fully clean; t=1 means fully masked.
63
+ alpha_schedule: Noise schedule.
64
+ "loglinear" (MDLM default): alpha_t = 1 - t
65
+ "linear": identical formula β€” both are provided for completeness
66
+ since the paper calls the 1-t schedule "log-linear" in the context
67
+ of the ELBO derivation.
68
+
69
+ Returns:
70
+ x_t : (B, T) int64 β€” noised sequence; masked positions hold
71
+ mask_token_id, unmasked positions equal targets.
72
+ mask_positions: (B, T) bool β€” True where the token was masked.
73
+ loss_weights : (B, T) float32 β€” RB weighting factor. On masked
74
+ positions: 1/alpha_t (clamped to _MAX_WEIGHT). On
75
+ unmasked positions: 0.0. Summing
76
+ (CE * loss_weights * mask_positions).sum() / mask.sum()
77
+ gives the per-sample RB-ELBO estimator.
78
+ """
79
+ B, T = targets.shape
80
+ device = targets.device
81
+ dtype = torch.float32
82
+
83
+ # --- sample or validate t ---
84
+ if t is None:
85
+ # Uniform(0, 1) per batch element; avoid exactly 0 and 1.
86
+ t = torch.rand(B, device=device, dtype=dtype)
87
+ else:
88
+ t = t.to(device=device, dtype=dtype)
89
+ if t.shape != (B,):
90
+ raise ValueError(f"t must be shape (B,)={(B,)}, got {t.shape}")
91
+ if (t < 0).any() or (t > 1).any():
92
+ raise ValueError("t must be in [0, 1]")
93
+
94
+ # --- noise schedule: alpha_t = probability that a token is NOT masked ---
95
+ # Both "linear" and "loglinear" in MDLM use alpha_t = 1 - t; the paper
96
+ # refers to "log-linear" because the schedule is linear in the *log* domain
97
+ # of the forward process probability. We expose both names for clarity.
98
+ if alpha_schedule in ("linear", "loglinear"):
99
+ alpha_t = 1.0 - t # (B,) float, in [0, 1]
100
+ else:
101
+ raise ValueError(f"Unknown alpha_schedule: {alpha_schedule!r}. Use 'linear' or 'loglinear'.")
102
+
103
+ # --- per-token Bernoulli mask ---
104
+ # alpha_t[:, None] broadcasts to (B, T).
105
+ alpha_t_expanded = alpha_t[:, None] # (B, 1)
106
+ # Bernoulli(1 - alpha_t) = 1 means "mask this token".
107
+ # We sample independently per token, per batch element.
108
+ rand = torch.rand(B, T, device=device, dtype=dtype)
109
+ mask_positions = rand > alpha_t_expanded # (B, T) bool
110
+ # True β†’ masked position
111
+ # False β†’ unmasked (kept as original)
112
+
113
+ # --- build x_t ---
114
+ x_t = targets.clone()
115
+ x_t = torch.where(mask_positions, torch.full_like(x_t, mask_token_id), x_t)
116
+
117
+ # --- RB loss weights: 1/alpha_t on masked positions, 0 elsewhere ---
118
+ # Clamp alpha_t so weights stay finite near t→1.
119
+ safe_alpha = alpha_t.clamp(min=_MIN_ALPHA) # (B,)
120
+ weight_per_sample = 1.0 / safe_alpha # (B,)
121
+ # Broadcast to (B, T) and zero out unmasked positions.
122
+ loss_weights = weight_per_sample[:, None].expand(B, T).to(dtype=dtype) # (B, T)
123
+ loss_weights = loss_weights * mask_positions.float()
124
+
125
+ return x_t, mask_positions, loss_weights
126
+
127
+
128
+ def mdlm_rb_loss(
129
+ logits: torch.Tensor,
130
+ targets: torch.Tensor,
131
+ mask_positions: torch.Tensor,
132
+ loss_weights: torch.Tensor,
133
+ ignore_index: int = -100,
134
+ ) -> torch.Tensor:
135
+ """Rao-Blackwellized negative ELBO.
136
+
137
+ Applies the MDLM loss: cross-entropy on masked positions only, weighted
138
+ per-token by loss_weights, averaged over the batch.
139
+
140
+ The formula (eq. 7-8 of arXiv:2406.07524):
141
+ L_RB = mean_B [ sum_T (weight_t * CE(logits_i, target_i) * mask_i)
142
+ / max(sum_T(mask_i), 1) ]
143
+
144
+ Args:
145
+ logits : (B, T, V) raw logits. May be bf16; internally cast to
146
+ float32 for CE computation.
147
+ targets : (B, T) int64 true token ids (x_0).
148
+ mask_positions: (B, T) bool β€” True = masked position.
149
+ loss_weights : (B, T) float32 β€” 1/alpha_t on masked positions, 0 elsewhere.
150
+ ignore_index : Passed to F.cross_entropy; positions with this label
151
+ are excluded from the loss.
152
+
153
+ Returns:
154
+ Scalar float32 loss. Returns 0.0 tensor if no positions are masked.
155
+ """
156
+ B, T, V = logits.shape
157
+
158
+ # Ensure float32 for numerical stability; F.cross_entropy accepts fp16/bf16
159
+ # logits but accumulates in float internally anyway. Being explicit avoids
160
+ # silent precision surprises.
161
+ logits_f = logits.float() # (B, T, V)
162
+
163
+ # Build targets with ignore_index on UNmasked positions so CE only fires
164
+ # where mask_positions is True. We also honour any pre-existing -100 values
165
+ # (e.g. doc-separator masking upstream).
166
+ targets_masked = torch.where(
167
+ mask_positions & (targets != ignore_index),
168
+ targets,
169
+ torch.full_like(targets, ignore_index),
170
+ )
171
+
172
+ # Per-token CE; shape (B, T). Positions with ignore_index β†’ 0 from CE.
173
+ per_tok_ce = F.cross_entropy(
174
+ logits_f.reshape(B * T, V),
175
+ targets_masked.reshape(B * T),
176
+ ignore_index=ignore_index,
177
+ reduction="none",
178
+ ).reshape(B, T) # (B, T) float32
179
+
180
+ # Apply RB weight. loss_weights already has 0 on unmasked positions.
181
+ weighted = per_tok_ce * loss_weights # (B, T)
182
+
183
+ # Per-sample mean over masked positions, then average over batch.
184
+ mask_f = mask_positions.float() # (B, T)
185
+ per_sample_mask_count = mask_f.sum(dim=1).clamp(min=1) # (B,)
186
+ per_sample_loss = weighted.sum(dim=1) / per_sample_mask_count # (B,)
187
+
188
+ return per_sample_loss.mean() # scalar float32
189
+
190
+
191
+ def mdlm_loss(
192
+ logits: torch.Tensor,
193
+ targets: torch.Tensor,
194
+ mask_token_id: int,
195
+ t: torch.Tensor | None = None,
196
+ alpha_schedule: Literal["linear", "loglinear"] = "loglinear",
197
+ ignore_index: int = -100,
198
+ ) -> torch.Tensor:
199
+ """Convenience wrapper: forward process + RB-ELBO in one call.
200
+
201
+ Suitable for the common case where the caller has full-vocab logits and
202
+ wants a drop-in replacement for a standard masked-LM CE loss.
203
+
204
+ Args:
205
+ logits : (B, T, V) raw logits.
206
+ targets : (B, T) int64 clean token ids.
207
+ mask_token_id : The MASK token id used to corrupt the input.
208
+ t : Optional (B,) timestep in (0, 1). Sampled if None.
209
+ alpha_schedule: "loglinear" (default) or "linear".
210
+ ignore_index : Token id to ignore in the loss (e.g. padding).
211
+
212
+ Returns:
213
+ Scalar float32 MDLM RB-ELBO loss.
214
+
215
+ Note on sampled-softmax / partial logits:
216
+ If your model only computes logits for a subset of vocab positions
217
+ (e.g. HYDRA's sampled-softmax head), call mdlm_masked_forward_process
218
+ and mdlm_rb_loss separately. mdlm_rb_loss expects full-vocab logits.
219
+ """
220
+ x_t, mask_positions, loss_weights = mdlm_masked_forward_process(
221
+ targets=targets,
222
+ mask_token_id=mask_token_id,
223
+ t=t,
224
+ alpha_schedule=alpha_schedule,
225
+ )
226
+ # x_t is produced for the model's input (not used by this convenience
227
+ # wrapper since logits are already provided by the caller). In a real
228
+ # training loop the caller feeds x_t into the model to get logits, THEN
229
+ # calls this function. See the orchestrator wiring note in training.py.
230
+ return mdlm_rb_loss(
231
+ logits=logits,
232
+ targets=targets,
233
+ mask_positions=mask_positions,
234
+ loss_weights=loss_weights,
235
+ ignore_index=ignore_index,
236
+ )
overlay/hydra/engram.py CHANGED
@@ -1,177 +1,177 @@
1
- """GPU Engram β€” Sparse Modern Hopfield retrieval path.
2
-
3
- ## What changed (scatter-gather β†’ Hopfield matmul)
4
-
5
- The original forward used `self.memory[indices]` (scatter-gather), which misses
6
- L2 cache at n_columns > 4096 and creates a hard tps ceiling.
7
-
8
- The replacement uses:
9
- scores = x @ self.memory.T # (B, T, n_columns) β€” coalesced matmul
10
- weights = entmax15(scores, dim=-1) # sparse attention; 95%+ exact zeros
11
- retrieved = weights @ self.memory # (B, T, d_model) β€” coalesced matmul
12
-
13
- Both matmuls are tile-friendly (cuBLAS GEMM), so L2 reuse is high regardless of
14
- n_columns. Gradient flows through both matmuls so `self.memory` learns via
15
- autograd in addition to (or instead of) the Hebbian EMA writes.
16
-
17
- ## Sparsity mechanism
18
-
19
- alpha-entmax with alpha=1.5 (entmax15) is a sparse attention operator that maps
20
- logit vectors to distributions where many entries are *exactly* zero (not merely
21
- small). It generalises softmax (alpha=1) and argmax (alphaβ†’βˆž). At n_columns=1024
22
- with d_model=64 a random batch typically hits β‰₯95% zero entries β€” the key
23
- property that keeps bandwidth proportional to *attended* columns, not all columns.
24
-
25
- Fallback: if `entmax` is not pip-installed, top-k softmax (k=32) is used instead.
26
- This is chosen at module-import time β€” NO runtime branching per forward call.
27
-
28
- ## token_ids argument
29
-
30
- token_ids is accepted for API compatibility with the rest of the hydra stack
31
- (train.py, lightning_module.py call `engram(x, token_ids)`). It is NOT used in
32
- the retrieval path β€” the Hopfield path computes dense similarity over the whole
33
- memory bank, which subsumes any hash-based column selection. Documented here to
34
- prevent confusion.
35
-
36
- ## Hebbian writes (hebbian_boost=False by default)
37
-
38
- With Hopfield retrieval, gradient signals reach self.memory through autograd, so
39
- Hebbian EMA writes are no longer critical. They are preserved as an *optional*
40
- boost (hebbian_boost=True) for experiments that want both signals. Default is off.
41
-
42
- ## Checkpoint compatibility
43
-
44
- `self.memory` shape (n_columns, d_model) is unchanged, so existing .pt / .ckpt
45
- files load without modification.
46
- """
47
-
48
- from __future__ import annotations
49
-
50
- import torch
51
- import torch.nn as nn
52
-
53
- # ---------------------------------------------------------------------------
54
- # Sparse-attention backend β€” chosen ONCE at import time, no runtime branching.
55
- # ---------------------------------------------------------------------------
56
-
57
- try:
58
- from entmax import entmax15 as _entmax15 # type: ignore[import]
59
-
60
- def _sparse_attention(scores: torch.Tensor) -> torch.Tensor:
61
- """alpha-entmax (alpha=1.5): truly sparse distribution over last dim."""
62
- return _entmax15(scores, dim=-1).to(dtype=scores.dtype)
63
-
64
- _BACKEND = "entmax15"
65
-
66
- except ImportError: # pragma: no cover β€” entmax always installed in CI
67
- _K = 32 # top-k for fallback
68
-
69
- def _sparse_attention(scores: torch.Tensor) -> torch.Tensor: # type: ignore[misc]
70
- """Top-k softmax fallback: zero outside the k highest-scoring columns."""
71
- topk_vals, topk_idx = scores.topk(_K, dim=-1)
72
- topk_w = torch.softmax(topk_vals, dim=-1)
73
- weights = torch.zeros_like(scores)
74
- weights.scatter_(-1, topk_idx, topk_w.to(dtype=weights.dtype))
75
- return weights
76
-
77
- _BACKEND = "topk32"
78
-
79
-
80
- class GPUEngram(nn.Module):
81
- """GPU Engram: Sparse Modern Hopfield retrieval.
82
-
83
- Args:
84
- d_model: Model dimension β€” must match the surrounding transformer.
85
- n_columns: Number of memory columns (key-value pairs). Safe at 32 768
86
- with the matmul path; the old scatter-gather had an L2
87
- cliff above ~4 096.
88
- max_ngram: Retained for API compatibility; unused in retrieval path.
89
- hebbian_boost: If True, also run a Hebbian EMA write on the memory bank
90
- during training (old behaviour, now optional). Default False.
91
- """
92
-
93
- def __init__(
94
- self,
95
- d_model: int,
96
- n_columns: int = 1024,
97
- max_ngram: int = 3,
98
- hebbian_boost: bool = False,
99
- ) -> None:
100
- super().__init__()
101
- self.n_columns = n_columns
102
- self.max_ngram = max_ngram
103
- self.hebbian_boost = hebbian_boost
104
- # Shape unchanged from original β€” existing checkpoints load cleanly.
105
- self.memory = nn.Parameter(torch.randn(n_columns, d_model) * 0.01)
106
- self.gate = nn.Linear(d_model, 1, bias=True)
107
- nn.init.constant_(self.gate.bias, 0.0) # START OPEN
108
- # Retained for any external code that reads these attrs.
109
- self.primes = [2654435761, 2246822519, 3266489917]
110
- self.hebbian_lr = 0.01
111
-
112
- # ------------------------------------------------------------------
113
- # _hash: retained for API/checkpoint compat; unused in forward below.
114
- # ------------------------------------------------------------------
115
-
116
- def _hash(self, token_ids: torch.Tensor) -> torch.Tensor:
117
- """N-gram hash β†’ column index (kept for backward-compat; not used in retrieval)."""
118
- B, T = token_ids.shape
119
- h = token_ids * self.primes[0]
120
- if T > 1:
121
- shifted1 = torch.roll(token_ids, 1, dims=1)
122
- shifted1[:, 0] = 0
123
- h = h ^ (shifted1 * self.primes[1])
124
- if T > 2:
125
- shifted2 = torch.roll(token_ids, 2, dims=1)
126
- shifted2[:, :2] = 0
127
- h = h ^ (shifted2 * self.primes[2])
128
- return h % self.n_columns
129
-
130
- # ------------------------------------------------------------------
131
- # forward
132
- # ------------------------------------------------------------------
133
-
134
- def forward(self, x: torch.Tensor, token_ids: torch.Tensor):
135
- """Hopfield retrieve + soft gate + residual.
136
-
137
- Args:
138
- x: (B, T, d_model) β€” input activations.
139
- token_ids: (B, T) β€” token indices. Accepted for API compatibility;
140
- NOT used in the retrieval path (see module docstring).
141
-
142
- Returns:
143
- (x + alpha * retrieved, hit_rate)
144
- - x + alpha * retrieved: (B, T, d_model)
145
- - hit_rate: scalar tensor β€” fraction of gate values > 0.1
146
- """
147
- # ---- 1. Similarity scores (coalesced GEMM) ----------------------
148
- # scores[b, t, c] = dot(x[b,t], memory[c])
149
- scores = x @ self.memory.T # (B, T, n_columns)
150
-
151
- # ---- 2. Sparse attention weights --------------------------------
152
- # _sparse_attention is fixed at import time (entmax15 or top-k).
153
- weights = _sparse_attention(scores) # (B, T, n_columns), many exact zeros
154
-
155
- # ---- 3. Retrieved vector (coalesced GEMM) -----------------------
156
- retrieved = weights @ self.memory # (B, T, d_model)
157
-
158
- # ---- 4. Soft gate (unchanged) -----------------------------------
159
- alpha = torch.sigmoid(self.gate(x)) # (B, T, 1)
160
-
161
- # ---- 5. Optional Hebbian EMA write ------------------------------
162
- if self.training and self.hebbian_boost:
163
- with torch.no_grad():
164
- # Reuse the hash-based indices for the write target (sparse update).
165
- indices = self._hash(token_ids)
166
- flat_idx = indices.reshape(-1) # (B*T,)
167
- flat_x = x.detach().reshape(-1, x.shape[-1]) # (B*T, d_model)
168
- mem_dtype = self.memory.data.dtype
169
- updates = (
170
- self.hebbian_lr * flat_x
171
- - self.hebbian_lr * self.memory.data[flat_idx]
172
- ).to(mem_dtype)
173
- self.memory.data.index_add_(0, flat_idx, updates)
174
-
175
- # ---- 6. Residual + hit_rate -------------------------------------
176
- hit_rate = (alpha.detach() > 0.1).float().mean()
177
- return x + alpha * retrieved, hit_rate
 
1
+ """GPU Engram β€” Sparse Modern Hopfield retrieval path.
2
+
3
+ ## What changed (scatter-gather β†’ Hopfield matmul)
4
+
5
+ The original forward used `self.memory[indices]` (scatter-gather), which misses
6
+ L2 cache at n_columns > 4096 and creates a hard tps ceiling.
7
+
8
+ The replacement uses:
9
+ scores = x @ self.memory.T # (B, T, n_columns) β€” coalesced matmul
10
+ weights = entmax15(scores, dim=-1) # sparse attention; 95%+ exact zeros
11
+ retrieved = weights @ self.memory # (B, T, d_model) β€” coalesced matmul
12
+
13
+ Both matmuls are tile-friendly (cuBLAS GEMM), so L2 reuse is high regardless of
14
+ n_columns. Gradient flows through both matmuls so `self.memory` learns via
15
+ autograd in addition to (or instead of) the Hebbian EMA writes.
16
+
17
+ ## Sparsity mechanism
18
+
19
+ alpha-entmax with alpha=1.5 (entmax15) is a sparse attention operator that maps
20
+ logit vectors to distributions where many entries are *exactly* zero (not merely
21
+ small). It generalises softmax (alpha=1) and argmax (alphaβ†’βˆž). At n_columns=1024
22
+ with d_model=64 a random batch typically hits β‰₯95% zero entries β€” the key
23
+ property that keeps bandwidth proportional to *attended* columns, not all columns.
24
+
25
+ Fallback: if `entmax` is not pip-installed, top-k softmax (k=32) is used instead.
26
+ This is chosen at module-import time β€” NO runtime branching per forward call.
27
+
28
+ ## token_ids argument
29
+
30
+ token_ids is accepted for API compatibility with the rest of the hydra stack
31
+ (train.py, lightning_module.py call `engram(x, token_ids)`). It is NOT used in
32
+ the retrieval path β€” the Hopfield path computes dense similarity over the whole
33
+ memory bank, which subsumes any hash-based column selection. Documented here to
34
+ prevent confusion.
35
+
36
+ ## Hebbian writes (hebbian_boost=False by default)
37
+
38
+ With Hopfield retrieval, gradient signals reach self.memory through autograd, so
39
+ Hebbian EMA writes are no longer critical. They are preserved as an *optional*
40
+ boost (hebbian_boost=True) for experiments that want both signals. Default is off.
41
+
42
+ ## Checkpoint compatibility
43
+
44
+ `self.memory` shape (n_columns, d_model) is unchanged, so existing .pt / .ckpt
45
+ files load without modification.
46
+ """
47
+
48
+ from __future__ import annotations
49
+
50
+ import torch
51
+ import torch.nn as nn
52
+
53
+ # ---------------------------------------------------------------------------
54
+ # Sparse-attention backend β€” chosen ONCE at import time, no runtime branching.
55
+ # ---------------------------------------------------------------------------
56
+
57
+ try:
58
+ from entmax import entmax15 as _entmax15 # type: ignore[import]
59
+
60
+ def _sparse_attention(scores: torch.Tensor) -> torch.Tensor:
61
+ """alpha-entmax (alpha=1.5): truly sparse distribution over last dim."""
62
+ return _entmax15(scores, dim=-1).to(dtype=scores.dtype)
63
+
64
+ _BACKEND = "entmax15"
65
+
66
+ except ImportError: # pragma: no cover β€” entmax always installed in CI
67
+ _K = 32 # top-k for fallback
68
+
69
+ def _sparse_attention(scores: torch.Tensor) -> torch.Tensor: # type: ignore[misc]
70
+ """Top-k softmax fallback: zero outside the k highest-scoring columns."""
71
+ topk_vals, topk_idx = scores.topk(_K, dim=-1)
72
+ topk_w = torch.softmax(topk_vals, dim=-1)
73
+ weights = torch.zeros_like(scores)
74
+ weights.scatter_(-1, topk_idx, topk_w.to(dtype=weights.dtype))
75
+ return weights
76
+
77
+ _BACKEND = "topk32"
78
+
79
+
80
+ class GPUEngram(nn.Module):
81
+ """GPU Engram: Sparse Modern Hopfield retrieval.
82
+
83
+ Args:
84
+ d_model: Model dimension β€” must match the surrounding transformer.
85
+ n_columns: Number of memory columns (key-value pairs). Safe at 32 768
86
+ with the matmul path; the old scatter-gather had an L2
87
+ cliff above ~4 096.
88
+ max_ngram: Retained for API compatibility; unused in retrieval path.
89
+ hebbian_boost: If True, also run a Hebbian EMA write on the memory bank
90
+ during training (old behaviour, now optional). Default False.
91
+ """
92
+
93
+ def __init__(
94
+ self,
95
+ d_model: int,
96
+ n_columns: int = 1024,
97
+ max_ngram: int = 3,
98
+ hebbian_boost: bool = False,
99
+ ) -> None:
100
+ super().__init__()
101
+ self.n_columns = n_columns
102
+ self.max_ngram = max_ngram
103
+ self.hebbian_boost = hebbian_boost
104
+ # Shape unchanged from original β€” existing checkpoints load cleanly.
105
+ self.memory = nn.Parameter(torch.randn(n_columns, d_model) * 0.01)
106
+ self.gate = nn.Linear(d_model, 1, bias=True)
107
+ nn.init.constant_(self.gate.bias, 0.0) # START OPEN
108
+ # Retained for any external code that reads these attrs.
109
+ self.primes = [2654435761, 2246822519, 3266489917]
110
+ self.hebbian_lr = 0.01
111
+
112
+ # ------------------------------------------------------------------
113
+ # _hash: retained for API/checkpoint compat; unused in forward below.
114
+ # ------------------------------------------------------------------
115
+
116
+ def _hash(self, token_ids: torch.Tensor) -> torch.Tensor:
117
+ """N-gram hash β†’ column index (kept for backward-compat; not used in retrieval)."""
118
+ B, T = token_ids.shape
119
+ h = token_ids * self.primes[0]
120
+ if T > 1:
121
+ shifted1 = torch.roll(token_ids, 1, dims=1)
122
+ shifted1[:, 0] = 0
123
+ h = h ^ (shifted1 * self.primes[1])
124
+ if T > 2:
125
+ shifted2 = torch.roll(token_ids, 2, dims=1)
126
+ shifted2[:, :2] = 0
127
+ h = h ^ (shifted2 * self.primes[2])
128
+ return h % self.n_columns
129
+
130
+ # ------------------------------------------------------------------
131
+ # forward
132
+ # ------------------------------------------------------------------
133
+
134
+ def forward(self, x: torch.Tensor, token_ids: torch.Tensor):
135
+ """Hopfield retrieve + soft gate + residual.
136
+
137
+ Args:
138
+ x: (B, T, d_model) β€” input activations.
139
+ token_ids: (B, T) β€” token indices. Accepted for API compatibility;
140
+ NOT used in the retrieval path (see module docstring).
141
+
142
+ Returns:
143
+ (x + alpha * retrieved, hit_rate)
144
+ - x + alpha * retrieved: (B, T, d_model)
145
+ - hit_rate: scalar tensor β€” fraction of gate values > 0.1
146
+ """
147
+ # ---- 1. Similarity scores (coalesced GEMM) ----------------------
148
+ # scores[b, t, c] = dot(x[b,t], memory[c])
149
+ scores = x @ self.memory.T # (B, T, n_columns)
150
+
151
+ # ---- 2. Sparse attention weights --------------------------------
152
+ # _sparse_attention is fixed at import time (entmax15 or top-k).
153
+ weights = _sparse_attention(scores) # (B, T, n_columns), many exact zeros
154
+
155
+ # ---- 3. Retrieved vector (coalesced GEMM) -----------------------
156
+ retrieved = weights @ self.memory # (B, T, d_model)
157
+
158
+ # ---- 4. Soft gate (unchanged) -----------------------------------
159
+ alpha = torch.sigmoid(self.gate(x)) # (B, T, 1)
160
+
161
+ # ---- 5. Optional Hebbian EMA write ------------------------------
162
+ if self.training and self.hebbian_boost:
163
+ with torch.no_grad():
164
+ # Reuse the hash-based indices for the write target (sparse update).
165
+ indices = self._hash(token_ids)
166
+ flat_idx = indices.reshape(-1) # (B*T,)
167
+ flat_x = x.detach().reshape(-1, x.shape[-1]) # (B*T, d_model)
168
+ mem_dtype = self.memory.data.dtype
169
+ updates = (
170
+ self.hebbian_lr * flat_x
171
+ - self.hebbian_lr * self.memory.data[flat_idx]
172
+ ).to(mem_dtype)
173
+ self.memory.data.index_add_(0, flat_idx, updates)
174
+
175
+ # ---- 6. Residual + hit_rate -------------------------------------
176
+ hit_rate = (alpha.detach() > 0.1).float().mean()
177
+ return x + alpha * retrieved, hit_rate
overlay/hydra/eval.py CHANGED
@@ -1,210 +1,210 @@
1
- """Evaluation: factual probes + sampled factual English scoring.
2
-
3
- Extracted from train.py (W1 modularization). Semantics unchanged.
4
-
5
- Perf optimizations (eval_perf_fix):
6
- - Probe mode: single forward per prompt instead of autoregressive gen
7
- - Batch decode: all GPU work first, all CPU decode after
8
- - Batched factual probes: single padded forward instead of N sequential
9
- """
10
-
11
- from __future__ import annotations
12
-
13
- import os
14
- import re as _re
15
-
16
- import torch
17
-
18
- from hydra.config import FACTUAL_SAMPLES, FACTUAL_BATCH, FACTUAL_GEN_TOKENS
19
-
20
- # Default to probe mode (1 forward per prompt); set HYDRA_FACTUAL_MODE=gen for
21
- # the original autoregressive generation path.
22
- FACTUAL_MODE = os.environ.get("HYDRA_FACTUAL_MODE", "probe")
23
-
24
- FACTUAL_EVAL = [
25
- # Hard factual recall β€” requires specific knowledge memorization
26
- ("The capital of France is", ["Paris", "paris"]),
27
- ("Water boils at", ["100", "boiling"]),
28
- ("The largest planet in our solar system is", ["Jupiter", "jupiter"]),
29
- # Easier completions β€” common collocations / patterns the model may pick up
30
- ("Once upon a", ["time"]),
31
- ("Hello, my name", ["is", "'s"]),
32
- ("The cat sat on the", ["mat", "floor", "rug", "table", "couch", "chair", "ground"]),
33
- ("She opened the door and", ["walked", "saw", "found", "stepped", "looked", "went", "ran"]),
34
- # Original hard ones kept for completeness
35
- ("The speed of light is approximately", ["299", "300", "186,000", "light speed"]),
36
- ("Two plus two equals", ["4", "four"]),
37
- ]
38
-
39
- _FACTUAL_PROBES = [
40
- "The capital of France is",
41
- "Water boils at",
42
- "The largest planet in our solar system is",
43
- "The speed of light is approximately",
44
- "Shakespeare wrote",
45
- ]
46
-
47
-
48
- def run_factual_probes(model, tokenizer, device, autocast_ctx) -> None:
49
- """Top-5 next-token predictions for canonical factual prompts.
50
-
51
- Batched: pads all prompts into a single forward pass instead of N
52
- sequential passes.
53
- """
54
- print("\n--- Factual Probes ---")
55
- model.eval()
56
-
57
- # Process probes one at a time to avoid cooperative launch limit
58
- # (batched forward with B=len(probes) can exceed SM residency cap).
59
- for prompt_text in _FACTUAL_PROBES:
60
- ids = tokenizer.encode(prompt_text)
61
- x = torch.tensor([ids], device=device)
62
- with torch.no_grad(), autocast_ctx:
63
- logits = model(x)
64
- probs = torch.softmax(logits[0, -1].float(), dim=-1)
65
- top5 = torch.topk(probs, 5)
66
- completions = [tokenizer.decode([idx.item()]) for idx in top5.indices]
67
- probs_list = [f"{p:.4f}" for p in top5.values[:3].tolist()]
68
- print(f' "{prompt_text}" -> {completions[:3]} (p={probs_list})')
69
- print("--- End Factual Probes ---\n")
70
-
71
-
72
- # ---------------------------------------------------------------------------
73
- # Probe mode: single forward per prompt (Fix D)
74
- # ---------------------------------------------------------------------------
75
-
76
- def _run_factual_english_probe(model, tokenizer, max_seq_len: int):
77
- """Fast probe mode: for each (prompt, answers), encode prompt + each answer
78
- candidate as a single sequence, do ONE forward pass, and check if the model's
79
- argmax at the last prompt token matches the first answer token.
80
-
81
- Falls back to checking top-K predictions to be generous (same as gen mode
82
- which samples multiple temperatures).
83
- """
84
- print("---")
85
- print("factual_english_samples: (probe mode)")
86
- model.eval()
87
- hits = 0
88
-
89
- with torch.no_grad(), torch.amp.autocast(device_type="cuda", dtype=torch.bfloat16):
90
- for prompt, answers in FACTUAL_EVAL:
91
- prompt_ids = tokenizer.encode(prompt)
92
- prompt_len = len(prompt_ids)
93
- x = torch.tensor([prompt_ids], device="cuda", dtype=torch.long)
94
- logits = model(x, targets=None)
95
- # logits shape: [1, seq_len, vocab] or [1, vocab]
96
- if logits.dim() == 3:
97
- last_logits = logits[0, -1, :]
98
- else:
99
- last_logits = logits[0]
100
-
101
- probs = torch.softmax(last_logits.float(), dim=-1)
102
- # Check top-K predictions (generous: K=20 to match multi-sample gen)
103
- top_k = min(20, probs.shape[-1])
104
- top_ids = torch.topk(probs, top_k).indices.tolist()
105
- top_tokens = [tokenizer.decode([tid]).strip().lower() for tid in top_ids]
106
-
107
- answers_lower = [a.lower() for a in answers]
108
- any_hit = any(
109
- any(a in tok for a in answers_lower)
110
- for tok in top_tokens
111
- )
112
- if any_hit:
113
- hits += 1
114
-
115
- best_completion = tokenizer.decode([top_ids[0]])
116
- print(f" prompt: {prompt!r}")
117
- print(f" output: {(prompt + best_completion).replace(chr(10), ' ')!r}")
118
- print(f" hit: {any_hit} (probe top-{top_k})")
119
-
120
- score = hits / len(FACTUAL_EVAL)
121
- print("---")
122
- print(f"factual_english_score: {score:.4f}")
123
- print(f"factual_english_hits: {hits}/{len(FACTUAL_EVAL)}")
124
- return score, hits, len(FACTUAL_EVAL)
125
-
126
-
127
- # ---------------------------------------------------------------------------
128
- # Gen mode: original autoregressive path (Fix F: batch decode)
129
- # ---------------------------------------------------------------------------
130
-
131
- def _run_factual_english_gen(model, tokenizer, max_seq_len: int):
132
- """Original autoregressive generation path with batch decode optimization:
133
- all GPU work runs first, then all CPU decoding happens after."""
134
- print("---")
135
- print("factual_english_samples: (gen mode)")
136
- model.eval()
137
-
138
- num_samples = FACTUAL_SAMPLES
139
- batch = FACTUAL_BATCH
140
- gen_tokens = FACTUAL_GEN_TOKENS
141
- temps = [0.7, 0.9, 1.1]
142
- hits = 0
143
-
144
- with torch.no_grad(), torch.amp.autocast(device_type="cuda", dtype=torch.bfloat16):
145
- for prompt, answers in FACTUAL_EVAL:
146
- ids = tokenizer.encode(prompt)
147
- answers_lower = [a.lower() for a in answers]
148
- # Collect all generated token sequences on GPU first
149
- all_rows: list[list[int]] = []
150
- samples_done = 0
151
- batch_idx = 0
152
- while samples_done < num_samples:
153
- b = min(batch, num_samples - samples_done)
154
- temp = temps[batch_idx % len(temps)]
155
- batch_idx += 1
156
- ctx = torch.tensor([ids] * b, device="cuda", dtype=torch.long)
157
- for _ in range(gen_tokens):
158
- logits = model(ctx, targets=None)
159
- next_logits = logits[:, -1, :] if logits.dim() == 3 else logits
160
- probs = torch.softmax(next_logits.float() / temp, dim=-1)
161
- next_id = torch.multinomial(probs, num_samples=1)
162
- ctx = torch.cat([ctx, next_id], dim=1)
163
- if ctx.size(1) >= max_seq_len:
164
- break
165
- # Transfer to CPU in one shot, no per-row sync
166
- all_rows.extend(ctx.cpu().tolist())
167
- samples_done += b
168
-
169
- # CPU-side batch decode β€” no GPU sync between decodes
170
- any_hit = False
171
- first_gen = None
172
- hit_gen = None
173
- for row in all_rows:
174
- generated = tokenizer.decode(row)
175
- continuation = generated[len(prompt):].strip()
176
- _words = set(w.lower() for w in _re.findall(r"\b[\w'-]+\b", continuation))
177
- hit = any(a in _words for a in answers_lower)
178
- if first_gen is None:
179
- first_gen = generated
180
- if hit:
181
- any_hit = True
182
- if hit_gen is None:
183
- hit_gen = generated
184
- if any_hit:
185
- hits += 1
186
- print(f" prompt: {prompt!r}")
187
- print(f" output: {(first_gen or '').replace(chr(10), ' ')!r}")
188
- print(f" hit: {any_hit} (any of {num_samples} samples, temps={temps}, gen={gen_tokens}tok)")
189
- if hit_gen is not None and hit_gen != first_gen:
190
- print(f" hit_sample: {hit_gen.replace(chr(10), ' ')!r}")
191
-
192
- score = hits / len(FACTUAL_EVAL)
193
- print("---")
194
- print(f"factual_english_score: {score:.4f}")
195
- print(f"factual_english_hits: {hits}/{len(FACTUAL_EVAL)}")
196
- return score, hits, len(FACTUAL_EVAL)
197
-
198
-
199
- # ---------------------------------------------------------------------------
200
- # Public entry point
201
- # ---------------------------------------------------------------------------
202
-
203
- def run_factual_english(model, tokenizer, max_seq_len: int):
204
- """Dispatch to probe (fast, default) or gen (original) mode.
205
-
206
- Set HYDRA_FACTUAL_MODE=gen to use the autoregressive path.
207
- """
208
- if FACTUAL_MODE == "gen":
209
- return _run_factual_english_gen(model, tokenizer, max_seq_len)
210
- return _run_factual_english_probe(model, tokenizer, max_seq_len)
 
1
+ """Evaluation: factual probes + sampled factual English scoring.
2
+
3
+ Extracted from train.py (W1 modularization). Semantics unchanged.
4
+
5
+ Perf optimizations (eval_perf_fix):
6
+ - Probe mode: single forward per prompt instead of autoregressive gen
7
+ - Batch decode: all GPU work first, all CPU decode after
8
+ - Batched factual probes: single padded forward instead of N sequential
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ import re as _re
15
+
16
+ import torch
17
+
18
+ from hydra.config import FACTUAL_SAMPLES, FACTUAL_BATCH, FACTUAL_GEN_TOKENS
19
+
20
+ # Default to probe mode (1 forward per prompt); set HYDRA_FACTUAL_MODE=gen for
21
+ # the original autoregressive generation path.
22
+ FACTUAL_MODE = os.environ.get("HYDRA_FACTUAL_MODE", "probe")
23
+
24
+ FACTUAL_EVAL = [
25
+ # Hard factual recall β€” requires specific knowledge memorization
26
+ ("The capital of France is", ["Paris", "paris"]),
27
+ ("Water boils at", ["100", "boiling"]),
28
+ ("The largest planet in our solar system is", ["Jupiter", "jupiter"]),
29
+ # Easier completions β€” common collocations / patterns the model may pick up
30
+ ("Once upon a", ["time"]),
31
+ ("Hello, my name", ["is", "'s"]),
32
+ ("The cat sat on the", ["mat", "floor", "rug", "table", "couch", "chair", "ground"]),
33
+ ("She opened the door and", ["walked", "saw", "found", "stepped", "looked", "went", "ran"]),
34
+ # Original hard ones kept for completeness
35
+ ("The speed of light is approximately", ["299", "300", "186,000", "light speed"]),
36
+ ("Two plus two equals", ["4", "four"]),
37
+ ]
38
+
39
+ _FACTUAL_PROBES = [
40
+ "The capital of France is",
41
+ "Water boils at",
42
+ "The largest planet in our solar system is",
43
+ "The speed of light is approximately",
44
+ "Shakespeare wrote",
45
+ ]
46
+
47
+
48
+ def run_factual_probes(model, tokenizer, device, autocast_ctx) -> None:
49
+ """Top-5 next-token predictions for canonical factual prompts.
50
+
51
+ Batched: pads all prompts into a single forward pass instead of N
52
+ sequential passes.
53
+ """
54
+ print("\n--- Factual Probes ---")
55
+ model.eval()
56
+
57
+ # Process probes one at a time to avoid cooperative launch limit
58
+ # (batched forward with B=len(probes) can exceed SM residency cap).
59
+ for prompt_text in _FACTUAL_PROBES:
60
+ ids = tokenizer.encode(prompt_text)
61
+ x = torch.tensor([ids], device=device)
62
+ with torch.no_grad(), autocast_ctx:
63
+ logits = model(x)
64
+ probs = torch.softmax(logits[0, -1].float(), dim=-1)
65
+ top5 = torch.topk(probs, 5)
66
+ completions = [tokenizer.decode([idx.item()]) for idx in top5.indices]
67
+ probs_list = [f"{p:.4f}" for p in top5.values[:3].tolist()]
68
+ print(f' "{prompt_text}" -> {completions[:3]} (p={probs_list})')
69
+ print("--- End Factual Probes ---\n")
70
+
71
+
72
+ # ---------------------------------------------------------------------------
73
+ # Probe mode: single forward per prompt (Fix D)
74
+ # ---------------------------------------------------------------------------
75
+
76
+ def _run_factual_english_probe(model, tokenizer, max_seq_len: int):
77
+ """Fast probe mode: for each (prompt, answers), encode prompt + each answer
78
+ candidate as a single sequence, do ONE forward pass, and check if the model's
79
+ argmax at the last prompt token matches the first answer token.
80
+
81
+ Falls back to checking top-K predictions to be generous (same as gen mode
82
+ which samples multiple temperatures).
83
+ """
84
+ print("---")
85
+ print("factual_english_samples: (probe mode)")
86
+ model.eval()
87
+ hits = 0
88
+
89
+ with torch.no_grad(), torch.amp.autocast(device_type="cuda", dtype=torch.bfloat16):
90
+ for prompt, answers in FACTUAL_EVAL:
91
+ prompt_ids = tokenizer.encode(prompt)
92
+ prompt_len = len(prompt_ids)
93
+ x = torch.tensor([prompt_ids], device="cuda", dtype=torch.long)
94
+ logits = model(x, targets=None)
95
+ # logits shape: [1, seq_len, vocab] or [1, vocab]
96
+ if logits.dim() == 3:
97
+ last_logits = logits[0, -1, :]
98
+ else:
99
+ last_logits = logits[0]
100
+
101
+ probs = torch.softmax(last_logits.float(), dim=-1)
102
+ # Check top-K predictions (generous: K=20 to match multi-sample gen)
103
+ top_k = min(20, probs.shape[-1])
104
+ top_ids = torch.topk(probs, top_k).indices.tolist()
105
+ top_tokens = [tokenizer.decode([tid]).strip().lower() for tid in top_ids]
106
+
107
+ answers_lower = [a.lower() for a in answers]
108
+ any_hit = any(
109
+ any(a in tok for a in answers_lower)
110
+ for tok in top_tokens
111
+ )
112
+ if any_hit:
113
+ hits += 1
114
+
115
+ best_completion = tokenizer.decode([top_ids[0]])
116
+ print(f" prompt: {prompt!r}")
117
+ print(f" output: {(prompt + best_completion).replace(chr(10), ' ')!r}")
118
+ print(f" hit: {any_hit} (probe top-{top_k})")
119
+
120
+ score = hits / len(FACTUAL_EVAL)
121
+ print("---")
122
+ print(f"factual_english_score: {score:.4f}")
123
+ print(f"factual_english_hits: {hits}/{len(FACTUAL_EVAL)}")
124
+ return score, hits, len(FACTUAL_EVAL)
125
+
126
+
127
+ # ---------------------------------------------------------------------------
128
+ # Gen mode: original autoregressive path (Fix F: batch decode)
129
+ # ---------------------------------------------------------------------------
130
+
131
+ def _run_factual_english_gen(model, tokenizer, max_seq_len: int):
132
+ """Original autoregressive generation path with batch decode optimization:
133
+ all GPU work runs first, then all CPU decoding happens after."""
134
+ print("---")
135
+ print("factual_english_samples: (gen mode)")
136
+ model.eval()
137
+
138
+ num_samples = FACTUAL_SAMPLES
139
+ batch = FACTUAL_BATCH
140
+ gen_tokens = FACTUAL_GEN_TOKENS
141
+ temps = [0.7, 0.9, 1.1]
142
+ hits = 0
143
+
144
+ with torch.no_grad(), torch.amp.autocast(device_type="cuda", dtype=torch.bfloat16):
145
+ for prompt, answers in FACTUAL_EVAL:
146
+ ids = tokenizer.encode(prompt)
147
+ answers_lower = [a.lower() for a in answers]
148
+ # Collect all generated token sequences on GPU first
149
+ all_rows: list[list[int]] = []
150
+ samples_done = 0
151
+ batch_idx = 0
152
+ while samples_done < num_samples:
153
+ b = min(batch, num_samples - samples_done)
154
+ temp = temps[batch_idx % len(temps)]
155
+ batch_idx += 1
156
+ ctx = torch.tensor([ids] * b, device="cuda", dtype=torch.long)
157
+ for _ in range(gen_tokens):
158
+ logits = model(ctx, targets=None)
159
+ next_logits = logits[:, -1, :] if logits.dim() == 3 else logits
160
+ probs = torch.softmax(next_logits.float() / temp, dim=-1)
161
+ next_id = torch.multinomial(probs, num_samples=1)
162
+ ctx = torch.cat([ctx, next_id], dim=1)
163
+ if ctx.size(1) >= max_seq_len:
164
+ break
165
+ # Transfer to CPU in one shot, no per-row sync
166
+ all_rows.extend(ctx.cpu().tolist())
167
+ samples_done += b
168
+
169
+ # CPU-side batch decode β€” no GPU sync between decodes
170
+ any_hit = False
171
+ first_gen = None
172
+ hit_gen = None
173
+ for row in all_rows:
174
+ generated = tokenizer.decode(row)
175
+ continuation = generated[len(prompt):].strip()
176
+ _words = set(w.lower() for w in _re.findall(r"\b[\w'-]+\b", continuation))
177
+ hit = any(a in _words for a in answers_lower)
178
+ if first_gen is None:
179
+ first_gen = generated
180
+ if hit:
181
+ any_hit = True
182
+ if hit_gen is None:
183
+ hit_gen = generated
184
+ if any_hit:
185
+ hits += 1
186
+ print(f" prompt: {prompt!r}")
187
+ print(f" output: {(first_gen or '').replace(chr(10), ' ')!r}")
188
+ print(f" hit: {any_hit} (any of {num_samples} samples, temps={temps}, gen={gen_tokens}tok)")
189
+ if hit_gen is not None and hit_gen != first_gen:
190
+ print(f" hit_sample: {hit_gen.replace(chr(10), ' ')!r}")
191
+
192
+ score = hits / len(FACTUAL_EVAL)
193
+ print("---")
194
+ print(f"factual_english_score: {score:.4f}")
195
+ print(f"factual_english_hits: {hits}/{len(FACTUAL_EVAL)}")
196
+ return score, hits, len(FACTUAL_EVAL)
197
+
198
+
199
+ # ---------------------------------------------------------------------------
200
+ # Public entry point
201
+ # ---------------------------------------------------------------------------
202
+
203
+ def run_factual_english(model, tokenizer, max_seq_len: int):
204
+ """Dispatch to probe (fast, default) or gen (original) mode.
205
+
206
+ Set HYDRA_FACTUAL_MODE=gen to use the autoregressive path.
207
+ """
208
+ if FACTUAL_MODE == "gen":
209
+ return _run_factual_english_gen(model, tokenizer, max_seq_len)
210
+ return _run_factual_english_probe(model, tokenizer, max_seq_len)
overlay/hydra/gdn_block.py CHANGED
@@ -1,126 +1,126 @@
1
- """GDNBlock β€” Gated Delta Net block, drop-in shape-compatible with Mamba3Block and HyenaBlock.
2
-
3
- GatedDeltaNet (GDN) reference: arXiv:2412.06464 (ICLR 2025, NVLabs).
4
- Implementation: flash-linear-attention (fla) library, Triton kernels, sm86-compatible.
5
-
6
- Interface contract (MUST match how Mamba3/Hyena are called in hydra/model.py):
7
- block = GDNBlock(d_model, ...)
8
- y = block(x) # x: [B, T, d_model] -> y: [B, T, d_model]
9
-
10
- The surrounding mHC layer does NOT pre-norm before calling this block (the
11
- raw hidden state is passed in); the block itself applies no input normalization,
12
- same as HyenaBlock. We return the raw operator output; the mHC layer adds it
13
- as a residual stream contribution.
14
-
15
- NO attention, NO softmax-over-sequence-dim. All state is stateless between
16
- .forward() calls by default (use_cache=False, past_key_values=None).
17
- """
18
-
19
- from __future__ import annotations
20
-
21
- try:
22
- from fla.layers.gated_deltanet import GatedDeltaNet as _GatedDeltaNet
23
- except ImportError as _fla_err:
24
- raise ImportError(
25
- "flash-linear-attention (fla) is required for GDNBlock but could not be imported. "
26
- "Install it with:\n"
27
- " pip install flash-linear-attention\n"
28
- "or from source:\n"
29
- " pip install git+https://github.com/fla-org/flash-linear-attention.git\n"
30
- f"Original error: {_fla_err}"
31
- ) from _fla_err
32
-
33
- import torch
34
- import torch.nn as nn
35
-
36
-
37
- class GDNBlock(nn.Module):
38
- """Gated Delta Net block, drop-in shape-compatible with HYDRA's Mamba3Block and HyenaBlock.
39
-
40
- Wraps `fla.layers.GatedDeltaNet` with the same external API that
41
- `hydra.hyena_block.HyenaBlock` exposes:
42
-
43
- forward(x: Tensor[B, T, d_model]) -> Tensor[B, T, d_model]
44
-
45
- Internal GatedDeltaNet.forward returns a 3-tuple
46
- (hidden_states, attn_weights, past_key_values); we extract [0] and
47
- return only the hidden states, keeping the residual stream unchanged.
48
-
49
- GDN outperforms Mamba-2 on in-context retrieval benchmarks (MQAR, etc.)
50
- at equal or faster compute, making it a targeted fix for HYDRA's factual
51
- plateau.
52
-
53
- Parameter counts are deliberately kept within 2x of a Mamba3 block at the
54
- same d_model/n_heads to be drop-in affordable.
55
- """
56
-
57
- def __init__(
58
- self,
59
- d_model: int,
60
- n_heads: int = 6,
61
- mode: str = "chunk", # 'chunk' for training, 'fused_recurrent' for inference
62
- expand_v: float = 2.0, # value-projection expansion; controls KV memory
63
- use_short_conv: bool = True,
64
- conv_size: int = 4,
65
- ):
66
- super().__init__()
67
- self.d_model = d_model
68
- self.n_heads = n_heads
69
- self.mode = mode
70
-
71
- # head_dim must divide d_model. GDN uses separate q/k head_dim from v;
72
- # we set head_dim for q/k such that n_heads * head_dim == d_model.
73
- if d_model % n_heads != 0:
74
- raise ValueError(
75
- f"d_model={d_model} must be divisible by n_heads={n_heads} "
76
- "so that head_dim = d_model // n_heads is an integer."
77
- )
78
- head_dim = d_model // n_heads
79
-
80
- self.gdn = _GatedDeltaNet(
81
- hidden_size=d_model,
82
- expand_v=expand_v,
83
- head_dim=head_dim,
84
- num_heads=n_heads,
85
- mode=mode,
86
- use_gate=True, # gating is the key architectural feature of GDN
87
- use_short_conv=use_short_conv,
88
- conv_size=conv_size,
89
- layer_idx=None, # no KV-cache layer indexing; we manage state ourselves
90
- )
91
-
92
- # ------------------------------------------------------------------
93
- # Forward
94
- # ------------------------------------------------------------------
95
-
96
- def forward(self, x: torch.Tensor) -> torch.Tensor:
97
- """x: [B, T, d_model] -> y: [B, T, d_model].
98
-
99
- Passes through GatedDeltaNet with use_cache=False so no recurrent
100
- state leaks between independent forward() calls (important for
101
- gradient-accumulation loops and eval).
102
- """
103
- # GatedDeltaNet.forward signature:
104
- # (hidden_states, attention_mask=None, past_key_values=None,
105
- # use_cache=False, output_attentions=False)
106
- # Returns: tuple(hidden_states, attn_weights|None, past_kv|None)
107
- out, _, _ = self.gdn(
108
- hidden_states=x,
109
- attention_mask=None,
110
- past_key_values=None,
111
- use_cache=False,
112
- output_attentions=False,
113
- )
114
- return out
115
-
116
- # ------------------------------------------------------------------
117
- # API parity with HyenaBlock and Mamba3Block
118
- # ------------------------------------------------------------------
119
-
120
- def invalidate_caches(self) -> None:
121
- """No-op β€” GDNBlock holds no persistent filter cache.
122
-
123
- Provided for API parity with HyenaBlock, which invalidates its
124
- Hyena filter cache here. Calling this is always safe.
125
- """
126
- pass
 
1
+ """GDNBlock β€” Gated Delta Net block, drop-in shape-compatible with Mamba3Block and HyenaBlock.
2
+
3
+ GatedDeltaNet (GDN) reference: arXiv:2412.06464 (ICLR 2025, NVLabs).
4
+ Implementation: flash-linear-attention (fla) library, Triton kernels, sm86-compatible.
5
+
6
+ Interface contract (MUST match how Mamba3/Hyena are called in hydra/model.py):
7
+ block = GDNBlock(d_model, ...)
8
+ y = block(x) # x: [B, T, d_model] -> y: [B, T, d_model]
9
+
10
+ The surrounding mHC layer does NOT pre-norm before calling this block (the
11
+ raw hidden state is passed in); the block itself applies no input normalization,
12
+ same as HyenaBlock. We return the raw operator output; the mHC layer adds it
13
+ as a residual stream contribution.
14
+
15
+ NO attention, NO softmax-over-sequence-dim. All state is stateless between
16
+ .forward() calls by default (use_cache=False, past_key_values=None).
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ try:
22
+ from fla.layers.gated_deltanet import GatedDeltaNet as _GatedDeltaNet
23
+ except ImportError as _fla_err:
24
+ raise ImportError(
25
+ "flash-linear-attention (fla) is required for GDNBlock but could not be imported. "
26
+ "Install it with:\n"
27
+ " pip install flash-linear-attention\n"
28
+ "or from source:\n"
29
+ " pip install git+https://github.com/fla-org/flash-linear-attention.git\n"
30
+ f"Original error: {_fla_err}"
31
+ ) from _fla_err
32
+
33
+ import torch
34
+ import torch.nn as nn
35
+
36
+
37
+ class GDNBlock(nn.Module):
38
+ """Gated Delta Net block, drop-in shape-compatible with HYDRA's Mamba3Block and HyenaBlock.
39
+
40
+ Wraps `fla.layers.GatedDeltaNet` with the same external API that
41
+ `hydra.hyena_block.HyenaBlock` exposes:
42
+
43
+ forward(x: Tensor[B, T, d_model]) -> Tensor[B, T, d_model]
44
+
45
+ Internal GatedDeltaNet.forward returns a 3-tuple
46
+ (hidden_states, attn_weights, past_key_values); we extract [0] and
47
+ return only the hidden states, keeping the residual stream unchanged.
48
+
49
+ GDN outperforms Mamba-2 on in-context retrieval benchmarks (MQAR, etc.)
50
+ at equal or faster compute, making it a targeted fix for HYDRA's factual
51
+ plateau.
52
+
53
+ Parameter counts are deliberately kept within 2x of a Mamba3 block at the
54
+ same d_model/n_heads to be drop-in affordable.
55
+ """
56
+
57
+ def __init__(
58
+ self,
59
+ d_model: int,
60
+ n_heads: int = 6,
61
+ mode: str = "chunk", # 'chunk' for training, 'fused_recurrent' for inference
62
+ expand_v: float = 2.0, # value-projection expansion; controls KV memory
63
+ use_short_conv: bool = True,
64
+ conv_size: int = 4,
65
+ ):
66
+ super().__init__()
67
+ self.d_model = d_model
68
+ self.n_heads = n_heads
69
+ self.mode = mode
70
+
71
+ # head_dim must divide d_model. GDN uses separate q/k head_dim from v;
72
+ # we set head_dim for q/k such that n_heads * head_dim == d_model.
73
+ if d_model % n_heads != 0:
74
+ raise ValueError(
75
+ f"d_model={d_model} must be divisible by n_heads={n_heads} "
76
+ "so that head_dim = d_model // n_heads is an integer."
77
+ )
78
+ head_dim = d_model // n_heads
79
+
80
+ self.gdn = _GatedDeltaNet(
81
+ hidden_size=d_model,
82
+ expand_v=expand_v,
83
+ head_dim=head_dim,
84
+ num_heads=n_heads,
85
+ mode=mode,
86
+ use_gate=True, # gating is the key architectural feature of GDN
87
+ use_short_conv=use_short_conv,
88
+ conv_size=conv_size,
89
+ layer_idx=None, # no KV-cache layer indexing; we manage state ourselves
90
+ )
91
+
92
+ # ------------------------------------------------------------------
93
+ # Forward
94
+ # ------------------------------------------------------------------
95
+
96
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
97
+ """x: [B, T, d_model] -> y: [B, T, d_model].
98
+
99
+ Passes through GatedDeltaNet with use_cache=False so no recurrent
100
+ state leaks between independent forward() calls (important for
101
+ gradient-accumulation loops and eval).
102
+ """
103
+ # GatedDeltaNet.forward signature:
104
+ # (hidden_states, attention_mask=None, past_key_values=None,
105
+ # use_cache=False, output_attentions=False)
106
+ # Returns: tuple(hidden_states, attn_weights|None, past_kv|None)
107
+ out, _, _ = self.gdn(
108
+ hidden_states=x,
109
+ attention_mask=None,
110
+ past_key_values=None,
111
+ use_cache=False,
112
+ output_attentions=False,
113
+ )
114
+ return out
115
+
116
+ # ------------------------------------------------------------------
117
+ # API parity with HyenaBlock and Mamba3Block
118
+ # ------------------------------------------------------------------
119
+
120
+ def invalidate_caches(self) -> None:
121
+ """No-op β€” GDNBlock holds no persistent filter cache.
122
+
123
+ Provided for API parity with HyenaBlock, which invalidates its
124
+ Hyena filter cache here. Calling this is always safe.
125
+ """
126
+ pass
overlay/hydra/hyena_block.py CHANGED
@@ -1,68 +1,68 @@
1
- """HyenaBlock β€” drop-in block for HYDRA, supplement to Mamba3.
2
-
3
- Wraps `subsystems.hyena_pure.HyenaOperator` with a pre-norm + residual scheme
4
- consistent with how the mHC stack wraps Mamba3 in `hydra/model.py`.
5
-
6
- Interface contract (MUST match how Mamba3 is called in model.py):
7
- block = HyenaBlock(d_model, seq_len)
8
- y = block(x) # x: [B, T, d_model] -> y: [B, T, d_model]
9
-
10
- The surrounding mHC layer does the pre-norm (`norm(h)`) BEFORE calling the
11
- block, so the block itself should NOT re-normalize at input β€” same as Mamba3
12
- in the current model. We return the raw operator output; the mHC layer then
13
- adds it as a residual stream contribution.
14
-
15
- NO attention, NO softmax-over-sequence-dim, NO KV-cache. All forbidden
16
- imports enumerated in tests/test_hyena.py (test #7) are absent.
17
- """
18
-
19
- from __future__ import annotations
20
-
21
- import os
22
-
23
- import torch
24
- import torch.nn as nn
25
-
26
- from subsystems.hyena_pure import HyenaOperator
27
-
28
-
29
- class HyenaBlock(nn.Module):
30
- """Single Hyena block, shape-compatible with Mamba3 in HYDRA."""
31
-
32
- def __init__(
33
- self,
34
- d_model: int,
35
- seq_len: int,
36
- order: int | None = None,
37
- filter_order: int | None = None,
38
- dropout: float = 0.0,
39
- filter_dropout: float = 0.0,
40
- short_filter_order: int = 3,
41
- activation: str = "id",
42
- ):
43
- super().__init__()
44
- # Env overrides (documented in hydra/config.py).
45
- if order is None:
46
- order = int(os.environ.get("HYDRA_HYENA_ORDER", "2"))
47
- if filter_order is None:
48
- filter_order = int(os.environ.get("HYDRA_HYENA_FILTER_DIM", "64"))
49
-
50
- self.d_model = d_model
51
- self.seq_len = seq_len
52
- self.order = order
53
- self.filter_order = filter_order
54
-
55
- self.operator = HyenaOperator(
56
- d_model=d_model,
57
- l_max=seq_len,
58
- order=order,
59
- filter_order=filter_order,
60
- dropout=dropout,
61
- filter_dropout=filter_dropout,
62
- short_filter_order=short_filter_order,
63
- activation=activation,
64
- )
65
-
66
- def forward(self, x: torch.Tensor) -> torch.Tensor:
67
- """x: [B, T, d_model] -> y: [B, T, d_model]."""
68
- return self.operator(x)
 
1
+ """HyenaBlock β€” drop-in block for HYDRA, supplement to Mamba3.
2
+
3
+ Wraps `subsystems.hyena_pure.HyenaOperator` with a pre-norm + residual scheme
4
+ consistent with how the mHC stack wraps Mamba3 in `hydra/model.py`.
5
+
6
+ Interface contract (MUST match how Mamba3 is called in model.py):
7
+ block = HyenaBlock(d_model, seq_len)
8
+ y = block(x) # x: [B, T, d_model] -> y: [B, T, d_model]
9
+
10
+ The surrounding mHC layer does the pre-norm (`norm(h)`) BEFORE calling the
11
+ block, so the block itself should NOT re-normalize at input β€” same as Mamba3
12
+ in the current model. We return the raw operator output; the mHC layer then
13
+ adds it as a residual stream contribution.
14
+
15
+ NO attention, NO softmax-over-sequence-dim, NO KV-cache. All forbidden
16
+ imports enumerated in tests/test_hyena.py (test #7) are absent.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import os
22
+
23
+ import torch
24
+ import torch.nn as nn
25
+
26
+ from subsystems.hyena_pure import HyenaOperator
27
+
28
+
29
+ class HyenaBlock(nn.Module):
30
+ """Single Hyena block, shape-compatible with Mamba3 in HYDRA."""
31
+
32
+ def __init__(
33
+ self,
34
+ d_model: int,
35
+ seq_len: int,
36
+ order: int | None = None,
37
+ filter_order: int | None = None,
38
+ dropout: float = 0.0,
39
+ filter_dropout: float = 0.0,
40
+ short_filter_order: int = 3,
41
+ activation: str = "id",
42
+ ):
43
+ super().__init__()
44
+ # Env overrides (documented in hydra/config.py).
45
+ if order is None:
46
+ order = int(os.environ.get("HYDRA_HYENA_ORDER", "2"))
47
+ if filter_order is None:
48
+ filter_order = int(os.environ.get("HYDRA_HYENA_FILTER_DIM", "64"))
49
+
50
+ self.d_model = d_model
51
+ self.seq_len = seq_len
52
+ self.order = order
53
+ self.filter_order = filter_order
54
+
55
+ self.operator = HyenaOperator(
56
+ d_model=d_model,
57
+ l_max=seq_len,
58
+ order=order,
59
+ filter_order=filter_order,
60
+ dropout=dropout,
61
+ filter_dropout=filter_dropout,
62
+ short_filter_order=short_filter_order,
63
+ activation=activation,
64
+ )
65
+
66
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
67
+ """x: [B, T, d_model] -> y: [B, T, d_model]."""
68
+ return self.operator(x)
overlay/hydra/lightning_module.py CHANGED
@@ -1,326 +1,326 @@
1
- """LightningModule wrapping PostSemClawModel.
2
-
3
- Thin adapter. The model and the MuonAdamW optimizer are unchanged. This
4
- module implements:
5
-
6
- β€’ configure_optimizers β€” returns the existing MuonAdamW (subclass of
7
- torch.optim.Optimizer) built by model.setup_optimizer. Lightning accepts
8
- this directly.
9
- β€’ training_step β€” splits (B, T+1) batches into (x, y), forwards through
10
- the model, logs loss / bpb / tps / mfu / vram. Preserves the
11
- sampled-softmax path inside PostSemClawModel (no changes there).
12
- β€’ optimizer_step β€” before each step we update LR + muon momentum + WD
13
- using the same time-progress schedule as hydra/training.py
14
- (get_lr_multiplier / get_muon_momentum / get_weight_decay). Lightning
15
- handles grad accumulation via Trainer(accumulate_grad_batches=N).
16
-
17
- The SDR SOM update and Hestia QAT snap are called at the same cadence as
18
- the legacy loop, but inline on the main thread (Lightning provides its own
19
- callbacks for async work if we need to extract them later β€” keeping it
20
- simple for now).
21
-
22
- Env vars respected:
23
- HYDRA_TIME_BUDGET β€” wall-clock budget (s) used for LR schedule
24
- and as Trainer max_time
25
- HYDRA_HESTIA_INTERVAL β€” steps between Hestia snaps (default 100)
26
- HYDRA_BATCH_SIZE β€” device batch size (for throughput calc)
27
- HYDRA_SEQ_LEN β€” sequence length (for throughput calc)
28
- """
29
- from __future__ import annotations
30
-
31
- import math
32
- import os
33
- import time
34
-
35
- import torch
36
- import lightning as L
37
-
38
- from hydra.config import (
39
- ADAM_BETAS,
40
- EMBEDDING_LR,
41
- FINAL_LR_FRAC,
42
- GPU_BF16_PEAK_FLOPS,
43
- MATRIX_LR,
44
- SCALAR_LR,
45
- UNEMBEDDING_LR,
46
- WARMUP_RATIO,
47
- WEIGHT_DECAY,
48
- PostSemClawConfig,
49
- )
50
- from hydra.model import PostSemClawModel
51
-
52
-
53
- # ---------------------------------------------------------------------------
54
- # LR / momentum / wd schedules β€” verbatim copy of hydra/training.py so the
55
- # curves match exactly. Kept here to avoid import cycles.
56
- # ---------------------------------------------------------------------------
57
-
58
-
59
- def _lr_multiplier(progress: float) -> float:
60
- if progress < WARMUP_RATIO:
61
- return progress / WARMUP_RATIO if WARMUP_RATIO > 0 else 1.0
62
- decay_progress = (progress - WARMUP_RATIO) / max(1.0 - WARMUP_RATIO, 1e-9)
63
- return FINAL_LR_FRAC + 0.5 * (1.0 - FINAL_LR_FRAC) * (
64
- 1 + math.cos(math.pi * decay_progress)
65
- )
66
-
67
-
68
- def _muon_momentum(step: int) -> float:
69
- frac = min(step / 300.0, 1.0)
70
- return (1 - frac) * 0.85 + frac * 0.95
71
-
72
-
73
- def _weight_decay(progress: float) -> float:
74
- return WEIGHT_DECAY * (1 - progress)
75
-
76
-
77
- # ---------------------------------------------------------------------------
78
-
79
-
80
- class HydraLightningModule(L.LightningModule):
81
- """Lightning wrapper. Public attrs: self.model, self.config."""
82
-
83
- def __init__(self, config: PostSemClawConfig):
84
- super().__init__()
85
- self.config = config
86
- self.model = PostSemClawModel(config)
87
- # Model weights init must be deferred to the correct device; done by
88
- # caller after construction (to match the meta-device + to_empty()
89
- # pattern used in the legacy loop).
90
-
91
- # Time-based progress tracks the legacy loop's semantics: LR cosine
92
- # is driven by wall-clock, not step count. We capture training start
93
- # in on_train_start and TIME_BUDGET from env.
94
- self.time_budget = float(
95
- int(os.environ.get("HYDRA_TIME_BUDGET", "300"))
96
- )
97
- self._train_start_time: float | None = None
98
- self._total_training_time = 0.0
99
- self._last_step_end: float | None = None
100
- self._hestia_interval = int(os.environ.get("HYDRA_HESTIA_INTERVAL", "100"))
101
- self._flops_per_token = 0
102
- self._tokens_per_step = 0
103
-
104
- # Smoothed loss for the header-line log (matches legacy format).
105
- self._ema_beta = 0.9
106
- self._smooth_loss = 0.0
107
- self._bpt_ema = 0.0
108
- self._token_bytes: torch.Tensor | None = None
109
-
110
- # ------------------------------------------------------------------
111
- # Lifecycle
112
- # ------------------------------------------------------------------
113
-
114
- def on_train_start(self) -> None:
115
- self._train_start_time = time.time()
116
- self._last_step_end = self._train_start_time
117
- self._flops_per_token = self.model.estimate_flops()
118
- # Tokens processed per optimizer step (pre-accum).
119
- B = int(os.environ.get("HYDRA_BATCH_SIZE", "1"))
120
- T = int(os.environ.get("HYDRA_SEQ_LEN", "512"))
121
- self._tokens_per_step = B * T
122
-
123
- # Build/cache token_bytes LUT (for bits-per-byte live metric).
124
- import prepare as _p
125
- self._token_bytes = _p.get_token_bytes(device=self.device)
126
-
127
- def configure_optimizers(self):
128
- optimizer = self.model.setup_optimizer(
129
- unembedding_lr=UNEMBEDDING_LR,
130
- embedding_lr=EMBEDDING_LR,
131
- scalar_lr=SCALAR_LR,
132
- adam_betas=ADAM_BETAS,
133
- matrix_lr=MATRIX_LR,
134
- weight_decay=WEIGHT_DECAY,
135
- )
136
- return optimizer
137
-
138
- # ------------------------------------------------------------------
139
- # Training step. Lightning auto-handles: autocast (via precision flag
140
- # on Trainer), backward, grad-accum, zero_grad. We only:
141
- # - split batch into (x, y)
142
- # - forward through model (autocast is established by Trainer)
143
- # - return loss (grads flow from return)
144
- # ------------------------------------------------------------------
145
-
146
- def training_step(self, batch: torch.Tensor, batch_idx: int):
147
- # DataLoader produces (B, T+1) rows; split into input/target.
148
- # Lightning's default collate already moved batch to self.device via
149
- # the accelerator callback when pin_memory=True and device != cpu.
150
- if batch.dim() != 2:
151
- raise RuntimeError(f"Expected (B, T+1) batch, got shape {tuple(batch.shape)}")
152
- x = batch[:, :-1].contiguous()
153
- y = batch[:, 1:].contiguous()
154
-
155
- loss = self.model(x, y)
156
- # Lightning applies the grad-accum divisor automatically; we just
157
- # return the raw loss. loss.detach() is stored for logging.
158
- self._log_step(loss.detach(), y)
159
- return loss
160
-
161
- # ------------------------------------------------------------------
162
- # Optimizer step hook: update LR / momentum / WD using time-progress.
163
- # Runs once per optimizer step (after all accum micro-batches).
164
- # ------------------------------------------------------------------
165
-
166
- def optimizer_step(self, epoch, batch_idx, optimizer, optimizer_closure):
167
- # Update schedules from wall-clock progress.
168
- now = time.time()
169
- if self._train_start_time is None:
170
- self._train_start_time = now
171
- self._last_step_end = now
172
- progress = min(self._total_training_time / max(self.time_budget, 1.0), 1.0)
173
-
174
- step = self.global_step
175
- lrm = _lr_multiplier(progress)
176
- mom = _muon_momentum(step)
177
- wd = _weight_decay(progress)
178
- for group in optimizer.param_groups:
179
- group["lr"] = group["initial_lr"] * lrm
180
- if group.get("kind") == "muon":
181
- group["momentum"] = mom
182
- group["weight_decay"] = wd
183
-
184
- # Grad clip (matches legacy loop). Lightning provides this via
185
- # Trainer(gradient_clip_val=1.0) but we want the exact call-site.
186
- torch.nn.utils.clip_grad_norm_(self.parameters(), max_norm=1.0)
187
-
188
- # Hyena train-cache: we must flush accumulated micro-batch grads BACK
189
- # into the filter MLP params AFTER the accum-backward closure has run
190
- # but BEFORE the optimizer actually consumes the grads. Lightning
191
- # composes these so the closure runs inside optimizer.step(). We wrap
192
- # the closure to insert our flush at the exact right moment.
193
- #
194
- # Ordering within the wrapped closure:
195
- # 1. optimizer_closure() β€” runs all micro-batch forwards + backwards.
196
- # Each Hyena micro-batch backward accumulates into _k_leaf.grad.
197
- # 2. flush_hyena_pending_grads() β€” one-shot
198
- # torch.autograd.backward(_k_graph, _k_leaf.grad) per HyenaFilter.
199
- # Now filter MLP / pos_emb / bias params have their correct grads.
200
- #
201
- # No-op when HYDRA_HYENA_TRAIN_CACHE=0 or no Hyena blocks exist.
202
- _has_flush = hasattr(self.model, "flush_hyena_pending_grads")
203
- if _has_flush:
204
- _orig_closure = optimizer_closure
205
-
206
- def _wrapped_closure():
207
- result = _orig_closure()
208
- self.model.flush_hyena_pending_grads()
209
- return result
210
-
211
- effective_closure = _wrapped_closure
212
- else:
213
- effective_closure = optimizer_closure
214
-
215
- # Run the step (this is what Lightning would have done for us).
216
- optimizer.step(closure=effective_closure)
217
- self.model.zero_grad(set_to_none=True)
218
-
219
- # Hyena filter-rfft cache invalidation. No-op if:
220
- # (a) no Hyena layers are in the model, or
221
- # (b) HYDRA_HYENA_FILTER_CACHE=0 and HYDRA_HYENA_TRAIN_CACHE=0
222
- # (the operators never populated either cache)
223
- # In either case this is a handful of Python attribute resets.
224
- if hasattr(self.model, "invalidate_hyena_caches"):
225
- self.model.invalidate_hyena_caches()
226
-
227
- # Hestia QAT snap every N steps. Temperature anneals every step.
228
- progress_now = (now - self._train_start_time) / max(self.time_budget, 1.0)
229
- self.model.hestia.anneal_temperature(progress_now)
230
- if self._hestia_interval > 0 and step % self._hestia_interval == 0:
231
- self.model.hestia.apply_to(self.model)
232
-
233
- # SDR SOM update when the model stashed an sdr in the last forward.
234
- _last_sdr = getattr(self.model, "_last_sdr", None)
235
- if _last_sdr is not None and hasattr(self.model.sdr_semantic, "maybe_som_update"):
236
- # x from the last training_step is not available here without
237
- # captured state; the legacy loop passed (x, _last_sdr). To keep
238
- # the interface clean we pass the last batch's x via a buffer.
239
- # Since _last_sdr is derived from idx, we reuse self._last_x.
240
- if getattr(self, "_last_x", None) is not None:
241
- self.model.sdr_semantic.maybe_som_update(self._last_x, _last_sdr)
242
-
243
- # Advance the wall-clock counter for LR schedule (matches legacy
244
- # behavior which incremented only after the first warm-up step).
245
- dt = now - (self._last_step_end or now)
246
- self._last_step_end = now
247
- if step > 10:
248
- self._total_training_time += dt
249
-
250
- # ------------------------------------------------------------------
251
- # Logging β€” mirrors the step=NNNNN line format of the legacy loop so
252
- # grep/tee pipelines keep working.
253
- # ------------------------------------------------------------------
254
-
255
- def _log_step(self, loss: torch.Tensor, y: torch.Tensor) -> None:
256
- # Stash the current x so optimizer_step can drive SOM update.
257
- self._last_x = None # reset; we will set it below.
258
- # We don't have x here (already discarded); emit a None marker that
259
- # the SOM hook will silently skip if absent.
260
-
261
- loss_f = float(loss.item())
262
- if not math.isfinite(loss_f) or loss_f > 100:
263
- # Let Lightning raise / the trainer callbacks handle this.
264
- self.log("train_loss_nan", 1.0)
265
- return
266
-
267
- step = self.global_step
268
- self._smooth_loss = (
269
- self._ema_beta * self._smooth_loss + (1 - self._ema_beta) * loss_f
270
- )
271
- debiased = self._smooth_loss / max(1 - self._ema_beta ** (step + 1), 1e-9)
272
- dt = max(time.time() - (self._last_step_end or time.time()), 1e-6)
273
- tps = int(self._tokens_per_step / dt) if dt > 0 else 0
274
- mfu = (
275
- 100.0
276
- * self._flops_per_token
277
- * self._tokens_per_step
278
- / dt
279
- / GPU_BF16_PEAK_FLOPS
280
- if dt > 0
281
- else 0.0
282
- )
283
-
284
- # bpb live: y flat -> token_bytes LUT -> avg bytes/token
285
- bpt = debiased / math.log(2)
286
- if self._token_bytes is not None:
287
- with torch.no_grad():
288
- y_flat = y.reshape(-1)
289
- nbytes = self._token_bytes[y_flat]
290
- mask = nbytes > 0
291
- denom = mask.sum().clamp(min=1).float()
292
- avg_bpt = (nbytes.float() * mask.float()).sum() / denom
293
- bpt_batch = float(avg_bpt.item())
294
- if step == 0 or self._bpt_ema <= 0.0:
295
- self._bpt_ema = bpt_batch
296
- else:
297
- self._bpt_ema = 0.98 * self._bpt_ema + 0.02 * bpt_batch
298
- bpb = bpt / max(self._bpt_ema, 1e-6)
299
- vram = (
300
- torch.cuda.memory_allocated() / 1024 / 1024
301
- if torch.cuda.is_available()
302
- else 0.0
303
- )
304
-
305
- self.log_dict(
306
- {
307
- "train/loss": debiased,
308
- "train/bpb": bpb,
309
- "train/bpt": bpt,
310
- "train/tps": float(tps),
311
- "train/mfu": float(mfu),
312
- "train/vram_mib": float(vram),
313
- },
314
- prog_bar=False,
315
- on_step=True,
316
- on_epoch=False,
317
- )
318
-
319
- # Match legacy one-line format: "step=NNNNN loss=x bpb=y tps=z ..."
320
- print(
321
- f"step={step:05d} loss={debiased:.4f} bpb={bpb:.4f} "
322
- f"bpt={bpt:.3f} bpt_div={self._bpt_ema:.2f} "
323
- f"tps={tps} dt_ms={dt*1000:.0f} mfu={mfu:.1f} "
324
- f"vram={vram:.0f}MiB",
325
- flush=True,
326
- )
 
1
+ """LightningModule wrapping PostSemClawModel.
2
+
3
+ Thin adapter. The model and the MuonAdamW optimizer are unchanged. This
4
+ module implements:
5
+
6
+ β€’ configure_optimizers β€” returns the existing MuonAdamW (subclass of
7
+ torch.optim.Optimizer) built by model.setup_optimizer. Lightning accepts
8
+ this directly.
9
+ β€’ training_step β€” splits (B, T+1) batches into (x, y), forwards through
10
+ the model, logs loss / bpb / tps / mfu / vram. Preserves the
11
+ sampled-softmax path inside PostSemClawModel (no changes there).
12
+ β€’ optimizer_step β€” before each step we update LR + muon momentum + WD
13
+ using the same time-progress schedule as hydra/training.py
14
+ (get_lr_multiplier / get_muon_momentum / get_weight_decay). Lightning
15
+ handles grad accumulation via Trainer(accumulate_grad_batches=N).
16
+
17
+ The SDR SOM update and Hestia QAT snap are called at the same cadence as
18
+ the legacy loop, but inline on the main thread (Lightning provides its own
19
+ callbacks for async work if we need to extract them later β€” keeping it
20
+ simple for now).
21
+
22
+ Env vars respected:
23
+ HYDRA_TIME_BUDGET β€” wall-clock budget (s) used for LR schedule
24
+ and as Trainer max_time
25
+ HYDRA_HESTIA_INTERVAL β€” steps between Hestia snaps (default 100)
26
+ HYDRA_BATCH_SIZE β€” device batch size (for throughput calc)
27
+ HYDRA_SEQ_LEN β€” sequence length (for throughput calc)
28
+ """
29
+ from __future__ import annotations
30
+
31
+ import math
32
+ import os
33
+ import time
34
+
35
+ import torch
36
+ import lightning as L
37
+
38
+ from hydra.config import (
39
+ ADAM_BETAS,
40
+ EMBEDDING_LR,
41
+ FINAL_LR_FRAC,
42
+ GPU_BF16_PEAK_FLOPS,
43
+ MATRIX_LR,
44
+ SCALAR_LR,
45
+ UNEMBEDDING_LR,
46
+ WARMUP_RATIO,
47
+ WEIGHT_DECAY,
48
+ PostSemClawConfig,
49
+ )
50
+ from hydra.model import PostSemClawModel
51
+
52
+
53
+ # ---------------------------------------------------------------------------
54
+ # LR / momentum / wd schedules β€” verbatim copy of hydra/training.py so the
55
+ # curves match exactly. Kept here to avoid import cycles.
56
+ # ---------------------------------------------------------------------------
57
+
58
+
59
+ def _lr_multiplier(progress: float) -> float:
60
+ if progress < WARMUP_RATIO:
61
+ return progress / WARMUP_RATIO if WARMUP_RATIO > 0 else 1.0
62
+ decay_progress = (progress - WARMUP_RATIO) / max(1.0 - WARMUP_RATIO, 1e-9)
63
+ return FINAL_LR_FRAC + 0.5 * (1.0 - FINAL_LR_FRAC) * (
64
+ 1 + math.cos(math.pi * decay_progress)
65
+ )
66
+
67
+
68
+ def _muon_momentum(step: int) -> float:
69
+ frac = min(step / 300.0, 1.0)
70
+ return (1 - frac) * 0.85 + frac * 0.95
71
+
72
+
73
+ def _weight_decay(progress: float) -> float:
74
+ return WEIGHT_DECAY * (1 - progress)
75
+
76
+
77
+ # ---------------------------------------------------------------------------
78
+
79
+
80
+ class HydraLightningModule(L.LightningModule):
81
+ """Lightning wrapper. Public attrs: self.model, self.config."""
82
+
83
+ def __init__(self, config: PostSemClawConfig):
84
+ super().__init__()
85
+ self.config = config
86
+ self.model = PostSemClawModel(config)
87
+ # Model weights init must be deferred to the correct device; done by
88
+ # caller after construction (to match the meta-device + to_empty()
89
+ # pattern used in the legacy loop).
90
+
91
+ # Time-based progress tracks the legacy loop's semantics: LR cosine
92
+ # is driven by wall-clock, not step count. We capture training start
93
+ # in on_train_start and TIME_BUDGET from env.
94
+ self.time_budget = float(
95
+ int(os.environ.get("HYDRA_TIME_BUDGET", "300"))
96
+ )
97
+ self._train_start_time: float | None = None
98
+ self._total_training_time = 0.0
99
+ self._last_step_end: float | None = None
100
+ self._hestia_interval = int(os.environ.get("HYDRA_HESTIA_INTERVAL", "100"))
101
+ self._flops_per_token = 0
102
+ self._tokens_per_step = 0
103
+
104
+ # Smoothed loss for the header-line log (matches legacy format).
105
+ self._ema_beta = 0.9
106
+ self._smooth_loss = 0.0
107
+ self._bpt_ema = 0.0
108
+ self._token_bytes: torch.Tensor | None = None
109
+
110
+ # ------------------------------------------------------------------
111
+ # Lifecycle
112
+ # ------------------------------------------------------------------
113
+
114
+ def on_train_start(self) -> None:
115
+ self._train_start_time = time.time()
116
+ self._last_step_end = self._train_start_time
117
+ self._flops_per_token = self.model.estimate_flops()
118
+ # Tokens processed per optimizer step (pre-accum).
119
+ B = int(os.environ.get("HYDRA_BATCH_SIZE", "1"))
120
+ T = int(os.environ.get("HYDRA_SEQ_LEN", "512"))
121
+ self._tokens_per_step = B * T
122
+
123
+ # Build/cache token_bytes LUT (for bits-per-byte live metric).
124
+ import prepare as _p
125
+ self._token_bytes = _p.get_token_bytes(device=self.device)
126
+
127
+ def configure_optimizers(self):
128
+ optimizer = self.model.setup_optimizer(
129
+ unembedding_lr=UNEMBEDDING_LR,
130
+ embedding_lr=EMBEDDING_LR,
131
+ scalar_lr=SCALAR_LR,
132
+ adam_betas=ADAM_BETAS,
133
+ matrix_lr=MATRIX_LR,
134
+ weight_decay=WEIGHT_DECAY,
135
+ )
136
+ return optimizer
137
+
138
+ # ------------------------------------------------------------------
139
+ # Training step. Lightning auto-handles: autocast (via precision flag
140
+ # on Trainer), backward, grad-accum, zero_grad. We only:
141
+ # - split batch into (x, y)
142
+ # - forward through model (autocast is established by Trainer)
143
+ # - return loss (grads flow from return)
144
+ # ------------------------------------------------------------------
145
+
146
+ def training_step(self, batch: torch.Tensor, batch_idx: int):
147
+ # DataLoader produces (B, T+1) rows; split into input/target.
148
+ # Lightning's default collate already moved batch to self.device via
149
+ # the accelerator callback when pin_memory=True and device != cpu.
150
+ if batch.dim() != 2:
151
+ raise RuntimeError(f"Expected (B, T+1) batch, got shape {tuple(batch.shape)}")
152
+ x = batch[:, :-1].contiguous()
153
+ y = batch[:, 1:].contiguous()
154
+
155
+ loss = self.model(x, y)
156
+ # Lightning applies the grad-accum divisor automatically; we just
157
+ # return the raw loss. loss.detach() is stored for logging.
158
+ self._log_step(loss.detach(), y)
159
+ return loss
160
+
161
+ # ------------------------------------------------------------------
162
+ # Optimizer step hook: update LR / momentum / WD using time-progress.
163
+ # Runs once per optimizer step (after all accum micro-batches).
164
+ # ------------------------------------------------------------------
165
+
166
+ def optimizer_step(self, epoch, batch_idx, optimizer, optimizer_closure):
167
+ # Update schedules from wall-clock progress.
168
+ now = time.time()
169
+ if self._train_start_time is None:
170
+ self._train_start_time = now
171
+ self._last_step_end = now
172
+ progress = min(self._total_training_time / max(self.time_budget, 1.0), 1.0)
173
+
174
+ step = self.global_step
175
+ lrm = _lr_multiplier(progress)
176
+ mom = _muon_momentum(step)
177
+ wd = _weight_decay(progress)
178
+ for group in optimizer.param_groups:
179
+ group["lr"] = group["initial_lr"] * lrm
180
+ if group.get("kind") == "muon":
181
+ group["momentum"] = mom
182
+ group["weight_decay"] = wd
183
+
184
+ # Grad clip (matches legacy loop). Lightning provides this via
185
+ # Trainer(gradient_clip_val=1.0) but we want the exact call-site.
186
+ torch.nn.utils.clip_grad_norm_(self.parameters(), max_norm=1.0)
187
+
188
+ # Hyena train-cache: we must flush accumulated micro-batch grads BACK
189
+ # into the filter MLP params AFTER the accum-backward closure has run
190
+ # but BEFORE the optimizer actually consumes the grads. Lightning
191
+ # composes these so the closure runs inside optimizer.step(). We wrap
192
+ # the closure to insert our flush at the exact right moment.
193
+ #
194
+ # Ordering within the wrapped closure:
195
+ # 1. optimizer_closure() β€” runs all micro-batch forwards + backwards.
196
+ # Each Hyena micro-batch backward accumulates into _k_leaf.grad.
197
+ # 2. flush_hyena_pending_grads() β€” one-shot
198
+ # torch.autograd.backward(_k_graph, _k_leaf.grad) per HyenaFilter.
199
+ # Now filter MLP / pos_emb / bias params have their correct grads.
200
+ #
201
+ # No-op when HYDRA_HYENA_TRAIN_CACHE=0 or no Hyena blocks exist.
202
+ _has_flush = hasattr(self.model, "flush_hyena_pending_grads")
203
+ if _has_flush:
204
+ _orig_closure = optimizer_closure
205
+
206
+ def _wrapped_closure():
207
+ result = _orig_closure()
208
+ self.model.flush_hyena_pending_grads()
209
+ return result
210
+
211
+ effective_closure = _wrapped_closure
212
+ else:
213
+ effective_closure = optimizer_closure
214
+
215
+ # Run the step (this is what Lightning would have done for us).
216
+ optimizer.step(closure=effective_closure)
217
+ self.model.zero_grad(set_to_none=True)
218
+
219
+ # Hyena filter-rfft cache invalidation. No-op if:
220
+ # (a) no Hyena layers are in the model, or
221
+ # (b) HYDRA_HYENA_FILTER_CACHE=0 and HYDRA_HYENA_TRAIN_CACHE=0
222
+ # (the operators never populated either cache)
223
+ # In either case this is a handful of Python attribute resets.
224
+ if hasattr(self.model, "invalidate_hyena_caches"):
225
+ self.model.invalidate_hyena_caches()
226
+
227
+ # Hestia QAT snap every N steps. Temperature anneals every step.
228
+ progress_now = (now - self._train_start_time) / max(self.time_budget, 1.0)
229
+ self.model.hestia.anneal_temperature(progress_now)
230
+ if self._hestia_interval > 0 and step % self._hestia_interval == 0:
231
+ self.model.hestia.apply_to(self.model)
232
+
233
+ # SDR SOM update when the model stashed an sdr in the last forward.
234
+ _last_sdr = getattr(self.model, "_last_sdr", None)
235
+ if _last_sdr is not None and hasattr(self.model.sdr_semantic, "maybe_som_update"):
236
+ # x from the last training_step is not available here without
237
+ # captured state; the legacy loop passed (x, _last_sdr). To keep
238
+ # the interface clean we pass the last batch's x via a buffer.
239
+ # Since _last_sdr is derived from idx, we reuse self._last_x.
240
+ if getattr(self, "_last_x", None) is not None:
241
+ self.model.sdr_semantic.maybe_som_update(self._last_x, _last_sdr)
242
+
243
+ # Advance the wall-clock counter for LR schedule (matches legacy
244
+ # behavior which incremented only after the first warm-up step).
245
+ dt = now - (self._last_step_end or now)
246
+ self._last_step_end = now
247
+ if step > 10:
248
+ self._total_training_time += dt
249
+
250
+ # ------------------------------------------------------------------
251
+ # Logging β€” mirrors the step=NNNNN line format of the legacy loop so
252
+ # grep/tee pipelines keep working.
253
+ # ------------------------------------------------------------------
254
+
255
+ def _log_step(self, loss: torch.Tensor, y: torch.Tensor) -> None:
256
+ # Stash the current x so optimizer_step can drive SOM update.
257
+ self._last_x = None # reset; we will set it below.
258
+ # We don't have x here (already discarded); emit a None marker that
259
+ # the SOM hook will silently skip if absent.
260
+
261
+ loss_f = float(loss.item())
262
+ if not math.isfinite(loss_f) or loss_f > 100:
263
+ # Let Lightning raise / the trainer callbacks handle this.
264
+ self.log("train_loss_nan", 1.0)
265
+ return
266
+
267
+ step = self.global_step
268
+ self._smooth_loss = (
269
+ self._ema_beta * self._smooth_loss + (1 - self._ema_beta) * loss_f
270
+ )
271
+ debiased = self._smooth_loss / max(1 - self._ema_beta ** (step + 1), 1e-9)
272
+ dt = max(time.time() - (self._last_step_end or time.time()), 1e-6)
273
+ tps = int(self._tokens_per_step / dt) if dt > 0 else 0
274
+ mfu = (
275
+ 100.0
276
+ * self._flops_per_token
277
+ * self._tokens_per_step
278
+ / dt
279
+ / GPU_BF16_PEAK_FLOPS
280
+ if dt > 0
281
+ else 0.0
282
+ )
283
+
284
+ # bpb live: y flat -> token_bytes LUT -> avg bytes/token
285
+ bpt = debiased / math.log(2)
286
+ if self._token_bytes is not None:
287
+ with torch.no_grad():
288
+ y_flat = y.reshape(-1)
289
+ nbytes = self._token_bytes[y_flat]
290
+ mask = nbytes > 0
291
+ denom = mask.sum().clamp(min=1).float()
292
+ avg_bpt = (nbytes.float() * mask.float()).sum() / denom
293
+ bpt_batch = float(avg_bpt.item())
294
+ if step == 0 or self._bpt_ema <= 0.0:
295
+ self._bpt_ema = bpt_batch
296
+ else:
297
+ self._bpt_ema = 0.98 * self._bpt_ema + 0.02 * bpt_batch
298
+ bpb = bpt / max(self._bpt_ema, 1e-6)
299
+ vram = (
300
+ torch.cuda.memory_allocated() / 1024 / 1024
301
+ if torch.cuda.is_available()
302
+ else 0.0
303
+ )
304
+
305
+ self.log_dict(
306
+ {
307
+ "train/loss": debiased,
308
+ "train/bpb": bpb,
309
+ "train/bpt": bpt,
310
+ "train/tps": float(tps),
311
+ "train/mfu": float(mfu),
312
+ "train/vram_mib": float(vram),
313
+ },
314
+ prog_bar=False,
315
+ on_step=True,
316
+ on_epoch=False,
317
+ )
318
+
319
+ # Match legacy one-line format: "step=NNNNN loss=x bpb=y tps=z ..."
320
+ print(
321
+ f"step={step:05d} loss={debiased:.4f} bpb={bpb:.4f} "
322
+ f"bpt={bpt:.3f} bpt_div={self._bpt_ema:.2f} "
323
+ f"tps={tps} dt_ms={dt*1000:.0f} mfu={mfu:.1f} "
324
+ f"vram={vram:.0f}MiB",
325
+ flush=True,
326
+ )
overlay/hydra/model.py CHANGED
@@ -1,894 +1,894 @@
1
- """PostSemClawModel β€” full-architecture model assembly.
2
-
3
- Extracted from the monolithic train.py (W1 modularization). Semantics
4
- unchanged. Imports `GPUEngram` from `hydra.engram` and `MuonAdamW` from
5
- `hydra.optimizer`.
6
-
7
- Triton kernel integration status (Phase 2):
8
- HYDRA_FUSED_BCNORM β€” DEFERRED. The bcnorm_fused Triton kernel fuses
9
- LayerNorm + RoPE on B/C projections. However, mamba-ssm's Mamba3 block
10
- uses RMSNormGated (not LayerNorm) for B/C, and RoPE is applied inside
11
- the mamba3_siso_combined CUDA kernel via the Angles parameter. Replacing
12
- would require either (a) monkey-patching RMSNormGated + intercepting the
13
- fused CUDA scan β€” invasive, 50+ lines, high breakage risk β€” or (b) a
14
- full custom Mamba3Block reimplementation. Both are out of scope for
15
- Phase 2. The kernel is validated standalone; integration deferred to
16
- Phase 3 when HYDRA moves to a custom SSM block.
17
-
18
- HYDRA_FUSED_SSD β€” DEFERRED. The ssd_exp_trap Triton kernel implements
19
- exponential-trapezoidal discretization as a sequential scan. mamba-ssm's
20
- Mamba3 block delegates the entire scan + gating + output projection to
21
- mamba3_siso_combined (a compiled CUDA kernel with tilelang). Replacing
22
- it would require decomposing the combined kernel into constituent ops
23
- and substituting only the scan β€” not feasible without a custom block.
24
- Same Phase 3 gate as above.
25
-
26
- Both env vars are accepted but currently no-ops (gates read, logged, but
27
- the code path is unchanged). This avoids silent regression if someone
28
- sets them expecting a speedup.
29
- """
30
-
31
- from __future__ import annotations
32
-
33
- import os
34
-
35
- import torch
36
- import torch.nn as nn
37
- import torch.nn.functional as F
38
-
39
- from mamba_ssm import Mamba3
40
-
41
- from subsystems.hestia_mini import HestiaQAT
42
- from subsystems.htm import HTMLayer
43
- from subsystems.mhc_mini import ManifoldHyperConnection
44
- from subsystems.sdr_semantic import SemanticFoldingSDR
45
-
46
- from hydra.engram import GPUEngram
47
- from hydra.hyena_block import HyenaBlock
48
- # GDNBlock is imported lazily inside __init__ so the `fla` dependency is
49
- # only required when HYDRA_GDN_LAYERS is actually non-empty. Baseline
50
- # pure-Mamba3 runs continue to work without flash-linear-attention installed.
51
- from hydra.optimizer import MuonAdamW
52
-
53
-
54
- def norm(x: torch.Tensor) -> torch.Tensor:
55
- """RMSNorm over the last dim β€” stateless, autocast-friendly."""
56
- return F.rms_norm(x, (x.size(-1),))
57
-
58
-
59
- class PostSemClawModel(nn.Module):
60
- """Full Post-SEM-Claw model assembly.
61
-
62
- Architecture:
63
- Token Embedding -> [Mamba3 + residual] x n_layer
64
- -> SDR + Engram (at configured layer) -> norm -> LM head
65
-
66
- Interface (must match prepare.py evaluate_bpb):
67
- model(x, y, reduction='none').view(-1) -> per-token losses
68
- model(x, y, reduction='mean') -> scalar loss
69
- """
70
-
71
- def __init__(self, config):
72
- super().__init__()
73
- self.config = config
74
-
75
- # Token embedding
76
- self.wte = nn.Embedding(config.vocab_size, config.d_model)
77
-
78
- # Mamba-3 blocks β€” official mamba-ssm fused CUDA kernel. No fallbacks.
79
- # RoPE is applied internally by the Mamba3 CUDA kernel via the Angles
80
- # parameter; external cos/sin buffers are not needed.
81
- #
82
- # Hyena supplement: layers whose index appears in `config.hyena_layers`
83
- # are instantiated as HyenaBlock instead of Mamba3. The config field
84
- # is populated from HYDRA_HYENA_LAYERS at construction time and then
85
- # persisted to checkpoints, so resume is safe even when the env var
86
- # is unset. Empty tuple β†’ all-Mamba3, byte-identical to pre-port.
87
- _hyena_layer_set = set(getattr(config, "hyena_layers", ()) or ())
88
- _gdn_layer_set = set(getattr(config, "gdn_layers", ()) or ())
89
- # Hyena wins on overlap; conflict is logged at construction time.
90
- _both = _hyena_layer_set & _gdn_layer_set
91
- if _both:
92
- print(f"[WARN] layers in both hyena_layers and gdn_layers; using Hyena: {sorted(_both)}", flush=True)
93
- _gdn_layer_set -= _hyena_layer_set
94
-
95
- if _gdn_layer_set:
96
- from hydra.gdn_block import GDNBlock # requires `fla` package
97
-
98
- def _build_block(i: int) -> nn.Module:
99
- if i in _hyena_layer_set:
100
- return HyenaBlock(
101
- d_model=config.d_model,
102
- seq_len=config.sequence_len,
103
- order=int(os.environ.get("HYDRA_HYENA_ORDER", "2")),
104
- filter_order=int(os.environ.get("HYDRA_HYENA_FILTER_DIM", "64")),
105
- )
106
- if i in _gdn_layer_set:
107
- return GDNBlock(
108
- d_model=config.d_model,
109
- n_heads=config.n_heads,
110
- )
111
- return Mamba3(
112
- d_model=config.d_model,
113
- d_state=config.d_state,
114
- expand=config.expand,
115
- headdim=config.headdim,
116
- is_mimo=False, # SISO path uses stable mamba3_siso_combined kernel
117
- chunk_size=64, # upstream-recommended SISO chunk; 16 violated tl.dot M>=16 constraint
118
- is_outproj_norm=False,
119
- dtype=torch.bfloat16,
120
- )
121
-
122
- self.blocks = nn.ModuleList([_build_block(i) for i in range(config.n_layer)])
123
-
124
- # Full-architecture SDR: offline semantic retina + STE (no-bypass).
125
- self.sdr_semantic = SemanticFoldingSDR(
126
- vocab_size=config.vocab_size,
127
- n_bits=config.sdr_n_bits,
128
- target_active=config.sdr_target_active,
129
- delta_rank=config.sdr_delta_rank,
130
- som_warmup_steps=config.sdr_som_warmup,
131
- som_update_interval=config.sdr_som_interval,
132
- )
133
-
134
- # HTM spatial pooler + temporal memory (Rust, Hebbian).
135
- self.htm = HTMLayer(
136
- input_bits=config.sdr_n_bits,
137
- n_columns=config.htm_n_columns,
138
- cells_per_column=config.htm_cells_per_column,
139
- batch_size=1, # grows lazily to actual B on first forward
140
- seed=42,
141
- learn=True,
142
- reset_each_forward=True,
143
- )
144
-
145
- # Gradient bridge: (n_columns + anomaly) -> d_model.
146
- self.htm_proj = nn.Linear(config.htm_n_columns + 1, config.d_model, bias=False)
147
-
148
- # GPU Engram with Hebbian writes β€” runs EVERY step.
149
- self.engram = GPUEngram(
150
- d_model=config.d_model,
151
- n_columns=config.engram_n_columns,
152
- max_ngram=3,
153
- )
154
- self.engram_layer_idx = config.engram_layer_idx
155
-
156
- # Manifold-Constrained Hyper-Connections (one per Mamba-3 block).
157
- self.mhc = nn.ModuleList([
158
- ManifoldHyperConnection(d_model=config.d_model, n_streams=2, sinkhorn_iters=3)
159
- for _ in range(config.n_layer)
160
- ])
161
-
162
- # Hestia QAT β€” ternary weight quantization applied post-optimizer-step.
163
- self.hestia = HestiaQAT(enabled=True, bits=1.58)
164
-
165
- # LM head
166
- self.lm_head = nn.Linear(config.d_model, config.vocab_size, bias=False)
167
-
168
- # Learnability knob 1: Multi-Token Prediction (Llama-3 style).
169
- # MTP_K=1 -> standard next-token. MTP_K>1 -> extra heads predict
170
- # tokens at positions t+1, t+2, ..., t+K. Heads are weight-tied to
171
- # lm_head (we share Parameters), so the only extra compute is
172
- # additional CE losses; no new params. Activated via HYDRA_MTP_K.
173
- self._mtp_k = max(1, int(os.environ.get("HYDRA_MTP_K", "1")))
174
-
175
- # Learnability knob 3: gradient checkpointing on Mamba3 blocks.
176
- self._grad_ckpt = os.environ.get("HYDRA_GRAD_CKPT", "0") == "1"
177
-
178
- # Learnability knob 4: doc-separator BOS masking in packed sequences.
179
- self._doc_sep_mask = os.environ.get("HYDRA_DOC_SEP_MASK", "0") == "1"
180
- # BOS token id is looked up lazily on first forward (requires tokenizer
181
- # load); -1 means uninitialized.
182
- self._bos_token_id = -1
183
-
184
- # Learnability knob 5: explicit stop-grad on HTM tensor (htm_rust
185
- # outputs already have requires_grad=False; this is defense-in-depth).
186
- self._htm_stop_grad = os.environ.get("HYDRA_HTM_STOP_GRAD", "0") == "1"
187
-
188
- # Learnability knob 6: entropy penalty coefficient on LM logits.
189
- self._entropy_penalty = float(os.environ.get("HYDRA_ENTROPY_PENALTY", "0.0"))
190
-
191
- # Residual dropout
192
- self.drop = nn.Dropout(float(os.environ.get("HYDRA_DROPOUT", "0.2")))
193
-
194
- # Logits soft-capping
195
- self.softcap = 15.0
196
-
197
- # Secondary metrics storage
198
- self._metrics = {}
199
-
200
- # Per-layer diagnostic panel. Env-gated; zero overhead when off.
201
- # Emits residual-contribution (delta_ratio), feature std, effective rank,
202
- # gradient norm per layer; used to identify minimum viable n_layer + find
203
- # entropy leakage / dead layers. See docs/depth-sweep.md.
204
- self._diag_enabled = os.environ.get("HYDRA_LAYER_DIAGNOSTICS", "0") == "1"
205
- self._diag_step = 0
206
- self._diag_svd_every = int(os.environ.get("HYDRA_LAYER_DIAG_SVD_EVERY", "100"))
207
- if self._diag_enabled:
208
- # Gradient-norm backward hooks on each Mamba3 block output.
209
- for _i, _block in enumerate(self.blocks):
210
- def _mk_grad_hook(_layer_idx):
211
- def _hook(module, grad_input, grad_output):
212
- if grad_output and grad_output[0] is not None:
213
- g = grad_output[0].detach()
214
- self._metrics[f'layer_{_layer_idx}_grad_norm'] = float(
215
- g.pow(2).mean().sqrt().item()
216
- )
217
- return _hook
218
- _block.register_full_backward_hook(_mk_grad_hook(_i))
219
-
220
- # Forward hooks on each Mamba3 block capture the block's OUTPUT
221
- # directly. This is the clean measurement: unlike merge_streams()
222
- # sampling which sees (streams + M*block_output) in bf16 β€” where
223
- # small block contributions round to zero against unit-norm
224
- # residuals β€” this captures `block_output` itself as produced.
225
- # Reports both its absolute RMS norm and its ratio to the block
226
- # INPUT's RMS norm (contribution magnitude relative to the
227
- # residual it's added to).
228
- for _i, _block in enumerate(self.blocks):
229
- def _mk_fwd_hook(_layer_idx):
230
- def _hook(module, inputs, output):
231
- with torch.no_grad():
232
- inp = inputs[0].detach().float() if inputs else None
233
- out = output.detach().float() if isinstance(output, torch.Tensor) else None
234
- if out is not None:
235
- out_rms = out.pow(2).mean().sqrt().item()
236
- self._metrics[f'layer_{_layer_idx}_block_out_rms'] = float(out_rms)
237
- if inp is not None:
238
- in_rms = inp.pow(2).mean().sqrt().item()
239
- self._metrics[f'layer_{_layer_idx}_block_in_rms'] = float(in_rms)
240
- self._metrics[f'layer_{_layer_idx}_contrib_ratio'] = float(
241
- out_rms / (in_rms + 1e-8)
242
- )
243
- return _hook
244
- _block.register_forward_hook(_mk_fwd_hook(_i))
245
-
246
- # Triton kernel integration gates (Phase 2 β€” deferred, see module docstring).
247
- self._fused_bcnorm = os.environ.get("HYDRA_FUSED_BCNORM", "0") == "1"
248
- self._fused_ssd = os.environ.get("HYDRA_FUSED_SSD", "0") == "1"
249
- if self._fused_bcnorm or self._fused_ssd:
250
- import sys
251
- _active = []
252
- if self._fused_bcnorm:
253
- _active.append("HYDRA_FUSED_BCNORM")
254
- if self._fused_ssd:
255
- _active.append("HYDRA_FUSED_SSD")
256
- print(
257
- f"[HYDRA] Triton kernel gates set: {', '.join(_active)}. "
258
- f"NOTE: Both are DEFERRED (mamba-ssm Mamba3 uses internal "
259
- f"CUDA kernels). Gates accepted but currently no-ops.",
260
- file=sys.stderr,
261
- )
262
-
263
- # R6 optional torch.compile on the impl forward. Gated (default OFF).
264
- if os.environ.get("HYDRA_MODEL_COMPILE", "0") == "1":
265
- self._forward_impl = torch.compile(
266
- self._forward_impl,
267
- fullgraph=False,
268
- dynamic=True,
269
- mode="default",
270
- )
271
-
272
- @torch.no_grad()
273
- def init_weights(self) -> None:
274
- s = 3 ** 0.5 * self.config.d_model ** -0.5
275
-
276
- # Move SDR retina indices (plain attribute, not buffer) to same device as params.
277
- # Required because to_empty() only moves params/buffers, and _retina_indices
278
- # is loaded from numpy (always CPU) by SemanticFoldingSDR.__init__.
279
- device = self.wte.weight.device
280
- if hasattr(self.sdr_semantic, '_retina_indices'):
281
- self.sdr_semantic._retina_indices = self.sdr_semantic._retina_indices.to(device)
282
-
283
- # Embedding init: GPT-2 / LLaMA convention. std=1.0 was chosen for
284
- # vocab=8192; at larger vocabs, smaller std prevents logit blowup.
285
- # Use std = 1/sqrt(d_model) which scales sensibly with model width.
286
- import math as _math
287
- _d_model = self.wte.weight.shape[1]
288
- wte_std = float(os.environ.get("HYDRA_WTE_STD", str(1.0 / _math.sqrt(_d_model))))
289
- nn.init.normal_(self.wte.weight, mean=0.0, std=wte_std)
290
- # LM head init: was std=0.001 β€” PATHOLOGICAL at vocab>=32k because
291
- # logits collapse to zero, loss locks at log(V)~=11, gradient through
292
- # head ∝ 1/V is too small to escape. GPT-2 uses std=0.02; LLaMA uses
293
- # std=1/sqrt(d_model). Pick 0.02 as robust default, env-overridable.
294
- lm_head_std = float(os.environ.get("HYDRA_LM_HEAD_STD", "0.02"))
295
- nn.init.normal_(self.lm_head.weight, mean=0.0, std=lm_head_std)
296
- # F8 (NOT APPLIED): Weight tying would save V*D params but current LR
297
- # groups have embedding_lr=1.0 and unembedding_lr=0.005 Γ— d_model_scale
298
- # β€” tying forces the shared tensor under a single LR group and either
299
- # the embeddings learn 200x too slow (under unembed LR) or the LM head
300
- # becomes unstable (under embed LR). Short 15-step smoke with tying +
301
- # embed-group update showed initial loss jump 9 -> 20. Deferred until
302
- # LR groups are re-tuned; see docs/OPTIMIZATION_PLAN.md Post-plan.
303
-
304
- for li, block in enumerate(self.blocks):
305
- if hasattr(block, 'in_proj') and hasattr(block.in_proj, 'weight'):
306
- nn.init.uniform_(block.in_proj.weight, -s, s)
307
- if hasattr(block, 'out_proj') and hasattr(block.out_proj, 'weight'):
308
- # GPT-2 residual init: std = 0.02 / sqrt(2 * n_layer).
309
- # NOT zeros β€” zero init makes the block a permanent pass-through
310
- # (block_out_rms=0, zero gradient flow to SSM internals).
311
- # With non-zero init the block contributes to the residual stream
312
- # from step 1, giving the SSM scan actual gradient signal.
313
- n_layer = self.config.n_layer
314
- out_std = float(os.environ.get(
315
- "HYDRA_OUT_PROJ_STD",
316
- str(0.02 / (2 * n_layer) ** 0.5),
317
- ))
318
- nn.init.normal_(block.out_proj.weight, mean=0.0, std=out_std)
319
-
320
- nn.init.normal_(self.htm_proj.weight, mean=0.0, std=s)
321
-
322
- # Cast to bf16 to match Mamba3 dtype; Muon groups by shape so mixed
323
- # dtypes in the same shape group would break lerp_ dtype checks.
324
- self.wte.to(dtype=torch.bfloat16)
325
- self.htm_proj.to(dtype=torch.bfloat16)
326
- self.engram.to(dtype=torch.bfloat16)
327
-
328
- def set_bos_token_id(self, bos_id: int) -> None:
329
- """Inform the model of the tokenizer's BOS id so doc-separator
330
- masking (learnability #4) knows which positions to skip. Called from
331
- training setup once the tokenizer is loaded."""
332
- self._bos_token_id = int(bos_id)
333
-
334
- def invalidate_hyena_caches(self) -> None:
335
- """Invalidate filter-rfft caches on all Hyena blocks.
336
-
337
- MUST be called after each `optimizer.step()` when
338
- `HYDRA_HYENA_FILTER_CACHE=1` is set, otherwise cached rfft values
339
- will be reused with stale filter parameters.
340
-
341
- No-op for blocks that are not HyenaBlock (Mamba3, etc.).
342
- """
343
- for block in self.blocks:
344
- if hasattr(block, "operator") and hasattr(block.operator, "invalidate_filter_cache"):
345
- block.operator.invalidate_filter_cache()
346
-
347
- def flush_hyena_pending_grads(self) -> None:
348
- """Push pending train-cache filter gradients into filter params.
349
-
350
- Used ONLY when HYDRA_HYENA_TRAIN_CACHE=1. Must be called exactly once
351
- per optimizer step, BEFORE `optimizer.step()` and BEFORE
352
- `invalidate_hyena_caches()`. The lightning_module wires this in
353
- `optimizer_step` around the existing optimizer.step() call.
354
-
355
- No-op if:
356
- * No HyenaBlocks are in the model, OR
357
- * No micro-batch ever ran with grad enabled (e.g. all-eval step).
358
- """
359
- for block in self.blocks:
360
- if hasattr(block, "operator") and hasattr(block.operator, "flush_pending_filter_grads"):
361
- block.operator.flush_pending_filter_grads()
362
-
363
- def estimate_flops(self) -> int:
364
- nparams = sum(p.numel() for p in self.parameters())
365
- embed_params = self.wte.weight.numel()
366
- return 6 * (nparams - embed_params)
367
-
368
- def num_scaling_params(self) -> dict:
369
- wte = sum(p.numel() for p in self.wte.parameters())
370
- lm_head = sum(p.numel() for p in self.lm_head.parameters())
371
- blocks = sum(p.numel() for p in self.blocks.parameters())
372
- sdr = sum(p.numel() for p in self.sdr_semantic.parameters())
373
- htm_proj = sum(p.numel() for p in self.htm_proj.parameters())
374
- engram = sum(p.numel() for p in self.engram.parameters())
375
- total = sum(p.numel() for p in self.parameters())
376
- return {
377
- 'wte': wte, 'lm_head': lm_head, 'blocks': blocks,
378
- 'sdr_semantic': sdr, 'htm_proj': htm_proj,
379
- 'engram': engram, 'total': total,
380
- }
381
-
382
- def get_secondary_metrics(self) -> dict:
383
- """Flush any lingering CUDA tensors to host (single sync)."""
384
- flushed = {}
385
- for k, v in self._metrics.items():
386
- if hasattr(v, 'item'):
387
- try:
388
- flushed[k] = float(v.item())
389
- except Exception:
390
- flushed[k] = v
391
- else:
392
- flushed[k] = v
393
- return flushed
394
-
395
- def setup_optimizer(self, unembedding_lr=0.004, embedding_lr=0.6, matrix_lr=0.04,
396
- weight_decay=0.2, adam_betas=(0.8, 0.95), scalar_lr=0.5):
397
- """Setup MuonAdamW optimizer with per-component LR groups."""
398
- model_dim = self.config.d_model
399
-
400
- embedding_params = list(self.wte.parameters())
401
- lm_head_params = list(self.lm_head.parameters())
402
-
403
- # Muon routing guard: 2D parameters are NOT automatically matrices.
404
- # Exclude:
405
- # (a) params whose name ends in `.freq` β€” Sin frequency vectors used
406
- # by Hyena's implicit filter MLP. Shape (1, dim) is nominally 2D
407
- # but semantically a per-dim scalar. Muon's polar-express
408
- # orthogonalization would force it toward an orthogonal matrix,
409
- # destroying the learned modulation frequencies.
410
- # (b) 2-D params with min(shape) < MUON_MIN_DIM. Tiny projections
411
- # (e.g. HyenaFilter.implicit_filter.0.weight of shape (64, 3))
412
- # get collapsed toward near-identity by orthogonalization on the
413
- # narrow axis, damaging expressivity. These belong in AdamW.
414
- # These exclusions route the params into the AdamW scalar/vector group.
415
- MUON_MIN_DIM = 8
416
-
417
- def _muon_eligible(name: str, p: torch.Tensor) -> bool:
418
- if p.dim() != 2:
419
- return False
420
- if name.endswith(".freq"):
421
- return False
422
- if min(p.shape) < MUON_MIN_DIM:
423
- return False
424
- return True
425
-
426
- # Matrix params -> Muon (2D weight matrices passing the routing guard).
427
- matrix_params = []
428
- for name, p in self.blocks.named_parameters():
429
- if _muon_eligible(name, p):
430
- matrix_params.append(p)
431
- # NOTE (W1 audit REG-2): SemanticFoldingSDR.delta_u / delta_v are
432
- # currently GRADIENT-DEAD. The forward path uses `binary_only(idx)` for
433
- # HTM and stores it as `self._last_sdr`, but does NOT route the STE
434
- # output through any downstream op. Including them in the Muon group
435
- # burns compute (stack + orthogonalize + lerp) on zero-grad params
436
- # every step. Excluded here; a later W5 pass can reconnect STE via a
437
- # gated residual if the SDR signal is wanted back in-graph. The
438
- # parameters still exist, so no state_dict break.
439
- # for p in self.sdr_semantic.parameters():
440
- # if p.dim() == 2:
441
- # matrix_params.append(p)
442
- for name, p in self.htm_proj.named_parameters():
443
- if _muon_eligible(name, p):
444
- matrix_params.append(p)
445
- for name, p in self.engram.named_parameters():
446
- if _muon_eligible(name, p):
447
- matrix_params.append(p)
448
-
449
- # SDR params are intentionally not in any optimizer group β€” they
450
- # receive no gradient in the current forward, so any update would be
451
- # pure noise (weight_decay Γ— lr on a zero-grad param).
452
- sdr_param_ids = set(id(p) for p in self.sdr_semantic.parameters())
453
- assigned = set(id(p) for p in embedding_params + lm_head_params + matrix_params)
454
- scalar_params = [
455
- p for p in self.parameters()
456
- if id(p) not in assigned and id(p) not in sdr_param_ids
457
- ]
458
-
459
- total_assigned = len(embedding_params) + len(lm_head_params) + len(matrix_params) + len(scalar_params)
460
- total_params = len(list(self.parameters()))
461
- sdr_excluded = len(list(self.sdr_semantic.parameters()))
462
- assert total_assigned + sdr_excluded == total_params, (
463
- f"Parameter count mismatch: assigned {total_assigned} + sdr_excluded "
464
- f"{sdr_excluded} vs total {total_params}"
465
- )
466
-
467
- dmodel_lr_scale = (model_dim / 768) ** -0.5
468
- print(f"Scaling AdamW LRs by 1/sqrt({model_dim}/768) = {dmodel_lr_scale:.6f}")
469
-
470
- param_groups = [
471
- dict(kind='adamw', params=lm_head_params,
472
- lr=unembedding_lr * dmodel_lr_scale, betas=adam_betas,
473
- eps=1e-10, weight_decay=0.0),
474
- dict(kind='adamw', params=embedding_params,
475
- lr=embedding_lr * dmodel_lr_scale, betas=adam_betas,
476
- eps=1e-10, weight_decay=0.0),
477
- ]
478
-
479
- if scalar_params:
480
- param_groups.append(
481
- dict(kind='adamw', params=scalar_params,
482
- lr=scalar_lr * dmodel_lr_scale, betas=adam_betas,
483
- eps=1e-10, weight_decay=0.0)
484
- )
485
-
486
- for shape in sorted({p.shape for p in matrix_params}):
487
- group_params = [p for p in matrix_params if p.shape == shape]
488
- param_groups.append(dict(
489
- kind='muon', params=group_params, lr=matrix_lr,
490
- momentum=0.95, ns_steps=5, beta2=0.95, weight_decay=weight_decay,
491
- ))
492
-
493
- optimizer = MuonAdamW(param_groups)
494
- for group in optimizer.param_groups:
495
- group["initial_lr"] = group["lr"]
496
- return optimizer
497
-
498
- def forward(self, idx, targets=None, reduction='mean'):
499
- """idx: (B, T) int64. Returns loss if targets given, else logits.
500
-
501
- Nested bf16 autocast is a no-op when ambient autocast is already on;
502
- when it's off (e.g. integration tests) we establish the dtype contract.
503
- """
504
- if torch.is_autocast_enabled():
505
- return self._forward_impl(idx, targets=targets, reduction=reduction)
506
- with torch.amp.autocast(device_type="cuda", dtype=torch.bfloat16):
507
- return self._forward_impl(idx, targets=targets, reduction=reduction)
508
-
509
- def _forward_impl(self, idx, targets=None, reduction='mean'):
510
- B, T = idx.shape
511
-
512
- # Diagnostic: per-subsystem CUDA event timing. Env-gated; zero overhead
513
- # when disabled. Logs one timing line per forward call. Used to isolate
514
- # which subsystem is the tps bottleneck on paid hardware.
515
- _profile = os.environ.get("HYDRA_PROFILE_FORWARD", "0") == "1"
516
- if _profile:
517
- def _ev():
518
- e = torch.cuda.Event(enable_timing=True)
519
- e.record()
520
- return e
521
- _t0 = _ev()
522
- else:
523
- _t0 = None
524
-
525
- # Compute SDR binary ONCE and reuse for both HTM input and the stash.
526
- sdr_binary = self.sdr_semantic.binary_only(idx)
527
- self._last_sdr = sdr_binary # uint8 stash (not bf16 β†’ 256MB avoidance)
528
-
529
- # HTM subsampling: run HTM on 1 of every N micro-batches within a
530
- # gradient accumulation step, reuse the cached result for the other
531
- # N-1 micro-batches. Cooperative launch monopolizes all SMs (grid.sync
532
- # requires full-grid residency), so HTM and mamba can't overlap via
533
- # streams. Subsampling removes HTM from most micro-batches' critical
534
- # path instead.
535
- #
536
- # Math: N=8, 64 accum steps β†’ 8 HTM calls (10.6ms each) + 56 fast
537
- # calls (4ms each). Total = 84.8 + 224 = 309ms β†’ 106k tps.
538
- #
539
- # HYDRA_HTM_SUBSAMPLE=N (default 8). Set =1 for every-microbatch HTM.
540
- _htm_sub = int(os.environ.get("HYDRA_HTM_SUBSAMPLE", "8"))
541
- if not hasattr(self, '_htm_call_idx'):
542
- self._htm_call_idx = 0
543
-
544
- _run_htm = (self._htm_call_idx % _htm_sub == 0)
545
- self._htm_call_idx += 1
546
-
547
- if _run_htm:
548
- htm_handle = self.htm.forward_async(sdr_binary)
549
- else:
550
- htm_handle = None
551
-
552
- if _profile: _t_htm_async = _ev()
553
-
554
- dense_emb = self.wte(idx) # (B, T, d_model) bf16
555
-
556
- if _profile: _t_wte = _ev()
557
-
558
- if _run_htm:
559
- htm_out = self.htm.forward_await(htm_handle)
560
- self._htm_cache = htm_out.detach() # cache for non-HTM micro-batches
561
- elif hasattr(self, '_htm_cache') and self._htm_cache is not None \
562
- and self._htm_cache.shape[0] == B and self._htm_cache.shape[1] == T:
563
- htm_out = self._htm_cache
564
- else:
565
- # Very first call with subsample > 1: run HTM anyway.
566
- htm_handle = self.htm.forward_async(sdr_binary)
567
- htm_out = self.htm.forward_await(htm_handle)
568
- self._htm_cache = htm_out.detach()
569
-
570
- if _profile: _t_htm_await = _ev()
571
- with torch.no_grad():
572
- sdr_active_bits = float(self.sdr_semantic.target_active)
573
- htm_anomaly = htm_out[..., -1].mean()
574
-
575
- # Learnability #5: explicit stop-grad on HTM output. htm_rust already
576
- # produces a detached tensor, but making it explicit here hardens the
577
- # contract against future refactors that might route HTM through a
578
- # grad-enabled op.
579
- if self._htm_stop_grad:
580
- htm_out = htm_out.detach()
581
-
582
- # Gradient bridge: HTM columns+anomaly -> d_model.
583
- htm_proj_out = self.htm_proj(htm_out.to(dense_emb.dtype))
584
- x = dense_emb + htm_proj_out
585
- x = norm(x)
586
-
587
- if _profile: _t_htm_proj = _ev()
588
-
589
- # mHC-routed Mamba-3 stack with Engram injection at configured layer.
590
- streams = self.mhc[0].init_streams(x)
591
- _engram_ev = None
592
-
593
- # Per-layer diagnostic panel. The pre-layer merged state h_pre lets us
594
- # measure residual contribution of each layer: delta_N = h_post - h_pre.
595
- # All reads are detached no-grad to avoid autograd graph pollution.
596
- _diag = self._diag_enabled
597
- if _diag:
598
- # Cast to float32 for the diagnostic arithmetic: the layer's
599
- # residual contribution is small (~0.5 Γ— rms-normed block output),
600
- # which underflows in bf16 subtraction (3-digit mantissa) and
601
- # reports delta_ratio=0 at the boundaries. float32 snapshot is
602
- # ~3.8 MB extra memory per diag sample (B=1, T=2048, d=96) β€”
603
- # negligible vs peak VRAM.
604
- with torch.no_grad():
605
- h_pre = self.mhc[0].merge_streams(streams).detach().float()
606
- _run_svd = (self._diag_step % self._diag_svd_every) == 0
607
-
608
- for i, (block, mhc_layer) in enumerate(zip(self.blocks, self.mhc)):
609
- def _block_fn(h, _block=block):
610
- return self.drop(_block(norm(h)))
611
-
612
- # Learnability #3: gradient checkpointing. Wrap the block-fn so
613
- # the mhc layer's internal uses of it re-run the block in backward
614
- # (trading compute for activation memory). use_reentrant=False is
615
- # the modern API and works cleanly under autocast.
616
- if self._grad_ckpt and self.training:
617
- import torch.utils.checkpoint as _ckpt
618
- _raw_fn = _block_fn
619
- def _block_fn(h, _raw=_raw_fn): # noqa: E731
620
- return _ckpt.checkpoint(_raw, h, use_reentrant=False)
621
-
622
- streams = mhc_layer(streams, _block_fn)
623
-
624
- if i == self.engram_layer_idx:
625
- if _profile: _t_pre_engram = _ev()
626
- x_mid = mhc_layer.merge_streams(streams)
627
- x_mid, hit_rate = self.engram(x_mid, idx)
628
- streams = mhc_layer.init_streams(x_mid)
629
- self._metrics['engram_hit_rate'] = hit_rate
630
- if _profile: _engram_ev = _ev()
631
-
632
- if _diag:
633
- with torch.no_grad():
634
- h_post = mhc_layer.merge_streams(streams).detach().float()
635
- in_n = h_pre.pow(2).mean().sqrt()
636
- out_n = h_post.pow(2).mean().sqrt()
637
- d_n = (h_post - h_pre).pow(2).mean().sqrt()
638
- self._metrics[f'layer_{i}_in_norm'] = float(in_n.item())
639
- self._metrics[f'layer_{i}_out_norm'] = float(out_n.item())
640
- self._metrics[f'layer_{i}_delta_ratio'] = float((d_n / (in_n + 1e-6)).item())
641
- self._metrics[f'layer_{i}_feat_std'] = float(h_post.std(dim=-1).mean().item())
642
- if _run_svd:
643
- # Effective rank via participation ratio of singular values.
644
- # eff_rank = (Σσ)^2 / Σσ² β€” smooth rank proxy, bounded by d_model.
645
- # Sampled to keep overhead low (SVD is O(min(B*T, D)^2Β·D)).
646
- flat = h_post.reshape(-1, h_post.shape[-1])[:512].float()
647
- try:
648
- s = torch.linalg.svdvals(flat)
649
- eff_rank = float(((s.sum() ** 2) / (s.pow(2).sum() + 1e-6)).item())
650
- self._metrics[f'layer_{i}_eff_rank'] = eff_rank
651
- except Exception:
652
- pass
653
- h_pre = h_post
654
-
655
- if _diag:
656
- self._diag_step += 1
657
-
658
- if _profile: _t_blocks = _ev()
659
-
660
- self._metrics['sdr_active_bits'] = sdr_active_bits
661
- self._metrics['htm_anomaly'] = htm_anomaly
662
-
663
- x = self.mhc[-1].merge_streams(streams)
664
- x = norm(x)
665
-
666
- if _profile: _t_merge = _ev()
667
-
668
- softcap = self.softcap
669
- _softcap_clamp = os.environ.get("HYDRA_SOFTCAP_CLAMP", "0") == "1"
670
- if targets is not None:
671
- smoothing = self.config.label_smoothing
672
- V = self.config.vocab_size
673
-
674
- # Learnability #4: doc-separator masking. In packed rows,
675
- # tokenizer.encode(..., prepend=bos_token) places a BOS at every
676
- # document boundary. Without masking, the model is penalized for
677
- # failing to predict "doc B's BOS" from the last tokens of doc A
678
- # β€” pure noise. We set targets==bos to -1 (ignore_index). Done
679
- # BEFORE MTP/entropy/sampled-softmax branches so all downstream
680
- # losses inherit the mask.
681
- if self._doc_sep_mask and self._bos_token_id >= 0:
682
- targets = torch.where(
683
- targets == self._bos_token_id,
684
- torch.full_like(targets, -1),
685
- targets,
686
- )
687
-
688
- # Sampled softmax: instead of computing logits for ALL V tokens,
689
- # compute only for the target + K random negatives. Reduces the
690
- # lm_head matmul from (B*T, d) Γ— (d, V) to (B*T, d) Γ— (d, K+1).
691
- # At V=65536 and K=4096: 16Γ— less compute, ~4Γ— tps improvement.
692
- # The log-sum-exp correction adjusts for the sampling bias.
693
- # Set HYDRA_SAMPLED_SOFTMAX=0 to disable (full softmax).
694
- K_neg = int(os.environ.get("HYDRA_SAMPLED_SOFTMAX", "4096"))
695
- use_sampled = K_neg > 0 and K_neg < V and self.training
696
-
697
- if use_sampled:
698
- # Flatten hidden states + targets
699
- h_flat = x.reshape(-1, x.shape[-1]) # (B*T, d)
700
- t_flat = targets.reshape(-1) # (B*T,)
701
- n = h_flat.shape[0]
702
-
703
- # Learnability #4 hardening: sampled-softmax gather crashes on
704
- # negative ids (-1 from doc-sep mask). Replace -1 with 0 for
705
- # gather; the actual loss is masked below.
706
- valid_mask_flat = (t_flat >= 0)
707
- t_flat_safe = torch.where(valid_mask_flat, t_flat, torch.zeros_like(t_flat))
708
-
709
- # Sample K negatives uniformly from [0, V)
710
- neg_ids = torch.randint(0, V, (K_neg,), device=x.device)
711
- # Gather lm_head weights for target + negatives
712
- all_ids = torch.cat([t_flat_safe, neg_ids]) # (B*T + K,)
713
- sampled_w = self.lm_head.weight[all_ids] # (B*T + K, d)
714
-
715
- # Compute sampled logits: for each position, dot with its
716
- # target weight and all K negative weights.
717
- # Target logit: dot product of h[i] with w[target[i]]
718
- target_w = sampled_w[:n] # (B*T, d)
719
- neg_w = sampled_w[n:] # (K, d)
720
- target_logit = (h_flat * target_w).sum(-1) # (B*T,)
721
- neg_logits = h_flat @ neg_w.t() # (B*T, K)
722
-
723
- if not _softcap_clamp:
724
- target_logit = softcap * torch.tanh(target_logit / softcap)
725
- neg_logits = softcap * torch.tanh(neg_logits / softcap)
726
-
727
- # Sampled softmax loss: -log(exp(target) / (exp(target) + sum(exp(neg))))
728
- # With log-sum-exp correction for sampling K of V negatives.
729
- # Correction: add log(V/K) to negative logits to account for
730
- # the fact that we're only seeing K of V possible negatives.
731
- log_correction = torch.tensor(V / K_neg, device=x.device).log()
732
- all_logits = torch.cat([
733
- target_logit.unsqueeze(-1), # (B*T, 1)
734
- neg_logits + log_correction, # (B*T, K)
735
- ], dim=-1).float() # (B*T, K+1)
736
-
737
- # CE with target always at index 0
738
- ce_targets = torch.zeros(n, dtype=torch.long, device=x.device)
739
- if reduction == 'none':
740
- per_tok = F.cross_entropy(all_logits, ce_targets, reduction='none')
741
- if self._doc_sep_mask and self._bos_token_id >= 0:
742
- per_tok = torch.where(valid_mask_flat, per_tok, torch.zeros_like(per_tok))
743
- return per_tok
744
- per_tok_ce = F.cross_entropy(
745
- all_logits, ce_targets, reduction='none',
746
- label_smoothing=smoothing,
747
- )
748
- # Mask doc-separator positions. valid_mask_flat is always
749
- # computed; when doc_sep_mask is off every token is valid so
750
- # this reduces to a plain mean.
751
- valid_f = valid_mask_flat.float()
752
- valid_n = valid_f.sum().clamp(min=1)
753
- out = (per_tok_ce * valid_f).sum() / valid_n
754
- else:
755
- # Full softmax path (eval or HYDRA_SAMPLED_SOFTMAX=0)
756
- chunk_size = int(os.environ.get("HYDRA_CE_CHUNK", "1024"))
757
- if chunk_size <= 0:
758
- MAX_LOGITS_BYTES = 256 * 1024 * 1024
759
- tokens_per_chunk = max(V, MAX_LOGITS_BYTES // (V * 4))
760
- chunk_size = max(1, tokens_per_chunk // max(1, B))
761
- chunk_size = min(chunk_size, T)
762
-
763
- if reduction == 'none':
764
- loss_parts = []
765
- for start in range(0, T, chunk_size):
766
- end = min(start + chunk_size, T)
767
- chunk_logits = self.lm_head(x[:, start:end, :]).float()
768
- if _softcap_clamp:
769
- chunk_logits = torch.clamp(chunk_logits, -softcap, softcap)
770
- else:
771
- chunk_logits = softcap * torch.tanh(chunk_logits / softcap)
772
- chunk_targets = targets[:, start:end].reshape(-1)
773
- chunk_loss = F.cross_entropy(
774
- chunk_logits.view(-1, chunk_logits.size(-1)),
775
- chunk_targets, ignore_index=-1, reduction='none',
776
- )
777
- loss_parts.append(chunk_loss)
778
- return torch.cat(loss_parts)
779
-
780
- total_loss = 0.0
781
- total_tokens = 0
782
- for start in range(0, T, chunk_size):
783
- end = min(start + chunk_size, T)
784
- chunk_logits = self.lm_head(x[:, start:end, :]).float()
785
- if _softcap_clamp:
786
- chunk_logits = torch.clamp(chunk_logits, -softcap, softcap)
787
- else:
788
- chunk_logits = softcap * torch.tanh(chunk_logits / softcap)
789
- chunk_targets = targets[:, start:end].reshape(-1)
790
- chunk_loss = F.cross_entropy(
791
- chunk_logits.view(-1, chunk_logits.size(-1)),
792
- chunk_targets, ignore_index=-1, reduction='sum',
793
- label_smoothing=smoothing,
794
- )
795
- total_loss = total_loss + chunk_loss
796
- total_tokens += (chunk_targets != -1).sum()
797
- out = total_loss / total_tokens
798
-
799
- # -----------------------------------------------------------
800
- # Learnability #1: Multi-Token Prediction.
801
- # For k in {2..K}, add a CE loss at position (t) predicting
802
- # the token at position (t+k), using the SAME lm_head weights
803
- # (weight-tied). Cost: K-1 extra CEs on a subset of positions.
804
- # Only triggered in reduction='mean' path, training only.
805
- # -----------------------------------------------------------
806
- if reduction == 'mean' and self._mtp_k > 1 and self.training and use_sampled:
807
- # TRUE zero-cost MTP: reuse primary's neg_logits (B*T, K_neg)
808
- # entirely. Only cost per extra head: O(B*T*d) target-weight
809
- # gather + dot product. neg_logits is sliced (view) to match.
810
- mtp_loss_sum = out.new_tensor(0.0)
811
- mtp_terms = 0
812
- # Reshape primary neg_logits back to (B, T, K_neg) so we can slice positions
813
- neg_logits_bt = neg_logits.view(B, T, K_neg)
814
- for k in range(2, self._mtp_k + 1):
815
- shift = k - 1
816
- if T <= shift:
817
- continue
818
- n_k = B * (T - shift)
819
- h_k_flat = x[:, :T - shift, :].reshape(n_k, -1) # (n_k, d)
820
- t_k = targets[:, shift:].reshape(-1) # (n_k,)
821
- mask_k = (t_k >= 0)
822
- t_k_safe = torch.where(mask_k, t_k, torch.zeros_like(t_k))
823
- tgt_w_k = self.lm_head.weight[t_k_safe] # (n_k, d)
824
- tgt_logit_k = (h_k_flat * tgt_w_k).sum(-1) # (n_k,)
825
- if not _softcap_clamp:
826
- tgt_logit_k = softcap * torch.tanh(tgt_logit_k / softcap)
827
- # REUSE primary neg_logits β€” slice positions [:T-shift]
828
- neg_logits_k = neg_logits_bt[:, :T - shift, :].reshape(n_k, K_neg)
829
- all_logits_k = torch.cat([
830
- tgt_logit_k.unsqueeze(-1),
831
- neg_logits_k + log_correction,
832
- ], dim=-1).float()
833
- ce_targets_k = torch.zeros(n_k, dtype=torch.long, device=x.device)
834
- per_tok_ce_k = F.cross_entropy(
835
- all_logits_k, ce_targets_k, reduction='none',
836
- label_smoothing=smoothing,
837
- )
838
- per_tok_ce_k = torch.where(mask_k, per_tok_ce_k, torch.zeros_like(per_tok_ce_k))
839
- n_valid_k = mask_k.sum().clamp(min=1)
840
- mtp_loss_sum = mtp_loss_sum + per_tok_ce_k.sum() / n_valid_k
841
- mtp_terms += 1
842
- if mtp_terms > 0:
843
- out = (out + mtp_loss_sum) / float(mtp_terms + 1)
844
-
845
- # -----------------------------------------------------------
846
- # Learnability #6: output entropy penalty.
847
- # L += -lambda * H(softmax(logits)). Negative entropy penalizes
848
- # peaked distributions; encourages diverse predictions and
849
- # breaks repetition loops. Computed on a small subset of
850
- # positions to keep V-sized logits cost bounded.
851
- # -----------------------------------------------------------
852
- if reduction == 'mean' and self._entropy_penalty > 0.0 and self.training:
853
- # Sample up to 64 random positions. V-sized logits on 64
854
- # positions = 64 * V * 4 bytes (~50 MB at V=200k) β€” fits
855
- # on the 3060 and adds ~2 ms.
856
- h_flat = x.reshape(-1, x.shape[-1])
857
- n_pos = h_flat.shape[0]
858
- n_sample = min(64, n_pos)
859
- idx_sample = torch.randint(0, n_pos, (n_sample,), device=x.device)
860
- h_sample = h_flat[idx_sample]
861
- logits_s = F.linear(h_sample, self.lm_head.weight).float()
862
- if _softcap_clamp:
863
- logits_s = torch.clamp(logits_s, -softcap, softcap)
864
- else:
865
- logits_s = softcap * torch.tanh(logits_s / softcap)
866
- log_probs = F.log_softmax(logits_s, dim=-1)
867
- probs = log_probs.exp()
868
- entropy = -(probs * log_probs).sum(-1).mean() # scalar, nats
869
- out = out - self._entropy_penalty * entropy
870
-
871
- if _profile:
872
- _t_end = _ev()
873
- torch.cuda.synchronize()
874
- def _ms(a, b): return a.elapsed_time(b)
875
- print(
876
- f"[PROFILE B={B} T={T}] "
877
- f"htm_launch={_ms(_t0, _t_htm_async):.2f} "
878
- f"wte={_ms(_t_htm_async, _t_wte):.2f} "
879
- f"htm_await={_ms(_t_wte, _t_htm_await):.2f} "
880
- f"htm_proj={_ms(_t_htm_await, _t_htm_proj):.2f} "
881
- f"mamba_mhc_engram={_ms(_t_htm_proj, _t_blocks):.2f} "
882
- f"merge={_ms(_t_blocks, _t_merge):.2f} "
883
- f"lm_head_loss={_ms(_t_merge, _t_end):.2f} "
884
- f"total={_ms(_t0, _t_end):.2f} ms",
885
- flush=True,
886
- )
887
- return out
888
-
889
- logits = self.lm_head(x).float()
890
- if _softcap_clamp:
891
- logits = torch.clamp(logits, -softcap, softcap)
892
- else:
893
- logits = softcap * torch.tanh(logits / softcap)
894
- return logits
 
1
+ """PostSemClawModel β€” full-architecture model assembly.
2
+
3
+ Extracted from the monolithic train.py (W1 modularization). Semantics
4
+ unchanged. Imports `GPUEngram` from `hydra.engram` and `MuonAdamW` from
5
+ `hydra.optimizer`.
6
+
7
+ Triton kernel integration status (Phase 2):
8
+ HYDRA_FUSED_BCNORM β€” DEFERRED. The bcnorm_fused Triton kernel fuses
9
+ LayerNorm + RoPE on B/C projections. However, mamba-ssm's Mamba3 block
10
+ uses RMSNormGated (not LayerNorm) for B/C, and RoPE is applied inside
11
+ the mamba3_siso_combined CUDA kernel via the Angles parameter. Replacing
12
+ would require either (a) monkey-patching RMSNormGated + intercepting the
13
+ fused CUDA scan β€” invasive, 50+ lines, high breakage risk β€” or (b) a
14
+ full custom Mamba3Block reimplementation. Both are out of scope for
15
+ Phase 2. The kernel is validated standalone; integration deferred to
16
+ Phase 3 when HYDRA moves to a custom SSM block.
17
+
18
+ HYDRA_FUSED_SSD β€” DEFERRED. The ssd_exp_trap Triton kernel implements
19
+ exponential-trapezoidal discretization as a sequential scan. mamba-ssm's
20
+ Mamba3 block delegates the entire scan + gating + output projection to
21
+ mamba3_siso_combined (a compiled CUDA kernel with tilelang). Replacing
22
+ it would require decomposing the combined kernel into constituent ops
23
+ and substituting only the scan β€” not feasible without a custom block.
24
+ Same Phase 3 gate as above.
25
+
26
+ Both env vars are accepted but currently no-ops (gates read, logged, but
27
+ the code path is unchanged). This avoids silent regression if someone
28
+ sets them expecting a speedup.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import os
34
+
35
+ import torch
36
+ import torch.nn as nn
37
+ import torch.nn.functional as F
38
+
39
+ from mamba_ssm import Mamba3
40
+
41
+ from subsystems.hestia_mini import HestiaQAT
42
+ from subsystems.htm import HTMLayer
43
+ from subsystems.mhc_mini import ManifoldHyperConnection
44
+ from subsystems.sdr_semantic import SemanticFoldingSDR
45
+
46
+ from hydra.engram import GPUEngram
47
+ from hydra.hyena_block import HyenaBlock
48
+ # GDNBlock is imported lazily inside __init__ so the `fla` dependency is
49
+ # only required when HYDRA_GDN_LAYERS is actually non-empty. Baseline
50
+ # pure-Mamba3 runs continue to work without flash-linear-attention installed.
51
+ from hydra.optimizer import MuonAdamW
52
+
53
+
54
+ def norm(x: torch.Tensor) -> torch.Tensor:
55
+ """RMSNorm over the last dim β€” stateless, autocast-friendly."""
56
+ return F.rms_norm(x, (x.size(-1),))
57
+
58
+
59
+ class PostSemClawModel(nn.Module):
60
+ """Full Post-SEM-Claw model assembly.
61
+
62
+ Architecture:
63
+ Token Embedding -> [Mamba3 + residual] x n_layer
64
+ -> SDR + Engram (at configured layer) -> norm -> LM head
65
+
66
+ Interface (must match prepare.py evaluate_bpb):
67
+ model(x, y, reduction='none').view(-1) -> per-token losses
68
+ model(x, y, reduction='mean') -> scalar loss
69
+ """
70
+
71
+ def __init__(self, config):
72
+ super().__init__()
73
+ self.config = config
74
+
75
+ # Token embedding
76
+ self.wte = nn.Embedding(config.vocab_size, config.d_model)
77
+
78
+ # Mamba-3 blocks β€” official mamba-ssm fused CUDA kernel. No fallbacks.
79
+ # RoPE is applied internally by the Mamba3 CUDA kernel via the Angles
80
+ # parameter; external cos/sin buffers are not needed.
81
+ #
82
+ # Hyena supplement: layers whose index appears in `config.hyena_layers`
83
+ # are instantiated as HyenaBlock instead of Mamba3. The config field
84
+ # is populated from HYDRA_HYENA_LAYERS at construction time and then
85
+ # persisted to checkpoints, so resume is safe even when the env var
86
+ # is unset. Empty tuple β†’ all-Mamba3, byte-identical to pre-port.
87
+ _hyena_layer_set = set(getattr(config, "hyena_layers", ()) or ())
88
+ _gdn_layer_set = set(getattr(config, "gdn_layers", ()) or ())
89
+ # Hyena wins on overlap; conflict is logged at construction time.
90
+ _both = _hyena_layer_set & _gdn_layer_set
91
+ if _both:
92
+ print(f"[WARN] layers in both hyena_layers and gdn_layers; using Hyena: {sorted(_both)}", flush=True)
93
+ _gdn_layer_set -= _hyena_layer_set
94
+
95
+ if _gdn_layer_set:
96
+ from hydra.gdn_block import GDNBlock # requires `fla` package
97
+
98
+ def _build_block(i: int) -> nn.Module:
99
+ if i in _hyena_layer_set:
100
+ return HyenaBlock(
101
+ d_model=config.d_model,
102
+ seq_len=config.sequence_len,
103
+ order=int(os.environ.get("HYDRA_HYENA_ORDER", "2")),
104
+ filter_order=int(os.environ.get("HYDRA_HYENA_FILTER_DIM", "64")),
105
+ )
106
+ if i in _gdn_layer_set:
107
+ return GDNBlock(
108
+ d_model=config.d_model,
109
+ n_heads=config.n_heads,
110
+ )
111
+ return Mamba3(
112
+ d_model=config.d_model,
113
+ d_state=config.d_state,
114
+ expand=config.expand,
115
+ headdim=config.headdim,
116
+ is_mimo=False, # SISO path uses stable mamba3_siso_combined kernel
117
+ chunk_size=64, # upstream-recommended SISO chunk; 16 violated tl.dot M>=16 constraint
118
+ is_outproj_norm=False,
119
+ dtype=torch.bfloat16,
120
+ )
121
+
122
+ self.blocks = nn.ModuleList([_build_block(i) for i in range(config.n_layer)])
123
+
124
+ # Full-architecture SDR: offline semantic retina + STE (no-bypass).
125
+ self.sdr_semantic = SemanticFoldingSDR(
126
+ vocab_size=config.vocab_size,
127
+ n_bits=config.sdr_n_bits,
128
+ target_active=config.sdr_target_active,
129
+ delta_rank=config.sdr_delta_rank,
130
+ som_warmup_steps=config.sdr_som_warmup,
131
+ som_update_interval=config.sdr_som_interval,
132
+ )
133
+
134
+ # HTM spatial pooler + temporal memory (Rust, Hebbian).
135
+ self.htm = HTMLayer(
136
+ input_bits=config.sdr_n_bits,
137
+ n_columns=config.htm_n_columns,
138
+ cells_per_column=config.htm_cells_per_column,
139
+ batch_size=1, # grows lazily to actual B on first forward
140
+ seed=42,
141
+ learn=True,
142
+ reset_each_forward=True,
143
+ )
144
+
145
+ # Gradient bridge: (n_columns + anomaly) -> d_model.
146
+ self.htm_proj = nn.Linear(config.htm_n_columns + 1, config.d_model, bias=False)
147
+
148
+ # GPU Engram with Hebbian writes β€” runs EVERY step.
149
+ self.engram = GPUEngram(
150
+ d_model=config.d_model,
151
+ n_columns=config.engram_n_columns,
152
+ max_ngram=3,
153
+ )
154
+ self.engram_layer_idx = config.engram_layer_idx
155
+
156
+ # Manifold-Constrained Hyper-Connections (one per Mamba-3 block).
157
+ self.mhc = nn.ModuleList([
158
+ ManifoldHyperConnection(d_model=config.d_model, n_streams=2, sinkhorn_iters=3)
159
+ for _ in range(config.n_layer)
160
+ ])
161
+
162
+ # Hestia QAT β€” ternary weight quantization applied post-optimizer-step.
163
+ self.hestia = HestiaQAT(enabled=True, bits=1.58)
164
+
165
+ # LM head
166
+ self.lm_head = nn.Linear(config.d_model, config.vocab_size, bias=False)
167
+
168
+ # Learnability knob 1: Multi-Token Prediction (Llama-3 style).
169
+ # MTP_K=1 -> standard next-token. MTP_K>1 -> extra heads predict
170
+ # tokens at positions t+1, t+2, ..., t+K. Heads are weight-tied to
171
+ # lm_head (we share Parameters), so the only extra compute is
172
+ # additional CE losses; no new params. Activated via HYDRA_MTP_K.
173
+ self._mtp_k = max(1, int(os.environ.get("HYDRA_MTP_K", "1")))
174
+
175
+ # Learnability knob 3: gradient checkpointing on Mamba3 blocks.
176
+ self._grad_ckpt = os.environ.get("HYDRA_GRAD_CKPT", "0") == "1"
177
+
178
+ # Learnability knob 4: doc-separator BOS masking in packed sequences.
179
+ self._doc_sep_mask = os.environ.get("HYDRA_DOC_SEP_MASK", "0") == "1"
180
+ # BOS token id is looked up lazily on first forward (requires tokenizer
181
+ # load); -1 means uninitialized.
182
+ self._bos_token_id = -1
183
+
184
+ # Learnability knob 5: explicit stop-grad on HTM tensor (htm_rust
185
+ # outputs already have requires_grad=False; this is defense-in-depth).
186
+ self._htm_stop_grad = os.environ.get("HYDRA_HTM_STOP_GRAD", "0") == "1"
187
+
188
+ # Learnability knob 6: entropy penalty coefficient on LM logits.
189
+ self._entropy_penalty = float(os.environ.get("HYDRA_ENTROPY_PENALTY", "0.0"))
190
+
191
+ # Residual dropout
192
+ self.drop = nn.Dropout(float(os.environ.get("HYDRA_DROPOUT", "0.2")))
193
+
194
+ # Logits soft-capping
195
+ self.softcap = 15.0
196
+
197
+ # Secondary metrics storage
198
+ self._metrics = {}
199
+
200
+ # Per-layer diagnostic panel. Env-gated; zero overhead when off.
201
+ # Emits residual-contribution (delta_ratio), feature std, effective rank,
202
+ # gradient norm per layer; used to identify minimum viable n_layer + find
203
+ # entropy leakage / dead layers. See docs/depth-sweep.md.
204
+ self._diag_enabled = os.environ.get("HYDRA_LAYER_DIAGNOSTICS", "0") == "1"
205
+ self._diag_step = 0
206
+ self._diag_svd_every = int(os.environ.get("HYDRA_LAYER_DIAG_SVD_EVERY", "100"))
207
+ if self._diag_enabled:
208
+ # Gradient-norm backward hooks on each Mamba3 block output.
209
+ for _i, _block in enumerate(self.blocks):
210
+ def _mk_grad_hook(_layer_idx):
211
+ def _hook(module, grad_input, grad_output):
212
+ if grad_output and grad_output[0] is not None:
213
+ g = grad_output[0].detach()
214
+ self._metrics[f'layer_{_layer_idx}_grad_norm'] = float(
215
+ g.pow(2).mean().sqrt().item()
216
+ )
217
+ return _hook
218
+ _block.register_full_backward_hook(_mk_grad_hook(_i))
219
+
220
+ # Forward hooks on each Mamba3 block capture the block's OUTPUT
221
+ # directly. This is the clean measurement: unlike merge_streams()
222
+ # sampling which sees (streams + M*block_output) in bf16 β€” where
223
+ # small block contributions round to zero against unit-norm
224
+ # residuals β€” this captures `block_output` itself as produced.
225
+ # Reports both its absolute RMS norm and its ratio to the block
226
+ # INPUT's RMS norm (contribution magnitude relative to the
227
+ # residual it's added to).
228
+ for _i, _block in enumerate(self.blocks):
229
+ def _mk_fwd_hook(_layer_idx):
230
+ def _hook(module, inputs, output):
231
+ with torch.no_grad():
232
+ inp = inputs[0].detach().float() if inputs else None
233
+ out = output.detach().float() if isinstance(output, torch.Tensor) else None
234
+ if out is not None:
235
+ out_rms = out.pow(2).mean().sqrt().item()
236
+ self._metrics[f'layer_{_layer_idx}_block_out_rms'] = float(out_rms)
237
+ if inp is not None:
238
+ in_rms = inp.pow(2).mean().sqrt().item()
239
+ self._metrics[f'layer_{_layer_idx}_block_in_rms'] = float(in_rms)
240
+ self._metrics[f'layer_{_layer_idx}_contrib_ratio'] = float(
241
+ out_rms / (in_rms + 1e-8)
242
+ )
243
+ return _hook
244
+ _block.register_forward_hook(_mk_fwd_hook(_i))
245
+
246
+ # Triton kernel integration gates (Phase 2 β€” deferred, see module docstring).
247
+ self._fused_bcnorm = os.environ.get("HYDRA_FUSED_BCNORM", "0") == "1"
248
+ self._fused_ssd = os.environ.get("HYDRA_FUSED_SSD", "0") == "1"
249
+ if self._fused_bcnorm or self._fused_ssd:
250
+ import sys
251
+ _active = []
252
+ if self._fused_bcnorm:
253
+ _active.append("HYDRA_FUSED_BCNORM")
254
+ if self._fused_ssd:
255
+ _active.append("HYDRA_FUSED_SSD")
256
+ print(
257
+ f"[HYDRA] Triton kernel gates set: {', '.join(_active)}. "
258
+ f"NOTE: Both are DEFERRED (mamba-ssm Mamba3 uses internal "
259
+ f"CUDA kernels). Gates accepted but currently no-ops.",
260
+ file=sys.stderr,
261
+ )
262
+
263
+ # R6 optional torch.compile on the impl forward. Gated (default OFF).
264
+ if os.environ.get("HYDRA_MODEL_COMPILE", "0") == "1":
265
+ self._forward_impl = torch.compile(
266
+ self._forward_impl,
267
+ fullgraph=False,
268
+ dynamic=True,
269
+ mode="default",
270
+ )
271
+
272
+ @torch.no_grad()
273
+ def init_weights(self) -> None:
274
+ s = 3 ** 0.5 * self.config.d_model ** -0.5
275
+
276
+ # Move SDR retina indices (plain attribute, not buffer) to same device as params.
277
+ # Required because to_empty() only moves params/buffers, and _retina_indices
278
+ # is loaded from numpy (always CPU) by SemanticFoldingSDR.__init__.
279
+ device = self.wte.weight.device
280
+ if hasattr(self.sdr_semantic, '_retina_indices'):
281
+ self.sdr_semantic._retina_indices = self.sdr_semantic._retina_indices.to(device)
282
+
283
+ # Embedding init: GPT-2 / LLaMA convention. std=1.0 was chosen for
284
+ # vocab=8192; at larger vocabs, smaller std prevents logit blowup.
285
+ # Use std = 1/sqrt(d_model) which scales sensibly with model width.
286
+ import math as _math
287
+ _d_model = self.wte.weight.shape[1]
288
+ wte_std = float(os.environ.get("HYDRA_WTE_STD", str(1.0 / _math.sqrt(_d_model))))
289
+ nn.init.normal_(self.wte.weight, mean=0.0, std=wte_std)
290
+ # LM head init: was std=0.001 β€” PATHOLOGICAL at vocab>=32k because
291
+ # logits collapse to zero, loss locks at log(V)~=11, gradient through
292
+ # head ∝ 1/V is too small to escape. GPT-2 uses std=0.02; LLaMA uses
293
+ # std=1/sqrt(d_model). Pick 0.02 as robust default, env-overridable.
294
+ lm_head_std = float(os.environ.get("HYDRA_LM_HEAD_STD", "0.02"))
295
+ nn.init.normal_(self.lm_head.weight, mean=0.0, std=lm_head_std)
296
+ # F8 (NOT APPLIED): Weight tying would save V*D params but current LR
297
+ # groups have embedding_lr=1.0 and unembedding_lr=0.005 Γ— d_model_scale
298
+ # β€” tying forces the shared tensor under a single LR group and either
299
+ # the embeddings learn 200x too slow (under unembed LR) or the LM head
300
+ # becomes unstable (under embed LR). Short 15-step smoke with tying +
301
+ # embed-group update showed initial loss jump 9 -> 20. Deferred until
302
+ # LR groups are re-tuned; see docs/OPTIMIZATION_PLAN.md Post-plan.
303
+
304
+ for li, block in enumerate(self.blocks):
305
+ if hasattr(block, 'in_proj') and hasattr(block.in_proj, 'weight'):
306
+ nn.init.uniform_(block.in_proj.weight, -s, s)
307
+ if hasattr(block, 'out_proj') and hasattr(block.out_proj, 'weight'):
308
+ # GPT-2 residual init: std = 0.02 / sqrt(2 * n_layer).
309
+ # NOT zeros β€” zero init makes the block a permanent pass-through
310
+ # (block_out_rms=0, zero gradient flow to SSM internals).
311
+ # With non-zero init the block contributes to the residual stream
312
+ # from step 1, giving the SSM scan actual gradient signal.
313
+ n_layer = self.config.n_layer
314
+ out_std = float(os.environ.get(
315
+ "HYDRA_OUT_PROJ_STD",
316
+ str(0.02 / (2 * n_layer) ** 0.5),
317
+ ))
318
+ nn.init.normal_(block.out_proj.weight, mean=0.0, std=out_std)
319
+
320
+ nn.init.normal_(self.htm_proj.weight, mean=0.0, std=s)
321
+
322
+ # Cast to bf16 to match Mamba3 dtype; Muon groups by shape so mixed
323
+ # dtypes in the same shape group would break lerp_ dtype checks.
324
+ self.wte.to(dtype=torch.bfloat16)
325
+ self.htm_proj.to(dtype=torch.bfloat16)
326
+ self.engram.to(dtype=torch.bfloat16)
327
+
328
+ def set_bos_token_id(self, bos_id: int) -> None:
329
+ """Inform the model of the tokenizer's BOS id so doc-separator
330
+ masking (learnability #4) knows which positions to skip. Called from
331
+ training setup once the tokenizer is loaded."""
332
+ self._bos_token_id = int(bos_id)
333
+
334
+ def invalidate_hyena_caches(self) -> None:
335
+ """Invalidate filter-rfft caches on all Hyena blocks.
336
+
337
+ MUST be called after each `optimizer.step()` when
338
+ `HYDRA_HYENA_FILTER_CACHE=1` is set, otherwise cached rfft values
339
+ will be reused with stale filter parameters.
340
+
341
+ No-op for blocks that are not HyenaBlock (Mamba3, etc.).
342
+ """
343
+ for block in self.blocks:
344
+ if hasattr(block, "operator") and hasattr(block.operator, "invalidate_filter_cache"):
345
+ block.operator.invalidate_filter_cache()
346
+
347
+ def flush_hyena_pending_grads(self) -> None:
348
+ """Push pending train-cache filter gradients into filter params.
349
+
350
+ Used ONLY when HYDRA_HYENA_TRAIN_CACHE=1. Must be called exactly once
351
+ per optimizer step, BEFORE `optimizer.step()` and BEFORE
352
+ `invalidate_hyena_caches()`. The lightning_module wires this in
353
+ `optimizer_step` around the existing optimizer.step() call.
354
+
355
+ No-op if:
356
+ * No HyenaBlocks are in the model, OR
357
+ * No micro-batch ever ran with grad enabled (e.g. all-eval step).
358
+ """
359
+ for block in self.blocks:
360
+ if hasattr(block, "operator") and hasattr(block.operator, "flush_pending_filter_grads"):
361
+ block.operator.flush_pending_filter_grads()
362
+
363
+ def estimate_flops(self) -> int:
364
+ nparams = sum(p.numel() for p in self.parameters())
365
+ embed_params = self.wte.weight.numel()
366
+ return 6 * (nparams - embed_params)
367
+
368
+ def num_scaling_params(self) -> dict:
369
+ wte = sum(p.numel() for p in self.wte.parameters())
370
+ lm_head = sum(p.numel() for p in self.lm_head.parameters())
371
+ blocks = sum(p.numel() for p in self.blocks.parameters())
372
+ sdr = sum(p.numel() for p in self.sdr_semantic.parameters())
373
+ htm_proj = sum(p.numel() for p in self.htm_proj.parameters())
374
+ engram = sum(p.numel() for p in self.engram.parameters())
375
+ total = sum(p.numel() for p in self.parameters())
376
+ return {
377
+ 'wte': wte, 'lm_head': lm_head, 'blocks': blocks,
378
+ 'sdr_semantic': sdr, 'htm_proj': htm_proj,
379
+ 'engram': engram, 'total': total,
380
+ }
381
+
382
+ def get_secondary_metrics(self) -> dict:
383
+ """Flush any lingering CUDA tensors to host (single sync)."""
384
+ flushed = {}
385
+ for k, v in self._metrics.items():
386
+ if hasattr(v, 'item'):
387
+ try:
388
+ flushed[k] = float(v.item())
389
+ except Exception:
390
+ flushed[k] = v
391
+ else:
392
+ flushed[k] = v
393
+ return flushed
394
+
395
+ def setup_optimizer(self, unembedding_lr=0.004, embedding_lr=0.6, matrix_lr=0.04,
396
+ weight_decay=0.2, adam_betas=(0.8, 0.95), scalar_lr=0.5):
397
+ """Setup MuonAdamW optimizer with per-component LR groups."""
398
+ model_dim = self.config.d_model
399
+
400
+ embedding_params = list(self.wte.parameters())
401
+ lm_head_params = list(self.lm_head.parameters())
402
+
403
+ # Muon routing guard: 2D parameters are NOT automatically matrices.
404
+ # Exclude:
405
+ # (a) params whose name ends in `.freq` β€” Sin frequency vectors used
406
+ # by Hyena's implicit filter MLP. Shape (1, dim) is nominally 2D
407
+ # but semantically a per-dim scalar. Muon's polar-express
408
+ # orthogonalization would force it toward an orthogonal matrix,
409
+ # destroying the learned modulation frequencies.
410
+ # (b) 2-D params with min(shape) < MUON_MIN_DIM. Tiny projections
411
+ # (e.g. HyenaFilter.implicit_filter.0.weight of shape (64, 3))
412
+ # get collapsed toward near-identity by orthogonalization on the
413
+ # narrow axis, damaging expressivity. These belong in AdamW.
414
+ # These exclusions route the params into the AdamW scalar/vector group.
415
+ MUON_MIN_DIM = 8
416
+
417
+ def _muon_eligible(name: str, p: torch.Tensor) -> bool:
418
+ if p.dim() != 2:
419
+ return False
420
+ if name.endswith(".freq"):
421
+ return False
422
+ if min(p.shape) < MUON_MIN_DIM:
423
+ return False
424
+ return True
425
+
426
+ # Matrix params -> Muon (2D weight matrices passing the routing guard).
427
+ matrix_params = []
428
+ for name, p in self.blocks.named_parameters():
429
+ if _muon_eligible(name, p):
430
+ matrix_params.append(p)
431
+ # NOTE (W1 audit REG-2): SemanticFoldingSDR.delta_u / delta_v are
432
+ # currently GRADIENT-DEAD. The forward path uses `binary_only(idx)` for
433
+ # HTM and stores it as `self._last_sdr`, but does NOT route the STE
434
+ # output through any downstream op. Including them in the Muon group
435
+ # burns compute (stack + orthogonalize + lerp) on zero-grad params
436
+ # every step. Excluded here; a later W5 pass can reconnect STE via a
437
+ # gated residual if the SDR signal is wanted back in-graph. The
438
+ # parameters still exist, so no state_dict break.
439
+ # for p in self.sdr_semantic.parameters():
440
+ # if p.dim() == 2:
441
+ # matrix_params.append(p)
442
+ for name, p in self.htm_proj.named_parameters():
443
+ if _muon_eligible(name, p):
444
+ matrix_params.append(p)
445
+ for name, p in self.engram.named_parameters():
446
+ if _muon_eligible(name, p):
447
+ matrix_params.append(p)
448
+
449
+ # SDR params are intentionally not in any optimizer group β€” they
450
+ # receive no gradient in the current forward, so any update would be
451
+ # pure noise (weight_decay Γ— lr on a zero-grad param).
452
+ sdr_param_ids = set(id(p) for p in self.sdr_semantic.parameters())
453
+ assigned = set(id(p) for p in embedding_params + lm_head_params + matrix_params)
454
+ scalar_params = [
455
+ p for p in self.parameters()
456
+ if id(p) not in assigned and id(p) not in sdr_param_ids
457
+ ]
458
+
459
+ total_assigned = len(embedding_params) + len(lm_head_params) + len(matrix_params) + len(scalar_params)
460
+ total_params = len(list(self.parameters()))
461
+ sdr_excluded = len(list(self.sdr_semantic.parameters()))
462
+ assert total_assigned + sdr_excluded == total_params, (
463
+ f"Parameter count mismatch: assigned {total_assigned} + sdr_excluded "
464
+ f"{sdr_excluded} vs total {total_params}"
465
+ )
466
+
467
+ dmodel_lr_scale = (model_dim / 768) ** -0.5
468
+ print(f"Scaling AdamW LRs by 1/sqrt({model_dim}/768) = {dmodel_lr_scale:.6f}")
469
+
470
+ param_groups = [
471
+ dict(kind='adamw', params=lm_head_params,
472
+ lr=unembedding_lr * dmodel_lr_scale, betas=adam_betas,
473
+ eps=1e-10, weight_decay=0.0),
474
+ dict(kind='adamw', params=embedding_params,
475
+ lr=embedding_lr * dmodel_lr_scale, betas=adam_betas,
476
+ eps=1e-10, weight_decay=0.0),
477
+ ]
478
+
479
+ if scalar_params:
480
+ param_groups.append(
481
+ dict(kind='adamw', params=scalar_params,
482
+ lr=scalar_lr * dmodel_lr_scale, betas=adam_betas,
483
+ eps=1e-10, weight_decay=0.0)
484
+ )
485
+
486
+ for shape in sorted({p.shape for p in matrix_params}):
487
+ group_params = [p for p in matrix_params if p.shape == shape]
488
+ param_groups.append(dict(
489
+ kind='muon', params=group_params, lr=matrix_lr,
490
+ momentum=0.95, ns_steps=5, beta2=0.95, weight_decay=weight_decay,
491
+ ))
492
+
493
+ optimizer = MuonAdamW(param_groups)
494
+ for group in optimizer.param_groups:
495
+ group["initial_lr"] = group["lr"]
496
+ return optimizer
497
+
498
+ def forward(self, idx, targets=None, reduction='mean'):
499
+ """idx: (B, T) int64. Returns loss if targets given, else logits.
500
+
501
+ Nested bf16 autocast is a no-op when ambient autocast is already on;
502
+ when it's off (e.g. integration tests) we establish the dtype contract.
503
+ """
504
+ if torch.is_autocast_enabled():
505
+ return self._forward_impl(idx, targets=targets, reduction=reduction)
506
+ with torch.amp.autocast(device_type="cuda", dtype=torch.bfloat16):
507
+ return self._forward_impl(idx, targets=targets, reduction=reduction)
508
+
509
+ def _forward_impl(self, idx, targets=None, reduction='mean'):
510
+ B, T = idx.shape
511
+
512
+ # Diagnostic: per-subsystem CUDA event timing. Env-gated; zero overhead
513
+ # when disabled. Logs one timing line per forward call. Used to isolate
514
+ # which subsystem is the tps bottleneck on paid hardware.
515
+ _profile = os.environ.get("HYDRA_PROFILE_FORWARD", "0") == "1"
516
+ if _profile:
517
+ def _ev():
518
+ e = torch.cuda.Event(enable_timing=True)
519
+ e.record()
520
+ return e
521
+ _t0 = _ev()
522
+ else:
523
+ _t0 = None
524
+
525
+ # Compute SDR binary ONCE and reuse for both HTM input and the stash.
526
+ sdr_binary = self.sdr_semantic.binary_only(idx)
527
+ self._last_sdr = sdr_binary # uint8 stash (not bf16 β†’ 256MB avoidance)
528
+
529
+ # HTM subsampling: run HTM on 1 of every N micro-batches within a
530
+ # gradient accumulation step, reuse the cached result for the other
531
+ # N-1 micro-batches. Cooperative launch monopolizes all SMs (grid.sync
532
+ # requires full-grid residency), so HTM and mamba can't overlap via
533
+ # streams. Subsampling removes HTM from most micro-batches' critical
534
+ # path instead.
535
+ #
536
+ # Math: N=8, 64 accum steps β†’ 8 HTM calls (10.6ms each) + 56 fast
537
+ # calls (4ms each). Total = 84.8 + 224 = 309ms β†’ 106k tps.
538
+ #
539
+ # HYDRA_HTM_SUBSAMPLE=N (default 8). Set =1 for every-microbatch HTM.
540
+ _htm_sub = int(os.environ.get("HYDRA_HTM_SUBSAMPLE", "8"))
541
+ if not hasattr(self, '_htm_call_idx'):
542
+ self._htm_call_idx = 0
543
+
544
+ _run_htm = (self._htm_call_idx % _htm_sub == 0)
545
+ self._htm_call_idx += 1
546
+
547
+ if _run_htm:
548
+ htm_handle = self.htm.forward_async(sdr_binary)
549
+ else:
550
+ htm_handle = None
551
+
552
+ if _profile: _t_htm_async = _ev()
553
+
554
+ dense_emb = self.wte(idx) # (B, T, d_model) bf16
555
+
556
+ if _profile: _t_wte = _ev()
557
+
558
+ if _run_htm:
559
+ htm_out = self.htm.forward_await(htm_handle)
560
+ self._htm_cache = htm_out.detach() # cache for non-HTM micro-batches
561
+ elif hasattr(self, '_htm_cache') and self._htm_cache is not None \
562
+ and self._htm_cache.shape[0] == B and self._htm_cache.shape[1] == T:
563
+ htm_out = self._htm_cache
564
+ else:
565
+ # Very first call with subsample > 1: run HTM anyway.
566
+ htm_handle = self.htm.forward_async(sdr_binary)
567
+ htm_out = self.htm.forward_await(htm_handle)
568
+ self._htm_cache = htm_out.detach()
569
+
570
+ if _profile: _t_htm_await = _ev()
571
+ with torch.no_grad():
572
+ sdr_active_bits = float(self.sdr_semantic.target_active)
573
+ htm_anomaly = htm_out[..., -1].mean()
574
+
575
+ # Learnability #5: explicit stop-grad on HTM output. htm_rust already
576
+ # produces a detached tensor, but making it explicit here hardens the
577
+ # contract against future refactors that might route HTM through a
578
+ # grad-enabled op.
579
+ if self._htm_stop_grad:
580
+ htm_out = htm_out.detach()
581
+
582
+ # Gradient bridge: HTM columns+anomaly -> d_model.
583
+ htm_proj_out = self.htm_proj(htm_out.to(dense_emb.dtype))
584
+ x = dense_emb + htm_proj_out
585
+ x = norm(x)
586
+
587
+ if _profile: _t_htm_proj = _ev()
588
+
589
+ # mHC-routed Mamba-3 stack with Engram injection at configured layer.
590
+ streams = self.mhc[0].init_streams(x)
591
+ _engram_ev = None
592
+
593
+ # Per-layer diagnostic panel. The pre-layer merged state h_pre lets us
594
+ # measure residual contribution of each layer: delta_N = h_post - h_pre.
595
+ # All reads are detached no-grad to avoid autograd graph pollution.
596
+ _diag = self._diag_enabled
597
+ if _diag:
598
+ # Cast to float32 for the diagnostic arithmetic: the layer's
599
+ # residual contribution is small (~0.5 Γ— rms-normed block output),
600
+ # which underflows in bf16 subtraction (3-digit mantissa) and
601
+ # reports delta_ratio=0 at the boundaries. float32 snapshot is
602
+ # ~3.8 MB extra memory per diag sample (B=1, T=2048, d=96) β€”
603
+ # negligible vs peak VRAM.
604
+ with torch.no_grad():
605
+ h_pre = self.mhc[0].merge_streams(streams).detach().float()
606
+ _run_svd = (self._diag_step % self._diag_svd_every) == 0
607
+
608
+ for i, (block, mhc_layer) in enumerate(zip(self.blocks, self.mhc)):
609
+ def _block_fn(h, _block=block):
610
+ return self.drop(_block(norm(h)))
611
+
612
+ # Learnability #3: gradient checkpointing. Wrap the block-fn so
613
+ # the mhc layer's internal uses of it re-run the block in backward
614
+ # (trading compute for activation memory). use_reentrant=False is
615
+ # the modern API and works cleanly under autocast.
616
+ if self._grad_ckpt and self.training:
617
+ import torch.utils.checkpoint as _ckpt
618
+ _raw_fn = _block_fn
619
+ def _block_fn(h, _raw=_raw_fn): # noqa: E731
620
+ return _ckpt.checkpoint(_raw, h, use_reentrant=False)
621
+
622
+ streams = mhc_layer(streams, _block_fn)
623
+
624
+ if i == self.engram_layer_idx:
625
+ if _profile: _t_pre_engram = _ev()
626
+ x_mid = mhc_layer.merge_streams(streams)
627
+ x_mid, hit_rate = self.engram(x_mid, idx)
628
+ streams = mhc_layer.init_streams(x_mid)
629
+ self._metrics['engram_hit_rate'] = hit_rate
630
+ if _profile: _engram_ev = _ev()
631
+
632
+ if _diag:
633
+ with torch.no_grad():
634
+ h_post = mhc_layer.merge_streams(streams).detach().float()
635
+ in_n = h_pre.pow(2).mean().sqrt()
636
+ out_n = h_post.pow(2).mean().sqrt()
637
+ d_n = (h_post - h_pre).pow(2).mean().sqrt()
638
+ self._metrics[f'layer_{i}_in_norm'] = float(in_n.item())
639
+ self._metrics[f'layer_{i}_out_norm'] = float(out_n.item())
640
+ self._metrics[f'layer_{i}_delta_ratio'] = float((d_n / (in_n + 1e-6)).item())
641
+ self._metrics[f'layer_{i}_feat_std'] = float(h_post.std(dim=-1).mean().item())
642
+ if _run_svd:
643
+ # Effective rank via participation ratio of singular values.
644
+ # eff_rank = (Σσ)^2 / Σσ² β€” smooth rank proxy, bounded by d_model.
645
+ # Sampled to keep overhead low (SVD is O(min(B*T, D)^2Β·D)).
646
+ flat = h_post.reshape(-1, h_post.shape[-1])[:512].float()
647
+ try:
648
+ s = torch.linalg.svdvals(flat)
649
+ eff_rank = float(((s.sum() ** 2) / (s.pow(2).sum() + 1e-6)).item())
650
+ self._metrics[f'layer_{i}_eff_rank'] = eff_rank
651
+ except Exception:
652
+ pass
653
+ h_pre = h_post
654
+
655
+ if _diag:
656
+ self._diag_step += 1
657
+
658
+ if _profile: _t_blocks = _ev()
659
+
660
+ self._metrics['sdr_active_bits'] = sdr_active_bits
661
+ self._metrics['htm_anomaly'] = htm_anomaly
662
+
663
+ x = self.mhc[-1].merge_streams(streams)
664
+ x = norm(x)
665
+
666
+ if _profile: _t_merge = _ev()
667
+
668
+ softcap = self.softcap
669
+ _softcap_clamp = os.environ.get("HYDRA_SOFTCAP_CLAMP", "0") == "1"
670
+ if targets is not None:
671
+ smoothing = self.config.label_smoothing
672
+ V = self.config.vocab_size
673
+
674
+ # Learnability #4: doc-separator masking. In packed rows,
675
+ # tokenizer.encode(..., prepend=bos_token) places a BOS at every
676
+ # document boundary. Without masking, the model is penalized for
677
+ # failing to predict "doc B's BOS" from the last tokens of doc A
678
+ # β€” pure noise. We set targets==bos to -1 (ignore_index). Done
679
+ # BEFORE MTP/entropy/sampled-softmax branches so all downstream
680
+ # losses inherit the mask.
681
+ if self._doc_sep_mask and self._bos_token_id >= 0:
682
+ targets = torch.where(
683
+ targets == self._bos_token_id,
684
+ torch.full_like(targets, -1),
685
+ targets,
686
+ )
687
+
688
+ # Sampled softmax: instead of computing logits for ALL V tokens,
689
+ # compute only for the target + K random negatives. Reduces the
690
+ # lm_head matmul from (B*T, d) Γ— (d, V) to (B*T, d) Γ— (d, K+1).
691
+ # At V=65536 and K=4096: 16Γ— less compute, ~4Γ— tps improvement.
692
+ # The log-sum-exp correction adjusts for the sampling bias.
693
+ # Set HYDRA_SAMPLED_SOFTMAX=0 to disable (full softmax).
694
+ K_neg = int(os.environ.get("HYDRA_SAMPLED_SOFTMAX", "4096"))
695
+ use_sampled = K_neg > 0 and K_neg < V and self.training
696
+
697
+ if use_sampled:
698
+ # Flatten hidden states + targets
699
+ h_flat = x.reshape(-1, x.shape[-1]) # (B*T, d)
700
+ t_flat = targets.reshape(-1) # (B*T,)
701
+ n = h_flat.shape[0]
702
+
703
+ # Learnability #4 hardening: sampled-softmax gather crashes on
704
+ # negative ids (-1 from doc-sep mask). Replace -1 with 0 for
705
+ # gather; the actual loss is masked below.
706
+ valid_mask_flat = (t_flat >= 0)
707
+ t_flat_safe = torch.where(valid_mask_flat, t_flat, torch.zeros_like(t_flat))
708
+
709
+ # Sample K negatives uniformly from [0, V)
710
+ neg_ids = torch.randint(0, V, (K_neg,), device=x.device)
711
+ # Gather lm_head weights for target + negatives
712
+ all_ids = torch.cat([t_flat_safe, neg_ids]) # (B*T + K,)
713
+ sampled_w = self.lm_head.weight[all_ids] # (B*T + K, d)
714
+
715
+ # Compute sampled logits: for each position, dot with its
716
+ # target weight and all K negative weights.
717
+ # Target logit: dot product of h[i] with w[target[i]]
718
+ target_w = sampled_w[:n] # (B*T, d)
719
+ neg_w = sampled_w[n:] # (K, d)
720
+ target_logit = (h_flat * target_w).sum(-1) # (B*T,)
721
+ neg_logits = h_flat @ neg_w.t() # (B*T, K)
722
+
723
+ if not _softcap_clamp:
724
+ target_logit = softcap * torch.tanh(target_logit / softcap)
725
+ neg_logits = softcap * torch.tanh(neg_logits / softcap)
726
+
727
+ # Sampled softmax loss: -log(exp(target) / (exp(target) + sum(exp(neg))))
728
+ # With log-sum-exp correction for sampling K of V negatives.
729
+ # Correction: add log(V/K) to negative logits to account for
730
+ # the fact that we're only seeing K of V possible negatives.
731
+ log_correction = torch.tensor(V / K_neg, device=x.device).log()
732
+ all_logits = torch.cat([
733
+ target_logit.unsqueeze(-1), # (B*T, 1)
734
+ neg_logits + log_correction, # (B*T, K)
735
+ ], dim=-1).float() # (B*T, K+1)
736
+
737
+ # CE with target always at index 0
738
+ ce_targets = torch.zeros(n, dtype=torch.long, device=x.device)
739
+ if reduction == 'none':
740
+ per_tok = F.cross_entropy(all_logits, ce_targets, reduction='none')
741
+ if self._doc_sep_mask and self._bos_token_id >= 0:
742
+ per_tok = torch.where(valid_mask_flat, per_tok, torch.zeros_like(per_tok))
743
+ return per_tok
744
+ per_tok_ce = F.cross_entropy(
745
+ all_logits, ce_targets, reduction='none',
746
+ label_smoothing=smoothing,
747
+ )
748
+ # Mask doc-separator positions. valid_mask_flat is always
749
+ # computed; when doc_sep_mask is off every token is valid so
750
+ # this reduces to a plain mean.
751
+ valid_f = valid_mask_flat.float()
752
+ valid_n = valid_f.sum().clamp(min=1)
753
+ out = (per_tok_ce * valid_f).sum() / valid_n
754
+ else:
755
+ # Full softmax path (eval or HYDRA_SAMPLED_SOFTMAX=0)
756
+ chunk_size = int(os.environ.get("HYDRA_CE_CHUNK", "1024"))
757
+ if chunk_size <= 0:
758
+ MAX_LOGITS_BYTES = 256 * 1024 * 1024
759
+ tokens_per_chunk = max(V, MAX_LOGITS_BYTES // (V * 4))
760
+ chunk_size = max(1, tokens_per_chunk // max(1, B))
761
+ chunk_size = min(chunk_size, T)
762
+
763
+ if reduction == 'none':
764
+ loss_parts = []
765
+ for start in range(0, T, chunk_size):
766
+ end = min(start + chunk_size, T)
767
+ chunk_logits = self.lm_head(x[:, start:end, :]).float()
768
+ if _softcap_clamp:
769
+ chunk_logits = torch.clamp(chunk_logits, -softcap, softcap)
770
+ else:
771
+ chunk_logits = softcap * torch.tanh(chunk_logits / softcap)
772
+ chunk_targets = targets[:, start:end].reshape(-1)
773
+ chunk_loss = F.cross_entropy(
774
+ chunk_logits.view(-1, chunk_logits.size(-1)),
775
+ chunk_targets, ignore_index=-1, reduction='none',
776
+ )
777
+ loss_parts.append(chunk_loss)
778
+ return torch.cat(loss_parts)
779
+
780
+ total_loss = 0.0
781
+ total_tokens = 0
782
+ for start in range(0, T, chunk_size):
783
+ end = min(start + chunk_size, T)
784
+ chunk_logits = self.lm_head(x[:, start:end, :]).float()
785
+ if _softcap_clamp:
786
+ chunk_logits = torch.clamp(chunk_logits, -softcap, softcap)
787
+ else:
788
+ chunk_logits = softcap * torch.tanh(chunk_logits / softcap)
789
+ chunk_targets = targets[:, start:end].reshape(-1)
790
+ chunk_loss = F.cross_entropy(
791
+ chunk_logits.view(-1, chunk_logits.size(-1)),
792
+ chunk_targets, ignore_index=-1, reduction='sum',
793
+ label_smoothing=smoothing,
794
+ )
795
+ total_loss = total_loss + chunk_loss
796
+ total_tokens += (chunk_targets != -1).sum()
797
+ out = total_loss / total_tokens
798
+
799
+ # -----------------------------------------------------------
800
+ # Learnability #1: Multi-Token Prediction.
801
+ # For k in {2..K}, add a CE loss at position (t) predicting
802
+ # the token at position (t+k), using the SAME lm_head weights
803
+ # (weight-tied). Cost: K-1 extra CEs on a subset of positions.
804
+ # Only triggered in reduction='mean' path, training only.
805
+ # -----------------------------------------------------------
806
+ if reduction == 'mean' and self._mtp_k > 1 and self.training and use_sampled:
807
+ # TRUE zero-cost MTP: reuse primary's neg_logits (B*T, K_neg)
808
+ # entirely. Only cost per extra head: O(B*T*d) target-weight
809
+ # gather + dot product. neg_logits is sliced (view) to match.
810
+ mtp_loss_sum = out.new_tensor(0.0)
811
+ mtp_terms = 0
812
+ # Reshape primary neg_logits back to (B, T, K_neg) so we can slice positions
813
+ neg_logits_bt = neg_logits.view(B, T, K_neg)
814
+ for k in range(2, self._mtp_k + 1):
815
+ shift = k - 1
816
+ if T <= shift:
817
+ continue
818
+ n_k = B * (T - shift)
819
+ h_k_flat = x[:, :T - shift, :].reshape(n_k, -1) # (n_k, d)
820
+ t_k = targets[:, shift:].reshape(-1) # (n_k,)
821
+ mask_k = (t_k >= 0)
822
+ t_k_safe = torch.where(mask_k, t_k, torch.zeros_like(t_k))
823
+ tgt_w_k = self.lm_head.weight[t_k_safe] # (n_k, d)
824
+ tgt_logit_k = (h_k_flat * tgt_w_k).sum(-1) # (n_k,)
825
+ if not _softcap_clamp:
826
+ tgt_logit_k = softcap * torch.tanh(tgt_logit_k / softcap)
827
+ # REUSE primary neg_logits β€” slice positions [:T-shift]
828
+ neg_logits_k = neg_logits_bt[:, :T - shift, :].reshape(n_k, K_neg)
829
+ all_logits_k = torch.cat([
830
+ tgt_logit_k.unsqueeze(-1),
831
+ neg_logits_k + log_correction,
832
+ ], dim=-1).float()
833
+ ce_targets_k = torch.zeros(n_k, dtype=torch.long, device=x.device)
834
+ per_tok_ce_k = F.cross_entropy(
835
+ all_logits_k, ce_targets_k, reduction='none',
836
+ label_smoothing=smoothing,
837
+ )
838
+ per_tok_ce_k = torch.where(mask_k, per_tok_ce_k, torch.zeros_like(per_tok_ce_k))
839
+ n_valid_k = mask_k.sum().clamp(min=1)
840
+ mtp_loss_sum = mtp_loss_sum + per_tok_ce_k.sum() / n_valid_k
841
+ mtp_terms += 1
842
+ if mtp_terms > 0:
843
+ out = (out + mtp_loss_sum) / float(mtp_terms + 1)
844
+
845
+ # -----------------------------------------------------------
846
+ # Learnability #6: output entropy penalty.
847
+ # L += -lambda * H(softmax(logits)). Negative entropy penalizes
848
+ # peaked distributions; encourages diverse predictions and
849
+ # breaks repetition loops. Computed on a small subset of
850
+ # positions to keep V-sized logits cost bounded.
851
+ # -----------------------------------------------------------
852
+ if reduction == 'mean' and self._entropy_penalty > 0.0 and self.training:
853
+ # Sample up to 64 random positions. V-sized logits on 64
854
+ # positions = 64 * V * 4 bytes (~50 MB at V=200k) β€” fits
855
+ # on the 3060 and adds ~2 ms.
856
+ h_flat = x.reshape(-1, x.shape[-1])
857
+ n_pos = h_flat.shape[0]
858
+ n_sample = min(64, n_pos)
859
+ idx_sample = torch.randint(0, n_pos, (n_sample,), device=x.device)
860
+ h_sample = h_flat[idx_sample]
861
+ logits_s = F.linear(h_sample, self.lm_head.weight).float()
862
+ if _softcap_clamp:
863
+ logits_s = torch.clamp(logits_s, -softcap, softcap)
864
+ else:
865
+ logits_s = softcap * torch.tanh(logits_s / softcap)
866
+ log_probs = F.log_softmax(logits_s, dim=-1)
867
+ probs = log_probs.exp()
868
+ entropy = -(probs * log_probs).sum(-1).mean() # scalar, nats
869
+ out = out - self._entropy_penalty * entropy
870
+
871
+ if _profile:
872
+ _t_end = _ev()
873
+ torch.cuda.synchronize()
874
+ def _ms(a, b): return a.elapsed_time(b)
875
+ print(
876
+ f"[PROFILE B={B} T={T}] "
877
+ f"htm_launch={_ms(_t0, _t_htm_async):.2f} "
878
+ f"wte={_ms(_t_htm_async, _t_wte):.2f} "
879
+ f"htm_await={_ms(_t_wte, _t_htm_await):.2f} "
880
+ f"htm_proj={_ms(_t_htm_await, _t_htm_proj):.2f} "
881
+ f"mamba_mhc_engram={_ms(_t_htm_proj, _t_blocks):.2f} "
882
+ f"merge={_ms(_t_blocks, _t_merge):.2f} "
883
+ f"lm_head_loss={_ms(_t_merge, _t_end):.2f} "
884
+ f"total={_ms(_t0, _t_end):.2f} ms",
885
+ flush=True,
886
+ )
887
+ return out
888
+
889
+ logits = self.lm_head(x).float()
890
+ if _softcap_clamp:
891
+ logits = torch.clamp(logits, -softcap, softcap)
892
+ else:
893
+ logits = softcap * torch.tanh(logits / softcap)
894
+ return logits
overlay/hydra/optimizer.py CHANGED
@@ -1,252 +1,252 @@
1
- """MuonAdamW optimizer β€” combined Muon (2D matrices) + AdamW (everything else).
2
-
3
- Extracted verbatim from train.py (W1 modularization). Semantics unchanged.
4
-
5
- F1-F15 state preserved:
6
- - F7 REVERTED: `stacked_params_buf` persistent across steps was REMOVED β€” each
7
- step calls `torch.stack([p.grad for p in params])` / `torch.stack(params)`
8
- fresh. Persistent copies of param storage would be mutated between forward
9
- passes (via lerp_/sub_ on stacked tensors that share storage with params),
10
- triggering "modified in-place" errors on grad_accum=2 backwards.
11
- - F11/F15: `@torch.compile` on `adamw_step_fused` / `muon_step_fused` intact.
12
- - F15 compile is default-ON (HYDRA_MUON_COMPILE=1), configured with
13
- dynamic=True + mode="default" to avoid the step-17β†’18 cudagraphs
14
- stream-capture deadlock. See .omc/muon_compile_bug.md for the full
15
- investigation.
16
- """
17
-
18
- from __future__ import annotations
19
-
20
- import os
21
-
22
- import torch
23
-
24
- # HYDRA_FUSED_ADAMW=1 (default) -> vectorized torch._fused_adamw_ kernel.
25
- _HYDRA_FUSED_ADAMW = os.environ.get("HYDRA_FUSED_ADAMW", "1") == "1"
26
- _HAS_FUSED_ADAMW = hasattr(torch, "_fused_adamw_")
27
-
28
-
29
- polar_express_coeffs = [
30
- (8.156554524902461, -22.48329292557795, 15.878769915207462),
31
- (4.042929935166739, -2.808917465908714, 0.5000178451051316),
32
- (3.8916678022926607, -2.772484153217685, 0.5060648178503393),
33
- (3.285753657755655, -2.3681294933425376, 0.46449024233003106),
34
- (2.3465413258596377, -1.7097828382687081, 0.42323551169305323),
35
- ]
36
-
37
-
38
- def adamw_step_fused(p, grad, exp_avg, exp_avg_sq, step_t, lr_t, beta1_t, beta2_t, eps_t, wd_t):
39
- # Per-param AdamW fallback. Fast path is torch._fused_adamw_ (1 CUDA launch
40
- # for the whole group) driven from MuonAdamW._step_adamw below.
41
- grad = grad.to(p.dtype) # handle mixed bf16/fp32 from autocast
42
- p.mul_(1 - lr_t * wd_t)
43
- exp_avg.lerp_(grad, 1 - beta1_t)
44
- exp_avg_sq.lerp_(grad.square(), 1 - beta2_t)
45
- bias1 = 1 - beta1_t ** step_t
46
- bias2 = 1 - beta2_t ** step_t
47
- denom = (exp_avg_sq / bias2).sqrt() + eps_t
48
- step_size = lr_t / bias1
49
- p.add_(exp_avg / denom, alpha=-step_size)
50
-
51
-
52
- # ---------------------------------------------------------------------------
53
- # F15 muon_step_fused compile strategy.
54
- #
55
- # HYDRA_MUON_COMPILE env gate:
56
- # "1" (default ON) β€” wrap with torch.compile(dynamic=True, mode="default").
57
- # Dynamic=True collapses the per-shape specialization cache so that N
58
- # Muon param-groups with N distinct shapes trigger 1 compile, not N.
59
- # mode="default" keeps the inductor codegen but disables cudagraphs,
60
- # which is what caused the step-17β†’18 silent deadlock observed under
61
- # the original dynamic=False configuration: cudagraph stream capture
62
- # can deadlock against HTM's CUDA kernels running on the default
63
- # stream, and the failure mode at capture-time is a silent hang
64
- # (100% GPU util, no log output, process state R).
65
- # "0" β€” fall back to eager Python (slower, ~43k tps vs ~63k compiled).
66
- # Keeps an escape hatch in case a future torch/inductor regression
67
- # reintroduces a deadlock.
68
- #
69
- # Defensive .clone() on stacked_grads before in-place lerp_ eliminates the
70
- # alias-analysis edge case where inductor sees `g is stacked_grads` and
71
- # subsequent `stacked_grads.square()` operating on the post-lerp storage.
72
- # ---------------------------------------------------------------------------
73
- _MUON_COMPILE = os.environ.get("HYDRA_MUON_COMPILE", "1") == "1"
74
-
75
- def _maybe_compile(fn):
76
- if _MUON_COMPILE:
77
- # mode="default" explicitly opts OUT of cudagraphs (which reduce-overhead
78
- # would enable) to avoid stream-capture deadlocks against HTM's CUDA
79
- # kernels. dynamic=True minimizes recompile count across param-group
80
- # shapes.
81
- return torch.compile(fn, fullgraph=False, dynamic=True, mode="default")
82
- return fn
83
-
84
- @_maybe_compile
85
- def muon_step_fused(stacked_grads, stacked_params, momentum_buffer, second_momentum_buffer,
86
- momentum_t, lr_t, wd_t, beta2_t, ns_steps, red_dim):
87
- # Cast grads to param dtype AND clone defensively to break any alias
88
- # between the (freshly-stacked) input and the in-place lerp_ below.
89
- # Without this, inductor's alias analysis can emit code that reads from
90
- # post-mutation storage when computing `v_mean = g.square().mean(...)`.
91
- stacked_grads = stacked_grads.to(momentum_buffer.dtype).clone()
92
- # Nesterov momentum
93
- momentum = momentum_t.to(device=momentum_buffer.device, dtype=stacked_grads.dtype)
94
- momentum_buffer.lerp_(stacked_grads, 1 - momentum)
95
- g = stacked_grads.lerp_(momentum_buffer, momentum)
96
- # Polar express orthogonalization
97
- X = g.bfloat16()
98
- X = X / (X.norm(dim=(-2, -1), keepdim=True) * 1.02 + 1e-6)
99
- if g.size(-2) > g.size(-1):
100
- for a, b, c in polar_express_coeffs[:ns_steps]:
101
- A = X.mT @ X
102
- B = b * A + c * (A @ A)
103
- X = a * X + X @ B
104
- else:
105
- for a, b, c in polar_express_coeffs[:ns_steps]:
106
- A = X @ X.mT
107
- B = b * A + c * (A @ A)
108
- X = a * X + B @ X
109
- g = X
110
- # NorMuon variance reduction
111
- # Keep beta2 in the state-buffer dtype, not g.dtype, so lerp_ on the
112
- # float32 second_momentum_buffer doesn't hit a dtype mismatch on h200.
113
- beta2 = beta2_t.to(device=second_momentum_buffer.device, dtype=second_momentum_buffer.dtype)
114
- v_mean = g.float().square().mean(dim=red_dim, keepdim=True)
115
- red_dim_size = g.size(red_dim)
116
- v_norm_sq = v_mean.sum(dim=(-2, -1), keepdim=True) * red_dim_size
117
- v_norm = v_norm_sq.sqrt()
118
- second_momentum_buffer.lerp_(v_mean.to(dtype=second_momentum_buffer.dtype), 1 - beta2)
119
- step_size = second_momentum_buffer.clamp_min(1e-10).rsqrt()
120
- scaled_sq_sum = (v_mean * red_dim_size) * step_size.float().square()
121
- v_norm_new = scaled_sq_sum.sum(dim=(-2, -1), keepdim=True).sqrt()
122
- final_scale = step_size * (v_norm / v_norm_new.clamp_min(1e-10))
123
- g = g * final_scale.to(g.dtype)
124
- # Cautious weight decay + parameter update
125
- lr = lr_t.to(device=stacked_params.device, dtype=g.dtype)
126
- wd = wd_t.to(device=stacked_params.device, dtype=g.dtype)
127
- mask = (g * stacked_params) >= 0
128
- stacked_params.sub_(lr * g + lr * wd * stacked_params * mask)
129
-
130
-
131
- class MuonAdamW(torch.optim.Optimizer):
132
- """Combined optimizer: Muon for 2D matrix params, AdamW for others."""
133
-
134
- def __init__(self, param_groups):
135
- super().__init__(param_groups, defaults={})
136
- # 0-D CPU tensors to avoid torch.compile recompilation when values change
137
- self._adamw_step_t = torch.tensor(0.0, dtype=torch.float32, device="cpu")
138
- self._adamw_lr_t = torch.tensor(0.0, dtype=torch.float32, device="cpu")
139
- self._adamw_beta1_t = torch.tensor(0.0, dtype=torch.float32, device="cpu")
140
- self._adamw_beta2_t = torch.tensor(0.0, dtype=torch.float32, device="cpu")
141
- self._adamw_eps_t = torch.tensor(0.0, dtype=torch.float32, device="cpu")
142
- self._adamw_wd_t = torch.tensor(0.0, dtype=torch.float32, device="cpu")
143
- self._muon_momentum_t = torch.tensor(0.0, dtype=torch.float32, device="cpu")
144
- self._muon_lr_t = torch.tensor(0.0, dtype=torch.float32, device="cpu")
145
- self._muon_wd_t = torch.tensor(0.0, dtype=torch.float32, device="cpu")
146
- self._muon_beta2_t = torch.tensor(0.0, dtype=torch.float32, device="cpu")
147
-
148
- def _step_adamw(self, group):
149
- params, grads, exp_avgs, exp_avg_sqs, state_steps = [], [], [], [], []
150
- for p in group['params']:
151
- if p.grad is None:
152
- continue
153
- state = self.state[p]
154
- if not state:
155
- state['step'] = 0
156
- state['exp_avg'] = torch.zeros_like(p)
157
- state['exp_avg_sq'] = torch.zeros_like(p)
158
- if 'step_t' not in state:
159
- # _fused_adamw_ wants a per-param float step tensor on-device.
160
- state['step_t'] = torch.tensor(
161
- float(state['step']), dtype=torch.float32, device=p.device
162
- )
163
- state['step'] += 1
164
- params.append(p)
165
- grads.append(p.grad.to(p.dtype) if p.grad.dtype != p.dtype else p.grad)
166
- exp_avgs.append(state['exp_avg'])
167
- exp_avg_sqs.append(state['exp_avg_sq'])
168
- state_steps.append(state['step_t'])
169
-
170
- if not params:
171
- return
172
-
173
- if _HYDRA_FUSED_ADAMW and _HAS_FUSED_ADAMW and params[0].is_cuda:
174
- # _fused_adamw_ needs uniform (device, dtype) within a call, so
175
- # group by (device, dtype) β€” same pattern as PyTorch's own
176
- # AdamW(fused=True) path (_group_tensors_by_device_and_dtype).
177
- buckets = {}
178
- for p, g, ea, es, st in zip(params, grads, exp_avgs, exp_avg_sqs, state_steps):
179
- key = (p.device, p.dtype)
180
- buckets.setdefault(key, ([], [], [], [], []))
181
- b_p, b_g, b_ea, b_es, b_st = buckets[key]
182
- b_p.append(p); b_g.append(g); b_ea.append(ea); b_es.append(es); b_st.append(st)
183
-
184
- lr_f = float(group['lr'])
185
- b1_f = float(group['betas'][0])
186
- b2_f = float(group['betas'][1])
187
- wd_f = float(group['weight_decay'])
188
- eps_f = float(group['eps'])
189
- for (_dev, _dt), (b_p, b_g, b_ea, b_es, b_st) in buckets.items():
190
- torch._foreach_add_(b_st, 1.0)
191
- torch._fused_adamw_(
192
- b_p, b_g, b_ea, b_es,
193
- [], # max_exp_avg_sqs unused (amsgrad=False)
194
- b_st,
195
- amsgrad=False,
196
- lr=lr_f, beta1=b1_f, beta2=b2_f,
197
- weight_decay=wd_f, eps=eps_f,
198
- maximize=False,
199
- grad_scale=None, found_inf=None,
200
- )
201
- return
202
-
203
- # Fallback per-param path.
204
- self._adamw_lr_t.fill_(group['lr'])
205
- self._adamw_beta1_t.fill_(group['betas'][0])
206
- self._adamw_beta2_t.fill_(group['betas'][1])
207
- self._adamw_eps_t.fill_(group['eps'])
208
- self._adamw_wd_t.fill_(group['weight_decay'])
209
- for p, grad, exp_avg, exp_avg_sq in zip(params, grads, exp_avgs, exp_avg_sqs):
210
- self._adamw_step_t.fill_(self.state[p]['step'])
211
- adamw_step_fused(p, grad, exp_avg, exp_avg_sq,
212
- self._adamw_step_t, self._adamw_lr_t, self._adamw_beta1_t,
213
- self._adamw_beta2_t, self._adamw_eps_t, self._adamw_wd_t)
214
-
215
- def _step_muon(self, group):
216
- params = [p for p in group['params'] if p.grad is not None]
217
- if not params:
218
- return
219
- p = params[0]
220
- state = self.state[p]
221
- num_params = len(params)
222
- shape, device, dtype = p.shape, p.device, p.dtype
223
- if "momentum_buffer" not in state:
224
- state["momentum_buffer"] = torch.zeros(num_params, *shape, dtype=dtype, device=device)
225
- red_dim = -1 if shape[-2] >= shape[-1] else -2
226
- if "second_momentum_buffer" not in state:
227
- # Shape must match v_mean = stacked_grads.square().mean(dim=red_dim, keepdim=True)
228
- full_shape = (num_params, *shape)
229
- state_shape = list(full_shape)
230
- state_shape[len(state_shape) + red_dim] = 1 # red_dim is negative
231
- state["second_momentum_buffer"] = torch.zeros(state_shape, dtype=dtype, device=device)
232
- # F7 REVERT: fresh stacks each step (no persistent stacked_params_buf).
233
- # This was the autograd-safety fix that unblocks grad_accum>=2.
234
- stacked_grads = torch.stack([p.grad for p in params])
235
- stacked_params = torch.stack(params)
236
- self._muon_momentum_t.fill_(group["momentum"])
237
- self._muon_beta2_t.fill_(group["beta2"] if group["beta2"] is not None else 0.0)
238
- self._muon_lr_t.fill_(group["lr"] * max(1.0, shape[-2] / shape[-1]) ** 0.5)
239
- self._muon_wd_t.fill_(group["weight_decay"])
240
- muon_step_fused(stacked_grads, stacked_params,
241
- state["momentum_buffer"], state["second_momentum_buffer"],
242
- self._muon_momentum_t, self._muon_lr_t, self._muon_wd_t,
243
- self._muon_beta2_t, group["ns_steps"], red_dim)
244
- torch._foreach_copy_(params, list(stacked_params.unbind(0)))
245
-
246
- @torch.no_grad()
247
- def step(self):
248
- for group in self.param_groups:
249
- if group['kind'] == 'adamw':
250
- self._step_adamw(group)
251
- elif group['kind'] == 'muon':
252
- self._step_muon(group)
 
1
+ """MuonAdamW optimizer β€” combined Muon (2D matrices) + AdamW (everything else).
2
+
3
+ Extracted verbatim from train.py (W1 modularization). Semantics unchanged.
4
+
5
+ F1-F15 state preserved:
6
+ - F7 REVERTED: `stacked_params_buf` persistent across steps was REMOVED β€” each
7
+ step calls `torch.stack([p.grad for p in params])` / `torch.stack(params)`
8
+ fresh. Persistent copies of param storage would be mutated between forward
9
+ passes (via lerp_/sub_ on stacked tensors that share storage with params),
10
+ triggering "modified in-place" errors on grad_accum=2 backwards.
11
+ - F11/F15: `@torch.compile` on `adamw_step_fused` / `muon_step_fused` intact.
12
+ - F15 compile is default-ON (HYDRA_MUON_COMPILE=1), configured with
13
+ dynamic=True + mode="default" to avoid the step-17β†’18 cudagraphs
14
+ stream-capture deadlock. See .omc/muon_compile_bug.md for the full
15
+ investigation.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import os
21
+
22
+ import torch
23
+
24
+ # HYDRA_FUSED_ADAMW=1 (default) -> vectorized torch._fused_adamw_ kernel.
25
+ _HYDRA_FUSED_ADAMW = os.environ.get("HYDRA_FUSED_ADAMW", "1") == "1"
26
+ _HAS_FUSED_ADAMW = hasattr(torch, "_fused_adamw_")
27
+
28
+
29
+ polar_express_coeffs = [
30
+ (8.156554524902461, -22.48329292557795, 15.878769915207462),
31
+ (4.042929935166739, -2.808917465908714, 0.5000178451051316),
32
+ (3.8916678022926607, -2.772484153217685, 0.5060648178503393),
33
+ (3.285753657755655, -2.3681294933425376, 0.46449024233003106),
34
+ (2.3465413258596377, -1.7097828382687081, 0.42323551169305323),
35
+ ]
36
+
37
+
38
+ def adamw_step_fused(p, grad, exp_avg, exp_avg_sq, step_t, lr_t, beta1_t, beta2_t, eps_t, wd_t):
39
+ # Per-param AdamW fallback. Fast path is torch._fused_adamw_ (1 CUDA launch
40
+ # for the whole group) driven from MuonAdamW._step_adamw below.
41
+ grad = grad.to(p.dtype) # handle mixed bf16/fp32 from autocast
42
+ p.mul_(1 - lr_t * wd_t)
43
+ exp_avg.lerp_(grad, 1 - beta1_t)
44
+ exp_avg_sq.lerp_(grad.square(), 1 - beta2_t)
45
+ bias1 = 1 - beta1_t ** step_t
46
+ bias2 = 1 - beta2_t ** step_t
47
+ denom = (exp_avg_sq / bias2).sqrt() + eps_t
48
+ step_size = lr_t / bias1
49
+ p.add_(exp_avg / denom, alpha=-step_size)
50
+
51
+
52
+ # ---------------------------------------------------------------------------
53
+ # F15 muon_step_fused compile strategy.
54
+ #
55
+ # HYDRA_MUON_COMPILE env gate:
56
+ # "1" (default ON) β€” wrap with torch.compile(dynamic=True, mode="default").
57
+ # Dynamic=True collapses the per-shape specialization cache so that N
58
+ # Muon param-groups with N distinct shapes trigger 1 compile, not N.
59
+ # mode="default" keeps the inductor codegen but disables cudagraphs,
60
+ # which is what caused the step-17β†’18 silent deadlock observed under
61
+ # the original dynamic=False configuration: cudagraph stream capture
62
+ # can deadlock against HTM's CUDA kernels running on the default
63
+ # stream, and the failure mode at capture-time is a silent hang
64
+ # (100% GPU util, no log output, process state R).
65
+ # "0" β€” fall back to eager Python (slower, ~43k tps vs ~63k compiled).
66
+ # Keeps an escape hatch in case a future torch/inductor regression
67
+ # reintroduces a deadlock.
68
+ #
69
+ # Defensive .clone() on stacked_grads before in-place lerp_ eliminates the
70
+ # alias-analysis edge case where inductor sees `g is stacked_grads` and
71
+ # subsequent `stacked_grads.square()` operating on the post-lerp storage.
72
+ # ---------------------------------------------------------------------------
73
+ _MUON_COMPILE = os.environ.get("HYDRA_MUON_COMPILE", "1") == "1"
74
+
75
+ def _maybe_compile(fn):
76
+ if _MUON_COMPILE:
77
+ # mode="default" explicitly opts OUT of cudagraphs (which reduce-overhead
78
+ # would enable) to avoid stream-capture deadlocks against HTM's CUDA
79
+ # kernels. dynamic=True minimizes recompile count across param-group
80
+ # shapes.
81
+ return torch.compile(fn, fullgraph=False, dynamic=True, mode="default")
82
+ return fn
83
+
84
+ @_maybe_compile
85
+ def muon_step_fused(stacked_grads, stacked_params, momentum_buffer, second_momentum_buffer,
86
+ momentum_t, lr_t, wd_t, beta2_t, ns_steps, red_dim):
87
+ # Cast grads to param dtype AND clone defensively to break any alias
88
+ # between the (freshly-stacked) input and the in-place lerp_ below.
89
+ # Without this, inductor's alias analysis can emit code that reads from
90
+ # post-mutation storage when computing `v_mean = g.square().mean(...)`.
91
+ stacked_grads = stacked_grads.to(momentum_buffer.dtype).clone()
92
+ # Nesterov momentum
93
+ momentum = momentum_t.to(device=momentum_buffer.device, dtype=stacked_grads.dtype)
94
+ momentum_buffer.lerp_(stacked_grads, 1 - momentum)
95
+ g = stacked_grads.lerp_(momentum_buffer, momentum)
96
+ # Polar express orthogonalization
97
+ X = g.bfloat16()
98
+ X = X / (X.norm(dim=(-2, -1), keepdim=True) * 1.02 + 1e-6)
99
+ if g.size(-2) > g.size(-1):
100
+ for a, b, c in polar_express_coeffs[:ns_steps]:
101
+ A = X.mT @ X
102
+ B = b * A + c * (A @ A)
103
+ X = a * X + X @ B
104
+ else:
105
+ for a, b, c in polar_express_coeffs[:ns_steps]:
106
+ A = X @ X.mT
107
+ B = b * A + c * (A @ A)
108
+ X = a * X + B @ X
109
+ g = X
110
+ # NorMuon variance reduction
111
+ # Keep beta2 in the state-buffer dtype, not g.dtype, so lerp_ on the
112
+ # float32 second_momentum_buffer doesn't hit a dtype mismatch on h200.
113
+ beta2 = beta2_t.to(device=second_momentum_buffer.device, dtype=second_momentum_buffer.dtype)
114
+ v_mean = g.float().square().mean(dim=red_dim, keepdim=True)
115
+ red_dim_size = g.size(red_dim)
116
+ v_norm_sq = v_mean.sum(dim=(-2, -1), keepdim=True) * red_dim_size
117
+ v_norm = v_norm_sq.sqrt()
118
+ second_momentum_buffer.lerp_(v_mean.to(dtype=second_momentum_buffer.dtype), 1 - beta2)
119
+ step_size = second_momentum_buffer.clamp_min(1e-10).rsqrt()
120
+ scaled_sq_sum = (v_mean * red_dim_size) * step_size.float().square()
121
+ v_norm_new = scaled_sq_sum.sum(dim=(-2, -1), keepdim=True).sqrt()
122
+ final_scale = step_size * (v_norm / v_norm_new.clamp_min(1e-10))
123
+ g = g * final_scale.to(g.dtype)
124
+ # Cautious weight decay + parameter update
125
+ lr = lr_t.to(device=stacked_params.device, dtype=g.dtype)
126
+ wd = wd_t.to(device=stacked_params.device, dtype=g.dtype)
127
+ mask = (g * stacked_params) >= 0
128
+ stacked_params.sub_(lr * g + lr * wd * stacked_params * mask)
129
+
130
+
131
+ class MuonAdamW(torch.optim.Optimizer):
132
+ """Combined optimizer: Muon for 2D matrix params, AdamW for others."""
133
+
134
+ def __init__(self, param_groups):
135
+ super().__init__(param_groups, defaults={})
136
+ # 0-D CPU tensors to avoid torch.compile recompilation when values change
137
+ self._adamw_step_t = torch.tensor(0.0, dtype=torch.float32, device="cpu")
138
+ self._adamw_lr_t = torch.tensor(0.0, dtype=torch.float32, device="cpu")
139
+ self._adamw_beta1_t = torch.tensor(0.0, dtype=torch.float32, device="cpu")
140
+ self._adamw_beta2_t = torch.tensor(0.0, dtype=torch.float32, device="cpu")
141
+ self._adamw_eps_t = torch.tensor(0.0, dtype=torch.float32, device="cpu")
142
+ self._adamw_wd_t = torch.tensor(0.0, dtype=torch.float32, device="cpu")
143
+ self._muon_momentum_t = torch.tensor(0.0, dtype=torch.float32, device="cpu")
144
+ self._muon_lr_t = torch.tensor(0.0, dtype=torch.float32, device="cpu")
145
+ self._muon_wd_t = torch.tensor(0.0, dtype=torch.float32, device="cpu")
146
+ self._muon_beta2_t = torch.tensor(0.0, dtype=torch.float32, device="cpu")
147
+
148
+ def _step_adamw(self, group):
149
+ params, grads, exp_avgs, exp_avg_sqs, state_steps = [], [], [], [], []
150
+ for p in group['params']:
151
+ if p.grad is None:
152
+ continue
153
+ state = self.state[p]
154
+ if not state:
155
+ state['step'] = 0
156
+ state['exp_avg'] = torch.zeros_like(p)
157
+ state['exp_avg_sq'] = torch.zeros_like(p)
158
+ if 'step_t' not in state:
159
+ # _fused_adamw_ wants a per-param float step tensor on-device.
160
+ state['step_t'] = torch.tensor(
161
+ float(state['step']), dtype=torch.float32, device=p.device
162
+ )
163
+ state['step'] += 1
164
+ params.append(p)
165
+ grads.append(p.grad.to(p.dtype) if p.grad.dtype != p.dtype else p.grad)
166
+ exp_avgs.append(state['exp_avg'])
167
+ exp_avg_sqs.append(state['exp_avg_sq'])
168
+ state_steps.append(state['step_t'])
169
+
170
+ if not params:
171
+ return
172
+
173
+ if _HYDRA_FUSED_ADAMW and _HAS_FUSED_ADAMW and params[0].is_cuda:
174
+ # _fused_adamw_ needs uniform (device, dtype) within a call, so
175
+ # group by (device, dtype) β€” same pattern as PyTorch's own
176
+ # AdamW(fused=True) path (_group_tensors_by_device_and_dtype).
177
+ buckets = {}
178
+ for p, g, ea, es, st in zip(params, grads, exp_avgs, exp_avg_sqs, state_steps):
179
+ key = (p.device, p.dtype)
180
+ buckets.setdefault(key, ([], [], [], [], []))
181
+ b_p, b_g, b_ea, b_es, b_st = buckets[key]
182
+ b_p.append(p); b_g.append(g); b_ea.append(ea); b_es.append(es); b_st.append(st)
183
+
184
+ lr_f = float(group['lr'])
185
+ b1_f = float(group['betas'][0])
186
+ b2_f = float(group['betas'][1])
187
+ wd_f = float(group['weight_decay'])
188
+ eps_f = float(group['eps'])
189
+ for (_dev, _dt), (b_p, b_g, b_ea, b_es, b_st) in buckets.items():
190
+ torch._foreach_add_(b_st, 1.0)
191
+ torch._fused_adamw_(
192
+ b_p, b_g, b_ea, b_es,
193
+ [], # max_exp_avg_sqs unused (amsgrad=False)
194
+ b_st,
195
+ amsgrad=False,
196
+ lr=lr_f, beta1=b1_f, beta2=b2_f,
197
+ weight_decay=wd_f, eps=eps_f,
198
+ maximize=False,
199
+ grad_scale=None, found_inf=None,
200
+ )
201
+ return
202
+
203
+ # Fallback per-param path.
204
+ self._adamw_lr_t.fill_(group['lr'])
205
+ self._adamw_beta1_t.fill_(group['betas'][0])
206
+ self._adamw_beta2_t.fill_(group['betas'][1])
207
+ self._adamw_eps_t.fill_(group['eps'])
208
+ self._adamw_wd_t.fill_(group['weight_decay'])
209
+ for p, grad, exp_avg, exp_avg_sq in zip(params, grads, exp_avgs, exp_avg_sqs):
210
+ self._adamw_step_t.fill_(self.state[p]['step'])
211
+ adamw_step_fused(p, grad, exp_avg, exp_avg_sq,
212
+ self._adamw_step_t, self._adamw_lr_t, self._adamw_beta1_t,
213
+ self._adamw_beta2_t, self._adamw_eps_t, self._adamw_wd_t)
214
+
215
+ def _step_muon(self, group):
216
+ params = [p for p in group['params'] if p.grad is not None]
217
+ if not params:
218
+ return
219
+ p = params[0]
220
+ state = self.state[p]
221
+ num_params = len(params)
222
+ shape, device, dtype = p.shape, p.device, p.dtype
223
+ if "momentum_buffer" not in state:
224
+ state["momentum_buffer"] = torch.zeros(num_params, *shape, dtype=dtype, device=device)
225
+ red_dim = -1 if shape[-2] >= shape[-1] else -2
226
+ if "second_momentum_buffer" not in state:
227
+ # Shape must match v_mean = stacked_grads.square().mean(dim=red_dim, keepdim=True)
228
+ full_shape = (num_params, *shape)
229
+ state_shape = list(full_shape)
230
+ state_shape[len(state_shape) + red_dim] = 1 # red_dim is negative
231
+ state["second_momentum_buffer"] = torch.zeros(state_shape, dtype=dtype, device=device)
232
+ # F7 REVERT: fresh stacks each step (no persistent stacked_params_buf).
233
+ # This was the autograd-safety fix that unblocks grad_accum>=2.
234
+ stacked_grads = torch.stack([p.grad for p in params])
235
+ stacked_params = torch.stack(params)
236
+ self._muon_momentum_t.fill_(group["momentum"])
237
+ self._muon_beta2_t.fill_(group["beta2"] if group["beta2"] is not None else 0.0)
238
+ self._muon_lr_t.fill_(group["lr"] * max(1.0, shape[-2] / shape[-1]) ** 0.5)
239
+ self._muon_wd_t.fill_(group["weight_decay"])
240
+ muon_step_fused(stacked_grads, stacked_params,
241
+ state["momentum_buffer"], state["second_momentum_buffer"],
242
+ self._muon_momentum_t, self._muon_lr_t, self._muon_wd_t,
243
+ self._muon_beta2_t, group["ns_steps"], red_dim)
244
+ torch._foreach_copy_(params, list(stacked_params.unbind(0)))
245
+
246
+ @torch.no_grad()
247
+ def step(self):
248
+ for group in self.param_groups:
249
+ if group['kind'] == 'adamw':
250
+ self._step_adamw(group)
251
+ elif group['kind'] == 'muon':
252
+ self._step_muon(group)
overlay/hydra/training.py CHANGED
@@ -1,961 +1,961 @@
1
- """HYDRA training entry: setup, train loop, eval, summary.
2
-
3
- Extracted from the monolithic train.py (W1 modularization). Semantics
4
- preserved. Public entrypoint: `main()`.
5
- """
6
-
7
- from __future__ import annotations
8
-
9
- import gc
10
- import json
11
- import math
12
- import os
13
- import sys
14
- import threading
15
- import time
16
- from dataclasses import asdict
17
- from pathlib import Path
18
-
19
- import torch
20
-
21
- # Line-buffered stdout so `python -u train.py | tee run.log | grep step` is
22
- # live (no \r overwrite, no 4k block-buffered pipe stalls). Safe on Python
23
- # 3.7+ where io.TextIOWrapper.reconfigure exists.
24
- try:
25
- sys.stdout.reconfigure(line_buffering=True) # type: ignore[attr-defined]
26
- except Exception:
27
- pass
28
-
29
- from hydra.config import (
30
- ADAM_BETAS, CURRICULUM_SHORT_SEQ_LEN, CURRICULUM_SHORT_STEPS,
31
- D_MODEL, D_STATE, DEVICE_BATCH_SIZE, EMA_DECAY, EMBEDDING_LR,
32
- ENGRAM_KEY_DIM, ENGRAM_LAYER_IDX, ENGRAM_N_COLUMNS, EXPAND,
33
- FINAL_LR_FRAC, GPU_BF16_PEAK_FLOPS, HEADDIM, MATRIX_LR, N_HEADS,
34
- N_LAYER, PostSemClawConfig, SCALAR_LR, SEED, TOTAL_BATCH_SIZE,
35
- UNEMBEDDING_LR, USE_EMA, WARMUP_RATIO, WEIGHT_DECAY,
36
- )
37
- from hydra.diffusion_loss import mdlm_masked_forward_process, mdlm_rb_loss
38
- from hydra.eval import run_factual_english, run_factual_probes
39
- from hydra.model import PostSemClawModel
40
-
41
- import prepare as _prepare_mod
42
- from prepare import MAX_SEQ_LEN, TIME_BUDGET as _TIME_BUDGET, Tokenizer, evaluate_bpb as _evaluate_bpb_shards, get_token_bytes, make_dataloader as _make_dataloader_shards
43
-
44
- # Streaming Nemotron path (Super3 recipe). Opt-in via HYDRA_USE_NEMOTRON=1.
45
- if os.environ.get("HYDRA_USE_NEMOTRON", "0") == "1":
46
- import prepare_nemotron as _p_nemo
47
- make_dataloader = _p_nemo.make_dataloader
48
- evaluate_bpb = _p_nemo.evaluate_bpb
49
- else:
50
- make_dataloader = _make_dataloader_shards
51
- evaluate_bpb = _evaluate_bpb_shards
52
-
53
- TIME_BUDGET = int(os.environ.get("HYDRA_TIME_BUDGET", str(_TIME_BUDGET)))
54
- _prepare_mod.TIME_BUDGET = TIME_BUDGET # sync for evaluate_bpb
55
-
56
- CACHE_DIR = Path.home() / ".cache" / "autoresearch"
57
- LATEST_CKPT = CACHE_DIR / "latest.pt"
58
- PRETRAIN_FINAL_CKPT = CACHE_DIR / "pretrain_final.pt"
59
- FAILED_CKPT = CACHE_DIR / "latest_failed.pt" # crash/FAIL path β€” never overwrites good
60
- BEST_CKPT = CACHE_DIR / "best_bpb.pt" # lowest val_bpb seen
61
- CKPT_INTERVAL = int(os.environ.get("HYDRA_CKPT_INTERVAL", "250"))
62
- CKPT_ROTATIONS = int(os.environ.get("HYDRA_CKPT_ROTATIONS", "3")) # how many .N backups to keep
63
- RESUME_CKPT = os.environ.get("HYDRA_RESUME_CKPT", str(LATEST_CKPT))
64
-
65
- # MDLM (Masked Diffusion LM) Rao-Blackwellized ELBO loss path.
66
- # HYDRA_USE_MDLM=1 : switch training loss from AR sampled-softmax CE
67
- # to MDLM RB weighted CE (arXiv:2406.07524).
68
- # HYDRA_MDLM_MASK_ID=N : token id used for the MASK sentinel (default:
69
- # last valid id, vocab_size - 1). Ensure this id
70
- # never appears in training targets β€” typical
71
- # practice is to reserve it.
72
- # HYDRA_MDLM_SCHEDULE=loglinear|linear : noise schedule (default loglinear).
73
- # When enabled, the per-step flow is:
74
- # 1. mdlm_masked_forward_process(y) -> (x_noised, mask_positions, weights)
75
- # 2. logits = model(x_noised) (no targets -> full V logits)
76
- # 3. loss = mdlm_rb_loss(logits, y, mask_positions, weights)
77
- # Sampled-softmax is bypassed in this path because the RB ELBO needs
78
- # full-vocab logits on masked positions.
79
- USE_MDLM = os.environ.get("HYDRA_USE_MDLM", "0") == "1"
80
- MDLM_MASK_ID = int(os.environ.get("HYDRA_MDLM_MASK_ID", "-1")) # -1 => default to vocab_size-1 at runtime
81
- MDLM_SCHEDULE = os.environ.get("HYDRA_MDLM_SCHEDULE", "loglinear")
82
-
83
-
84
- # ---------------------------------------------------------------------------
85
- # Schedules
86
- # ---------------------------------------------------------------------------
87
-
88
- def get_lr_multiplier(progress: float) -> float:
89
- if progress < WARMUP_RATIO:
90
- return progress / WARMUP_RATIO if WARMUP_RATIO > 0 else 1.0
91
- decay_progress = (progress - WARMUP_RATIO) / (1.0 - WARMUP_RATIO)
92
- return FINAL_LR_FRAC + 0.5 * (1.0 - FINAL_LR_FRAC) * (1 + math.cos(math.pi * decay_progress))
93
-
94
-
95
- def get_muon_momentum(step: int) -> float:
96
- frac = min(step / 300, 1)
97
- return (1 - frac) * 0.85 + frac * 0.95
98
-
99
-
100
- def get_weight_decay(progress: float) -> float:
101
- return WEIGHT_DECAY * (1 - progress)
102
-
103
-
104
- _CKPT_WORKER_THREAD: threading.Thread | None = None
105
-
106
-
107
- def _ckpt_snapshot_state_dicts(
108
- model: PostSemClawModel,
109
- optimizer: torch.optim.Optimizer,
110
- ) -> tuple[dict, dict]:
111
- """Detach + CPU-clone every tensor so a bg thread can serialize safely
112
- while the main loop keeps mutating live weights/optimizer state."""
113
- msd = {k: (v.detach().to("cpu", copy=True) if torch.is_tensor(v) else v)
114
- for k, v in model.state_dict().items()}
115
- # optimizer.state_dict() is a nested dict; walk it.
116
- osd_raw = optimizer.state_dict()
117
-
118
- def _to_cpu(obj):
119
- if torch.is_tensor(obj):
120
- return obj.detach().to("cpu", copy=True)
121
- if isinstance(obj, dict):
122
- return {k: _to_cpu(v) for k, v in obj.items()}
123
- if isinstance(obj, list):
124
- return [_to_cpu(v) for v in obj]
125
- if isinstance(obj, tuple):
126
- return tuple(_to_cpu(v) for v in obj)
127
- return obj
128
-
129
- osd = _to_cpu(osd_raw)
130
- return msd, osd
131
-
132
-
133
- def save_ckpt(
134
- model: PostSemClawModel,
135
- optimizer: torch.optim.Optimizer,
136
- config: PostSemClawConfig,
137
- step: int,
138
- total_training_time: float,
139
- smooth_train_loss: float,
140
- bpt_ema: float,
141
- epoch: int,
142
- path: Path,
143
- *,
144
- val_bpb: float | None = None,
145
- blocking: bool = False,
146
- ) -> None:
147
- """Save a training checkpoint.
148
-
149
- Default behavior is async: the GPU→CPU state_dict clone runs on the main
150
- thread (unavoidable; needs to happen before the next optimizer.step that
151
- mutates live weights), then `torch.save` is dispatched to a daemon
152
- worker thread. The next call joins any still-running prior save so only
153
- one disk write is in flight.
154
-
155
- `blocking=True` restores the original synchronous behavior β€” used for
156
- end-of-training saves where correctness on process exit matters.
157
- """
158
- global _CKPT_WORKER_THREAD
159
- try:
160
- CACHE_DIR.mkdir(parents=True, exist_ok=True)
161
- msd, osd = _ckpt_snapshot_state_dicts(model, optimizer)
162
- # asdict() recursively converts dataclass fields to a dict and
163
- # renders tuples as lists. hyena_layers therefore round-trips as a
164
- # JSON-safe list; config_from_dict normalizes it back to a tuple.
165
- payload = {
166
- "model_state_dict": msd,
167
- "optimizer_state_dict": osd,
168
- "config": asdict(config),
169
- "step": step,
170
- "epoch": epoch,
171
- "train_seconds": total_training_time,
172
- "smoothed_loss": smooth_train_loss,
173
- "bpt_ema": bpt_ema,
174
- "val_bpb": val_bpb,
175
- }
176
- path_str = str(path)
177
-
178
- def _rotate(p: str) -> None:
179
- """Keep up to CKPT_ROTATIONS previous versions as p.1, p.2, ..."""
180
- if CKPT_ROTATIONS <= 0:
181
- return
182
- try:
183
- # Walk from oldest to newest so we don't clobber newer with older.
184
- for i in range(CKPT_ROTATIONS, 0, -1):
185
- src = f"{p}.{i-1}" if i > 1 else p
186
- dst = f"{p}.{i}"
187
- if os.path.exists(src):
188
- os.replace(src, dst)
189
- except Exception as e:
190
- # Rotation is best-effort; never block a save on it.
191
- print(f"[ckpt] rotate warn {p}: {type(e).__name__}: {e}", flush=True)
192
-
193
- def _write():
194
- try:
195
- _rotate(path_str)
196
- tmp = path_str + ".tmp"
197
- torch.save(payload, tmp)
198
- os.replace(tmp, path_str)
199
- print(f"[ckpt] saved {path_str} (step={step})", flush=True)
200
- except Exception as e:
201
- print(f"[ckpt] SAVE FAILED {path_str}: {type(e).__name__}: {e}", flush=True)
202
-
203
- if blocking:
204
- _write()
205
- return
206
-
207
- # Join previous writer so at most one torch.save runs at a time.
208
- if _CKPT_WORKER_THREAD is not None and _CKPT_WORKER_THREAD.is_alive():
209
- _CKPT_WORKER_THREAD.join()
210
- _CKPT_WORKER_THREAD = threading.Thread(
211
- target=_write, daemon=True, name=f"ckpt-save-{step}"
212
- )
213
- _CKPT_WORKER_THREAD.start()
214
- except Exception as e:
215
- print(f"[ckpt] SNAPSHOT FAILED {path}: {type(e).__name__}: {e}", flush=True)
216
-
217
-
218
- def config_from_dict(cfg_dict: dict) -> PostSemClawConfig:
219
- """Reconstruct a PostSemClawConfig from a checkpoint's asdict() payload.
220
-
221
- Newly-added fields (e.g. `hyena_layers`) are defaulted when absent in
222
- older checkpoints, and list-ified tuples are coerced back to tuples so
223
- the dataclass keeps its declared types.
224
-
225
- This is the ckpt-safe inverse of `asdict(config)` used by save_ckpt and
226
- guarantees that a resume path can rebuild the exact same model topology
227
- (Mamba3 vs HyenaBlock per layer) regardless of env-var state at resume.
228
- """
229
- # Only keep keys that are actually declared on PostSemClawConfig β€” extra
230
- # keys in older/newer checkpoints must not crash construction.
231
- field_names = {f.name for f in PostSemClawConfig.__dataclass_fields__.values()}
232
- filtered = {k: v for k, v in cfg_dict.items() if k in field_names}
233
- # asdict renders tuple[int,...] as list[int]; coerce back so the model
234
- # builder sees the declared type.
235
- if "hyena_layers" in filtered and filtered["hyena_layers"] is not None:
236
- filtered["hyena_layers"] = tuple(sorted(int(x) for x in filtered["hyena_layers"]))
237
- return PostSemClawConfig(**filtered)
238
-
239
-
240
- def _try_load_ckpt(path: Path, model, optimizer, device):
241
- """Attempt to load a single ckpt. Returns the tuple on success, None on any failure."""
242
- if not path.exists():
243
- return None
244
- ckpt = torch.load(str(path), map_location=device, weights_only=False)
245
- state = ckpt.get("model_state_dict", ckpt)
246
- missing, unexpected = model.load_state_dict(state, strict=False)
247
- if missing:
248
- print(f"[ckpt] {path.name} missing={len(missing)}", flush=True)
249
- if unexpected:
250
- print(f"[ckpt] {path.name} unexpected={len(unexpected)}", flush=True)
251
- optimizer_state = ckpt.get("optimizer_state_dict")
252
- if optimizer_state is not None:
253
- try:
254
- optimizer.load_state_dict(optimizer_state)
255
- except Exception as e:
256
- print(f"[ckpt] optimizer restore failed from {path.name}: {type(e).__name__}: {e}", flush=True)
257
- step = int(ckpt.get("step", 0))
258
- total_training_time = float(ckpt.get("train_seconds", 0.0))
259
- smooth_train_loss = float(ckpt.get("smoothed_loss", 0.0))
260
- bpt_ema = float(ckpt.get("bpt_ema", 0.0))
261
- epoch = int(ckpt.get("epoch", 0))
262
- print(
263
- f"[ckpt] resumed {path} step={step} train_seconds={total_training_time:.1f}",
264
- flush=True,
265
- )
266
- # Warn if resuming a schedule-exhausted ckpt β€” user is probably warm-starting.
267
- budget = float(os.environ.get("HYDRA_TIME_BUDGET", "0") or 0)
268
- if budget and total_training_time >= 0.99 * budget:
269
- print(
270
- f"[ckpt] WARNING: resumed ckpt used {total_training_time:.0f}s of {budget:.0f}s "
271
- f"budget. LR schedule is essentially exhausted. "
272
- f"Set HYDRA_WARMSTART=1 to reset optimizer + scheduler and keep only weights.",
273
- flush=True,
274
- )
275
- return step, total_training_time, smooth_train_loss, bpt_ema, epoch
276
-
277
-
278
- def maybe_resume_ckpt(
279
- model: PostSemClawModel,
280
- optimizer: torch.optim.Optimizer,
281
- device: torch.device,
282
- ) -> tuple[int, float, float, float, int]:
283
- if not RESUME_CKPT or RESUME_CKPT.lower() == "none":
284
- print("[ckpt] resume disabled; starting fresh", flush=True)
285
- return 0, 0.0, 0.0, 0.0, 0
286
-
287
- resume_path = Path(os.path.expanduser(RESUME_CKPT))
288
- # Try the primary path, then rotated backups. This is crucial because a
289
- # partial / killed torch.save on the primary path would leave a corrupt
290
- # file. If that fails we fall back to latest.pt.1, .2, .3 automatically.
291
- candidates: list[Path] = [resume_path]
292
- for i in range(1, CKPT_ROTATIONS + 1):
293
- candidates.append(Path(str(resume_path) + f".{i}"))
294
-
295
- for cand in candidates:
296
- if not cand.exists():
297
- continue
298
- try:
299
- result = _try_load_ckpt(cand, model, optimizer, device)
300
- if result is not None:
301
- if cand != resume_path:
302
- print(f"[ckpt] fell back to rotation {cand.name}", flush=True)
303
- return result
304
- except Exception as e:
305
- print(f"[ckpt] {cand.name} load failed: {type(e).__name__}: {e}", flush=True)
306
- continue
307
-
308
- print(f"[ckpt] no usable checkpoint in {resume_path} + rotations; starting fresh", flush=True)
309
- return 0, 0.0, 0.0, 0.0, 0
310
-
311
-
312
- # ---------------------------------------------------------------------------
313
- # Main entry
314
- # ---------------------------------------------------------------------------
315
-
316
- def main() -> None:
317
- t_start = time.time()
318
- torch.manual_seed(SEED)
319
- torch.cuda.manual_seed(SEED)
320
- # Precision / kernel-selection knobs for peak throughput on Ampere.
321
- # - high : matmul uses TF32 (Ampere's 10-bit mantissa accum) for fp32 ops
322
- # - allow_tf32 : explicit for both matmul + cudnn paths
323
- # - cudnn.benchmark : env-gated (HYDRA_CUDNN_BENCHMARK, default OFF).
324
- # TRUE can lock in a locally-better-but-globally-slower algorithm
325
- # after the autotune phase ends, causing tps to degrade 15-20%
326
- # over the first ~100 steps. Observed 2026-04-22 and confirmed by
327
- # differential profiling. Default is now FALSE; set =1 only if you
328
- # see a specific workload where benchmark helps sustained tps.
329
- torch.set_float32_matmul_precision("high")
330
- torch.backends.cuda.matmul.allow_tf32 = True
331
- torch.backends.cudnn.allow_tf32 = True
332
- torch.backends.cudnn.benchmark = os.environ.get("HYDRA_CUDNN_BENCHMARK", "0") == "1"
333
- device = torch.device("cuda")
334
- autocast_ctx = torch.amp.autocast(device_type="cuda", dtype=torch.bfloat16)
335
-
336
- # Streaming path skips prepare.py (which normally trains the tokenizer
337
- # and builds the retina), so we must materialize both before model init.
338
- if os.environ.get("HYDRA_USE_NEMOTRON", "0") == "1":
339
- _p_nemo.ensure_tokenizer()
340
- # Retina: HF Hub cache hit for this (vocab, n_bits, target_active) combo
341
- # returns in seconds; otherwise build_retina streams Nemotron docs to
342
- # compute cooccurrence + train SOM, then uploads back to the cache.
343
- import subsystems.sdr_retina as _sdr_retina
344
- _sdr_retina.build_retina()
345
- tokenizer = Tokenizer.from_directory()
346
- vocab_size = tokenizer.get_vocab_size()
347
- print(f"Vocab size: {vocab_size:,}")
348
-
349
- config = PostSemClawConfig(
350
- sequence_len=MAX_SEQ_LEN,
351
- vocab_size=vocab_size,
352
- n_layer=N_LAYER,
353
- d_model=D_MODEL,
354
- d_state=D_STATE,
355
- headdim=HEADDIM,
356
- n_heads=N_HEADS,
357
- expand=EXPAND,
358
- engram_n_columns=ENGRAM_N_COLUMNS,
359
- engram_key_dim=ENGRAM_KEY_DIM,
360
- engram_layer_idx=ENGRAM_LAYER_IDX,
361
- )
362
- print(f"Model config: {asdict(config)}")
363
-
364
- with torch.device("meta"):
365
- model = PostSemClawModel(config)
366
- model.to_empty(device=device)
367
- model.init_weights()
368
-
369
- param_counts = model.num_scaling_params()
370
- print("Parameter counts:")
371
- for key, value in param_counts.items():
372
- print(f" {key:24s}: {value:,}")
373
- num_params = param_counts['total']
374
- num_flops_per_token = model.estimate_flops()
375
- print(f"Estimated FLOPs per token: {num_flops_per_token:e}")
376
-
377
- tokens_per_fwdbwd = DEVICE_BATCH_SIZE * MAX_SEQ_LEN
378
- assert TOTAL_BATCH_SIZE % tokens_per_fwdbwd == 0
379
- grad_accum_steps = TOTAL_BATCH_SIZE // tokens_per_fwdbwd
380
-
381
- optimizer = model.setup_optimizer(
382
- unembedding_lr=UNEMBEDDING_LR,
383
- embedding_lr=EMBEDDING_LR,
384
- scalar_lr=SCALAR_LR,
385
- adam_betas=ADAM_BETAS,
386
- matrix_lr=MATRIX_LR,
387
- weight_decay=WEIGHT_DECAY,
388
- )
389
-
390
- step, total_training_time, smooth_train_loss, bpt_ema, resume_epoch = maybe_resume_ckpt(
391
- model, optimizer, device,
392
- )
393
-
394
- # Learnability #4: inform the model of the BOS token id so it can mask
395
- # doc-separator positions in packed sequences. Always set (the mask only
396
- # fires when HYDRA_DOC_SEP_MASK=1 is also on).
397
- if hasattr(model, 'set_bos_token_id'):
398
- model.set_bos_token_id(tokenizer.get_bos_token_id())
399
-
400
- # Learnability #2: EMA shadow copy of weights. AveragedModel clones every
401
- # parameter; we update it after every optimizer step and save it at the
402
- # end alongside the raw checkpoint. Defaults OFF.
403
- ema_model = None
404
- if USE_EMA:
405
- try:
406
- from torch.optim.swa_utils import AveragedModel, get_ema_multi_avg_fn
407
- # decay=EMA_DECAY; avg_fn uses get_ema_multi_avg_fn for numerical
408
- # stability across bf16/fp32 mixed parameter groups.
409
- ema_model = AveragedModel(
410
- model,
411
- multi_avg_fn=get_ema_multi_avg_fn(EMA_DECAY),
412
- )
413
- print(f"[EMA] enabled with decay={EMA_DECAY}")
414
- except Exception as _e:
415
- print(f"[EMA] disabled β€” AveragedModel init failed: {_e}")
416
- ema_model = None
417
-
418
- print("torch.compile: Muon step compiled; AdamW uses torch._fused_adamw_ (model blocks use native CUDA kernels)")
419
-
420
- # Learnability #7: curriculum short-then-long. If enabled, build the
421
- # initial dataloader at the short seq_len; we swap to full MAX_SEQ_LEN
422
- # after CURRICULUM_SHORT_STEPS optimizer steps (see loop below).
423
- _curriculum_active = CURRICULUM_SHORT_STEPS > 0 and CURRICULUM_SHORT_SEQ_LEN < MAX_SEQ_LEN
424
- _current_seq_len = CURRICULUM_SHORT_SEQ_LEN if _curriculum_active else MAX_SEQ_LEN
425
- if _curriculum_active:
426
- print(
427
- f"[CURRICULUM] starting at T={_current_seq_len} for "
428
- f"{CURRICULUM_SHORT_STEPS} steps, then switching to T={MAX_SEQ_LEN}"
429
- )
430
- train_loader = make_dataloader(tokenizer, DEVICE_BATCH_SIZE, _current_seq_len, "train")
431
- x, y, epoch = next(train_loader) # prefetch first batch
432
- if resume_epoch > 0:
433
- epoch = max(epoch, resume_epoch)
434
-
435
- print(f"Time budget: {TIME_BUDGET}s")
436
- print(f"Gradient accumulation steps: {grad_accum_steps}")
437
-
438
- # Token→byte LUT for bits-per-byte computation. evaluate_bpb in prepare.py
439
- # uses total_nats / (ln(2) * total_bytes); our live metric needs to match.
440
- # Without this, `bpb = loss/ln(2)` is actually bits-per-TOKEN, which at
441
- # vocab=8192 scales by ~4 and makes live train bpb non-comparable with
442
- # val_bpb (champion 1.279 bpb vs train printing "8.04").
443
- token_bytes = get_token_bytes(device=device)
444
-
445
- # -----------------------------------------------------------------------
446
- # Training loop
447
- # -----------------------------------------------------------------------
448
-
449
- t_start_training = time.time()
450
-
451
- # Async postprocessing β€” run SOM + Hestia on background threads so
452
- # the GPU doesn't idle during their CPU-bound work.
453
- _ASYNC_POSTPROCESS = os.environ.get("HYDRA_ASYNC_POSTPROCESS", "1") == "1"
454
- _som_thread: threading.Thread | None = None
455
- _hestia_thread: threading.Thread | None = None
456
- _hestia_stream: torch.cuda.Stream | None = (
457
- torch.cuda.Stream() if _ASYNC_POSTPROCESS else None
458
- )
459
-
460
- # HYDRA_PROFILE_STEPS=N prints a per-phase cpu/gpu time breakdown for the
461
- # first N steps (and every 100th step thereafter if N<0). Zero overhead
462
- # when disabled. Used to find what's eating CPU budget when GPU should
463
- # be the bottleneck.
464
- _profile_steps = int(os.environ.get("HYDRA_PROFILE_STEPS", "0"))
465
-
466
- while True:
467
- torch.cuda.synchronize()
468
- t0 = time.time()
469
- _prof = _profile_steps and (step < _profile_steps or (_profile_steps < 0 and step % 100 == 0))
470
- _gpu_ms = 0.0
471
- _data_ms = 0.0
472
- for micro_step in range(grad_accum_steps):
473
- if _prof:
474
- torch.cuda.synchronize(); _t_micro = time.time()
475
- if USE_MDLM:
476
- # MDLM path: corrupt y -> x_noised, run model to get full-V logits,
477
- # compute RB weighted CE on masked positions. x (original input) is
478
- # unused in this path β€” the model only sees the noised version of y.
479
- _mask_id = MDLM_MASK_ID if MDLM_MASK_ID >= 0 else (vocab_size - 1)
480
- x_noised, mask_positions, loss_weights = mdlm_masked_forward_process(
481
- y, mask_token_id=_mask_id, alpha_schedule=MDLM_SCHEDULE,
482
- )
483
- with autocast_ctx:
484
- logits = model(x_noised) # targets=None -> (B, T, V) logits
485
- loss = mdlm_rb_loss(logits, y, mask_positions, loss_weights)
486
- else:
487
- with autocast_ctx:
488
- loss = model(x, y)
489
- train_loss = loss.detach()
490
- loss = loss / grad_accum_steps
491
- loss.backward()
492
- if _prof:
493
- torch.cuda.synchronize()
494
- _gpu_ms += (time.time() - _t_micro) * 1000
495
- _t_data = time.time()
496
- x, y, epoch = next(train_loader)
497
- if _prof:
498
- _data_ms += (time.time() - _t_data) * 1000
499
- if _prof:
500
- torch.cuda.synchronize(); _t_fb = time.time()
501
-
502
- # Progress and schedules
503
- progress = min(total_training_time / TIME_BUDGET, 1.0)
504
- lrm = get_lr_multiplier(progress)
505
- muon_momentum = get_muon_momentum(step)
506
- muon_weight_decay = get_weight_decay(progress)
507
- for group in optimizer.param_groups:
508
- group["lr"] = group["initial_lr"] * lrm
509
- if group['kind'] == 'muon':
510
- group["momentum"] = muon_momentum
511
- group["weight_decay"] = muon_weight_decay
512
- torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
513
- optimizer.step()
514
- if _prof:
515
- torch.cuda.synchronize(); _t_opt = time.time()
516
-
517
- # Learnability #2: EMA update after every optimizer step.
518
- if ema_model is not None:
519
- try:
520
- ema_model.update_parameters(model)
521
- except Exception as _e:
522
- print(f"[EMA] update failed at step {step}: {_e}", flush=True)
523
-
524
- # Learnability #7: curriculum transition. After
525
- # CURRICULUM_SHORT_STEPS optimizer steps, rebuild the dataloader at
526
- # MAX_SEQ_LEN. Done once, then the flag flips off.
527
- if _curriculum_active and step + 1 >= CURRICULUM_SHORT_STEPS:
528
- print(
529
- f"[CURRICULUM] step={step+1} β€” switching from T={_current_seq_len} "
530
- f"to T={MAX_SEQ_LEN}",
531
- flush=True,
532
- )
533
- _current_seq_len = MAX_SEQ_LEN
534
- _curriculum_active = False
535
- train_loader = make_dataloader(tokenizer, DEVICE_BATCH_SIZE, _current_seq_len, "train")
536
- # Prefetch the next batch at the new seq_len so the following
537
- # loop iteration consumes fresh data.
538
- x, y, epoch = next(train_loader)
539
-
540
- # Online SOM update β€” retina is now a plain Python attribute (not a
541
- # registered buffer) so mutations do not invalidate torch.compile guards.
542
- # Runs fully on CPU; safe to overlap with GPU forward pass.
543
- _last_sdr = getattr(model, "_last_sdr", None)
544
- if _last_sdr is not None:
545
- if _ASYNC_POSTPROCESS:
546
- if _som_thread is not None:
547
- _som_thread.join()
548
- # Clone tensors before next step overwrites them
549
- _som_x = x.clone()
550
- _som_sdr = _last_sdr.clone()
551
- _som_thread = threading.Thread(
552
- target=model.sdr_semantic.maybe_som_update,
553
- args=(_som_x, _som_sdr),
554
- daemon=True,
555
- )
556
- _som_thread.start()
557
- else:
558
- model.sdr_semantic.maybe_som_update(x, _last_sdr)
559
-
560
- # Hestia QAT β€” anneal temperature every step, snap every N steps.
561
- # apply_to walks all Linear modules (CPU) then does .data.copy_ (GPU).
562
- # Background thread + separate CUDA stream lets this overlap with
563
- # the next forward pass on the default stream.
564
- _hestia_progress = (time.time() - t_start_training) / max(TIME_BUDGET, 1)
565
- _hestia_interval = int(os.environ.get("HYDRA_HESTIA_INTERVAL", "100"))
566
- if step % _hestia_interval == 0:
567
- if _ASYNC_POSTPROCESS:
568
- if _hestia_thread is not None:
569
- _hestia_thread.join()
570
-
571
- def _hestia_bg(mdl: torch.nn.Module, prog: float) -> None:
572
- assert _hestia_stream is not None
573
- with torch.cuda.stream(_hestia_stream):
574
- mdl.hestia.anneal_temperature(prog)
575
- mdl.hestia.apply_to(mdl)
576
-
577
- _hestia_thread = threading.Thread(
578
- target=_hestia_bg,
579
- args=(model, _hestia_progress),
580
- daemon=True,
581
- )
582
- _hestia_thread.start()
583
- else:
584
- model.hestia.anneal_temperature(_hestia_progress)
585
- model.hestia.apply_to(model)
586
- else:
587
- # anneal_temperature is cheap (~1 us), keep inline
588
- model.hestia.anneal_temperature(_hestia_progress)
589
-
590
- model.zero_grad(set_to_none=True)
591
-
592
- train_loss_f = train_loss.item()
593
- if math.isnan(train_loss_f) or train_loss_f > 100:
594
- print("FAIL")
595
- # Save to a DIFFERENT file β€” never clobber a good latest.pt with
596
- # a NaN/diverged state. The good ckpt from the last periodic save
597
- # is the right place to resume from.
598
- save_ckpt(
599
- model,
600
- optimizer,
601
- config,
602
- step,
603
- total_training_time,
604
- smooth_train_loss,
605
- bpt_ema,
606
- epoch,
607
- FAILED_CKPT,
608
- blocking=True,
609
- )
610
- raise SystemExit(1)
611
-
612
- torch.cuda.synchronize()
613
- t1 = time.time()
614
- dt = t1 - t0
615
-
616
- if _prof:
617
- fb = (_t_fb - t0) * 1000
618
- opt = (_t_opt - _t_fb) * 1000
619
- rest = (t1 - _t_opt) * 1000
620
- print(
621
- f"[PROF step={step:05d}] gpu={_gpu_ms:.0f}ms data_fetch={_data_ms:.0f}ms "
622
- f"(sum_fb={fb:.0f}) opt={opt:.0f}ms rest={rest:.0f}ms total={dt*1000:.0f}ms",
623
- flush=True,
624
- )
625
-
626
- if step > 10:
627
- total_training_time += dt
628
-
629
- ema_beta = 0.9
630
- smooth_train_loss = ema_beta * smooth_train_loss + (1 - ema_beta) * train_loss_f
631
- debiased_smooth_loss = smooth_train_loss / (1 - ema_beta ** (step + 1))
632
- pct_done = 100 * progress
633
- tok_per_sec = int(TOTAL_BATCH_SIZE / dt)
634
- mfu = 100 * num_flops_per_token * TOTAL_BATCH_SIZE / dt / GPU_BF16_PEAK_FLOPS
635
- remaining = max(0, TIME_BUDGET - total_training_time)
636
-
637
- # Bytes-per-token for the CURRENT batch. evaluate_bpb in prepare.py
638
- # computes bits-per-BYTE (total_nats / (ln2 * total_bytes)); to match
639
- # that semantics live, we EMA-smooth the per-batch bytes/token and
640
- # divide. Without this, the old `bpb = loss/ln2` was actually
641
- # bits-per-token β€” ~4Γ— larger than val_bpb at vocab=8192 and
642
- # therefore not comparable to the champion 1.279 bpb metric.
643
- with torch.no_grad():
644
- y_flat = y.view(-1)
645
- nbytes_batch = token_bytes[y_flat]
646
- mask = nbytes_batch > 0
647
- mask_count = mask.sum().clamp(min=1).float()
648
- avg_bytes_per_tok = (nbytes_batch.float() * mask.float()).sum() / mask_count
649
- bpt_batch = float(avg_bytes_per_tok.item())
650
- if step == 0 or bpt_ema <= 0.0:
651
- bpt_ema = bpt_batch
652
- else:
653
- bpt_ema = 0.98 * bpt_ema + 0.02 * bpt_batch
654
-
655
- # Dual metric: bpb (byte-normalized, comparable with val_bpb) AND
656
- # bpt (bits per token, the raw loss in bits). bpt_div exposes the
657
- # current avg bytes-per-token so the conversion is transparent.
658
- bpt = debiased_smooth_loss / math.log(2)
659
- bpb = bpt / max(bpt_ema, 1e-6)
660
- vram_mib = torch.cuda.memory_allocated() / 1024 / 1024
661
- current_lr = optimizer.param_groups[0]["lr"]
662
-
663
- # Per-step line-buffered log. NOT \r-overwritten so tee/grep see it.
664
- # Keep key=value pairs grep-friendly.
665
- ppl = 2.0 ** bpb # perplexity (byte-level)
666
- print(
667
- f"step={step:05d} loss={debiased_smooth_loss:.4f} bpb={bpb:.4f} ppl={ppl:.3f} "
668
- f"bpt={bpt:.3f} bpt_div={bpt_ema:.2f} "
669
- f"tps={tok_per_sec} dt_ms={dt*1000:.0f} mfu={mfu:.1f} "
670
- f"lr={current_lr:.2e} vram={vram_mib:.0f}MiB "
671
- f"pct={pct_done:.1f} epoch={epoch} remaining={remaining:.0f}s",
672
- flush=True,
673
- )
674
-
675
- if step == 0:
676
- gc.collect()
677
- gc.freeze()
678
- gc.disable()
679
- # No periodic gc.collect() β€” we disabled+froze at step 0 on purpose,
680
- # so a manual collect every 5k steps just re-scans frozen objects
681
- # (burned ~900 ms/event in production) for no live-garbage reason.
682
-
683
- if CKPT_INTERVAL > 0 and step > 0 and step % CKPT_INTERVAL == 0:
684
- save_ckpt(
685
- model,
686
- optimizer,
687
- config,
688
- step,
689
- total_training_time,
690
- smooth_train_loss,
691
- bpt_ema,
692
- epoch,
693
- LATEST_CKPT,
694
- )
695
-
696
- # Periodic mid-training validation so we can see the model learning
697
- # English in real time (not just at the end). Small val batch so it
698
- # doesn't eat significant training time.
699
- mid_val_interval = int(os.environ.get("HYDRA_MID_VAL_INTERVAL", "500"))
700
- if mid_val_interval > 0 and step > 0 and step % mid_val_interval == 0:
701
- model.eval()
702
- try:
703
- # Defrag GPU memory before eval allocates fresh chunks β€”
704
- # without this the eval path can OOM on 6GB cards even
705
- # though total usage fits, because the allocator's free
706
- # blocks are fragmented.
707
- torch.cuda.empty_cache()
708
- _orig_mid = _prepare_mod.EVAL_TOKENS
709
- _prepare_mod.EVAL_TOKENS = 262144 # ~260K tokens, fast
710
- with torch.no_grad():
711
- with autocast_ctx:
712
- mid_bpb = evaluate_bpb(model, tokenizer, DEVICE_BATCH_SIZE)
713
- _prepare_mod.EVAL_TOKENS = _orig_mid
714
- mid_ppl = 2.0 ** mid_bpb
715
- print(f"[MID_VAL] step={step} val_bpb={mid_bpb:.4f} val_ppl={mid_ppl:.3f}", flush=True)
716
-
717
- # Per-layer diagnostic panel. Only printed when HYDRA_LAYER_DIAGNOSTICS=1
718
- # is set (otherwise the layer_* keys are absent from _metrics).
719
- _diag_metrics = model.get_secondary_metrics()
720
- _layer_keys = sorted([k for k in _diag_metrics.keys() if k.startswith('layer_')])
721
- if _layer_keys:
722
- # Condense: one row per layer showing the four core signals.
723
- n_layers = len(model.blocks)
724
- print(f"[LAYER_DIAG] step={step}", flush=True)
725
- for li in range(n_layers):
726
- d_ratio = _diag_metrics.get(f'layer_{li}_delta_ratio', float('nan'))
727
- out_n = _diag_metrics.get(f'layer_{li}_out_norm', float('nan'))
728
- g_norm = _diag_metrics.get(f'layer_{li}_grad_norm', float('nan'))
729
- eff_r = _diag_metrics.get(f'layer_{li}_eff_rank', float('nan'))
730
- f_std = _diag_metrics.get(f'layer_{li}_feat_std', float('nan'))
731
- print(
732
- f"[LAYER_DIAG] L{li:02d} delta_ratio={d_ratio:.4f} "
733
- f"out_norm={out_n:.4f} grad_norm={g_norm:.3e} "
734
- f"eff_rank={eff_r:.1f} feat_std={f_std:.4f}",
735
- flush=True,
736
- )
737
- htm_proj_g = _diag_metrics.get('htm_proj_grad_norm', None)
738
- if htm_proj_g is not None:
739
- print(f"[LAYER_DIAG] htm_proj grad_norm={htm_proj_g:.3e}", flush=True)
740
- except Exception as e:
741
- print(f"[MID_VAL] failed: {e}", flush=True)
742
- model.train()
743
-
744
- step += 1
745
-
746
- if step > 10 and total_training_time >= TIME_BUDGET:
747
- break
748
-
749
- # Drain async postprocessing threads before eval
750
- if _som_thread is not None:
751
- _som_thread.join()
752
- if _hestia_thread is not None:
753
- _hestia_thread.join()
754
- if _hestia_stream is not None:
755
- _hestia_stream.synchronize()
756
-
757
- total_tokens = step * TOTAL_BATCH_SIZE
758
-
759
- # ----------------------------------------------------------------------
760
- # SAVE ORDER (critical):
761
- # 1. Save PRETRAIN_FINAL_CKPT with val_bpb=None (hedge against eval OOM)
762
- # 2. Save LATEST_CKPT with val_bpb=None (hedge against eval OOM)
763
- # 3. Run eval (may OOM on small GPUs; we survive it)
764
- # 4. Re-save both ckpts with val_bpb filled in
765
- # This way we NEVER lose the final trained weights to an eval crash.
766
- # Previous ordering put eval first, so an eval-time OOM destroyed the
767
- # only record of a 6h training run (2026-04-22 incident).
768
- # ----------------------------------------------------------------------
769
-
770
- save_ckpt(
771
- model, optimizer, config, step, total_training_time,
772
- smooth_train_loss, bpt_ema, epoch, PRETRAIN_FINAL_CKPT,
773
- val_bpb=None, blocking=True,
774
- )
775
- save_ckpt(
776
- model, optimizer, config, step, total_training_time,
777
- smooth_train_loss, bpt_ema, epoch, LATEST_CKPT,
778
- val_bpb=None, blocking=True,
779
- )
780
-
781
- # Now it's safe to eval β€” ckpts are on disk regardless of what happens here.
782
- # HYDRA_EVAL_BATCH overrides DEVICE_BATCH_SIZE (env-tunable; default halves
783
- # the training batch because eval holds activations for full sequence and
784
- # does not benefit from overlap with backward). HYDRA_EVAL_TOKENS controls
785
- # how many val tokens to sweep (default 2 M, short enough for autoresearch
786
- # 5-min budgets).
787
- val_bpb: float | None = None
788
- # Eval batch: default to 4 on cloud GPUs (enough freed VRAM after optimizer
789
- # clear), fall back to DEVICE_BATCH_SIZE//2 on tiny cards. Env-overridable.
790
- _eval_B = int(os.environ.get("HYDRA_EVAL_BATCH",
791
- str(max(1, DEVICE_BATCH_SIZE // 2) if DEVICE_BATCH_SIZE <= 8 else 4)))
792
- # Eval tokens: default 1M (1,048,576) β€” gives statistically meaningful BPB
793
- # (256 forward passes at B=4, seq=1024). Env-overridable for fast/slow sweeps.
794
- _eval_tokens = int(os.environ.get("HYDRA_EVAL_TOKENS", str(1048576)))
795
- try:
796
- # Aggressive VRAM reclaim for 6GB cards. Peak training VRAM = 5.1GB
797
- # which leaves < 1GB for the eval forward β€” the driver can't satisfy
798
- # the allocation. Free EVERY tensor we don't strictly need:
799
- # - optimizer grads (set_to_none releases tensor)
800
- # - optimizer.state (fp32 Muon NS workspace, AdamW moments β€” ~size-of-params each)
801
- # - model internal caches (HTM subsample cache, SDR stash)
802
- # After this, VRAM should be ~params only (bf16 β‰ˆ 120MB at 60M params).
803
- optimizer.zero_grad(set_to_none=True)
804
- if hasattr(optimizer, 'state') and optimizer.state:
805
- for p, st in list(optimizer.state.items()):
806
- st.clear()
807
- optimizer.state.clear()
808
- for p in model.parameters():
809
- if p.grad is not None:
810
- p.grad = None
811
- if hasattr(model, '_htm_cache'):
812
- model._htm_cache = None
813
- if hasattr(model, '_last_sdr'):
814
- model._last_sdr = None
815
- import gc as _gc
816
- _gc.collect()
817
- torch.cuda.empty_cache()
818
- torch.cuda.synchronize()
819
- try:
820
- _free_mb = torch.cuda.mem_get_info()[0] / 1024 / 1024
821
- print(f"[VAL] free_vram_mb={_free_mb:.0f} (cleared optimizer state)", flush=True)
822
- except Exception:
823
- pass
824
- print(f"[VAL] running eval on {_eval_tokens} tokens at B={_eval_B}...", flush=True)
825
- model.eval()
826
- _orig = _prepare_mod.EVAL_TOKENS
827
- _prepare_mod.EVAL_TOKENS = _eval_tokens
828
- # Nemotron path reads HYDRA_STREAM_EVAL_TOKENS env var directly,
829
- # not _prepare_mod.EVAL_TOKENS. Sync both so eval budget is
830
- # respected regardless of which dataloader path is active.
831
- _orig_stream = os.environ.get("HYDRA_STREAM_EVAL_TOKENS")
832
- os.environ["HYDRA_STREAM_EVAL_TOKENS"] = str(_eval_tokens)
833
- with autocast_ctx:
834
- val_bpb = evaluate_bpb(model, tokenizer, _eval_B)
835
- _prepare_mod.EVAL_TOKENS = _orig
836
- if _orig_stream is not None:
837
- os.environ["HYDRA_STREAM_EVAL_TOKENS"] = _orig_stream
838
- else:
839
- os.environ.pop("HYDRA_STREAM_EVAL_TOKENS", None)
840
- val_ppl = 2 ** val_bpb
841
- print(f"[VAL] step={step} val_bpb={val_bpb:.4f} val_ppl={val_ppl:.3f}", flush=True)
842
- except torch.cuda.OutOfMemoryError as e:
843
- print(f"[VAL] SKIPPED (OOM): {e}", flush=True)
844
- torch.cuda.empty_cache()
845
- except Exception as e:
846
- import traceback as _tb
847
- print(f"[VAL] SKIPPED ({type(e).__name__}): {e}", flush=True)
848
- _tb.print_exc()
849
- try:
850
- _free = torch.cuda.mem_get_info()[0] / 1024 / 1024
851
- print(f"[VAL] post-crash free_vram_mb={_free:.0f}", flush=True)
852
- except Exception:
853
- pass
854
-
855
- # Final ckpts with val_bpb filled in (if eval succeeded).
856
- save_ckpt(
857
- model, optimizer, config, step, total_training_time,
858
- smooth_train_loss, bpt_ema, epoch, LATEST_CKPT,
859
- val_bpb=val_bpb, blocking=True,
860
- )
861
- save_ckpt(
862
- model, optimizer, config, step, total_training_time,
863
- smooth_train_loss, bpt_ema, epoch, PRETRAIN_FINAL_CKPT,
864
- val_bpb=val_bpb, blocking=True,
865
- )
866
-
867
- # Learnability #2: persist EMA weights alongside the raw checkpoint.
868
- # latest_ema.pt contains ema_model.module (the Averaged params) so it
869
- # can be loaded by evaluation / inference code that expects the same
870
- # state_dict shape as the raw model.
871
- if ema_model is not None:
872
- try:
873
- ema_ckpt_path = CACHE_DIR / "latest_ema.pt"
874
- CACHE_DIR.mkdir(parents=True, exist_ok=True)
875
- torch.save({
876
- "model_state_dict": ema_model.module.state_dict(),
877
- "config": asdict(config),
878
- "step": step,
879
- "epoch": epoch,
880
- "train_seconds": total_training_time,
881
- "val_bpb": val_bpb,
882
- "ema_decay": EMA_DECAY,
883
- }, str(ema_ckpt_path))
884
- print(f"[EMA] saved {ema_ckpt_path} (step={step})", flush=True)
885
- except Exception as _e:
886
- print(f"[EMA] save failed: {_e}", flush=True)
887
-
888
- run_factual_probes(model, tokenizer, device, autocast_ctx)
889
-
890
- t_end = time.time()
891
- startup_time = t_start_training - t_start
892
- steady_state_mfu = (
893
- 100 * num_flops_per_token * TOTAL_BATCH_SIZE * (step - 10)
894
- / total_training_time / GPU_BF16_PEAK_FLOPS
895
- if total_training_time > 0 else 0
896
- )
897
- peak_vram_mb = torch.cuda.max_memory_allocated() / 1024 / 1024
898
- metrics = model.get_secondary_metrics()
899
-
900
- print("---")
901
- print(f"val_bpb: {val_bpb:.6f}" if val_bpb is not None else "val_bpb: SKIPPED")
902
- print(f"training_seconds: {total_training_time:.1f}")
903
- print(f"total_seconds: {t_end - t_start:.1f}")
904
- print(f"peak_vram_mb: {peak_vram_mb:.1f}")
905
- print(f"mfu_percent: {steady_state_mfu:.2f}")
906
- print(f"total_tokens_M: {total_tokens / 1e6:.1f}")
907
- print(f"num_steps: {step}")
908
- print(f"num_params_M: {num_params / 1e6:.1f}")
909
- print(f"n_layer: {N_LAYER}")
910
- print(f"d_model: {D_MODEL}")
911
- print(f"engram_hit_rate: {metrics.get('engram_hit_rate', 0.0):.4f}")
912
- print(f"sdr_active_bits: {metrics.get('sdr_active_bits', 0):.1f}")
913
- print(f"htm_anomaly: {metrics.get('htm_anomaly', 0):.4f}")
914
-
915
- # Per-layer summary panel β€” only printed when diagnostics were active.
916
- _layer_keys = sorted([k for k in metrics.keys() if k.startswith('layer_')])
917
- if _layer_keys:
918
- n_layers = len(model.blocks)
919
- print("--- per-layer diagnostic panel ---")
920
- for li in range(n_layers):
921
- d_ratio = metrics.get(f'layer_{li}_delta_ratio', float('nan'))
922
- out_n = metrics.get(f'layer_{li}_out_norm', float('nan'))
923
- g_norm = metrics.get(f'layer_{li}_grad_norm', float('nan'))
924
- eff_r = metrics.get(f'layer_{li}_eff_rank', float('nan'))
925
- f_std = metrics.get(f'layer_{li}_feat_std', float('nan'))
926
- print(
927
- f"L{li:02d} delta_ratio={d_ratio:.4f} out_norm={out_n:.4f} "
928
- f"grad_norm={g_norm:.3e} eff_rank={eff_r:.1f} feat_std={f_std:.4f}"
929
- )
930
-
931
- # Emit full metrics dictionary as JSON for sweep aggregation. Path from
932
- # HYDRA_METRICS_OUT env var; default=/tmp/hydra_run_metrics.json. Always
933
- # written (even without diagnostics) so the aggregator can compare runs.
934
- _metrics_out = os.environ.get("HYDRA_METRICS_OUT", "/tmp/hydra_run_metrics.json")
935
- try:
936
- _dump = dict(metrics)
937
- _dump.update({
938
- 'val_bpb': (float(val_bpb) if val_bpb is not None else None),
939
- 'val_ppl': (float(val_ppl) if val_ppl is not None else None),
940
- 'n_layer': int(N_LAYER),
941
- 'd_model': int(D_MODEL),
942
- 'num_params_M': float(num_params / 1e6),
943
- 'num_steps': int(step),
944
- 'total_tokens_M': float(total_tokens / 1e6),
945
- 'peak_vram_mb': float(peak_vram_mb),
946
- 'training_seconds': float(total_training_time),
947
- 'sdr_target_active': int(os.environ.get("HYDRA_SDR_TARGET_ACTIVE", "327")),
948
- })
949
- Path(_metrics_out).parent.mkdir(parents=True, exist_ok=True)
950
- with open(_metrics_out, 'w') as _f:
951
- json.dump(_dump, _f, indent=2, sort_keys=True)
952
- print(f"[METRICS] wrote {_metrics_out}", flush=True)
953
- # Also emit a single-line JSON to stdout so the sweep aggregator can
954
- # scrape it from HF Jobs logs without pulling files out of the container.
955
- print("[METRICS_JSON] " + json.dumps(_dump, sort_keys=True), flush=True)
956
- except Exception as _e:
957
- print(f"[METRICS] write failed: {_e}", flush=True)
958
-
959
- run_factual_english(model, tokenizer, MAX_SEQ_LEN)
960
- # startup_time is informative but not printed (preserve historical output)
961
- _ = startup_time
 
1
+ """HYDRA training entry: setup, train loop, eval, summary.
2
+
3
+ Extracted from the monolithic train.py (W1 modularization). Semantics
4
+ preserved. Public entrypoint: `main()`.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import gc
10
+ import json
11
+ import math
12
+ import os
13
+ import sys
14
+ import threading
15
+ import time
16
+ from dataclasses import asdict
17
+ from pathlib import Path
18
+
19
+ import torch
20
+
21
+ # Line-buffered stdout so `python -u train.py | tee run.log | grep step` is
22
+ # live (no \r overwrite, no 4k block-buffered pipe stalls). Safe on Python
23
+ # 3.7+ where io.TextIOWrapper.reconfigure exists.
24
+ try:
25
+ sys.stdout.reconfigure(line_buffering=True) # type: ignore[attr-defined]
26
+ except Exception:
27
+ pass
28
+
29
+ from hydra.config import (
30
+ ADAM_BETAS, CURRICULUM_SHORT_SEQ_LEN, CURRICULUM_SHORT_STEPS,
31
+ D_MODEL, D_STATE, DEVICE_BATCH_SIZE, EMA_DECAY, EMBEDDING_LR,
32
+ ENGRAM_KEY_DIM, ENGRAM_LAYER_IDX, ENGRAM_N_COLUMNS, EXPAND,
33
+ FINAL_LR_FRAC, GPU_BF16_PEAK_FLOPS, HEADDIM, MATRIX_LR, N_HEADS,
34
+ N_LAYER, PostSemClawConfig, SCALAR_LR, SEED, TOTAL_BATCH_SIZE,
35
+ UNEMBEDDING_LR, USE_EMA, WARMUP_RATIO, WEIGHT_DECAY,
36
+ )
37
+ from hydra.diffusion_loss import mdlm_masked_forward_process, mdlm_rb_loss
38
+ from hydra.eval import run_factual_english, run_factual_probes
39
+ from hydra.model import PostSemClawModel
40
+
41
+ import prepare as _prepare_mod
42
+ from prepare import MAX_SEQ_LEN, TIME_BUDGET as _TIME_BUDGET, Tokenizer, evaluate_bpb as _evaluate_bpb_shards, get_token_bytes, make_dataloader as _make_dataloader_shards
43
+
44
+ # Streaming Nemotron path (Super3 recipe). Opt-in via HYDRA_USE_NEMOTRON=1.
45
+ if os.environ.get("HYDRA_USE_NEMOTRON", "0") == "1":
46
+ import prepare_nemotron as _p_nemo
47
+ make_dataloader = _p_nemo.make_dataloader
48
+ evaluate_bpb = _p_nemo.evaluate_bpb
49
+ else:
50
+ make_dataloader = _make_dataloader_shards
51
+ evaluate_bpb = _evaluate_bpb_shards
52
+
53
+ TIME_BUDGET = int(os.environ.get("HYDRA_TIME_BUDGET", str(_TIME_BUDGET)))
54
+ _prepare_mod.TIME_BUDGET = TIME_BUDGET # sync for evaluate_bpb
55
+
56
+ CACHE_DIR = Path.home() / ".cache" / "autoresearch"
57
+ LATEST_CKPT = CACHE_DIR / "latest.pt"
58
+ PRETRAIN_FINAL_CKPT = CACHE_DIR / "pretrain_final.pt"
59
+ FAILED_CKPT = CACHE_DIR / "latest_failed.pt" # crash/FAIL path β€” never overwrites good
60
+ BEST_CKPT = CACHE_DIR / "best_bpb.pt" # lowest val_bpb seen
61
+ CKPT_INTERVAL = int(os.environ.get("HYDRA_CKPT_INTERVAL", "250"))
62
+ CKPT_ROTATIONS = int(os.environ.get("HYDRA_CKPT_ROTATIONS", "3")) # how many .N backups to keep
63
+ RESUME_CKPT = os.environ.get("HYDRA_RESUME_CKPT", str(LATEST_CKPT))
64
+
65
+ # MDLM (Masked Diffusion LM) Rao-Blackwellized ELBO loss path.
66
+ # HYDRA_USE_MDLM=1 : switch training loss from AR sampled-softmax CE
67
+ # to MDLM RB weighted CE (arXiv:2406.07524).
68
+ # HYDRA_MDLM_MASK_ID=N : token id used for the MASK sentinel (default:
69
+ # last valid id, vocab_size - 1). Ensure this id
70
+ # never appears in training targets β€” typical
71
+ # practice is to reserve it.
72
+ # HYDRA_MDLM_SCHEDULE=loglinear|linear : noise schedule (default loglinear).
73
+ # When enabled, the per-step flow is:
74
+ # 1. mdlm_masked_forward_process(y) -> (x_noised, mask_positions, weights)
75
+ # 2. logits = model(x_noised) (no targets -> full V logits)
76
+ # 3. loss = mdlm_rb_loss(logits, y, mask_positions, weights)
77
+ # Sampled-softmax is bypassed in this path because the RB ELBO needs
78
+ # full-vocab logits on masked positions.
79
+ USE_MDLM = os.environ.get("HYDRA_USE_MDLM", "0") == "1"
80
+ MDLM_MASK_ID = int(os.environ.get("HYDRA_MDLM_MASK_ID", "-1")) # -1 => default to vocab_size-1 at runtime
81
+ MDLM_SCHEDULE = os.environ.get("HYDRA_MDLM_SCHEDULE", "loglinear")
82
+
83
+
84
+ # ---------------------------------------------------------------------------
85
+ # Schedules
86
+ # ---------------------------------------------------------------------------
87
+
88
+ def get_lr_multiplier(progress: float) -> float:
89
+ if progress < WARMUP_RATIO:
90
+ return progress / WARMUP_RATIO if WARMUP_RATIO > 0 else 1.0
91
+ decay_progress = (progress - WARMUP_RATIO) / (1.0 - WARMUP_RATIO)
92
+ return FINAL_LR_FRAC + 0.5 * (1.0 - FINAL_LR_FRAC) * (1 + math.cos(math.pi * decay_progress))
93
+
94
+
95
+ def get_muon_momentum(step: int) -> float:
96
+ frac = min(step / 300, 1)
97
+ return (1 - frac) * 0.85 + frac * 0.95
98
+
99
+
100
+ def get_weight_decay(progress: float) -> float:
101
+ return WEIGHT_DECAY * (1 - progress)
102
+
103
+
104
+ _CKPT_WORKER_THREAD: threading.Thread | None = None
105
+
106
+
107
+ def _ckpt_snapshot_state_dicts(
108
+ model: PostSemClawModel,
109
+ optimizer: torch.optim.Optimizer,
110
+ ) -> tuple[dict, dict]:
111
+ """Detach + CPU-clone every tensor so a bg thread can serialize safely
112
+ while the main loop keeps mutating live weights/optimizer state."""
113
+ msd = {k: (v.detach().to("cpu", copy=True) if torch.is_tensor(v) else v)
114
+ for k, v in model.state_dict().items()}
115
+ # optimizer.state_dict() is a nested dict; walk it.
116
+ osd_raw = optimizer.state_dict()
117
+
118
+ def _to_cpu(obj):
119
+ if torch.is_tensor(obj):
120
+ return obj.detach().to("cpu", copy=True)
121
+ if isinstance(obj, dict):
122
+ return {k: _to_cpu(v) for k, v in obj.items()}
123
+ if isinstance(obj, list):
124
+ return [_to_cpu(v) for v in obj]
125
+ if isinstance(obj, tuple):
126
+ return tuple(_to_cpu(v) for v in obj)
127
+ return obj
128
+
129
+ osd = _to_cpu(osd_raw)
130
+ return msd, osd
131
+
132
+
133
+ def save_ckpt(
134
+ model: PostSemClawModel,
135
+ optimizer: torch.optim.Optimizer,
136
+ config: PostSemClawConfig,
137
+ step: int,
138
+ total_training_time: float,
139
+ smooth_train_loss: float,
140
+ bpt_ema: float,
141
+ epoch: int,
142
+ path: Path,
143
+ *,
144
+ val_bpb: float | None = None,
145
+ blocking: bool = False,
146
+ ) -> None:
147
+ """Save a training checkpoint.
148
+
149
+ Default behavior is async: the GPU→CPU state_dict clone runs on the main
150
+ thread (unavoidable; needs to happen before the next optimizer.step that
151
+ mutates live weights), then `torch.save` is dispatched to a daemon
152
+ worker thread. The next call joins any still-running prior save so only
153
+ one disk write is in flight.
154
+
155
+ `blocking=True` restores the original synchronous behavior β€” used for
156
+ end-of-training saves where correctness on process exit matters.
157
+ """
158
+ global _CKPT_WORKER_THREAD
159
+ try:
160
+ CACHE_DIR.mkdir(parents=True, exist_ok=True)
161
+ msd, osd = _ckpt_snapshot_state_dicts(model, optimizer)
162
+ # asdict() recursively converts dataclass fields to a dict and
163
+ # renders tuples as lists. hyena_layers therefore round-trips as a
164
+ # JSON-safe list; config_from_dict normalizes it back to a tuple.
165
+ payload = {
166
+ "model_state_dict": msd,
167
+ "optimizer_state_dict": osd,
168
+ "config": asdict(config),
169
+ "step": step,
170
+ "epoch": epoch,
171
+ "train_seconds": total_training_time,
172
+ "smoothed_loss": smooth_train_loss,
173
+ "bpt_ema": bpt_ema,
174
+ "val_bpb": val_bpb,
175
+ }
176
+ path_str = str(path)
177
+
178
+ def _rotate(p: str) -> None:
179
+ """Keep up to CKPT_ROTATIONS previous versions as p.1, p.2, ..."""
180
+ if CKPT_ROTATIONS <= 0:
181
+ return
182
+ try:
183
+ # Walk from oldest to newest so we don't clobber newer with older.
184
+ for i in range(CKPT_ROTATIONS, 0, -1):
185
+ src = f"{p}.{i-1}" if i > 1 else p
186
+ dst = f"{p}.{i}"
187
+ if os.path.exists(src):
188
+ os.replace(src, dst)
189
+ except Exception as e:
190
+ # Rotation is best-effort; never block a save on it.
191
+ print(f"[ckpt] rotate warn {p}: {type(e).__name__}: {e}", flush=True)
192
+
193
+ def _write():
194
+ try:
195
+ _rotate(path_str)
196
+ tmp = path_str + ".tmp"
197
+ torch.save(payload, tmp)
198
+ os.replace(tmp, path_str)
199
+ print(f"[ckpt] saved {path_str} (step={step})", flush=True)
200
+ except Exception as e:
201
+ print(f"[ckpt] SAVE FAILED {path_str}: {type(e).__name__}: {e}", flush=True)
202
+
203
+ if blocking:
204
+ _write()
205
+ return
206
+
207
+ # Join previous writer so at most one torch.save runs at a time.
208
+ if _CKPT_WORKER_THREAD is not None and _CKPT_WORKER_THREAD.is_alive():
209
+ _CKPT_WORKER_THREAD.join()
210
+ _CKPT_WORKER_THREAD = threading.Thread(
211
+ target=_write, daemon=True, name=f"ckpt-save-{step}"
212
+ )
213
+ _CKPT_WORKER_THREAD.start()
214
+ except Exception as e:
215
+ print(f"[ckpt] SNAPSHOT FAILED {path}: {type(e).__name__}: {e}", flush=True)
216
+
217
+
218
+ def config_from_dict(cfg_dict: dict) -> PostSemClawConfig:
219
+ """Reconstruct a PostSemClawConfig from a checkpoint's asdict() payload.
220
+
221
+ Newly-added fields (e.g. `hyena_layers`) are defaulted when absent in
222
+ older checkpoints, and list-ified tuples are coerced back to tuples so
223
+ the dataclass keeps its declared types.
224
+
225
+ This is the ckpt-safe inverse of `asdict(config)` used by save_ckpt and
226
+ guarantees that a resume path can rebuild the exact same model topology
227
+ (Mamba3 vs HyenaBlock per layer) regardless of env-var state at resume.
228
+ """
229
+ # Only keep keys that are actually declared on PostSemClawConfig β€” extra
230
+ # keys in older/newer checkpoints must not crash construction.
231
+ field_names = {f.name for f in PostSemClawConfig.__dataclass_fields__.values()}
232
+ filtered = {k: v for k, v in cfg_dict.items() if k in field_names}
233
+ # asdict renders tuple[int,...] as list[int]; coerce back so the model
234
+ # builder sees the declared type.
235
+ if "hyena_layers" in filtered and filtered["hyena_layers"] is not None:
236
+ filtered["hyena_layers"] = tuple(sorted(int(x) for x in filtered["hyena_layers"]))
237
+ return PostSemClawConfig(**filtered)
238
+
239
+
240
+ def _try_load_ckpt(path: Path, model, optimizer, device):
241
+ """Attempt to load a single ckpt. Returns the tuple on success, None on any failure."""
242
+ if not path.exists():
243
+ return None
244
+ ckpt = torch.load(str(path), map_location=device, weights_only=False)
245
+ state = ckpt.get("model_state_dict", ckpt)
246
+ missing, unexpected = model.load_state_dict(state, strict=False)
247
+ if missing:
248
+ print(f"[ckpt] {path.name} missing={len(missing)}", flush=True)
249
+ if unexpected:
250
+ print(f"[ckpt] {path.name} unexpected={len(unexpected)}", flush=True)
251
+ optimizer_state = ckpt.get("optimizer_state_dict")
252
+ if optimizer_state is not None:
253
+ try:
254
+ optimizer.load_state_dict(optimizer_state)
255
+ except Exception as e:
256
+ print(f"[ckpt] optimizer restore failed from {path.name}: {type(e).__name__}: {e}", flush=True)
257
+ step = int(ckpt.get("step", 0))
258
+ total_training_time = float(ckpt.get("train_seconds", 0.0))
259
+ smooth_train_loss = float(ckpt.get("smoothed_loss", 0.0))
260
+ bpt_ema = float(ckpt.get("bpt_ema", 0.0))
261
+ epoch = int(ckpt.get("epoch", 0))
262
+ print(
263
+ f"[ckpt] resumed {path} step={step} train_seconds={total_training_time:.1f}",
264
+ flush=True,
265
+ )
266
+ # Warn if resuming a schedule-exhausted ckpt β€” user is probably warm-starting.
267
+ budget = float(os.environ.get("HYDRA_TIME_BUDGET", "0") or 0)
268
+ if budget and total_training_time >= 0.99 * budget:
269
+ print(
270
+ f"[ckpt] WARNING: resumed ckpt used {total_training_time:.0f}s of {budget:.0f}s "
271
+ f"budget. LR schedule is essentially exhausted. "
272
+ f"Set HYDRA_WARMSTART=1 to reset optimizer + scheduler and keep only weights.",
273
+ flush=True,
274
+ )
275
+ return step, total_training_time, smooth_train_loss, bpt_ema, epoch
276
+
277
+
278
+ def maybe_resume_ckpt(
279
+ model: PostSemClawModel,
280
+ optimizer: torch.optim.Optimizer,
281
+ device: torch.device,
282
+ ) -> tuple[int, float, float, float, int]:
283
+ if not RESUME_CKPT or RESUME_CKPT.lower() == "none":
284
+ print("[ckpt] resume disabled; starting fresh", flush=True)
285
+ return 0, 0.0, 0.0, 0.0, 0
286
+
287
+ resume_path = Path(os.path.expanduser(RESUME_CKPT))
288
+ # Try the primary path, then rotated backups. This is crucial because a
289
+ # partial / killed torch.save on the primary path would leave a corrupt
290
+ # file. If that fails we fall back to latest.pt.1, .2, .3 automatically.
291
+ candidates: list[Path] = [resume_path]
292
+ for i in range(1, CKPT_ROTATIONS + 1):
293
+ candidates.append(Path(str(resume_path) + f".{i}"))
294
+
295
+ for cand in candidates:
296
+ if not cand.exists():
297
+ continue
298
+ try:
299
+ result = _try_load_ckpt(cand, model, optimizer, device)
300
+ if result is not None:
301
+ if cand != resume_path:
302
+ print(f"[ckpt] fell back to rotation {cand.name}", flush=True)
303
+ return result
304
+ except Exception as e:
305
+ print(f"[ckpt] {cand.name} load failed: {type(e).__name__}: {e}", flush=True)
306
+ continue
307
+
308
+ print(f"[ckpt] no usable checkpoint in {resume_path} + rotations; starting fresh", flush=True)
309
+ return 0, 0.0, 0.0, 0.0, 0
310
+
311
+
312
+ # ---------------------------------------------------------------------------
313
+ # Main entry
314
+ # ---------------------------------------------------------------------------
315
+
316
+ def main() -> None:
317
+ t_start = time.time()
318
+ torch.manual_seed(SEED)
319
+ torch.cuda.manual_seed(SEED)
320
+ # Precision / kernel-selection knobs for peak throughput on Ampere.
321
+ # - high : matmul uses TF32 (Ampere's 10-bit mantissa accum) for fp32 ops
322
+ # - allow_tf32 : explicit for both matmul + cudnn paths
323
+ # - cudnn.benchmark : env-gated (HYDRA_CUDNN_BENCHMARK, default OFF).
324
+ # TRUE can lock in a locally-better-but-globally-slower algorithm
325
+ # after the autotune phase ends, causing tps to degrade 15-20%
326
+ # over the first ~100 steps. Observed 2026-04-22 and confirmed by
327
+ # differential profiling. Default is now FALSE; set =1 only if you
328
+ # see a specific workload where benchmark helps sustained tps.
329
+ torch.set_float32_matmul_precision("high")
330
+ torch.backends.cuda.matmul.allow_tf32 = True
331
+ torch.backends.cudnn.allow_tf32 = True
332
+ torch.backends.cudnn.benchmark = os.environ.get("HYDRA_CUDNN_BENCHMARK", "0") == "1"
333
+ device = torch.device("cuda")
334
+ autocast_ctx = torch.amp.autocast(device_type="cuda", dtype=torch.bfloat16)
335
+
336
+ # Streaming path skips prepare.py (which normally trains the tokenizer
337
+ # and builds the retina), so we must materialize both before model init.
338
+ if os.environ.get("HYDRA_USE_NEMOTRON", "0") == "1":
339
+ _p_nemo.ensure_tokenizer()
340
+ # Retina: HF Hub cache hit for this (vocab, n_bits, target_active) combo
341
+ # returns in seconds; otherwise build_retina streams Nemotron docs to
342
+ # compute cooccurrence + train SOM, then uploads back to the cache.
343
+ import subsystems.sdr_retina as _sdr_retina
344
+ _sdr_retina.build_retina()
345
+ tokenizer = Tokenizer.from_directory()
346
+ vocab_size = tokenizer.get_vocab_size()
347
+ print(f"Vocab size: {vocab_size:,}")
348
+
349
+ config = PostSemClawConfig(
350
+ sequence_len=MAX_SEQ_LEN,
351
+ vocab_size=vocab_size,
352
+ n_layer=N_LAYER,
353
+ d_model=D_MODEL,
354
+ d_state=D_STATE,
355
+ headdim=HEADDIM,
356
+ n_heads=N_HEADS,
357
+ expand=EXPAND,
358
+ engram_n_columns=ENGRAM_N_COLUMNS,
359
+ engram_key_dim=ENGRAM_KEY_DIM,
360
+ engram_layer_idx=ENGRAM_LAYER_IDX,
361
+ )
362
+ print(f"Model config: {asdict(config)}")
363
+
364
+ with torch.device("meta"):
365
+ model = PostSemClawModel(config)
366
+ model.to_empty(device=device)
367
+ model.init_weights()
368
+
369
+ param_counts = model.num_scaling_params()
370
+ print("Parameter counts:")
371
+ for key, value in param_counts.items():
372
+ print(f" {key:24s}: {value:,}")
373
+ num_params = param_counts['total']
374
+ num_flops_per_token = model.estimate_flops()
375
+ print(f"Estimated FLOPs per token: {num_flops_per_token:e}")
376
+
377
+ tokens_per_fwdbwd = DEVICE_BATCH_SIZE * MAX_SEQ_LEN
378
+ assert TOTAL_BATCH_SIZE % tokens_per_fwdbwd == 0
379
+ grad_accum_steps = TOTAL_BATCH_SIZE // tokens_per_fwdbwd
380
+
381
+ optimizer = model.setup_optimizer(
382
+ unembedding_lr=UNEMBEDDING_LR,
383
+ embedding_lr=EMBEDDING_LR,
384
+ scalar_lr=SCALAR_LR,
385
+ adam_betas=ADAM_BETAS,
386
+ matrix_lr=MATRIX_LR,
387
+ weight_decay=WEIGHT_DECAY,
388
+ )
389
+
390
+ step, total_training_time, smooth_train_loss, bpt_ema, resume_epoch = maybe_resume_ckpt(
391
+ model, optimizer, device,
392
+ )
393
+
394
+ # Learnability #4: inform the model of the BOS token id so it can mask
395
+ # doc-separator positions in packed sequences. Always set (the mask only
396
+ # fires when HYDRA_DOC_SEP_MASK=1 is also on).
397
+ if hasattr(model, 'set_bos_token_id'):
398
+ model.set_bos_token_id(tokenizer.get_bos_token_id())
399
+
400
+ # Learnability #2: EMA shadow copy of weights. AveragedModel clones every
401
+ # parameter; we update it after every optimizer step and save it at the
402
+ # end alongside the raw checkpoint. Defaults OFF.
403
+ ema_model = None
404
+ if USE_EMA:
405
+ try:
406
+ from torch.optim.swa_utils import AveragedModel, get_ema_multi_avg_fn
407
+ # decay=EMA_DECAY; avg_fn uses get_ema_multi_avg_fn for numerical
408
+ # stability across bf16/fp32 mixed parameter groups.
409
+ ema_model = AveragedModel(
410
+ model,
411
+ multi_avg_fn=get_ema_multi_avg_fn(EMA_DECAY),
412
+ )
413
+ print(f"[EMA] enabled with decay={EMA_DECAY}")
414
+ except Exception as _e:
415
+ print(f"[EMA] disabled β€” AveragedModel init failed: {_e}")
416
+ ema_model = None
417
+
418
+ print("torch.compile: Muon step compiled; AdamW uses torch._fused_adamw_ (model blocks use native CUDA kernels)")
419
+
420
+ # Learnability #7: curriculum short-then-long. If enabled, build the
421
+ # initial dataloader at the short seq_len; we swap to full MAX_SEQ_LEN
422
+ # after CURRICULUM_SHORT_STEPS optimizer steps (see loop below).
423
+ _curriculum_active = CURRICULUM_SHORT_STEPS > 0 and CURRICULUM_SHORT_SEQ_LEN < MAX_SEQ_LEN
424
+ _current_seq_len = CURRICULUM_SHORT_SEQ_LEN if _curriculum_active else MAX_SEQ_LEN
425
+ if _curriculum_active:
426
+ print(
427
+ f"[CURRICULUM] starting at T={_current_seq_len} for "
428
+ f"{CURRICULUM_SHORT_STEPS} steps, then switching to T={MAX_SEQ_LEN}"
429
+ )
430
+ train_loader = make_dataloader(tokenizer, DEVICE_BATCH_SIZE, _current_seq_len, "train")
431
+ x, y, epoch = next(train_loader) # prefetch first batch
432
+ if resume_epoch > 0:
433
+ epoch = max(epoch, resume_epoch)
434
+
435
+ print(f"Time budget: {TIME_BUDGET}s")
436
+ print(f"Gradient accumulation steps: {grad_accum_steps}")
437
+
438
+ # Token→byte LUT for bits-per-byte computation. evaluate_bpb in prepare.py
439
+ # uses total_nats / (ln(2) * total_bytes); our live metric needs to match.
440
+ # Without this, `bpb = loss/ln(2)` is actually bits-per-TOKEN, which at
441
+ # vocab=8192 scales by ~4 and makes live train bpb non-comparable with
442
+ # val_bpb (champion 1.279 bpb vs train printing "8.04").
443
+ token_bytes = get_token_bytes(device=device)
444
+
445
+ # -----------------------------------------------------------------------
446
+ # Training loop
447
+ # -----------------------------------------------------------------------
448
+
449
+ t_start_training = time.time()
450
+
451
+ # Async postprocessing β€” run SOM + Hestia on background threads so
452
+ # the GPU doesn't idle during their CPU-bound work.
453
+ _ASYNC_POSTPROCESS = os.environ.get("HYDRA_ASYNC_POSTPROCESS", "1") == "1"
454
+ _som_thread: threading.Thread | None = None
455
+ _hestia_thread: threading.Thread | None = None
456
+ _hestia_stream: torch.cuda.Stream | None = (
457
+ torch.cuda.Stream() if _ASYNC_POSTPROCESS else None
458
+ )
459
+
460
+ # HYDRA_PROFILE_STEPS=N prints a per-phase cpu/gpu time breakdown for the
461
+ # first N steps (and every 100th step thereafter if N<0). Zero overhead
462
+ # when disabled. Used to find what's eating CPU budget when GPU should
463
+ # be the bottleneck.
464
+ _profile_steps = int(os.environ.get("HYDRA_PROFILE_STEPS", "0"))
465
+
466
+ while True:
467
+ torch.cuda.synchronize()
468
+ t0 = time.time()
469
+ _prof = _profile_steps and (step < _profile_steps or (_profile_steps < 0 and step % 100 == 0))
470
+ _gpu_ms = 0.0
471
+ _data_ms = 0.0
472
+ for micro_step in range(grad_accum_steps):
473
+ if _prof:
474
+ torch.cuda.synchronize(); _t_micro = time.time()
475
+ if USE_MDLM:
476
+ # MDLM path: corrupt y -> x_noised, run model to get full-V logits,
477
+ # compute RB weighted CE on masked positions. x (original input) is
478
+ # unused in this path β€” the model only sees the noised version of y.
479
+ _mask_id = MDLM_MASK_ID if MDLM_MASK_ID >= 0 else (vocab_size - 1)
480
+ x_noised, mask_positions, loss_weights = mdlm_masked_forward_process(
481
+ y, mask_token_id=_mask_id, alpha_schedule=MDLM_SCHEDULE,
482
+ )
483
+ with autocast_ctx:
484
+ logits = model(x_noised) # targets=None -> (B, T, V) logits
485
+ loss = mdlm_rb_loss(logits, y, mask_positions, loss_weights)
486
+ else:
487
+ with autocast_ctx:
488
+ loss = model(x, y)
489
+ train_loss = loss.detach()
490
+ loss = loss / grad_accum_steps
491
+ loss.backward()
492
+ if _prof:
493
+ torch.cuda.synchronize()
494
+ _gpu_ms += (time.time() - _t_micro) * 1000
495
+ _t_data = time.time()
496
+ x, y, epoch = next(train_loader)
497
+ if _prof:
498
+ _data_ms += (time.time() - _t_data) * 1000
499
+ if _prof:
500
+ torch.cuda.synchronize(); _t_fb = time.time()
501
+
502
+ # Progress and schedules
503
+ progress = min(total_training_time / TIME_BUDGET, 1.0)
504
+ lrm = get_lr_multiplier(progress)
505
+ muon_momentum = get_muon_momentum(step)
506
+ muon_weight_decay = get_weight_decay(progress)
507
+ for group in optimizer.param_groups:
508
+ group["lr"] = group["initial_lr"] * lrm
509
+ if group['kind'] == 'muon':
510
+ group["momentum"] = muon_momentum
511
+ group["weight_decay"] = muon_weight_decay
512
+ torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
513
+ optimizer.step()
514
+ if _prof:
515
+ torch.cuda.synchronize(); _t_opt = time.time()
516
+
517
+ # Learnability #2: EMA update after every optimizer step.
518
+ if ema_model is not None:
519
+ try:
520
+ ema_model.update_parameters(model)
521
+ except Exception as _e:
522
+ print(f"[EMA] update failed at step {step}: {_e}", flush=True)
523
+
524
+ # Learnability #7: curriculum transition. After
525
+ # CURRICULUM_SHORT_STEPS optimizer steps, rebuild the dataloader at
526
+ # MAX_SEQ_LEN. Done once, then the flag flips off.
527
+ if _curriculum_active and step + 1 >= CURRICULUM_SHORT_STEPS:
528
+ print(
529
+ f"[CURRICULUM] step={step+1} β€” switching from T={_current_seq_len} "
530
+ f"to T={MAX_SEQ_LEN}",
531
+ flush=True,
532
+ )
533
+ _current_seq_len = MAX_SEQ_LEN
534
+ _curriculum_active = False
535
+ train_loader = make_dataloader(tokenizer, DEVICE_BATCH_SIZE, _current_seq_len, "train")
536
+ # Prefetch the next batch at the new seq_len so the following
537
+ # loop iteration consumes fresh data.
538
+ x, y, epoch = next(train_loader)
539
+
540
+ # Online SOM update β€” retina is now a plain Python attribute (not a
541
+ # registered buffer) so mutations do not invalidate torch.compile guards.
542
+ # Runs fully on CPU; safe to overlap with GPU forward pass.
543
+ _last_sdr = getattr(model, "_last_sdr", None)
544
+ if _last_sdr is not None:
545
+ if _ASYNC_POSTPROCESS:
546
+ if _som_thread is not None:
547
+ _som_thread.join()
548
+ # Clone tensors before next step overwrites them
549
+ _som_x = x.clone()
550
+ _som_sdr = _last_sdr.clone()
551
+ _som_thread = threading.Thread(
552
+ target=model.sdr_semantic.maybe_som_update,
553
+ args=(_som_x, _som_sdr),
554
+ daemon=True,
555
+ )
556
+ _som_thread.start()
557
+ else:
558
+ model.sdr_semantic.maybe_som_update(x, _last_sdr)
559
+
560
+ # Hestia QAT β€” anneal temperature every step, snap every N steps.
561
+ # apply_to walks all Linear modules (CPU) then does .data.copy_ (GPU).
562
+ # Background thread + separate CUDA stream lets this overlap with
563
+ # the next forward pass on the default stream.
564
+ _hestia_progress = (time.time() - t_start_training) / max(TIME_BUDGET, 1)
565
+ _hestia_interval = int(os.environ.get("HYDRA_HESTIA_INTERVAL", "100"))
566
+ if step % _hestia_interval == 0:
567
+ if _ASYNC_POSTPROCESS:
568
+ if _hestia_thread is not None:
569
+ _hestia_thread.join()
570
+
571
+ def _hestia_bg(mdl: torch.nn.Module, prog: float) -> None:
572
+ assert _hestia_stream is not None
573
+ with torch.cuda.stream(_hestia_stream):
574
+ mdl.hestia.anneal_temperature(prog)
575
+ mdl.hestia.apply_to(mdl)
576
+
577
+ _hestia_thread = threading.Thread(
578
+ target=_hestia_bg,
579
+ args=(model, _hestia_progress),
580
+ daemon=True,
581
+ )
582
+ _hestia_thread.start()
583
+ else:
584
+ model.hestia.anneal_temperature(_hestia_progress)
585
+ model.hestia.apply_to(model)
586
+ else:
587
+ # anneal_temperature is cheap (~1 us), keep inline
588
+ model.hestia.anneal_temperature(_hestia_progress)
589
+
590
+ model.zero_grad(set_to_none=True)
591
+
592
+ train_loss_f = train_loss.item()
593
+ if math.isnan(train_loss_f) or train_loss_f > 100:
594
+ print("FAIL")
595
+ # Save to a DIFFERENT file β€” never clobber a good latest.pt with
596
+ # a NaN/diverged state. The good ckpt from the last periodic save
597
+ # is the right place to resume from.
598
+ save_ckpt(
599
+ model,
600
+ optimizer,
601
+ config,
602
+ step,
603
+ total_training_time,
604
+ smooth_train_loss,
605
+ bpt_ema,
606
+ epoch,
607
+ FAILED_CKPT,
608
+ blocking=True,
609
+ )
610
+ raise SystemExit(1)
611
+
612
+ torch.cuda.synchronize()
613
+ t1 = time.time()
614
+ dt = t1 - t0
615
+
616
+ if _prof:
617
+ fb = (_t_fb - t0) * 1000
618
+ opt = (_t_opt - _t_fb) * 1000
619
+ rest = (t1 - _t_opt) * 1000
620
+ print(
621
+ f"[PROF step={step:05d}] gpu={_gpu_ms:.0f}ms data_fetch={_data_ms:.0f}ms "
622
+ f"(sum_fb={fb:.0f}) opt={opt:.0f}ms rest={rest:.0f}ms total={dt*1000:.0f}ms",
623
+ flush=True,
624
+ )
625
+
626
+ if step > 10:
627
+ total_training_time += dt
628
+
629
+ ema_beta = 0.9
630
+ smooth_train_loss = ema_beta * smooth_train_loss + (1 - ema_beta) * train_loss_f
631
+ debiased_smooth_loss = smooth_train_loss / (1 - ema_beta ** (step + 1))
632
+ pct_done = 100 * progress
633
+ tok_per_sec = int(TOTAL_BATCH_SIZE / dt)
634
+ mfu = 100 * num_flops_per_token * TOTAL_BATCH_SIZE / dt / GPU_BF16_PEAK_FLOPS
635
+ remaining = max(0, TIME_BUDGET - total_training_time)
636
+
637
+ # Bytes-per-token for the CURRENT batch. evaluate_bpb in prepare.py
638
+ # computes bits-per-BYTE (total_nats / (ln2 * total_bytes)); to match
639
+ # that semantics live, we EMA-smooth the per-batch bytes/token and
640
+ # divide. Without this, the old `bpb = loss/ln2` was actually
641
+ # bits-per-token β€” ~4Γ— larger than val_bpb at vocab=8192 and
642
+ # therefore not comparable to the champion 1.279 bpb metric.
643
+ with torch.no_grad():
644
+ y_flat = y.view(-1)
645
+ nbytes_batch = token_bytes[y_flat]
646
+ mask = nbytes_batch > 0
647
+ mask_count = mask.sum().clamp(min=1).float()
648
+ avg_bytes_per_tok = (nbytes_batch.float() * mask.float()).sum() / mask_count
649
+ bpt_batch = float(avg_bytes_per_tok.item())
650
+ if step == 0 or bpt_ema <= 0.0:
651
+ bpt_ema = bpt_batch
652
+ else:
653
+ bpt_ema = 0.98 * bpt_ema + 0.02 * bpt_batch
654
+
655
+ # Dual metric: bpb (byte-normalized, comparable with val_bpb) AND
656
+ # bpt (bits per token, the raw loss in bits). bpt_div exposes the
657
+ # current avg bytes-per-token so the conversion is transparent.
658
+ bpt = debiased_smooth_loss / math.log(2)
659
+ bpb = bpt / max(bpt_ema, 1e-6)
660
+ vram_mib = torch.cuda.memory_allocated() / 1024 / 1024
661
+ current_lr = optimizer.param_groups[0]["lr"]
662
+
663
+ # Per-step line-buffered log. NOT \r-overwritten so tee/grep see it.
664
+ # Keep key=value pairs grep-friendly.
665
+ ppl = 2.0 ** bpb # perplexity (byte-level)
666
+ print(
667
+ f"step={step:05d} loss={debiased_smooth_loss:.4f} bpb={bpb:.4f} ppl={ppl:.3f} "
668
+ f"bpt={bpt:.3f} bpt_div={bpt_ema:.2f} "
669
+ f"tps={tok_per_sec} dt_ms={dt*1000:.0f} mfu={mfu:.1f} "
670
+ f"lr={current_lr:.2e} vram={vram_mib:.0f}MiB "
671
+ f"pct={pct_done:.1f} epoch={epoch} remaining={remaining:.0f}s",
672
+ flush=True,
673
+ )
674
+
675
+ if step == 0:
676
+ gc.collect()
677
+ gc.freeze()
678
+ gc.disable()
679
+ # No periodic gc.collect() β€” we disabled+froze at step 0 on purpose,
680
+ # so a manual collect every 5k steps just re-scans frozen objects
681
+ # (burned ~900 ms/event in production) for no live-garbage reason.
682
+
683
+ if CKPT_INTERVAL > 0 and step > 0 and step % CKPT_INTERVAL == 0:
684
+ save_ckpt(
685
+ model,
686
+ optimizer,
687
+ config,
688
+ step,
689
+ total_training_time,
690
+ smooth_train_loss,
691
+ bpt_ema,
692
+ epoch,
693
+ LATEST_CKPT,
694
+ )
695
+
696
+ # Periodic mid-training validation so we can see the model learning
697
+ # English in real time (not just at the end). Small val batch so it
698
+ # doesn't eat significant training time.
699
+ mid_val_interval = int(os.environ.get("HYDRA_MID_VAL_INTERVAL", "500"))
700
+ if mid_val_interval > 0 and step > 0 and step % mid_val_interval == 0:
701
+ model.eval()
702
+ try:
703
+ # Defrag GPU memory before eval allocates fresh chunks β€”
704
+ # without this the eval path can OOM on 6GB cards even
705
+ # though total usage fits, because the allocator's free
706
+ # blocks are fragmented.
707
+ torch.cuda.empty_cache()
708
+ _orig_mid = _prepare_mod.EVAL_TOKENS
709
+ _prepare_mod.EVAL_TOKENS = 262144 # ~260K tokens, fast
710
+ with torch.no_grad():
711
+ with autocast_ctx:
712
+ mid_bpb = evaluate_bpb(model, tokenizer, DEVICE_BATCH_SIZE)
713
+ _prepare_mod.EVAL_TOKENS = _orig_mid
714
+ mid_ppl = 2.0 ** mid_bpb
715
+ print(f"[MID_VAL] step={step} val_bpb={mid_bpb:.4f} val_ppl={mid_ppl:.3f}", flush=True)
716
+
717
+ # Per-layer diagnostic panel. Only printed when HYDRA_LAYER_DIAGNOSTICS=1
718
+ # is set (otherwise the layer_* keys are absent from _metrics).
719
+ _diag_metrics = model.get_secondary_metrics()
720
+ _layer_keys = sorted([k for k in _diag_metrics.keys() if k.startswith('layer_')])
721
+ if _layer_keys:
722
+ # Condense: one row per layer showing the four core signals.
723
+ n_layers = len(model.blocks)
724
+ print(f"[LAYER_DIAG] step={step}", flush=True)
725
+ for li in range(n_layers):
726
+ d_ratio = _diag_metrics.get(f'layer_{li}_delta_ratio', float('nan'))
727
+ out_n = _diag_metrics.get(f'layer_{li}_out_norm', float('nan'))
728
+ g_norm = _diag_metrics.get(f'layer_{li}_grad_norm', float('nan'))
729
+ eff_r = _diag_metrics.get(f'layer_{li}_eff_rank', float('nan'))
730
+ f_std = _diag_metrics.get(f'layer_{li}_feat_std', float('nan'))
731
+ print(
732
+ f"[LAYER_DIAG] L{li:02d} delta_ratio={d_ratio:.4f} "
733
+ f"out_norm={out_n:.4f} grad_norm={g_norm:.3e} "
734
+ f"eff_rank={eff_r:.1f} feat_std={f_std:.4f}",
735
+ flush=True,
736
+ )
737
+ htm_proj_g = _diag_metrics.get('htm_proj_grad_norm', None)
738
+ if htm_proj_g is not None:
739
+ print(f"[LAYER_DIAG] htm_proj grad_norm={htm_proj_g:.3e}", flush=True)
740
+ except Exception as e:
741
+ print(f"[MID_VAL] failed: {e}", flush=True)
742
+ model.train()
743
+
744
+ step += 1
745
+
746
+ if step > 10 and total_training_time >= TIME_BUDGET:
747
+ break
748
+
749
+ # Drain async postprocessing threads before eval
750
+ if _som_thread is not None:
751
+ _som_thread.join()
752
+ if _hestia_thread is not None:
753
+ _hestia_thread.join()
754
+ if _hestia_stream is not None:
755
+ _hestia_stream.synchronize()
756
+
757
+ total_tokens = step * TOTAL_BATCH_SIZE
758
+
759
+ # ----------------------------------------------------------------------
760
+ # SAVE ORDER (critical):
761
+ # 1. Save PRETRAIN_FINAL_CKPT with val_bpb=None (hedge against eval OOM)
762
+ # 2. Save LATEST_CKPT with val_bpb=None (hedge against eval OOM)
763
+ # 3. Run eval (may OOM on small GPUs; we survive it)
764
+ # 4. Re-save both ckpts with val_bpb filled in
765
+ # This way we NEVER lose the final trained weights to an eval crash.
766
+ # Previous ordering put eval first, so an eval-time OOM destroyed the
767
+ # only record of a 6h training run (2026-04-22 incident).
768
+ # ----------------------------------------------------------------------
769
+
770
+ save_ckpt(
771
+ model, optimizer, config, step, total_training_time,
772
+ smooth_train_loss, bpt_ema, epoch, PRETRAIN_FINAL_CKPT,
773
+ val_bpb=None, blocking=True,
774
+ )
775
+ save_ckpt(
776
+ model, optimizer, config, step, total_training_time,
777
+ smooth_train_loss, bpt_ema, epoch, LATEST_CKPT,
778
+ val_bpb=None, blocking=True,
779
+ )
780
+
781
+ # Now it's safe to eval β€” ckpts are on disk regardless of what happens here.
782
+ # HYDRA_EVAL_BATCH overrides DEVICE_BATCH_SIZE (env-tunable; default halves
783
+ # the training batch because eval holds activations for full sequence and
784
+ # does not benefit from overlap with backward). HYDRA_EVAL_TOKENS controls
785
+ # how many val tokens to sweep (default 2 M, short enough for autoresearch
786
+ # 5-min budgets).
787
+ val_bpb: float | None = None
788
+ # Eval batch: default to 4 on cloud GPUs (enough freed VRAM after optimizer
789
+ # clear), fall back to DEVICE_BATCH_SIZE//2 on tiny cards. Env-overridable.
790
+ _eval_B = int(os.environ.get("HYDRA_EVAL_BATCH",
791
+ str(max(1, DEVICE_BATCH_SIZE // 2) if DEVICE_BATCH_SIZE <= 8 else 4)))
792
+ # Eval tokens: default 1M (1,048,576) β€” gives statistically meaningful BPB
793
+ # (256 forward passes at B=4, seq=1024). Env-overridable for fast/slow sweeps.
794
+ _eval_tokens = int(os.environ.get("HYDRA_EVAL_TOKENS", str(1048576)))
795
+ try:
796
+ # Aggressive VRAM reclaim for 6GB cards. Peak training VRAM = 5.1GB
797
+ # which leaves < 1GB for the eval forward β€” the driver can't satisfy
798
+ # the allocation. Free EVERY tensor we don't strictly need:
799
+ # - optimizer grads (set_to_none releases tensor)
800
+ # - optimizer.state (fp32 Muon NS workspace, AdamW moments β€” ~size-of-params each)
801
+ # - model internal caches (HTM subsample cache, SDR stash)
802
+ # After this, VRAM should be ~params only (bf16 β‰ˆ 120MB at 60M params).
803
+ optimizer.zero_grad(set_to_none=True)
804
+ if hasattr(optimizer, 'state') and optimizer.state:
805
+ for p, st in list(optimizer.state.items()):
806
+ st.clear()
807
+ optimizer.state.clear()
808
+ for p in model.parameters():
809
+ if p.grad is not None:
810
+ p.grad = None
811
+ if hasattr(model, '_htm_cache'):
812
+ model._htm_cache = None
813
+ if hasattr(model, '_last_sdr'):
814
+ model._last_sdr = None
815
+ import gc as _gc
816
+ _gc.collect()
817
+ torch.cuda.empty_cache()
818
+ torch.cuda.synchronize()
819
+ try:
820
+ _free_mb = torch.cuda.mem_get_info()[0] / 1024 / 1024
821
+ print(f"[VAL] free_vram_mb={_free_mb:.0f} (cleared optimizer state)", flush=True)
822
+ except Exception:
823
+ pass
824
+ print(f"[VAL] running eval on {_eval_tokens} tokens at B={_eval_B}...", flush=True)
825
+ model.eval()
826
+ _orig = _prepare_mod.EVAL_TOKENS
827
+ _prepare_mod.EVAL_TOKENS = _eval_tokens
828
+ # Nemotron path reads HYDRA_STREAM_EVAL_TOKENS env var directly,
829
+ # not _prepare_mod.EVAL_TOKENS. Sync both so eval budget is
830
+ # respected regardless of which dataloader path is active.
831
+ _orig_stream = os.environ.get("HYDRA_STREAM_EVAL_TOKENS")
832
+ os.environ["HYDRA_STREAM_EVAL_TOKENS"] = str(_eval_tokens)
833
+ with autocast_ctx:
834
+ val_bpb = evaluate_bpb(model, tokenizer, _eval_B)
835
+ _prepare_mod.EVAL_TOKENS = _orig
836
+ if _orig_stream is not None:
837
+ os.environ["HYDRA_STREAM_EVAL_TOKENS"] = _orig_stream
838
+ else:
839
+ os.environ.pop("HYDRA_STREAM_EVAL_TOKENS", None)
840
+ val_ppl = 2 ** val_bpb
841
+ print(f"[VAL] step={step} val_bpb={val_bpb:.4f} val_ppl={val_ppl:.3f}", flush=True)
842
+ except torch.cuda.OutOfMemoryError as e:
843
+ print(f"[VAL] SKIPPED (OOM): {e}", flush=True)
844
+ torch.cuda.empty_cache()
845
+ except Exception as e:
846
+ import traceback as _tb
847
+ print(f"[VAL] SKIPPED ({type(e).__name__}): {e}", flush=True)
848
+ _tb.print_exc()
849
+ try:
850
+ _free = torch.cuda.mem_get_info()[0] / 1024 / 1024
851
+ print(f"[VAL] post-crash free_vram_mb={_free:.0f}", flush=True)
852
+ except Exception:
853
+ pass
854
+
855
+ # Final ckpts with val_bpb filled in (if eval succeeded).
856
+ save_ckpt(
857
+ model, optimizer, config, step, total_training_time,
858
+ smooth_train_loss, bpt_ema, epoch, LATEST_CKPT,
859
+ val_bpb=val_bpb, blocking=True,
860
+ )
861
+ save_ckpt(
862
+ model, optimizer, config, step, total_training_time,
863
+ smooth_train_loss, bpt_ema, epoch, PRETRAIN_FINAL_CKPT,
864
+ val_bpb=val_bpb, blocking=True,
865
+ )
866
+
867
+ # Learnability #2: persist EMA weights alongside the raw checkpoint.
868
+ # latest_ema.pt contains ema_model.module (the Averaged params) so it
869
+ # can be loaded by evaluation / inference code that expects the same
870
+ # state_dict shape as the raw model.
871
+ if ema_model is not None:
872
+ try:
873
+ ema_ckpt_path = CACHE_DIR / "latest_ema.pt"
874
+ CACHE_DIR.mkdir(parents=True, exist_ok=True)
875
+ torch.save({
876
+ "model_state_dict": ema_model.module.state_dict(),
877
+ "config": asdict(config),
878
+ "step": step,
879
+ "epoch": epoch,
880
+ "train_seconds": total_training_time,
881
+ "val_bpb": val_bpb,
882
+ "ema_decay": EMA_DECAY,
883
+ }, str(ema_ckpt_path))
884
+ print(f"[EMA] saved {ema_ckpt_path} (step={step})", flush=True)
885
+ except Exception as _e:
886
+ print(f"[EMA] save failed: {_e}", flush=True)
887
+
888
+ run_factual_probes(model, tokenizer, device, autocast_ctx)
889
+
890
+ t_end = time.time()
891
+ startup_time = t_start_training - t_start
892
+ steady_state_mfu = (
893
+ 100 * num_flops_per_token * TOTAL_BATCH_SIZE * (step - 10)
894
+ / total_training_time / GPU_BF16_PEAK_FLOPS
895
+ if total_training_time > 0 else 0
896
+ )
897
+ peak_vram_mb = torch.cuda.max_memory_allocated() / 1024 / 1024
898
+ metrics = model.get_secondary_metrics()
899
+
900
+ print("---")
901
+ print(f"val_bpb: {val_bpb:.6f}" if val_bpb is not None else "val_bpb: SKIPPED")
902
+ print(f"training_seconds: {total_training_time:.1f}")
903
+ print(f"total_seconds: {t_end - t_start:.1f}")
904
+ print(f"peak_vram_mb: {peak_vram_mb:.1f}")
905
+ print(f"mfu_percent: {steady_state_mfu:.2f}")
906
+ print(f"total_tokens_M: {total_tokens / 1e6:.1f}")
907
+ print(f"num_steps: {step}")
908
+ print(f"num_params_M: {num_params / 1e6:.1f}")
909
+ print(f"n_layer: {N_LAYER}")
910
+ print(f"d_model: {D_MODEL}")
911
+ print(f"engram_hit_rate: {metrics.get('engram_hit_rate', 0.0):.4f}")
912
+ print(f"sdr_active_bits: {metrics.get('sdr_active_bits', 0):.1f}")
913
+ print(f"htm_anomaly: {metrics.get('htm_anomaly', 0):.4f}")
914
+
915
+ # Per-layer summary panel β€” only printed when diagnostics were active.
916
+ _layer_keys = sorted([k for k in metrics.keys() if k.startswith('layer_')])
917
+ if _layer_keys:
918
+ n_layers = len(model.blocks)
919
+ print("--- per-layer diagnostic panel ---")
920
+ for li in range(n_layers):
921
+ d_ratio = metrics.get(f'layer_{li}_delta_ratio', float('nan'))
922
+ out_n = metrics.get(f'layer_{li}_out_norm', float('nan'))
923
+ g_norm = metrics.get(f'layer_{li}_grad_norm', float('nan'))
924
+ eff_r = metrics.get(f'layer_{li}_eff_rank', float('nan'))
925
+ f_std = metrics.get(f'layer_{li}_feat_std', float('nan'))
926
+ print(
927
+ f"L{li:02d} delta_ratio={d_ratio:.4f} out_norm={out_n:.4f} "
928
+ f"grad_norm={g_norm:.3e} eff_rank={eff_r:.1f} feat_std={f_std:.4f}"
929
+ )
930
+
931
+ # Emit full metrics dictionary as JSON for sweep aggregation. Path from
932
+ # HYDRA_METRICS_OUT env var; default=/tmp/hydra_run_metrics.json. Always
933
+ # written (even without diagnostics) so the aggregator can compare runs.
934
+ _metrics_out = os.environ.get("HYDRA_METRICS_OUT", "/tmp/hydra_run_metrics.json")
935
+ try:
936
+ _dump = dict(metrics)
937
+ _dump.update({
938
+ 'val_bpb': (float(val_bpb) if val_bpb is not None else None),
939
+ 'val_ppl': (float(val_ppl) if val_ppl is not None else None),
940
+ 'n_layer': int(N_LAYER),
941
+ 'd_model': int(D_MODEL),
942
+ 'num_params_M': float(num_params / 1e6),
943
+ 'num_steps': int(step),
944
+ 'total_tokens_M': float(total_tokens / 1e6),
945
+ 'peak_vram_mb': float(peak_vram_mb),
946
+ 'training_seconds': float(total_training_time),
947
+ 'sdr_target_active': int(os.environ.get("HYDRA_SDR_TARGET_ACTIVE", "327")),
948
+ })
949
+ Path(_metrics_out).parent.mkdir(parents=True, exist_ok=True)
950
+ with open(_metrics_out, 'w') as _f:
951
+ json.dump(_dump, _f, indent=2, sort_keys=True)
952
+ print(f"[METRICS] wrote {_metrics_out}", flush=True)
953
+ # Also emit a single-line JSON to stdout so the sweep aggregator can
954
+ # scrape it from HF Jobs logs without pulling files out of the container.
955
+ print("[METRICS_JSON] " + json.dumps(_dump, sort_keys=True), flush=True)
956
+ except Exception as _e:
957
+ print(f"[METRICS] write failed: {_e}", flush=True)
958
+
959
+ run_factual_english(model, tokenizer, MAX_SEQ_LEN)
960
+ # startup_time is informative but not printed (preserve historical output)
961
+ _ = startup_time