| #------------------------------------------------------------- |
| # |
| # Licensed to the Apache Software Foundation (ASF) under one |
| # or more contributor license agreements. See the NOTICE file |
| # distributed with this work for additional information |
| # regarding copyright ownership. The ASF licenses this file |
| # to you under the Apache License, Version 2.0 (the |
| # "License"); you may not use this file except in compliance |
| # with the License. You may obtain a copy of the License at |
| # |
| # http://www.apache.org/licenses/LICENSE-2.0 |
| # |
| # Unless required by applicable law or agreed to in writing, |
| # software distributed under the License is distributed on an |
| # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY |
| # KIND, either express or implied. See the License for the |
| # specific language governing permissions and limitations |
| # under the License. |
| # |
| #------------------------------------------------------------- |
| # Independent Subnet Training (IST) |
| # |
| # This builtin implements independent subnet training as a |
| # second-order function. It orchestrates distributed / parallel |
| # training over disjoint subnets using parfor, while delegating |
| # architecture-specific logic to user-provided functions. |
| # ------------------------------------------------------------ |
| # INPUT: |
| # model : initial model parameters. A list of matrices for NN. |
| # features : X |
| # labels : Y |
| # val_features : validation X |
| # val_labels : validation Y |
| # upd : computes gradients and performs optimizer step |
| # agg : aggregation logic to combine updates of shared parameters (across subnets) |
| # epochs : number of epochs |
| # batchsize : batchsize for training |
| # j : number of gradient steps until aggregation -> determines length of the IST round (aggregation frequency) |
| # numSubnets : number of independent subnets/workers |
| # hyperparams : list of hyperparameters (e.g. lr, reg, mask params, etc.) |
| # verbose : print progress (boolean) |
| # paramsPerLayer : amount of parameters each layer consists of |
| # fullyConnectedLayers : list of all FC layer indices (starting at idx=1) |
| # |
| # OUTPUT: |
| # model_out : trained model parameters (IST: W) |
| # |
| # ASSUMPTION: |
| # - the last layer is the output layer |
| # ------------------------------------------------------------ |
| |
| m_independentSubnetTrain = function( |
| list[unknown] model, |
| matrix[double] features, |
| matrix[double] labels, |
| matrix[double] val_features, |
| matrix[double] val_labels, |
| string upd, |
| string agg, |
| int epochs, |
| int batchsize, |
| int j, |
| int numSubnets, |
| list[unknown] hyperparams, |
| boolean verbose, |
| int paramsPerLayer, |
| list[int] fullyConnectedLayers |
| ) |
| return (list[unknown] trained_model) |
| { |
| # ------------------------------------------------------------ |
| # Setup |
| # ------------------------------------------------------------ |
| model_out = model |
| |
| P = length(model) |
| N = nrow(features) |
| if (P %% paramsPerLayer != 0) stop("Model length not divisible by paramsPerLayer") |
| L = as.integer(P / paramsPerLayer) # total layers |
| |
| # I. determine shared parameters |
| isSharedParam = matrix(0, 1, P) |
| |
| # - create mask for all FC layers |
| fcLayers = fullyConnectedLayers |
| isFC = matrix(0, rows=1, cols=L) |
| for (i in 1:length(fcLayers)) { |
| idx = as.integer(as.scalar(fcLayers[i])) |
| isFC[1, idx] = 1 # TODO vectorize |
| } |
| |
| # - expand layer mask across all parameters |
| isFC_rep = isFC |
| for (r in 2:paramsPerLayer) { |
| isFC_rep = cbind(isFC_rep, isFC) |
| } |
| if (ncol(isFC_rep)!=P) stop("Dimension mismatch for FC layer mask.") |
| |
| # - all non-FC layers are shared |
| isSharedParam = 1 - isFC_rep |
| |
| # - edge case: FC bias parameters are shared in: output layer or at the end of a FC block |
| for (paramId in seq(2, paramsPerLayer, 2)) { # iterate bias blocks only |
| for (l in 1:L) { |
| if (as.scalar(isFC[1,l])==1 & l==L) { |
| p_out_bias = (paramId - 1) * L + L # output bias is shared across subnets |
| isSharedParam[1, p_out_bias] = 1 # TODO vectorize |
| } |
| else if (as.scalar(isFC[1,l])==1 & l<L & as.scalar(isFC[1,l+1])==0) { |
| p_out_bias = (paramId - 1) * L + l # end of FC block's bias is shared across subnets |
| isSharedParam[1, p_out_bias] = 1 # TODO vectorize |
| } |
| } |
| } |
| if (ncol(isSharedParam) != P) stop("isSharedParam dimension mismatch!") |
| |
| # II. calculate update-steps per epoch |
| if (batchsize<=0 | batchsize>N) { |
| stop("Batch size is out of bounds!") |
| } else { |
| stepsPerEpoch = ceil(N / batchsize) |
| } |
| |
| # III. training loop |
| for (epoch in 1:epochs) { |
| if (verbose) print("Entered epoch: " + epoch) |
| |
| # A.) reshuffle indices each epoch |
| allSampleIndicesRandom = order(target=rand(rows=N, cols=1), by=1, decreasing=FALSE, index.return=TRUE) |
| batchIndices = allSampleIndicesRandom[, 1] |
| b = nrow(batchIndices) |
| I = seq(1, b, 1) |
| V = matrix(1, rows=b, cols=1) |
| S = table(I, batchIndices, V, b, N) |
| |
| features_shuffled = S %*% features |
| labels_shuffled = S %*% labels |
| |
| # B.) iterate IST rounds |
| for (step in seq(1, stepsPerEpoch, j)) { |
| if (verbose) print("Starting new IST round at step: " + step) |
| round_model = model_out # prevent accidental mutation of model_out |
| |
| # 1.) create masks for all subnets |
| [masks, masks_meta_info] = ist_create_disjoint_masks(round_model, numSubnets, L, fcLayers, paramsPerLayer, isFC, verbose) |
| |
| # 2.) preallocate list to store all subnets TODO move outside epoch loop? to prevent constantly allocating... |
| updatedSubnets = list() |
| updatedSubnetsMasks = list() |
| for (s in 1:numSubnets) { |
| updatedSubnets = append(updatedSubnets, list()) |
| updatedSubnetsMasks = append(updatedSubnetsMasks, list()) |
| } |
| |
| # 3) create a template for each subnet based on input model (allows indexing in subsequent parfor-loop) TODO move outside epoch loop? to prevent constantly allocating... |
| subnetModelTemplate = list() |
| subnetModelMaskTemplate = list() |
| for (pIdx in 1:P) { |
| subnetModelTemplate = append(subnetModelTemplate, as.matrix(model[pIdx])) |
| subnetModelMaskTemplate = append(subnetModelMaskTemplate, as.matrix(model[pIdx])) |
| } |
| |
| # local optimization steps / IST round |
| localSteps = min(j, (stepsPerEpoch-step+1)) |
| |
| # 4.) obtain all minibatches for this IST round (doing it once prevents parfor confusion) |
| shuffled_features = list() |
| shuffled_labels = list() |
| for (localStep in 1:localSteps) { |
| mb = (step-1) + localStep |
| mb_local = mb-1 |
| start = mb_local*batchsize + 1 |
| end = min(mb*batchsize, N) |
| |
| Xb = features_shuffled[start:end, 1:ncol(features_shuffled)] |
| yb = labels_shuffled[start:end, 1:ncol(labels_shuffled)] |
| shuffled_features = append(shuffled_features, Xb) |
| shuffled_labels = append(shuffled_labels, yb) |
| } |
| |
| # 5.) perform 'j' local gradient steps for each subnet |
| parfor (subnet in 1:numSubnets) { |
| |
| # a.) obtain masked subnet |
| subnet_model = subnetModelTemplate |
| subnet_model_mask = subnetModelMaskTemplate |
| for (subnet_p in 1:length(round_model)) { |
| param_start_idx = as.integer(as.scalar(masks_meta_info[subnet_p,1])) |
| param_end_idx = as.integer(as.scalar(masks_meta_info[subnet_p,2])) |
| param_rows = as.integer(as.scalar(masks_meta_info[subnet_p,3])) |
| param_cols = as.integer(as.scalar(masks_meta_info[subnet_p,4])) |
| |
| vec = masks[subnet, param_start_idx:param_end_idx] |
| param_mask = matrix(vec, rows=param_rows, cols=param_cols, byrow=TRUE) |
| param = as.matrix(round_model[subnet_p]) |
| |
| subnet_model[subnet_p] = list(param * param_mask) # TODO sparse masking! dense masking will probably increase computational efficiency |
| subnet_model_mask[subnet_p] = list(param_mask) |
| } |
| |
| # b.) local optimization steps / IST round |
| for (localStep in 1:localSteps) { |
| feat = as.matrix(shuffled_features[localStep]) |
| lab = as.matrix(shuffled_labels[localStep]) |
| |
| # compute gradients for subnet s + apply update step on owned params (only) |
| subnet_model = as.list(evalList(upd, list(model=subnet_model, mask=subnet_model_mask, features=feat, labels=lab, hyperparams=hyperparams))) |
| } |
| |
| # c.) save updated subnet and mask |
| updatedSubnets[subnet] = list(subnet_model) |
| updatedSubnetsMasks[subnet] = list(subnet_model_mask) |
| } |
| if (verbose) print("All subnets have run successfully.") |
| |
| # 6.) aggregate updates into global model (i.e. model_out) |
| for (p in 1:P) { |
| if (as.scalar(isSharedParam[1, p])==1) { |
| # construct full model update by aggregating shared parameter updates from all subnets |
| subnetParams = list() |
| subnetMasks = list() |
| for (s in 1:numSubnets) { |
| subnet = as.list(updatedSubnets[s]) |
| subnetMask = as.list(updatedSubnetsMasks[s]) |
| |
| subnetParams = append(subnetParams, as.matrix(subnet[p])) |
| subnetMasks = append(subnetMasks, as.matrix(subnetMask[p])) |
| } |
| |
| # aggregate shared parameters based on provided function |
| averagedUpdatedParam = eval(agg, list(initialParam=as.matrix(round_model[p]), allSubnetsParam=subnetParams, allSubnetsMasks=subnetMasks)) |
| round_model[p] = averagedUpdatedParam |
| } |
| else { |
| # construct full model update by filling with disjointly partitioned parameter updates from all subnets |
| initialParam = as.matrix(round_model[p]) |
| updatedParam = matrix(0, nrow(initialParam), ncol(initialParam)) |
| owned = matrix(0, nrow(initialParam), ncol(initialParam)) |
| |
| for (s in 1:numSubnets) { |
| subnet = as.list(updatedSubnets[s]) |
| subnetMask = as.list(updatedSubnetsMasks[s]) |
| |
| owned = owned + as.matrix(subnetMask[p]) |
| updatedParam = updatedParam + as.matrix(subnet[p]) |
| } |
| |
| # SANITY CHECK: |
| max_freq = max(owned) |
| if (max_freq > 1) stop("Overlap detected") |
| |
| round_model[p] = updatedParam |
| } |
| } |
| if (verbose) print("Aggregation of subnets finished. IST round has been successfully executed!") |
| |
| # 7.) update global model (end of the IST round) |
| model_out = round_model |
| } |
| # TODO (potentially): add validation for early stopping etc. |
| } |
| trained_model = model_out |
| } |
| |
| |
| # ---------------------------------------------------------------------------------------------------------------------- |
| # Independent Subnet Masking |
| # |
| # This helper function creates two matrices: one contains all flattened masks, the other contains the info on |
| # how to reconstruct the mask matrices. Each mask is a binary vector indicating which parameters belong to that subnet. |
| # ---------------------------------------------------------------------------------------------------------------------- |
| # INPUT: |
| # model : list of parameter tensors grouped by parameter type i.e. blocks |
| # numSubnets : number of independent subnets/workers (K) |
| # L : total number of layers INCLUDING the output layer (layer indices are assumed to be 1..L) |
| # fullyConnectedLayers : list of all FC layer indices (starting at idx=1) |
| # paramsPerFCLayer : number of parameters / neurons to be partitioned per FC layer |
| # isFC : indicator matrix encoding which layers are FC => isFC[l] ∈ {0,1} |
| # verbose : print progress (boolean) |
| # |
| # OUTPUT: |
| # masks_new : mask matrix defining disjoint neuron ownership across subnets |
| # masks_new_meta : metadata matrix describing the mask layout and ownership mapping |
| # |
| # ASSUMPTIONS: |
| # - neuron ownership is defined via bias vectors |
| # - model is a list of parameter tensors |
| # - trainable parameters are grouped by parameter type i.e. param blocks like (W_l1, W_l2, ..., b_l1, b_l2, ...) |
| # - assumes W and b are always the first two param blocks |
| # - the pattern of optional optimizer state tensors (e.g., vW_l, vb_l) follow the same grouping and always W followed by b |
| # - (output layer & end of FC block) biases are shared -> gradients collide; must be handled by aggregation logic |
| # ---------------------------------------------------------------------------------------------------------------------- |
| |
| ist_create_disjoint_masks = function( |
| list[unknown] model, |
| int numSubnets, |
| int L, |
| list[int] fullyConnectedLayers, |
| int paramsPerFCLayer, |
| Matrix[Double] isFC, |
| boolean verbose |
| ) |
| return ( |
| Matrix[Double] masks_new, |
| Matrix[Double] masks_new_meta |
| ) |
| { |
| P = length(model) |
| |
| # SANITY CHECKS: ensure provided model can be masked correctly |
| if (as.integer(P / paramsPerFCLayer) != L) { |
| stop("Layer/parameter mismatch. Please make sure each layer has the same amount of parameters.") |
| }; |
| if (paramsPerFCLayer < 2 | paramsPerFCLayer %% 2 != 0) { |
| stop("At least 1 pair of W and b needs to be present, as well as parameters need to be W&b pairs.") |
| } |
| |
| # I.) initialize and preallocate masks |
| masks_new_meta = matrix(0, rows=length(model), cols=4) # columns=[start,end,rows,cols] |
| current_position = 1 |
| for (p in 1:length(model)) { |
| M = as.matrix(model[p]) |
| param_length = ncol(M) * nrow(M) # as.scalar(ncol(M)) * as.scalar(nrow(M)) |
| |
| masks_new_meta[p,1] = current_position |
| masks_new_meta[p,2] = current_position + param_length -1 |
| masks_new_meta[p,3] = nrow(M) |
| masks_new_meta[p,4] = ncol(M) |
| |
| current_position = current_position + param_length |
| } |
| mask_size = current_position-1 |
| masks_new = matrix(0, rows=numSubnets, cols=mask_size) # all subnets in one matrix |
| |
| # II.) iterate all layers |
| for (l in 1:L) { |
| |
| # FC layer: create #{numSubnets} disjoint partitions for this layer across all parameters |
| if (as.scalar(isFC[1,l]) == 1) { |
| W = as.matrix(model[l]) |
| b = as.matrix(model[l+L]) |
| H = ncol(W); # bias neurons in layer l |
| |
| # SANITY CHECKS: |
| if (nrow(b) != 1 | ncol(b) != H) { |
| if (verbose) print("Bias shape mismatch!") |
| if (verbose) print("b:", nrow(b), "x", ncol(b)) |
| if (verbose) print("expected: 1 x", H) |
| stop("Invalid bias shape") |
| } |
| if (l!=L & numSubnets>ncol(b)) { # TODO change to next layer is non-FC logic |
| if (verbose) print("More subnets than available neurons in layer:") |
| if (verbose) print(l) |
| stop("Please use a wider model or decrease the amount of subnets.") |
| } |
| |
| # A.) shuffle all neuron indices |
| allNeuronIndicesRandom = order(target=rand(rows=H, cols=1), by=1, decreasing=FALSE, index.return=TRUE) |
| |
| # B.) determine neuron ownership |
| chunk_size = floor(H/numSubnets) |
| remaining_neurons = H - chunk_size * numSubnets |
| amount_active_neurons = matrix(chunk_size, rows=numSubnets, cols=1) |
| if (remaining_neurons > 0) { |
| randomSubnetIndices = order(target=rand(rows=numSubnets, cols=1, seed=-1), by=1, decreasing=FALSE, index.return=TRUE) # TODO replace seed for experiments |
| for (i in 1:remaining_neurons) { |
| sid = as.integer(as.scalar(randomSubnetIndices[i,1])) |
| amount_active_neurons[sid,1] = as.scalar(amount_active_neurons[sid,1]) + 1 # TODO VECTORIZE |
| } |
| } |
| neuron_end_indices = cumsum(amount_active_neurons) |
| neuron_start_indices = neuron_end_indices - amount_active_neurons + 1 |
| |
| # C.) obtain masks for all subnets |
| for(s in 1:numSubnets) { |
| |
| # 1. obtain owned neurons for this layer |
| start = as.integer(as.scalar(neuron_start_indices[s,1])) |
| end = as.integer(as.scalar(neuron_end_indices[s,1])) |
| current_b_indices = allNeuronIndicesRandom[start:end, 1] |
| |
| # 2. create masked bias |
| if(l==L) { # output layer |
| masked_b = matrix(1, rows=1, cols=ncol(b)) |
| } |
| else if (l<L & as.scalar(isFC[1, l+1]) == 0) { # next layer is not FC |
| masked_b = matrix(1, rows=1, cols=ncol(b)) |
| } |
| else { |
| masked_b = matrix(0, rows=1, cols=ncol(b)) |
| for (i in 1:nrow(current_b_indices)) { # TODO VECTORIZE |
| idx = as.integer(as.scalar(current_b_indices[i,1])) |
| masked_b[1, idx] = 1 |
| } |
| } |
| |
| # 3. create masked weight |
| masked_W = matrix(0, rows=nrow(W), cols=ncol(W)) |
| if(l==1) { |
| for (i in 1:nrow(current_b_indices)) { # TODO VECTORIZE |
| idx = as.integer(as.scalar(current_b_indices[i,1])) |
| masked_W[1:nrow(W), idx] = matrix(1, rows=nrow(W), cols=1) |
| } |
| } |
| else if (l>1 & as.scalar(isFC[1, l-1])==0) { # previous layer is not FC |
| for (i in 1:nrow(current_b_indices)) { # TODO VECTORIZE |
| idx = as.integer(as.scalar(current_b_indices[i,1])) |
| masked_W[1:nrow(W), idx] = matrix(1, rows=nrow(W), cols=1) |
| } |
| } |
| else { |
| # obtain active neurons of previous layer |
| p = L + (l-1) |
| start = as.integer(as.scalar(masks_new_meta[p,1])) |
| end = as.integer(as.scalar(masks_new_meta[p,2])) |
| r = as.integer(as.scalar(masks_new_meta[p,3])) |
| c = as.integer(as.scalar(masks_new_meta[p,4])) |
| vec = masks_new[s, start:end] |
| previous_masked_b = matrix(vec, rows=r, cols=c, byrow=TRUE) |
| |
| # SANITY CHECK: dimensions with layers of previous layer match |
| if (l > 1 & ncol(previous_masked_b) != nrow(W)) { |
| if (verbose) print("W/prev layer mismatch in layer l=", l) |
| if (verbose) print("prev_b:", nrow(previous_masked_b), "x", ncol(previous_masked_b)) |
| if (verbose) print("W:", nrow(W), "x", ncol(W)) |
| stop("Invalid W shape wrt previous layer") |
| } |
| |
| if (nrow(previous_masked_b)==1) previous_masked_b = t(previous_masked_b) |
| if (ncol(masked_b) == 1) masked_b = t(masked_b) |
| |
| if(l==L) { # output layer |
| masked_W = previous_masked_b %*% matrix(1, 1, ncol(masked_W)) |
| } |
| else if (l<L & as.scalar(isFC[1, l+1]) == 0) { # next layer is not FC |
| masked_W = previous_masked_b %*% matrix(1, 1, ncol(masked_W)) |
| } else { |
| masked_W = previous_masked_b %*% masked_b |
| } |
| } |
| |
| # 4. forward these masks to all parameters in this layer |
| for (param in 1:paramsPerFCLayer) { |
| k = (param-1)*L + l |
| start = as.integer(as.scalar(masks_new_meta[k,1])) |
| end = as.integer(as.scalar(masks_new_meta[k,2])) |
| len = end - start + 1 |
| |
| if (param %% 2 == 0) { |
| flat = matrix(masked_b, rows=1, cols=len, byrow=TRUE) |
| } else { |
| flat = matrix(masked_W, rows=1, cols=len, byrow=TRUE) |
| } |
| masks_new[s, start:end] = flat |
| } |
| } |
| |
| # SANITY CHECK: accumulating all subnets bias's result in vector of all 1s (in FC hidden layers only) |
| if (l < L) { |
| if (as.scalar(isFC[1, l+1]) == 1) { |
| # bias parameter index for layer l is (L + l) |
| p = L + l |
| |
| start = as.integer(as.scalar(masks_new_meta[p,1])); |
| end = as.integer(as.scalar(masks_new_meta[p,2])); |
| r = as.integer(as.scalar(masks_new_meta[p,3])); |
| c = as.integer(as.scalar(masks_new_meta[p,4])); |
| |
| # sum across subnets for this bias slice |
| sumB_flat = colSums(masks_new[1:numSubnets, start:end]) |
| |
| # reshape back to bias shape (usually Hx1 or 1xH depending on how you store it) |
| sumB = matrix(sumB_flat, rows=r, cols=c, byrow=TRUE) |
| |
| if (min(sumB) != 1 | max(sumB) != 1) { |
| if (verbose) print("Subnet bias masks not a partition in layer l=" + l) |
| if (verbose) print("min(sumB)=" + min(sumB) + " max(sumB)=" + max(sumB)) |
| stop("Invalid subnet bias partition") |
| } |
| } |
| } |
| } |
| else { |
| # Non-FC layer: independent subnet training will not create disjoint partitions for this layer // shared across all subnets -> masks are all-ones |
| for (param in 1:paramsPerFCLayer) { |
| k = (param-1)*L + l |
| start = as.integer(as.scalar(masks_new_meta[k,1])) |
| end = as.integer(as.scalar(masks_new_meta[k,2])) |
| r = as.integer(as.scalar(masks_new_meta[k,3])) |
| c = as.integer(as.scalar(masks_new_meta[k,4])) |
| len = end - start + 1 |
| |
| if (param %% 2 == 0) { |
| shared_b = matrix(1, rows=r, cols=c) |
| flat = matrix(shared_b, rows=1, cols=len, byrow=TRUE) |
| } else { |
| shared_W = matrix(1, rows=r, cols=c) |
| flat = matrix(shared_W, rows=1, cols=len, byrow=TRUE) |
| } |
| masks_new[1:numSubnets, start:end] = matrix(1, numSubnets, 1) %*% flat |
| } |
| } |
| } |
| } |