docs: protected Pair<INDArray, INDArray> preOutput(boolean training, boolean forBackprop, LayerWorkspaceMgr workspaceMgr) {
          assertInputSet(false);
          INDArray bias = getParamWithNoise(ConvolutionParamInitializer.BIAS_KEY, training, workspaceMgr);
          INDArray weights = getParamWithNoise(ConvolutionParamInitializer.WEIGHT_KEY, training, workspaceMgr);
  
          validateInputRank();
  
          INDArray input = this.input.castTo(dataType);
  
          // FIXME: int cast
          int miniBatch = (int) input.size(0);
          int outDepth = (int) weights.size(0);
          int inDepth = (int) weights.size(1);
          validateInputDepth(inDepth);
  
          int kH = (int) weights.size(2);
          int kW = (int) weights.size(3);
  
          int[] dilation = layerConf().getDilation();
          int[] kernel = layerConf().getKernelSize();
          int[] strides = layerConf().getStride();
  
          int[] pad;
          int[] outSize;
          if (convolutionMode == ConvolutionMode.Same) {
              outSize = ConvolutionUtils.getOutputSize(input, kernel, strides, null, convolutionMode, dilation); //Also performs validation
  
              // FIXME: int cast
              pad = ConvolutionUtils.getSameModeTopLeftPadding(outSize, new int[] {(int) input.size(2), (int) input.size(3)}, kernel,
                              strides, dilation );
          } else {
              pad = layerConf().getPadding();
              outSize = ConvolutionUtils.getOutputSize(input, kernel, strides, pad, convolutionMode, dilation); //Also performs validation
          }
  
          int outH = outSize[0];
          int outW = outSize[1];
  
  
          if (helper != null && (helperCountFail == 0 || !layerConf().isCudnnAllowFallback())) {
              if (preOutput != null && forBackprop) {
                  return new Pair<>(preOutput, null);
              }
  
              //For no-bias convolutional layers: use an empty (all 0s) value for biases
              if(!hasBias()){
                  if(dummyBias == null){
                      try (MemoryWorkspace wsO = Nd4j.getMemoryManager().scopeOutOfWorkspaces()) {
                          dummyBias = Nd4j.create(1, layerConf().getNOut());
                      }
                  }
                  bias = dummyBias;
              }
  
              INDArray ret = null;
              try {
                  ret = helper.preOutput(input, weights, bias, kernel, strides, pad, layerConf().getCudnnAlgoMode(),
                          layerConf().getCudnnFwdAlgo(), convolutionMode, dilation, workspaceMgr);
              } catch (Exception e){
                  if(e.getMessage() != null && e.getMessage().contains("Failed to allocate")){
                      //This is a memory exception - don't fallback to built-in implementation
                      throw e;
                  }
  
                  if(layerConf().isCudnnAllowFallback()){
                      helperCountFail++;
                      if(helper instanceof MKLDNNConvHelper){
                          log.warn("MKL-DNN execution failed - falling back on built-in implementation",e);
                      } else {
                          log.warn("CuDNN execution failed - falling back on built-in implementation",e);
                      }
                  } else {
                      throw new RuntimeException(e);
                  }
              }
              if (ret != null) {
                  return new Pair<>(ret, null);
              }
          }
  
          if (preOutput != null && i2d != null && forBackprop) {
              return new Pair<>(preOutput, i2d);
          }
  
          //im2col in the required order: want [outW,outH,miniBatch,depthIn,kH,kW], but need to input [miniBatch,channels,kH,kW,outH,outW] given the current im2col implementation
          //To get this: create an array of the order we want, permute it to the order required by im2col implementation, and then do im2col on that
          //to get old order from required order: permute(0,3,4,5,1,2)
          //Post reshaping: rows are such that minibatch varies slowest, outW fastest as we step through the rows post-reshape
          INDArray col = Nd4j.createUninitialized(weights.dataType(), new long[] {miniBatch, outH, outW, inDepth, kH, kW}, 'c');
          INDArray col2 = col.permute(0, 3, 4, 5, 1, 2);
          INDArray im2ColIn = input.castTo(col2.dataType());      //No op if already (for example) float
          Convolution.im2col(im2ColIn, kH, kW, strides[0], strides[1], pad[0], pad[1], dilation[0], dilation[1],
                          convolutionMode == ConvolutionMode.Same, col2);
  
          INDArray im2col2d = Shape.newShapeNoCopy(col, new int[] {miniBatch * outH * outW, inDepth * kH * kW}, false);
  
          //Current order of weights: [depthOut,depthIn,kH,kW], c order
          //Permute to give [kW,kH,depthIn,depthOut], f order
          //Reshape to give [kW*kH*depthIn, depthOut]. This should always be zero-copy reshape, unless weights aren't in c order for some reason
          INDArray permutedW = weights.permute(3, 2, 1, 0);
          INDArray reshapedW = permutedW.reshape('f', kW * kH * inDepth, outDepth);
  
          //Do the MMUL; c and f orders in, f order out. output shape: [miniBatch*outH*outW,depthOut]
          INDArray z = workspaceMgr.createUninitialized(ArrayType.ACTIVATIONS, weights.dataType(), new long[]{im2col2d.size(0), reshapedW.size(1)}, 'f');
          im2col2d.mmuli(reshapedW, z);
  
          //Add biases, before reshaping. Note that biases are [1,depthOut] and currently z is [miniBatch*outH*outW,depthOut] -> addiRowVector
          if(layerConf().hasBias()){
              z.addiRowVector(bias);
          }
  
          //Now, reshape to [outW,outH,miniBatch,outDepth], and permute to have correct output order: [miniBath,outDepth,outH,outW];
          z = Shape.newShapeNoCopy(z, new int[] {outW, outH, miniBatch, outDepth}, true);
          z = z.permute(2, 3, 1, 0);
  
          if (training && cacheMode != CacheMode.NONE && workspaceMgr.hasConfiguration(ArrayType.FF_CACHE) && workspaceMgr.isWorkspaceOpen(ArrayType.FF_CACHE)) {
              try (MemoryWorkspace wsB = workspaceMgr.notifyScopeBorrowed(ArrayType.FF_CACHE)) {
                  i2d = im2col2d.unsafeDuplication();
              }
          }
  
          return new Pair<>(z, forBackprop ? im2col2d : null);
      }