docs: public Map<String, T> output(@NonNull List<String> variables, Map<String, T> placeholderValues) {
          Preconditions.checkState(!variables.isEmpty(), "Variables to perform forward pass for must not be empty");
          List<String> sdPlaceholders = sameDiff.inputs();
          Preconditions.checkState(sdPlaceholders == null || sdPlaceholders.isEmpty()
                          || (placeholderValues != null && placeholderValues.size() == sdPlaceholders.size() &&
                          placeholderValues.keySet().containsAll(sdPlaceholders)),
                  "Attempting to perform inference with invalid placeholders: SameDiff instance has placeholder variables %s, placeholders provided for inference: %s",
                  sdPlaceholders, (placeholderValues == null ? null : placeholderValues.keySet()));
  
  
          //Step 0: validation - that variables exist, placeholders have arrays, etc
          for (String s : variables) {
              Preconditions.checkState(sameDiff.variableMap().containsKey(s), "Requested output variable %s does not exist in SameDiff instance", s);
          }
  
          placeholderValues = preprocessPlaceholders(placeholderValues);
  
          //Clear state from past
          availableForExec.clear();
          availableForExecSet.clear();
          subgraph.clear();
          execInputs.clear();
          execInputsAllIter.clear();
          execConstInputs.clear();
          nodeOutputs.clear();            //TODO eventually we'll have cache here for later execs... main challenge is detecting in-place array modifications and invalidating old results
          tensorArrays.clear();
  
          //Step 1: determine subgraph structure we actually need to execute
          //Basic plan: work backwards from the variables we want, based on the graph structure, to work out what
          // we actually need to execute
          initSubgraph(variables);
  
          //Step 2: execute in any order, until we have all required nodeOutputs
          /*
          The idea is simple: we start off with a set of "available to execute" variables - just the placeholders and
          constants at this point.
  
          Then, we remove an "available to execute" node and execute it. Execution may be:
          (a) For constants and placeholders: just looking up the value
          (b) For variables as outputs of ops: actually executing the op
  
          After execution, we look at the graph structure and determine what that now executed/calculated variable is
          an input to. If all inputs are available for the op, we mark all output variables of that op as available for execution.
  
          We stop computation once all the required outputs are available. At this point, subgraph may NOT be empty - for example,
          switch ops may cause entire branches of the graph to be skipped.
           */
  
          Map<String, T> out = new HashMap<>();
          int step = 0;
          while (out.size() < variables.size()) {
              if(availableForExec.size() == 0){
                  int missingCount = variables.size() - out.size();
                  StringBuilder sb = new StringBuilder();
                  sb.append("No variable are available for execution at step ")
                          .append(step).append(": ").append(missingCount).append(" values remaining");
                  Set<String> missing = new HashSet<>();
                  for(String s : variables){
                      if(!out.containsKey(s)){
                          missing.add(s);
                      }
                  }
                  if(missingCount <= 10){
                      sb.append(". Missing variables: ");
                      sb.append(missing);
                  } else {
                      sb.append(". First 10 missing variables: ");
                      Iterator<String> iter = missing.iterator();
                      for( int i=0; i<10 && iter.hasNext(); i++ ){
                          if(i > 0)
                              sb.append(",");
                          sb.append(iter.next());
                      }
                  }
                  String s = sb.toString();
                  throw new IllegalStateException(s);
              }
  
              //Get any variable and execute it's corresponding op
              VarId varToExec = availableForExec.remove();
              availableForExecSet.remove(varToExec);
              if (nodeOutputs.containsKey(varToExec)) {
                  //Already processed this one. May occur if execution was triggered by a different output of a multi-output op
                  //But we'll still update its descendants to ensure they are marked as available
                  if (variables.contains(varToExec.getVariable())) {  //Check if required output
                      out.put(varToExec.getVariable(), nodeOutputs.get(varToExec));
                  }
                  updateDescendentsForExec(step, varToExec);
                  continue;
              }
  
              //Get inputs to this variable. May be actual op inputs, or just control dependencies
              Set<VarId> inputsToVar = execInputs.get(varToExec);
              VarId allIterInputVar = newVarId(varToExec.getVariable(), varToExec.getFrame(), 0, varToExec.getParentFrame());
              Set<VarId> inputsToVarAllIter = execInputsAllIter.get(allIterInputVar);
              Set<String> constPhForVar = execConstInputs.get(varToExec.getVariable());
  
              log.trace("Beginning execution step {}: variable {}", step, varToExec);
  
              if (sameDiff.getVariable(varToExec.getVariable()).isPlaceHolder()) {
                  //Variable is placeholder: do lookup
                  nodeOutputs.put(varToExec, placeholderValues.get(varToExec.getVariable()));
                  updateDescendentsForExec(step, varToExec); //Check + mark descendants as available for exec
                  if (variables.contains(varToExec.getVariable())) {  //Check if required output
                      out.put(varToExec.getVariable(), placeholderValues.get(varToExec.getVariable()));
                  }
              } else if (sameDiff.getVariable(varToExec.getVariable()).isConstant() ||
                      sameDiff.getVariable(varToExec.getVariable()).getVariableType() == VariableType.VARIABLE) {
                  //Variable is constant: do lookup
                  //OR variable is VARIABLE type - i.e., a trainable parameter...
                  T phArr = getConstantOrVariable(varToExec.getVariable());
                  Preconditions.checkNotNull(phArr, "Encountered null placeholder array for constant: %s", varToExec);
                  nodeOutputs.put(varToExec, phArr);
                  updateDescendentsForExec(step, varToExec); //Check + mark descendants as available for exec
                  if (variables.contains(varToExec.getVariable())) {  //Check if required output
                      out.put(varToExec.getVariable(), phArr);
                  }
  
  
              } else if (sameDiff.getVariableOutputFunction(varToExec.getVariable()) != null) {
                  //Variable is the output of an op -> execute op
                  String opName = sameDiff.getVariables().get(varToExec.getVariable()).getOutputOfOp();
  
                  //Execute op
                  FrameIter frameIter = varToExec.toFrameIter();
                  O parameterizedOp = getAndParameterizeOp(opName, frameIter, inputsToVar, inputsToVarAllIter, constPhForVar, placeholderValues);
                  T[] opOutputValues = getOutputs(parameterizedOp, frameIter, inputsToVar, inputsToVarAllIter, constPhForVar);
  
  
                  //Post execution: work out what is now available for exec
                  String[] opOutputVarNames = sameDiff.getFunctionById(opName).outputVariablesNames();
  
                  Preconditions.checkState(opOutputValues.length == opOutputVarNames.length, "Unexpected number of outputs from executed op %s:" +
                                  " got %s outputs when %s outputs were expected (%s)", parameterizedOp.getClass().getSimpleName(), opOutputValues.length,
                          opOutputVarNames.length, opOutputVarNames);
  
                  for (int i = 0; i < opOutputVarNames.length; i++) {
                      if (opOutputValues[i] == null && parameterizedOp instanceof Switch) {
                          //Skip null - for switch op only. Switch op forwards input to only one of its outputs
                          //All other ops should not
                          continue;
                      }
  
                      Preconditions.checkNotNull(opOutputValues[i], "Encountered null output (output %s) for op %s at execution step %s", i, parameterizedOp.getClass().getSimpleName(), step);
  
                      VarId outputVarId;
                      boolean addDummyOutput = false;
                      if (parameterizedOp instanceof Enter) {
                          //Enter op: output is variable in a new (specified) frame, iteration 0.
                          String frame = ((Enter) parameterizedOp).getFrameName();
                          boolean isConstant = ((Enter) parameterizedOp).isConstant();
                          FrameIter outParentFrame = varToExec.getParentFrame();
                          if(isConstant && outParentFrame != null){
                              //For enter nodes that are constants, we want iteration 0 in all frames in the heirarchy
                              //For example, const -> Enter(a) -> Enter(b) -> op; in this case, the input to Op (at any frame/iteration) should should
                              // be the constant value - which is recorded as (frame="a",iter=0,parent=(frame="b",iter=0))
                              outParentFrame = outParentFrame.clone();
                              FrameIter toZero = outParentFrame;
                              while(toZero != null){
                                  toZero.setIteration(0);
                                  toZero = toZero.getParentFrame();
                              }
                          }
                          outputVarId = newVarId(opOutputVarNames[i], frame, 0, outParentFrame);
                          addDummyOutput = true;
                      } else if (parameterizedOp instanceof Exit) {
                          //Exit node forwards input to parent frame (which is already reflected in varToExec)
                          outputVarId = newVarId(opOutputVarNames[i], varToExec.getFrame(), varToExec.getIteration(), varToExec.getParentFrame());
                          addDummyOutput = true;
                      } else if (parameterizedOp instanceof NextIteration) {
                          //NextIteration op: forwards its single input to its output varible in the current frame, but increments the iteration number
                          //Note that varToExec has already had its iteration number incremented by 1 (relative to its input) in updateDescendentsForExec... so don't increment here
                          outputVarId = newVarId(opOutputVarNames[i], varToExec.getFrame(), varToExec.getIteration(), varToExec.getParentFrame());
                          addDummyOutput = true;
                      } else if (parameterizedOp instanceof LoopCond) {
                          //LoopCond just forwards input to output
                          outputVarId = newVarId(opOutputVarNames[i], varToExec.getFrame(), varToExec.getIteration(), varToExec.getParentFrame());
                          addDummyOutput = true;
                      } else {
                          //Standard ops - output variable has same frame and iteration number as the input(s)
                          outputVarId = newVarId(opOutputVarNames[i], varToExec.getFrame(), varToExec.getIteration(), varToExec.getParentFrame());
                      }
  
                      if(addDummyOutput){
                          //For ops like enter/exit/nextiteration, these don't have a real output for that node
                          //But, we still want an entry in nodeOutputs, which we also use for checking if an op has already been executed
                          nodeOutputs.put(newVarId(opOutputVarNames[i], varToExec.getFrame(), varToExec.getIteration(), varToExec.getParentFrame()), null);
                      }
  
                      nodeOutputs.put(outputVarId, opOutputValues[i]);
                      updateDescendentsForExec(step, outputVarId); //Check + mark descendants as available for exec
  
                      if (variables.contains(opOutputVarNames[i])) {  //Check if required output
                          out.put(opOutputVarNames[i], opOutputValues[i]);
                      }
                  }
              } else {
                  Variable v = sameDiff.getVariables().get(varToExec.getVariable());
                  throw new IllegalStateException("Unable to execute variable " + varToExec + " of type " + v.getVariable().getVariableType());
              }
              step++;
          }
  
  
          //TODO under what circumstances should we clear the nodeOutputs map?
          //TODO when should we close the workspace? (Might want to leave it open if we expect to re-use)
  
          return out;
      }