docs: public Future<IEvaluation[]> execute(IEvaluation[] evals, int evalWorkers, int evalBatchSize, Iterator<DataSet> ds, Iterator<MultiDataSet> mds,
                                           boolean isCG, Broadcast<String> json, Broadcast<byte[]> params){
          Preconditions.checkArgument(evalWorkers > 0, "Invalid number of evaluation workers: must be > 0. Got: %s", evalWorkers);
          Preconditions.checkState(ds != null || mds != null, "No data provided - both DataSet and MultiDataSet iterators were null");
  
          //For multi-GPU we'll use a round robbin approach for worker thread/GPU affinity
          int numDevices = Nd4j.getAffinityManager().getNumberOfDevices();
          if(numDevices <= 0)
              numDevices = 1;
  
          //Create the device local params if required
          DeviceLocalNDArray deviceLocalParams;
          synchronized (this){
              if(!paramsMap.containsKey(params.getValue())){
                  //Due to singleton pattern, this block should execute only once (first thread)
                  //Initially put on device 0. For CPU, this means we only have a single copy of the params INDArray shared by
                  // all threads, which is both safe and uses the least amount of memory
                  //For CUDA, we can't share threads otherwise arrays will be continually relocated, causing a crash
                  //Nd4j.getMemoryManager().releaseCurrentContext();
                  //NativeOpsHolder.getInstance().getDeviceNativeOps().setDevice(0);
                  //Nd4j.getAffinityManager().attachThreadToDevice(Thread.currentThread(), 0);
                  byte[] pBytes = params.getValue();
                  INDArray p;
                  try{
                      p = Nd4j.read(new ByteArrayInputStream(pBytes));
                  } catch (IOException e){
                      throw new RuntimeException(e);  //Should never happen
                  }
                  DeviceLocalNDArray dlp = new DeviceLocalNDArray(p);
                  paramsMap.put(params.getValue(), dlp);
                  //log.info("paramsMap: size {}", paramsMap.size());
              }
              deviceLocalParams = paramsMap.get(params.getValue());
          }
  
          int currentWorkerCount;
          while((currentWorkerCount = workerCount.get()) < evalWorkers){
              //For load balancing: we're relying on the fact that threads are mapped to devices in a round-robbin approach
              // the first time they touch an INDArray. If we assume this method is called by new threads,
              // then the first N workers will be distributed evenly across available devices.
  
                  if (workerCount.compareAndSet(currentWorkerCount, currentWorkerCount + 1)) {
                      log.debug("Starting evaluation in thread {}", Thread.currentThread().getId());
                      //This thread is now a worker
                      EvaluationFuture f = new EvaluationFuture();
                      f.setResult(evals);
                      try {
                          Model m;
                          if (isCG) {
                              ComputationGraphConfiguration conf = ComputationGraphConfiguration.fromJson(json.getValue());
                              ComputationGraph cg = new ComputationGraph(conf);
                              cg.init(deviceLocalParams.get(), false);
                              m = cg;
                          } else {
                              MultiLayerConfiguration conf = MultiLayerConfiguration.fromJson(json.getValue());
                              MultiLayerNetwork net = new MultiLayerNetwork(conf);
                              net.init(deviceLocalParams.get(), false);
                              m = net;
                          }
  
                          //Perform eval on this thread's data
                          try {
                              doEval(m, evals, ds, mds, evalBatchSize);
                          } catch (Throwable t) {
                              f.setException(t);
                          } finally {
                              f.getSemaphore().release(1);
                          }
  
                          //Perform eval on other thread's data
                          while (!queue.isEmpty()) {
                              Eval e = queue.poll();  //Use poll not remove to avoid race condition on last element
                              if (e == null)
                                  continue;
                              try {
                                  doEval(m, evals, e.getDs(), e.getMds(), evalBatchSize);
                              } catch (Throwable t) {
                                  e.getFuture().setException(t);
                              } finally {
                                  e.getFuture().getSemaphore().release(1);
                              }
                          }
                      } finally {
                          workerCount.decrementAndGet();
                          log.debug("Finished evaluation in thread {}", Thread.currentThread().getId());
                      }
  
                      Nd4j.getExecutioner().commit();
                      return f;
                  }
          }
  
          //At this point: not a worker thread (otherwise, would have returned already)
          log.debug("Submitting evaluation from thread {} for processing in evaluation thread", Thread.currentThread().getId());
          EvaluationFuture f = new EvaluationFuture();
          queue.add(new Eval(ds, mds, evals, f));
          return f;
      }