Now it gets interesting. I got some quick help vectorizing over d3 (which I had previously discussed as an idea with Robert), and here are the results:
Single FP (fits easily in 24 GB VRAM): Elapsed time is 60.143706 seconds.
Double FP (requires 45GB VRAM): Elapsed time is 237.005294 seconds.
Looped Single FP: Elapsed time is 76.044933 seconds.
Looped Double FP (requires only 9GB VRAM): Elapsed time is 78.137489 seconds.
Since my GPU has only 32GB VRAM, thereâs a large performance hit as the card accesses âShared GPU Memoryâ. SingleFP matrices save a lot of space in the VFI computation realm when VRAM is in high demand.
Is it worth vectorizing everything? Is it worth having a singlefp option? It all dependsâŚ
For the curious, hereâs the vectorized loop (with commentary by Gemini):
% 1. Combine grids for N_d2 and N_d3
N_d23 = N_d2 * N_d3;
d23_gridvals_val_all = [repmat(d2_gridvals, N_d3, 1), repelem(d3_grid(:), N_d2, 1)];
% 2. Vectorize the Expected Value (EV) block over N_d3
pi_bothz_all = pi_semiz_J(:, :, :, jj); % Shape: [N_shocks, N_bothz, N_d3]
pi_bothz_perm = permute(pi_bothz_all, [2, 1, 3]);
pi_bothz_shifted = reshape(pi_bothz_perm, [1, size(pi_bothz_perm, 1), size(pi_bothz_perm, 2), N_d3]);
EV_all = EVpre .* pi_bothz_shifted;
EV_all(isnan(EV_all)) = 0;
EV_all = sum(EV_all, 2);
EV_3D = reshape(EV_all, [N_a, N_bothz, N_d3]);
% 3. Extract lower and upper bounds using 4D expanded indices
% Push the N_d3 offset to the 4th dimension (1x1x1xN_d3) so it broadcasts properly
offset_d3 = reshape((0:N_d3-1) * (N_a * N_bothz), 1, 1, 1, N_d3);
% Now implicit expansion works: [201x51x4] + [1x1x1x101] -> [201x51x4x101]
lin_lower_3D = lin_lower + offset_d3;
lin_upper_3D = lin_upper + offset_d3;
EV1_3D = EV_3D(lin_lower_3D);
EV2_3D = EV_3D(lin_upper_3D);
skipinterp_3D = (EV1_3D == EV2_3D);
% Expand aprimeProbs_full along the 4th dimension to match
aprimeProbs_3D = repmat(aprimeProbs_full, 1, 1, 1, N_d3);
aprimeProbs_3D(skipinterp_3D) = 0;
entireEV_3D = EV1_3D .* aprimeProbs_3D + EV2_3D .* (1 - aprimeProbs_3D);
% 4. Compute DiscountedEV for the combined dimensions
DiscountedEV_3D = DiscountFactorParamsVec * reshape(entireEV_3D, [N_d2, N_a1, 1, N_a2, N_bothz, N_d3]);
% Merge d2 and d3 into the first dimension to run interp1 cleanly
DiscountedEV_merged = reshape(permute(DiscountedEV_3D, [1, 6, 2, 3, 4, 5]), [N_d23, N_a1, 1, N_a2, N_bothz]);
DiscountedEVinterp_merged = permute(interp1(a1_gridvals, permute(DiscountedEV_merged, [2,1,3,4,5]), a1prime_grid), [2,1,3,4,5]);
% 5. First CreateReturnFnMatrix call (Combined N_d23)
ReturnMatrix_all = CreateReturnFnMatrix_ExpAsset_Disc(ReturnFn, 0, [N_d23, 1], n_a1, n_a1, n_a2, n_bothz, ...
d23_gridvals_val_all, a1_gridvals, a1_gridvals, a2_gridvals, bothz_gridvals_J(:,:,jj), ReturnFnParamsVec, 1, 0);
entireRHS_all = ReturnMatrix_all + DiscountedEV_merged;
% 6. First Max Operation and Midpoint calculations
[~, maxindex_all] = max(entireRHS_all, [], 2);
midpoint_all = max(min(maxindex_all, n_a1(1)-1), 2);
a1primeindexesfine_all = (midpoint_all + (midpoint_all - 1)*n2short) + (-n2short-1:1:1+n2short);
% 7. Second CreateReturnFnMatrix call (Refined Grid)
ReturnMatrix_ii_all = CreateReturnFnMatrix_ExpAsset_Disc(ReturnFn, 0, [N_d23, 1], n2long, n_a1, n_a2, n_bothz, ...
d23_gridvals_val_all, a1prime_grid(a1primeindexesfine_all), a1_gridvals, a2_gridvals, bothz_gridvals_J(:,:,jj), ReturnFnParamsVec, 2, 0);
d2a1primea2bothz_all = (1:1:N_d23)' + N_d23*(a1primeindexesfine_all-1) + N_d23*N_a1prime*a2ind + N_d23*N_a1prime*N_a2*bothzind;
entireRHS_ii_all = ReturnMatrix_ii_all + reshape(DiscountedEVinterp_merged(d2a1primea2bothz_all(:)), [N_d23*n2long, N_a1*N_a2, N_bothz]);
% 8. Second Max Operation: Separate d2 and d3 to ensure max is ONLY taken over n2long and d2
% Reshape and permute so d3 is pushed to the back, allowing dim 1 max to work accurately
temp = reshape(entireRHS_ii_all, [N_d2, N_d3, n2long, N_a1*N_a2, N_bothz]);
temp = permute(temp, [1, 3, 4, 5, 2]); % -> [N_d2, n2long, N_a1*N_a2, N_bothz, N_d3]
temp = reshape(temp, [N_d2*n2long, N_a1*N_a2, N_bothz, N_d3]);
[Vtempii_all, maxindexL2_all] = max(temp, [], 1);
V_ford3_jj = shiftdim(Vtempii_all, 1); % Native [N_a1*N_a2, N_bothz, N_d3] shape
% 9. Linear Indexing updates and Policy creation
d_ind = rem(maxindexL2_all - 1, N_d2) + 1;
d3_offset_n2long = reshape(0:N_d3-1, 1, 1, 1, N_d3) * N_d2;
allind_all = (d_ind + d3_offset_n2long) + N_d23*aind + N_d23*N_a*bothzBind;
% No need to loop over d3_c, we assign the expanded dimensions directly
Policy3_ford3_jj(1, :, :, :) = d_ind;
Policy3_ford3_jj(2, :, :, :) = midpoint_all(allind_all);
Policy3_ford3_jj(3, :, :, :) = ceil(maxindexL2_all / N_d2);
% 10. Boundary flags setup
base_ind = d_ind + d3_offset_n2long;
jump_aind = (N_d23 * n2long) * aind;
jump_bothz = (N_d23 * n2long) * N_a * bothzBind;
linidx_lower_all = base_ind + jump_aind + jump_bothz;
linidx_upper_all = base_ind + N_d23*(n2long-1) + jump_aind + jump_bothz;
isInfLower_all = (ReturnMatrix_ii_all(linidx_lower_all) == -Inf);
isInfUpper_all = (ReturnMatrix_ii_all(linidx_upper_all) == -Inf);
L2offset_all = ceil(maxindexL2_all / N_d2);
inLowerStrict_all = (L2offset_all >= 2) & (L2offset_all <= n2short+1);
inUpperStrict_all = (L2offset_all >= n2short+3) & (L2offset_all <= n2long-1);
flag_ford3_jj = shiftdim(2 + (inLowerStrict_all & isInfLower_all) - (inUpperStrict_all & isInfUpper_all), 1);
One small note: I noticed that the initializations of lin_lower and lin_upper are loop-invariant, and so can be lifted out of all loops to where we initialize aprimeIndex_full and aprimeplus1Index_full even if we do not vectorize. I leave it to Claude Code to push that change everywhere.