Skip to content

Commit e308123

Browse files
Hananel-Hazanclaude
andcommitted
Performance: fold cache, rank-1 MSTDP, cached MSTDPET decays
- topology.py: cache the folded (a_eff, b_sum) whenever every pipeline feature is static (is_static flag; Probability, MeanField, adaptation features, and sub-features are dynamic). Invalidated on learning updates, normalize, reset, and device/dtype moves. Weight*Mask* Intensity at 100x20000: 4506 -> 66 us/step CPU, 321 -> 58 us GPU. - topology_features.py: Weight's per-time-step normalization is deferred by the connection until after the fold consumes the pre-norm value, removing the full-matrix clone every step. Fix the reduction assertion (isinstance(x, callable) raised TypeError for any custom reduction). - MCC_learning.py: MSTDP with the default batch reductions applies the reward-weighted eligibility as two rank-1 addmm_ updates instead of materializing [batch, src, tgt] every step (5077 -> 899 us CPU, 834 -> 332 us GPU at 100x20000; exact same numbers, verified to 1e-7). Dense path kept for averaging buffers, custom reductions, and sparse weights. Skip the decay multiply when decay is the no-op 1.0. Give MSTDPET the same (dt, device)-keyed decay/learning-rate cache as MSTDP (was 3 exp() + 2 host-to-device copies per step). - New tests: rank-1 MSTDP == dense eligibility (batch 1/4, tensor rewards), fold-cache storage + invalidation, deferred time-step norm. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent b0b055b commit e308123

5 files changed

Lines changed: 311 additions & 92 deletions

File tree

‎bindsnet/datasets/torchvision_wrapper.py‎

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,18 +16,13 @@ def create_torchvision_dataset_wrapper(ds_type):
1616
ds_type = getattr(torchDB, ds_type)
1717

1818
class TorchvisionDatasetWrapper(ds_type):
19-
__doc__ = (
20-
"""BindsNET torchvision dataset wrapper for:
19+
__doc__ = """BindsNET torchvision dataset wrapper for:
2120
2221
The core difference is the output of __getitem__ is no longer
2322
(image, label) rather a dictionary containing the image, label,
2423
and their encoded versions if encoders were provided.
2524
26-
\n\n"""
27-
+ str(ds_type)
28-
if ds_type.__doc__ is None
29-
else ds_type.__doc__
30-
)
25+
\n\n""" + str(ds_type) if ds_type.__doc__ is None else ds_type.__doc__
3126

3227
def __init__(
3328
self,

‎bindsnet/learning/MCC_learning.py‎

Lines changed: 94 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -89,8 +89,9 @@ def update(self, **kwargs) -> None:
8989
Abstract method for a learning rule update.
9090
"""
9191

92-
# Implement decay.
93-
if self.decay:
92+
# Implement decay (self.decay == 1.0 is the no-op default; skip the
93+
# full-matrix multiply in that case).
94+
if self.decay != 1.0:
9495
self.feature_value *= self.decay
9596

9697
# Enforce polarities
@@ -497,11 +498,6 @@ def _connection_update(self, **kwargs) -> None:
497498
self.target.n,
498499
device=self.target.s.device,
499500
)
500-
if not hasattr(self, "eligibility"):
501-
self.eligibility = torch.zeros(
502-
batch_size, *self.feature_value.shape, device=self.feature_value.device
503-
)
504-
505501
# Reshape pre- and post-synaptic spikes.
506502
source_s = self.source.s.view(batch_size, -1).float()
507503
target_s = self.target.s.view(batch_size, -1).float()
@@ -532,38 +528,82 @@ def _connection_update(self, **kwargs) -> None:
532528
else torch.as_tensor(a_minus, device=self.feature_value.device)
533529
)
534530

535-
# Compute weight update based on the eligibility value of the past timestep.
536-
update = reward * self.eligibility
531+
# With no averaging and the standard batch reductions (squeeze for
532+
# batch size 1, sum otherwise), the [batch, src, tgt] eligibility never
533+
# needs to be materialized: its batch reduction is a sum of two matrix
534+
# products applied to the weights directly. The eligibility of the past
535+
# timestep is outer(p_plus, target_s) + outer(source_s, p_minus), with
536+
# p_plus/p_minus still holding their previous-step values here.
537+
fast = (
538+
self.average_update == 0
539+
and self.reduction in (torch.squeeze, torch.sum)
540+
and not self.feature_value.is_sparse
541+
)
542+
if fast:
543+
if hasattr(self, "_prev_target_s"):
544+
if isinstance(reward, torch.Tensor):
545+
# Keep reward on-device (no host sync for tensor rewards).
546+
update = (
547+
self.p_plus.t() @ self._prev_target_s
548+
+ self._prev_source_s.t() @ self.p_minus
549+
)
550+
self.feature_value += (self.nu[0].to(dev) * reward) * update
551+
else:
552+
alpha = float(self.nu[0]) * reward
553+
if alpha != 0.0:
554+
self.feature_value.addmm_(
555+
self.p_plus.t(), self._prev_target_s, alpha=alpha
556+
)
557+
self.feature_value.addmm_(
558+
self._prev_source_s.t(), self.p_minus, alpha=alpha
559+
)
560+
else:
561+
# Dense-eligibility path: averaging buffers, custom reductions, or
562+
# sparse weights.
563+
if not hasattr(self, "eligibility"):
564+
self.eligibility = torch.zeros(
565+
batch_size,
566+
*self.feature_value.shape,
567+
device=self.feature_value.device,
568+
)
537569

538-
if self.average_update > 0:
539-
self.average_buffer[self.average_buffer_index] = self.reduction(
540-
update, dim=0
541-
)
542-
self.average_buffer_index = (
543-
self.average_buffer_index + 1
544-
) % self.average_update
570+
# Compute weight update based on the eligibility value of the past timestep.
571+
update = reward * self.eligibility
545572

546-
if self.continues_update or self.average_buffer_index == 0:
547-
update = self.nu[0] * torch.mean(self.average_buffer, dim=0)
573+
if self.average_update > 0:
574+
self.average_buffer[self.average_buffer_index] = self.reduction(
575+
update, dim=0
576+
)
577+
self.average_buffer_index = (
578+
self.average_buffer_index + 1
579+
) % self.average_update
580+
581+
if self.continues_update or self.average_buffer_index == 0:
582+
update = self.nu[0] * torch.mean(self.average_buffer, dim=0)
583+
if self.feature_value.is_sparse:
584+
update = update.to_sparse()
585+
self.feature_value += update
586+
else:
587+
update = self.nu[0] * self.reduction(update, dim=0)
548588
if self.feature_value.is_sparse:
549589
update = update.to_sparse()
550590
self.feature_value += update
551-
else:
552-
update = self.nu[0] * self.reduction(update, dim=0)
553-
if self.feature_value.is_sparse:
554-
update = update.to_sparse()
555-
self.feature_value += update
556591

557592
# Update P^+ and P^- values.
558593
self.p_plus *= self._decay_plus
559594
self.p_plus += a_plus * source_s
560595
self.p_minus *= self._decay_minus
561596
self.p_minus += a_minus * target_s
562597

563-
# Calculate point eligibility value.
564-
self.eligibility = torch.bmm(
565-
self.p_plus.unsqueeze(2), target_s.unsqueeze(1)
566-
) + torch.bmm(source_s.unsqueeze(2), self.p_minus.unsqueeze(1))
598+
if fast:
599+
# Keep this step's spikes for the next step's rank-1 update.
600+
self._prev_source_s = source_s.clone()
601+
self._prev_target_s = target_s.clone()
602+
else:
603+
# Calculate point eligibility value.
604+
self.eligibility = torch.bmm(
605+
self.p_plus.unsqueeze(2), target_s.unsqueeze(1)
606+
) + torch.bmm(source_s.unsqueeze(2), self.p_minus.unsqueeze(1))
567607

568608
super().update()
569609

@@ -688,26 +728,36 @@ def _connection_update(self, **kwargs) -> None:
688728

689729
# Parse keyword arguments.
690730
reward = kwargs["reward"]
691-
a_plus = kwargs.get("a_plus", 1.0)
692-
# if isinstance(a_plus, dict):
693-
# for k, v in a_plus.items():
694-
# a_plus[k] = torch.tensor(v, device=self.feature_value.device)
695-
# else:
696-
a_plus = torch.tensor(a_plus, device=self.feature_value.device)
697-
a_minus = kwargs.get("a_minus", -1.0)
698-
# if isinstance(a_minus, dict):
699-
# for k, v in a_minus.items():
700-
# a_minus[k] = torch.tensor(v, device=self.feature_value.device)
701-
# else:
702-
a_minus = torch.tensor(a_minus, device=self.feature_value.device)
731+
732+
# Build learning-rate and decay tensors, cached on (dt, device) so a
733+
# change to either (network.dt edits, .to(device) moves) recomputes them.
734+
dev = self.feature_value.device
735+
dt = float(self.connection.dt)
736+
if getattr(self, "_decay_key", None) != (dt, dev):
737+
self._decay_key = (dt, dev)
738+
self._a_plus_default = torch.tensor(1.0, device=dev)
739+
self._a_minus_default = torch.tensor(-1.0, device=dev)
740+
self._decay_plus = torch.exp(-dt / self.tc_plus.to(dev))
741+
self._decay_minus = torch.exp(-dt / self.tc_minus.to(dev))
742+
self._decay_e_trace = torch.exp(-dt / self.tc_e_trace.to(dev))
743+
a_plus = kwargs.get("a_plus", None)
744+
a_plus = (
745+
self._a_plus_default
746+
if a_plus is None
747+
else torch.as_tensor(a_plus, device=dev)
748+
)
749+
a_minus = kwargs.get("a_minus", None)
750+
a_minus = (
751+
self._a_minus_default
752+
if a_minus is None
753+
else torch.as_tensor(a_minus, device=dev)
754+
)
703755

704756
# Calculate value of eligibility trace based on the value
705757
# of the point eligibility value of the past timestep.
706758
# Note: eligibility = [source.n, target.n] > 0 where source and target spiked
707759
# Note: high negs. ->
708-
self.eligibility_trace *= torch.exp(
709-
-self.connection.dt / self.tc_e_trace
710-
) # Decay
760+
self.eligibility_trace *= self._decay_e_trace # Decay
711761
self.eligibility_trace += self.eligibility / self.tc_e_trace # Additive changes
712762
# ^ Also effected by delay in last step
713763

@@ -733,9 +783,9 @@ def _connection_update(self, **kwargs) -> None:
733783
self.feature_value += update
734784

735785
# Update P^+ and P^- values.
736-
self.p_plus *= torch.exp(-self.connection.dt / self.tc_plus) # Decay
786+
self.p_plus *= self._decay_plus # Decay
737787
self.p_plus += a_plus * source_s # Scaled source spikes
738-
self.p_minus *= torch.exp(-self.connection.dt / self.tc_minus) # Decay
788+
self.p_minus *= self._decay_minus # Decay
739789
self.p_minus += a_minus * target_s # Scaled target spikes
740790

741791
# Notes:

‎bindsnet/network/topology.py‎

Lines changed: 62 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -442,6 +442,15 @@ def __init__(
442442

443443
self.sparse_compute = sparse_compute
444444

445+
# Cached (a_eff, b_sum) for pipelines whose features are all static
446+
# (see AbstractFeature.is_static). Invalidated whenever a feature can
447+
# change: learning updates, normalize, reset, device/dtype moves.
448+
self._fold_cache = None
449+
450+
def _apply(self, fn, recurse=True):
451+
self._fold_cache = None
452+
return super()._apply(fn, recurse)
453+
445454
def compute(self, s: torch.Tensor) -> torch.Tensor:
446455
# language=rst
447456
"""
@@ -463,34 +472,50 @@ def compute(self, s: torch.Tensor) -> torch.Tensor:
463472
"""
464473
s = s.view(s.size(0), self.source.n)
465474

466-
# running product of multiplicative factors, [source.n, target.n]
467-
a_eff = None
468-
# running additive offset, [source.n, target.n]; None while still zero
469-
b_eff = None
470-
for f in self.pipeline:
471-
factor = f.compute(s)
472-
if factor is None:
473-
# Side-effect-only pipeline entries (sub-features) fold as identity.
474-
continue
475-
if isinstance(factor, torch.Tensor) and factor.is_sparse:
476-
# Sparse feature values carry a leading batch dim ([1, src, tgt])
477-
# from prime_feature; densify to the fold's [src, tgt] shape.
478-
factor = factor.to_dense().view(self.source.n, self.target.n)
479-
op = getattr(f, "op", "mul")
480-
if op == "mul":
481-
a_eff = factor if a_eff is None else a_eff * factor
482-
if b_eff is not None:
483-
b_eff = b_eff * factor
484-
else: # additive contribution: "add" -> +factor, "sub" -> -factor
485-
term = factor if op == "add" else -factor
486-
b_eff = term if b_eff is None else b_eff + term
487-
488-
if a_eff is None:
489-
# Degenerate pipeline with no multiplicative feature: every source
490-
# neuron contributes with unit weight.
491-
a_eff = torch.ones(self.source.n, self.target.n, device=s.device)
492-
if not torch.is_floating_point(a_eff):
493-
a_eff = a_eff.float()
475+
deferred = None
476+
if self._fold_cache is not None:
477+
a_eff, b_sum = self._fold_cache
478+
else:
479+
# running product of multiplicative factors, [source.n, target.n]
480+
a_eff = None
481+
# running additive offset, [source.n, target.n]; None while still zero
482+
b_eff = None
483+
for f in self.pipeline:
484+
factor = f.compute(s)
485+
# Compute-time side effects (e.g. per-time-step weight
486+
# normalization) run after the fold has consumed this value.
487+
d = getattr(f, "defer", None)
488+
if d is not None:
489+
deferred = [d] if deferred is None else deferred + [d]
490+
if factor is None:
491+
# Side-effect-only pipeline entries (sub-features) fold as identity.
492+
continue
493+
if isinstance(factor, torch.Tensor) and factor.is_sparse:
494+
# Sparse feature values carry a leading batch dim ([1, src, tgt])
495+
# from prime_feature; densify to the fold's [src, tgt] shape.
496+
factor = factor.to_dense().view(self.source.n, self.target.n)
497+
op = getattr(f, "op", "mul")
498+
if op == "mul":
499+
a_eff = factor if a_eff is None else a_eff * factor
500+
if b_eff is not None:
501+
b_eff = b_eff * factor
502+
else: # additive contribution: "add" -> +factor, "sub" -> -factor
503+
term = factor if op == "add" else -factor
504+
b_eff = term if b_eff is None else b_eff + term
505+
506+
if a_eff is None:
507+
# Degenerate pipeline with no multiplicative feature: every source
508+
# neuron contributes with unit weight.
509+
a_eff = torch.ones(self.source.n, self.target.n, device=s.device)
510+
if not torch.is_floating_point(a_eff):
511+
a_eff = a_eff.float()
512+
513+
# Additive terms apply to every synapse regardless of spikes, so
514+
# their contribution is the source-sum, a constant [target.n] row.
515+
b_sum = b_eff.sum(dim=0) if b_eff is not None else None
516+
517+
if all(getattr(f, "is_static", True) for f in self.pipeline):
518+
self._fold_cache = (a_eff, b_sum)
494519

495520
# The gather pays for its ``nonzero()`` only where that sync is expensive
496521
# relative to the matmul: on CUDA it needs a large weight matrix to win;
@@ -511,11 +536,12 @@ def compute(self, s: torch.Tensor) -> torch.Tensor:
511536
else:
512537
out = s.to(a_eff.dtype) @ a_eff
513538

514-
# Additive terms apply to every synapse regardless of spikes, so sum over
515-
# all source rows (the closed form of the [batch, source.n, target.n]
516-
# source-sum).
517-
if b_eff is not None:
518-
out = out + b_eff.sum(dim=0)
539+
if b_sum is not None:
540+
out = out + b_sum
541+
542+
if deferred is not None:
543+
for fn in deferred:
544+
fn()
519545

520546
if self.traces:
521547
self.activity = out
@@ -559,6 +585,7 @@ def update(self, **kwargs) -> None:
559585
learning = kwargs.get("learning", False)
560586
if learning and not self.manual_update:
561587
# Pipeline learning
588+
self._fold_cache = None
562589
for f in self.pipeline:
563590
f.update(**kwargs)
564591

@@ -568,6 +595,7 @@ def normalize(self) -> None:
568595
Normalize all features in the connection.
569596
"""
570597
# Normalize pipeline features
598+
self._fold_cache = None
571599
for f in self.pipeline:
572600
f.normalize()
573601

@@ -578,6 +606,7 @@ def reset_state_variables(self) -> None:
578606
"""
579607
super().reset_state_variables()
580608

609+
self._fold_cache = None
581610
for f in self.pipeline:
582611
f.reset_state_variables()
583612

0 commit comments

Comments
 (0)