// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute // SPDX-License-Identifier: GPL-3.0-only #include "MolecularReplacementGPU.h" #include #include #include #include #include #include "../../common/JFJochException.h" #include "../indexing/CUDAMemHelpers.h" namespace { void cuda_err(cudaError_t val) { if (val != cudaSuccess) throw JFJochException(JFJochExceptionCategory::GPUCUDAError, cudaGetErrorString(val)); } void cufft_err(cufftResult val) { if (val != CUFFT_SUCCESS) throw JFJochException(JFJochExceptionCategory::GPUCUDAError, "cuFFT error " + std::to_string(static_cast(val))); } constexpr int THREADS = 256; constexpr int MAX_BINS = 16; // SEARCH_BINS (MolecularReplacement.cpp) is 10 // The rotation function's per-reflection scratch is made for this many orientations at a time. constexpr size_t ROTATION_BATCH = 2048; struct Transform { const float2 *data; int nu, nv, nwh; float box, blur; }; struct Rot { float m[9]; // R^T, row-major: the model turned by R has F_R(s) = F_model(R^T s) }; __device__ __forceinline__ float2 transform_at(const Transform &g, int i, int j, int l) { bool conj = false; if (l < 0) { // the half-l grid holds l >= 0; F(-h) = conj F(h) i = -i; j = -j; l = -l; conj = true; } // Wrapped into the grid; the division only for an index outside it. if (i < 0 || i >= g.nu) { i %= g.nu; if (i < 0) i += g.nu; } if (j < 0 || j >= g.nv) { j %= g.nv; if (j < 0) j += g.nv; } float2 v = g.data[i + static_cast(g.nu) * (j + static_cast(g.nv) * l)]; if (conj) v.y = -v.y; return v; } // MolecularTransform::At on the device. __device__ float2 transform_interpolate(const Transform &g, const Rot &r, float3 s0) { const float s[3] = {r.m[0] * s0.x + r.m[1] * s0.y + r.m[2] * s0.z, r.m[3] * s0.x + r.m[4] * s0.y + r.m[5] * s0.z, r.m[6] * s0.x + r.m[7] * s0.y + r.m[8] * s0.z}; float f[3]; int i0[3]; for (int a = 0; a < 3; a++) { const float u = s[a] * g.box; i0[a] = static_cast(floorf(u)); f[a] = u - i0[a]; } float2 out = make_float2(0, 0); for (int dx = 0; dx < 2; dx++) for (int dy = 0; dy < 2; dy++) for (int dz = 0; dz < 2; dz++) { const float w = (dx ? f[0] : 1 - f[0]) * (dy ? f[1] : 1 - f[1]) * (dz ? f[2] : 1 - f[2]); const float2 v = transform_at(g, i0[0] + dx, i0[1] + dy, i0[2] + dz); out.x += w * v.x; out.y += w * v.y; } const float unblur = expf(g.blur * (s[0] * s[0] + s[1] * s[1] + s[2] * s[2]) / 4.0f); return make_float2(out.x * unblur, out.y * unblur); } // A block-wide sum in a fixed order: each thread's value into shared memory, then a tree. __device__ double block_sum(double v, double *sh) { sh[threadIdx.x] = v; __syncthreads(); for (int stride = THREADS / 2; stride > 0; stride /= 2) { if (threadIdx.x < stride) sh[threadIdx.x] += sh[threadIdx.x + stride]; __syncthreads(); } const double out = sh[0]; __syncthreads(); return out; } // One block per orientation: |F|^2 summed over the copies for every reflection, binned, normalised by // its bin's mean, and correlated with E^2 - 1. __global__ void rotation_function(Transform g, const Rot *rot, const float3 *s, const float *e2, const int *bin, const int *bin_n, int n, int nops, int bins, double w_mean, double w_ss, float *scratch, double *out) { __shared__ double sh[THREADS]; __shared__ double bin_mean[MAX_BINS]; const Rot r = rot[blockIdx.x]; float *ic = scratch + static_cast(blockIdx.x) * n; double part[MAX_BINS]; for (int b = 0; b < bins; b++) part[b] = 0; for (int i = threadIdx.x; i < n; i += THREADS) { float sum = 0; for (int j = 0; j < nops; j++) { const float2 f = transform_interpolate(g, r, s[static_cast(i) * nops + j]); sum += f.x * f.x + f.y * f.y; } ic[i] = sum; part[bin[i]] += sum; } for (int b = 0; b < bins; b++) { const double total = block_sum(part[b], sh); if (threadIdx.x == 0) bin_mean[b] = total / bin_n[b]; } __syncthreads(); double x_sum = 0, x_ss = 0, xy = 0; for (int i = threadIdx.x; i < n; i += THREADS) { const double x = ic[i] / bin_mean[bin[i]]; x_sum += x; x_ss += x * x; xy += x * (e2[i] - w_mean); } x_sum = block_sum(x_sum, sh); x_ss = block_sum(x_ss, sh); xy = block_sum(xy, sh); if (threadIdx.x == 0) { const double sxx = x_ss - x_sum * x_sum / n; out[blockIdx.x] = sxx > 0 && w_ss > 0 ? xy / sqrt(sxx * w_ss) : 0.0; } } // g_j = G_j(h) exp(2 pi i h.t_j) for every term and operator, and each term's sum of |g_j|^2. __global__ void translation_copies(Transform g, Rot r, const float3 *s, const float2 *phase, int terms, int nops, float2 *copies, float *term_sum) { const int t = blockIdx.x * blockDim.x + threadIdx.x; if (t >= terms) return; float sum = 0; for (int j = 0; j < nops; j++) { const size_t e = static_cast(t) * nops + j; const float2 f = transform_interpolate(g, r, s[e]); const float2 p = phase[e]; const float2 v = make_float2(f.x * p.x - f.y * p.y, f.x * p.y + f.y * p.x); copies[e] = v; sum += v.x * v.x + v.y * v.y; } term_sum[t] = sum; } // Each term's weight over its bin's mean |g|^2 sum, the bins summed in a fixed order by one block. __global__ void translation_weights(const float *term_sum, const float *weight, const int *bin, const int *bin_n, int terms, int bins, float *scaled) { __shared__ double sh[THREADS]; __shared__ double bin_mean[MAX_BINS]; double part[MAX_BINS]; for (int b = 0; b < bins; b++) part[b] = 0; for (int t = threadIdx.x; t < terms; t += THREADS) part[bin[t]] += term_sum[t]; for (int b = 0; b < bins; b++) { const double total = block_sum(part[b], sh); if (threadIdx.x == 0) bin_mean[b] = total / bin_n[b]; } __syncthreads(); for (int t = threadIdx.x; t < terms; t += THREADS) scaled[t] = static_cast(weight[t] / bin_mean[bin[t]]); } // Every coefficient of the series, gathered: a grid point sums the (term, pair) entries that land on it, // in the order they were listed. __global__ void translation_gather(const int *offset, const int *entry, const float2 *copies, const float *scaled, const int *pair_j, const int *pair_k, int nops, int npairs, size_t cells, float2 *coef) { const size_t c = static_cast(blockIdx.x) * blockDim.x + threadIdx.x; if (c >= cells) return; float2 sum = make_float2(0, 0); for (int e = offset[c]; e < offset[c + 1]; e++) { const int t = entry[e] / npairs, p = entry[e] % npairs; const float2 a = copies[static_cast(t) * nops + pair_j[p]]; const float2 b = copies[static_cast(t) * nops + pair_k[p]]; const float w = scaled[t]; sum.x += w * (a.x * b.x + a.y * b.y); // a conj(b) sum.y += w * (a.y * b.x - a.x * b.y); } coef[c] = sum; } // The cross terms with a known partial structure, added to the coefficients: w conj(F_known) g_j, or its // conjugate, for every entry landing on a grid point, in the order they were listed. __global__ void translation_gather_cross(const int *offset, const int *entry, const float2 *copies, const float *scaled, const float2 *known, const int *cross_term, const int *cross_op, const char *cross_conj, int nops, size_t cells, float2 *coef) { const size_t c = static_cast(blockIdx.x) * blockDim.x + threadIdx.x; if (c >= cells) return; float2 sum = coef[c]; for (int e = offset[c]; e < offset[c + 1]; e++) { const int x = entry[e], t = cross_term[x]; const float2 k = known[t]; const float2 g = copies[static_cast(t) * nops + cross_op[x]]; const float w = scaled[t]; const float re = k.x * g.x + k.y * g.y, im = k.x * g.y - k.y * g.x; // conj(k) g sum.x += w * re; sum.y += w * (cross_conj[x] ? -im : im); } coef[c] = sum; } // Per block: the sum and the sum of squares of the function, and its highest point (the lowest index // on a tie). __global__ void translation_stats(const float *map, size_t n, double *sum, double *sum2, float *best, long long *best_index) { __shared__ double sh[THREADS]; __shared__ float sh_best[THREADS]; __shared__ long long sh_index[THREADS]; double s = 0, s2 = 0; float b = -INFINITY; long long bi = -1; for (size_t i = static_cast(blockIdx.x) * THREADS + threadIdx.x; i < n; i += static_cast(gridDim.x) * THREADS) { const float v = map[i]; s += v; s2 += static_cast(v) * v; if (v > b) { b = v; bi = static_cast(i); } } s = block_sum(s, sh); s2 = block_sum(s2, sh); sh_best[threadIdx.x] = b; sh_index[threadIdx.x] = bi; __syncthreads(); for (int stride = THREADS / 2; stride > 0; stride /= 2) { if (threadIdx.x < stride) { const float o = sh_best[threadIdx.x + stride]; const long long oi = sh_index[threadIdx.x + stride]; if (o > sh_best[threadIdx.x] || (o == sh_best[threadIdx.x] && oi >= 0 && oi < sh_index[threadIdx.x])) { sh_best[threadIdx.x] = o; sh_index[threadIdx.x] = oi; } } __syncthreads(); } if (threadIdx.x == 0) { sum[blockIdx.x] = s; sum2[blockIdx.x] = s2; best[blockIdx.x] = sh_best[0]; best_index[blockIdx.x] = sh_index[0]; } } // cuFFT's r2c output of a map stored u fastest ([w][v][u/2+1], u halved) taken to MapToFPhi's half-l // layout (u fastest, w halved), conjugated and scaled as gemmi does. __global__ void half_u_to_half_w(const cufftComplex *in, int nu, int nv, int nw, float norm, float2 *out) { const int nwh = nw / 2 + 1, nuh = nu / 2 + 1; const size_t n = static_cast(nu) * nv * nwh; const size_t i = static_cast(blockIdx.x) * blockDim.x + threadIdx.x; if (i >= n) return; const int u = static_cast(i % nu), v = static_cast((i / nu) % nv), l = static_cast(i / (static_cast(nu) * nv)); cufftComplex x; if (u < nuh) { x = in[u + static_cast(nuh) * (v + static_cast(nv) * l)]; } else { // F(-h) = conj F(h) x = in[(nu - u) + static_cast(nuh) * ((nv - v) % nv + static_cast(nv) * ((nw - l) % nw))]; x.y = -x.y; } out[i] = make_float2(x.x * norm, -x.y * norm); } Rot transposed(const gemmi::Mat33 &m) { Rot r; for (int i = 0; i < 3; i++) for (int j = 0; j < 3; j++) r.m[3 * i + j] = static_cast(m.a[j][i]); return r; } template CudaDevicePtr upload(const std::vector &v, cudaStream_t stream) { CudaDevicePtr d(std::max(v.size(), 1)); if (!v.empty()) cuda_err(cudaMemcpyAsync(d.get(), v.data(), v.size() * sizeof(T), cudaMemcpyHostToDevice, stream)); return d; } std::vector float3s(const std::vector &v) { std::vector out(v.size()); for (size_t i = 0; i < v.size(); i++) out[i] = make_float3(static_cast(v[i].x), static_cast(v[i].y), static_cast(v[i].z)); return out; } std::vector bin_counts(const std::vector &bin, int bins) { std::vector n(bins, 0); for (int b : bin) n[b]++; return n; } } // namespace bool MapToFPhiGPUFits(const gemmi::Grid &map) { const size_t points = static_cast(map.nu) * map.nv * map.nw; const size_t half_u = static_cast(map.nu / 2 + 1) * map.nv * map.nw; const size_t half_w = static_cast(map.nu) * map.nv * (map.nw / 2 + 1); const size_t bytes = points * sizeof(float) + (half_u + half_w) * sizeof(cufftComplex); size_t free_bytes = 0, total = 0; cuda_err(cudaMemGetInfo(&free_bytes, &total)); return bytes <= total / 4; } gemmi::FPhiGrid MapToFPhiGPU(const gemmi::Grid &map) { // One at a time: concurrent searches (the PAE domains') would each hold a large grid on the card. static std::mutex m; std::lock_guard lock(m); gemmi::FPhiGrid hkl; hkl.unit_cell = map.unit_cell; hkl.spacegroup = map.spacegroup; hkl.axis_order = map.axis_order; hkl.half_l = true; hkl.set_size_without_checking(map.nu, map.nv, map.nw / 2 + 1); const float norm = static_cast(map.unit_cell.volume / map.point_count()); CudaStream stream; auto d_map = upload(map.data, stream); CudaDevicePtr d_half(static_cast(map.nu / 2 + 1) * map.nv * map.nw); CudaDevicePtr d_out(hkl.data.size()); cufftHandle plan; cufft_err(cufftPlan3d(&plan, map.nw, map.nv, map.nu, CUFFT_R2C)); try { cufft_err(cufftSetStream(plan, stream)); cufft_err(cufftExecR2C(plan, d_map.get(), d_half.get())); half_u_to_half_w<<((hkl.data.size() + THREADS - 1) / THREADS), THREADS, 0, stream>>>( d_half, map.nu, map.nv, map.nw, norm, d_out); cuda_err(cudaGetLastError()); static_assert(sizeof(std::complex) == sizeof(float2)); cuda_err(cudaMemcpyAsync(hkl.data.data(), d_out.get(), hkl.data.size() * sizeof(float2), cudaMemcpyDeviceToHost, stream)); cuda_err(cudaStreamSynchronize(stream)); } catch (...) { cufftDestroy(plan); throw; } cufftDestroy(plan); return hkl; } struct MolecularReplacementGPUState { CudaStream stream; CudaDevicePtr grid; Transform transform{}; }; MolecularReplacementGPU::MolecularReplacementGPU(const MolecularTransform &ft) : state_(std::make_unique()) { const gemmi::FPhiGrid &g = ft.Grid(); std::vector host(g.data.size()); for (size_t i = 0; i < host.size(); i++) host[i] = make_float2(g.data[i].real(), g.data[i].imag()); state_->grid = upload(host, state_->stream); state_->transform = {state_->grid.get(), g.nu, g.nv, g.nw, static_cast(ft.Box()), static_cast(ft.Blur())}; cuda_err(cudaStreamSynchronize(state_->stream)); } MolecularReplacementGPU::~MolecularReplacementGPU() = default; std::vector MolecularReplacementGPU::RotationFunction(const SearchReflections &refl, const gemmi::UnitCell &cell, const gemmi::SpaceGroup &sg, const std::vector &rotations) { cudaStream_t stream = state_->stream; const int n = static_cast(refl.hkl.size()); const std::vector s = CopyVectors(refl, cell, sg); const int nops = static_cast(s.size() / std::max(n, 1)); double w_mean = 0; for (double e2 : refl.e2) w_mean += e2; w_mean /= n; double w_ss = 0; for (double e2 : refl.e2) w_ss += (e2 - w_mean) * (e2 - w_mean); const std::vector e2(refl.e2.begin(), refl.e2.end()); auto d_s = upload(float3s(s), stream); auto d_e2 = upload(e2, stream); auto d_bin = upload(refl.bin, stream); auto d_bin_n = upload(bin_counts(refl.bin, refl.bins), stream); std::vector rot(rotations.size()); for (size_t i = 0; i < rotations.size(); i++) rot[i] = transposed(rotations[i]); auto d_rot = upload(rot, stream); const size_t batch = std::min(ROTATION_BATCH, std::max(rotations.size(), 1)); CudaDevicePtr scratch(batch * std::max(n, 1)); CudaDevicePtr d_out(std::max(rotations.size(), 1)); for (size_t first = 0; first < rotations.size(); first += batch) { const int count = static_cast(std::min(batch, rotations.size() - first)); rotation_function<<>>(state_->transform, d_rot.get() + first, d_s, d_e2, d_bin, d_bin_n, n, nops, refl.bins, w_mean, w_ss, scratch, d_out.get() + first); cuda_err(cudaGetLastError()); } std::vector out(rotations.size()); cuda_err(cudaMemcpyAsync(out.data(), d_out.get(), out.size() * sizeof(double), cudaMemcpyDeviceToHost, stream)); cuda_err(cudaStreamSynchronize(stream)); return out; } // What the translation function needs of one setup, on the device: the gather's lists - for every point of the // half grid in cuFFT's layout ((u nv + v) nwh + l), the (term, pair) entries landing on it, in entry order, and // the same for the cross terms with a known partial structure - the terms' vectors, phases, weights and bins, // and the transform's plan. Made once where a setup is used for several functions in a row. struct TranslationSetupGPU { CudaDevicePtr cross_offset, cross_entry, cross_term, cross_op, bin, bin_n, pair_j, pair_k, offset, entry; CudaDevicePtr cross_conj; CudaDevicePtr s; CudaDevicePtr phase; CudaDevicePtr weight; cufftHandle plan = 0; ~TranslationSetupGPU() { if (plan != 0) cufftDestroy(plan); } }; namespace { std::unique_ptr prepare_translation(const TranslationSetup &setup, bool cross, cudaStream_t stream) { auto out = std::make_unique(); const int nu = setup.nu, nv = setup.nv, nw = setup.nw, nwh = nw / 2 + 1; const size_t cells = static_cast(nu) * nv * nwh; std::vector offset(cells + 1, 0); for (size_t e = 0; e < setup.target_u.size(); e++) if (setup.target_u[e] >= 0) offset[(static_cast(setup.target_u[e]) * nv + setup.target_v[e]) * nwh + setup.target_l[e] + 1]++; for (size_t c = 0; c < cells; c++) offset[c + 1] += offset[c]; std::vector entry(offset[cells]); { std::vector fill(offset.begin(), offset.end() - 1); for (size_t e = 0; e < setup.target_u.size(); e++) if (setup.target_u[e] >= 0) entry[fill[(static_cast(setup.target_u[e]) * nv + setup.target_v[e]) * nwh + setup.target_l[e]]++] = static_cast(e); } std::vector cross_offset(cells + 1, 0), cross_entry; if (cross) { for (size_t e = 0; e < setup.cross_term.size(); e++) cross_offset[(static_cast(setup.cross_u[e]) * nv + setup.cross_v[e]) * nwh + setup.cross_l[e] + 1]++; for (size_t c = 0; c < cells; c++) cross_offset[c + 1] += cross_offset[c]; cross_entry.resize(cross_offset[cells]); std::vector fill(cross_offset.begin(), cross_offset.end() - 1); for (size_t e = 0; e < setup.cross_term.size(); e++) cross_entry[fill[(static_cast(setup.cross_u[e]) * nv + setup.cross_v[e]) * nwh + setup.cross_l[e]]++] = static_cast(e); } out->cross_offset = upload(cross_offset, stream); out->cross_entry = upload(cross_entry, stream); out->cross_term = upload(setup.cross_term, stream); out->cross_op = upload(setup.cross_op, stream); out->cross_conj = upload(setup.cross_conj, stream); std::vector phase(setup.phase.size()); for (size_t i = 0; i < phase.size(); i++) phase[i] = make_float2(static_cast(setup.phase[i].real()), static_cast(setup.phase[i].imag())); const std::vector weight(setup.weight.begin(), setup.weight.end()); out->s = upload(float3s(setup.s), stream); out->phase = upload(phase, stream); out->weight = upload(weight, stream); out->bin = upload(setup.bin, stream); out->bin_n = upload(bin_counts(setup.bin, setup.bins), stream); out->pair_j = upload(setup.pair_j, stream); out->pair_k = upload(setup.pair_k, stream); out->offset = upload(offset, stream); out->entry = upload(entry, stream); cufft_err(cufftPlan3d(&out->plan, nu, nv, nw, CUFFT_C2R)); cuda_err(cudaStreamSynchronize(stream)); // the host vectors go out of scope return out; } } // namespace std::unique_ptr MolecularReplacementGPU::Prepare( const TranslationSetup &setup, bool known_part) { return {prepare_translation(setup, known_part, state_->stream).release(), [](TranslationSetupGPU *p) { delete p; }}; } std::vector MolecularReplacementGPU::TranslationFunction( const TranslationSetup &setup, const std::vector &rotations, const std::vector> *known, TranslationSetupGPU *prepared) { std::vector out(rotations.size()); const int terms = static_cast(setup.Terms()), nops = setup.ops; const int npairs = static_cast(setup.pair_j.size()); if (terms == 0 || rotations.empty() || (nops < 2 && known == nullptr)) return out; cudaStream_t stream = state_->stream; const int nu = setup.nu, nv = setup.nv, nw = setup.nw, nwh = nw / 2 + 1; const size_t cells = static_cast(nu) * nv * nwh, points = static_cast(nu) * nv * nw; std::unique_ptr made; if (prepared == nullptr) { made = prepare_translation(setup, known != nullptr, stream); prepared = made.get(); } TranslationSetupGPU &p = *prepared; std::vector known_f; if (known != nullptr) for (const std::complex &k : *known) known_f.push_back(make_float2(static_cast(k.real()), static_cast(k.imag()))); auto d_known = upload(known_f, stream); CudaDevicePtr copies(static_cast(terms) * nops); CudaDevicePtr term_sum(terms), scaled(terms); CudaDevicePtr coef(cells); CudaDevicePtr map(points); const int stat_blocks = static_cast(std::min(1024, (points + THREADS - 1) / THREADS)); const size_t nstat = static_cast(stat_blocks) * rotations.size(); CudaDevicePtr d_sum(nstat), d_sum2(nstat); CudaDevicePtr d_best(nstat); CudaDevicePtr d_best_index(nstat); const cufftHandle plan = p.plan; { cufft_err(cufftSetStream(plan, stream)); for (size_t r = 0; r < rotations.size(); r++) { const Rot rot = transposed(rotations[r]); translation_copies<<<(terms + THREADS - 1) / THREADS, THREADS, 0, stream>>>( state_->transform, rot, p.s, p.phase, terms, nops, copies, term_sum); translation_weights<<<1, THREADS, 0, stream>>>(term_sum, p.weight, p.bin, p.bin_n, terms, setup.bins, scaled); translation_gather<<((cells + THREADS - 1) / THREADS), THREADS, 0, stream>>>( p.offset, p.entry, copies, scaled, p.pair_j, p.pair_k, nops, npairs, cells, coef); if (known != nullptr) translation_gather_cross<<((cells + THREADS - 1) / THREADS), THREADS, 0, stream>>>( p.cross_offset, p.cross_entry, copies, scaled, d_known, p.cross_term, p.cross_op, p.cross_conj, nops, cells, coef); cufft_err(cufftExecC2R(plan, reinterpret_cast(coef.get()), map.get())); translation_stats<<>>( map, points, d_sum.get() + r * stat_blocks, d_sum2.get() + r * stat_blocks, d_best.get() + r * stat_blocks, d_best_index.get() + r * stat_blocks); cuda_err(cudaGetLastError()); } } std::vector sum(nstat), sum2(nstat); std::vector best(nstat); std::vector best_index(nstat); cuda_err(cudaMemcpyAsync(sum.data(), d_sum.get(), nstat * sizeof(double), cudaMemcpyDeviceToHost, stream)); cuda_err(cudaMemcpyAsync(sum2.data(), d_sum2.get(), nstat * sizeof(double), cudaMemcpyDeviceToHost, stream)); cuda_err(cudaMemcpyAsync(best.data(), d_best.get(), nstat * sizeof(float), cudaMemcpyDeviceToHost, stream)); cuda_err(cudaMemcpyAsync(best_index.data(), d_best_index.get(), nstat * sizeof(long long), cudaMemcpyDeviceToHost, stream)); cuda_err(cudaStreamSynchronize(stream)); for (size_t r = 0; r < rotations.size(); r++) { double s = 0, s2 = 0; float b = -INFINITY; long long bi = 0; for (int k = 0; k < stat_blocks; k++) { const size_t i = r * stat_blocks + k; s += sum[i]; s2 += sum2[i]; if (best[i] > b) { b = best[i]; bi = best_index[i]; } } const double count = static_cast(points); const double mean = s / count, sd = std::sqrt(std::max(0.0, s2 / count - mean * mean)); out[r].z = sd > 0 ? (b - mean) / sd : 0.0; const long long w = bi % nw, v = (bi / nw) % nv, u = bi / (static_cast(nw) * nv); out[r].t = gemmi::Fractional(static_cast(u) / nu, static_cast(v) / nv, static_cast(w) / nw); } return out; }