Spaces:
Build error
Build error
Update Feather a10g-large-gt80k training runtime image
Browse filesThis view is limited to 50 files because it contains too many changes. Β See raw diff
- .dockerignore +16 -16
- Dockerfile +124 -124
- entrypoint.py +278 -260
- mamba_ssm_init.py +69 -69
- overlay/.dockerignore +20 -20
- overlay/configs/__init__.py +5 -5
- overlay/configs/hardware_config.py +104 -104
- overlay/configs/harness_config.py +78 -78
- overlay/configs/model_config.py +80 -80
- overlay/htm_rust/Cargo.lock +383 -383
- overlay/htm_rust/Cargo.toml +37 -37
- overlay/htm_rust/bench_gpu.py +81 -81
- overlay/htm_rust/build.rs +162 -162
- overlay/htm_rust/docs/GPU_HTM.md +302 -302
- overlay/htm_rust/pyproject.toml +17 -17
- overlay/htm_rust/src/gpu/fused.rs +719 -719
- overlay/htm_rust/src/gpu/kernels/htm_fused_step.cu +677 -677
- overlay/htm_rust/src/gpu/kernels/sp_boost_fused.cu +59 -59
- overlay/htm_rust/src/gpu/kernels/sp_duty.cu +45 -45
- overlay/htm_rust/src/gpu/kernels/sp_learn.cu +45 -45
- overlay/htm_rust/src/gpu/kernels/sp_overlap.cu +78 -78
- overlay/htm_rust/src/gpu/kernels/sp_topk.cu +117 -117
- overlay/htm_rust/src/gpu/kernels/tm_activate.cu +66 -66
- overlay/htm_rust/src/gpu/kernels/tm_anomaly.cu +43 -43
- overlay/htm_rust/src/gpu/kernels/tm_grow.cu +155 -155
- overlay/htm_rust/src/gpu/kernels/tm_learn.cu +75 -75
- overlay/htm_rust/src/gpu/kernels/tm_predict.cu +102 -102
- overlay/htm_rust/src/gpu/kernels/tm_punish.cu +64 -64
- overlay/htm_rust/src/gpu/kernels/tm_reset.cu +36 -36
- overlay/htm_rust/src/gpu/mod.rs +549 -549
- overlay/htm_rust/src/gpu/sp_gpu.rs +796 -796
- overlay/htm_rust/src/gpu/tests.rs +663 -663
- overlay/htm_rust/src/gpu/tm_gpu.rs +460 -460
- overlay/htm_rust/src/lib.rs +198 -198
- overlay/htm_rust/src/region.rs +94 -94
- overlay/htm_rust/src/sp.rs +302 -302
- overlay/htm_rust/src/tm.rs +545 -545
- overlay/htm_rust/uv.lock +8 -8
- overlay/hydra/__init__.py +31 -31
- overlay/hydra/config.py +225 -225
- overlay/hydra/data_module.py +288 -288
- overlay/hydra/diffusion_loss.py +236 -236
- overlay/hydra/engram.py +177 -177
- overlay/hydra/eval.py +210 -210
- overlay/hydra/gdn_block.py +126 -126
- overlay/hydra/hyena_block.py +68 -68
- overlay/hydra/lightning_module.py +326 -326
- overlay/hydra/model.py +894 -894
- overlay/hydra/optimizer.py +252 -252
- 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 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
print(f'[
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|