Okay I’ve found a simple way to reproduce this, as well as a fix (though admittedly I think my own inexperience with pytorch is what crippled me here).
The gist is that my loss function was returning a nonscalar Variable, so I was taking gradients using the following block
if torch.numel(loss) > 1:
  torch.autograd.backward([loss], grad_variables=[input_variables])
else:
  torch.autograd.backward(loss)

thinking that this would do what I want since loss above was an Nx1x1x1 tensor (from input variables of shape NxCxHxW) and the i^th index of the loss only depended on the i^th element of the input.
Somehow, taking backward steps from a nonscalar loss allows memory to accumulate without being released in Cuda. Simply ensuring that my loss will always return a scalar, or just passing everything through torch.sum at the end is enough to fix my problem.
Here’s some code to demo:
##########################################################################
#   IMPORT BLOCK                                                         #
##########################################################################
import gc 

import torch
import torch.nn as nn
import torch.nn.functional as F 
from torch.autograd import Variable
import subprocess
import time 

# fxn taken from https://discuss.pytorch.org/t/memory-leaks-in-trans-conv/12492
def get_gpu_memory_map():   
    result = subprocess.check_output(
        [
            'nvidia-smi', '--query-gpu=memory.used',
            '--format=csv,nounits,noheader'
        ])
    
    return float(result)


##########################################################################
#   CLASS BLOCK                                                          #
##########################################################################

class ClassA(object):
    def __init__(self):
        self.nets = [] 
        
class BadSubclass(ClassA):
    def __init__(self, classifier):
        super(BadSubclass, self).__init__()
        self.classifier = classifier 
        self.nets.append(self.classifier)
        
    def forward(self, inp):
        return self.classifier.forward(inp).squeeze()            
    
class GoodSubclass(BadSubclass):
    def __init__(self, classifier):
        super(BadSubclass, self).__init__()
        self.classifier = classifier
        self.nets.append(self.classifier)
        
    def forward(self, inp):
        return torch.sum(super(GoodSubclass, self).forward(inp))
        
        
class MyModel(nn.Module):
    def __init__(self):
        super(MyModel, self).__init__()
        self.fc1 = nn.Linear(1000, 10000)
        self.fc2 = nn.Linear(10000, 1000)
        self.fcs = [self.fc1, self.fc2]
        
    def forward(self, x):
        for fc in self.fcs:
            x = fc(x)
            x = F.relu(x)
        return x # 1000 dimension output 

    

##########################################################################
#   EXAMPLE BLOCK                                                        #
##########################################################################

def memout_example(bad_or_good):
    # reuse all the code except for which subclass we use 
    # and which grad technique we use 
    
    
    assert bad_or_good in ['bad', 'good']
    if bad_or_good == 'bad':
        subclass = BadSubclass 
        grad_method = lambda output, inp: torch.autograd.backward(
                                          [output], grad_variables=[inp])
    else:
        subclass = GoodSubclass 
        grad_method = lambda output, inp: output.backward() 
            
    
    # Loop through, pick a random input, run it through model
    # then compute gradients, then clean up as much as possible 
        
    for i in xrange(10):    
        print "LOOP: (%s) | BASE STATE" % i, get_gpu_memory_map()
        x = Variable(torch.randn(1, 1000)).cuda()
        model = MyModel().cuda()

        example = subclass(model)
        out = example.forward(x)
        grad_method(out, x)
        print "LOOP: (%s) | PEAK STATE" % i, get_gpu_memory_map()
        del model 
        del example
        del out 
        del x 
        gc.collect()
        torch.cuda.empty_cache()
        time.sleep(5)

        print "LOOP: (%s) | OUT  STATE" % i, get_gpu_memory_map()   
        print '-' * 29 # pretty prints

Running  memout_example('bad') should cause things to accumulate, but memout_example('good') should be okay.