From 6f78c0ae216162d5a61840e6308e3b7269fc7eac Mon Sep 17 00:00:00 2001 From: "A.Olokhtonov" Date: Thu, 21 Mar 2024 23:36:23 +0300 Subject: [PATCH] AoS -> SoA for point coordinates --- client/client_recv.js | 20 +++++---- client/math.js | 19 ++++----- client/speed.js | 41 ++++++++++++------- client/wasm/lod.c | 92 +++++++++++++++++++++++++++++++++--------- client/wasm/lod.wasm | Bin 7584 -> 1779 bytes 5 files changed, 121 insertions(+), 51 deletions(-) diff --git a/client/client_recv.js b/client/client_recv.js index d74ae9d..24ae3f6 100644 --- a/client/client_recv.js +++ b/client/client_recv.js @@ -244,17 +244,23 @@ function handle_event(state, context, event, options = {}) { wasm_ensure_by(state, 1, event.coords.length); - const coordinates = state.wasm.buffers['coordinates']; const pressures = state.wasm.buffers['pressures']; + const xs = state.wasm.buffers['xs']; + const ys = state.wasm.buffers['ys']; + + event.coords_from = xs.tv.size; + event.coords_to = xs.tv.size + point_count; - event.coords_from = coordinates.tv.size; - event.coords_to = coordinates.tv.size + point_count * 2; - - tv_add(state.wasm.buffers['coords_from'].tv, coordinates.tv.size + point_count * 2); + tv_add(state.wasm.buffers['coords_from'].tv, xs.tv.size + point_count); state.wasm.buffers['coords_from'].used += 4; // 4 bytes, not 4 ints - tv_append(coordinates.tv, event.coords); - state.wasm.buffers['coordinates'].used += point_count * 2 * 4; + for (let i = 0; i < event.coords.length; i += 2) { + tv_add(xs.tv, event.coords[i + 0]); + tv_add(ys.tv, event.coords[i + 1]); + } + + state.wasm.buffers['xs'].used += point_count * 4; + state.wasm.buffers['ys'].used += point_count * 4; tv_append(pressures.tv, event.press); state.wasm.buffers['pressures'].used += point_count; diff --git a/client/math.js b/client/math.js index 432fa39..c9c4519 100644 --- a/client/math.js +++ b/client/math.js @@ -43,7 +43,7 @@ function process_rdp_indices_r(state, zoom, mask, stroke, start, end) { } function process_rdp_indices(state, zoom, stroke) { - const point_count = (stroke.coords_to - stroke.coords_from) / 2; + const point_count = stroke.coords_to - stroke.coords_from; if (state.rdp_mask.length < point_count) { state.rdp_mask = new Uint8Array(point_count); @@ -252,17 +252,18 @@ function segment_interesects_quad(a, b, quad_topleft, quad_bottomright, quad_top function stroke_bbox(state, stroke) { const radius = stroke.width; // do not divide by 2 to account for max possible pressure - const coordinates = state.wasm.buffers['coordinates'].tv.data; + const xs = state.wasm.buffers['xs'].tv.data; + const ys = state.wasm.buffers['ys'].tv.data; - let min_x = coordinates[stroke.coords_from + 0] - radius; - let max_x = coordinates[stroke.coords_from + 0] + radius; + let min_x = xs[stroke.coords_from] - radius; + let max_x = xs[stroke.coords_from] + radius; - let min_y = coordinates[stroke.coords_from + 1] - radius; - let max_y = coordinates[stroke.coords_from + 1] + radius; + let min_y = ys[stroke.coords_from] - radius; + let max_y = ys[stroke.coords_from] + radius; - for (let i = stroke.coords_from + 2; i < stroke.coords_to; i += 2) { - const px = coordinates[i + 0]; - const py = coordinates[i + 1]; + for (let i = stroke.coords_from + 1; i < stroke.coords_to; ++i) { + const px = xs[i]; + const py = ys[i]; min_x = Math.min(min_x, px - radius); min_y = Math.min(min_y, py - radius); diff --git a/client/speed.js b/client/speed.js index f4459a8..fbf801a 100644 --- a/client/speed.js +++ b/client/speed.js @@ -7,8 +7,12 @@ async function init_wasm(state) { state.wasm.stroke_bytes = 4096; state.wasm.coords_bytes = 4096; state.wasm.buffers = { - 'coordinates': { - 'offset': state.wasm.exports.alloc_static(state.wasm.coords_bytes), + 'xs': { + 'offset': state.wasm.exports.alloc_static(state.wasm.coords_bytes / 2), + 'used': 0 + }, + 'ys': { + 'offset': state.wasm.exports.alloc_static(state.wasm.coords_bytes / 2), 'used': 0 }, 'coords_from': { @@ -27,8 +31,10 @@ async function init_wasm(state) { const mem = state.wasm.exports.memory.buffer; - state.wasm.buffers['coordinates'].tv = tv_create_on(Float32Array, state.wasm.coords_bytes / 4, - mem, state.wasm.buffers['coordinates'].offset); + state.wasm.buffers['xs'].tv = tv_create_on(Float32Array, state.wasm.coords_bytes / 8, + mem, state.wasm.buffers['xs'].offset); + state.wasm.buffers['ys'].tv = tv_create_on(Float32Array, state.wasm.coords_bytes / 8, + mem, state.wasm.buffers['ys'].offset); state.wasm.buffers['coords_from'].tv = tv_create_on(Uint32Array, state.wasm.stroke_bytes / 4, mem, state.wasm.buffers['coords_from'].offset); state.wasm.buffers['line_threshold'].tv = tv_create_on(Float32Array, state.wasm.stroke_bytes / 4, @@ -52,13 +58,13 @@ function wasm_ensure_by(state, nstrokes, ncoords) { let realloc = false; - if (buffers['coordinates'].used + ncoords * 4 > state.wasm.coords_bytes) { - state.wasm.coords_bytes += round_to_pow2(ncoords, 4096 * 16); // 1 wasm page (although it doesn't matter here) + if (buffers['xs'].used + ncoords * 4 > state.wasm.coords_bytes / 2) { + state.wasm.coords_bytes += round_to_pow2(ncoords * 4, 4096 * 16); // 1 wasm page (although it doesn't matter here) realloc = true; } - if (buffers['coords_from'].used + nstrokes * 4 > state.wasm.stroke_bytes) { - state.wasm.stroke_bytes += round_to_pow2(nstrokes, 4096 * 16); + if (buffers['coords_from'].used + nstrokes * 4 > state.wasm.stroke_bytes / 2) { + state.wasm.stroke_bytes += round_to_pow2(nstrokes * 4, 4096 * 16); realloc = true; } @@ -70,22 +76,26 @@ function wasm_ensure_by(state, nstrokes, ncoords) { const mem = state.wasm.exports.memory.buffer; const memv = new Uint8Array(mem); - buffers['coordinates'].offset = state.wasm.exports.alloc_static(state.wasm.coords_bytes); + buffers['xs'].offset = state.wasm.exports.alloc_static(state.wasm.coords_bytes / 2); + buffers['ys'].offset = state.wasm.exports.alloc_static(state.wasm.coords_bytes / 2); buffers['coords_from'].offset = state.wasm.exports.alloc_static(state.wasm.stroke_bytes); buffers['line_threshold'].offset = state.wasm.exports.alloc_static(state.wasm.stroke_bytes); buffers['pressures'].offset = state.wasm.exports.alloc_static(state.wasm.coords_bytes / 8); - buffers['coordinates'].tv = tv_create_on(Float32Array, state.wasm.coords_bytes / 4, mem, buffers['coordinates'].offset); + buffers['xs'].tv = tv_create_on(Float32Array, state.wasm.coords_bytes / 8, mem, buffers['xs'].offset); + buffers['ys'].tv = tv_create_on(Float32Array, state.wasm.coords_bytes / 8, mem, buffers['ys'].offset); buffers['coords_from'].tv = tv_create_on(Uint32Array, state.wasm.stroke_bytes / 4, mem, buffers['coords_from'].offset); buffers['line_threshold'].tv = tv_create_on(Float32Array, state.wasm.stroke_bytes / 4, mem, buffers['line_threshold'].offset); buffers['pressures'].tv = tv_create_on(Uint8Array, state.wasm.coords_bytes / 8, mem, buffers['pressures'].offset); - buffers['coordinates'].tv.size = buffers['coordinates'].used / 4; + // TODO: this should have been automatic maybe? + buffers['xs'].tv.size = buffers['xs'].used / 4; + buffers['ys'].tv.size = buffers['ys'].used / 4; buffers['coords_from'].tv.size = buffers['coords_from'].used / 4; buffers['line_threshold'].tv.size = buffers['line_threshold'].used / 4; buffers['pressures'].tv.size = buffers['pressures'].used; - const tmp = new Uint8Array(Math.max(state.wasm.coords_bytes / 8, state.wasm.stroke_bytes)); // TODO: needed? + const tmp = new Uint8Array(Math.max(state.wasm.coords_bytes, state.wasm.stroke_bytes)); // TODO: needed? // Copy from back to front (otherwise we will overwrite) tmp.set(new Uint8Array(mem, old_pressures_offset, buffers['pressures'].used)); @@ -113,14 +123,15 @@ function do_lod(state, context) { clipped_indices, context.clipped_indices.size, state.canvas.zoom, buffers['coords_from'].offset, buffers['line_threshold'].offset, - buffers['coordinates'].offset, + buffers['xs'].offset, + buffers['ys'].offset, buffers['pressures'].offset, - buffers['coordinates'].used / 4, + buffers['xs'].used / 4, ); // Use results without copying from WASM memory const result_offset = clipped_indices + context.clipped_indices.size * 4 - + (context.clipped_indices.size + 1) * 4 + buffers['coordinates'].used / 2; + + (context.clipped_indices.size + 1) * 4 + buffers['xs'].used; const wasm_points = new Float32Array(state.wasm.exports.memory.buffer, result_offset, segment_count * 2); diff --git a/client/wasm/lod.c b/client/wasm/lod.c index 19a13e3..2c3420d 100644 --- a/client/wasm/lod.c +++ b/client/wasm/lod.c @@ -1,3 +1,5 @@ +// clang -g -Wall -Wextra -O3 -Wl,--export-all,--no-entry --target=wasm32 -Xclang -target-feature -Xclang +simd128 lod.c -nostdlib -o lod.wasm + #include extern char __heap_base; @@ -34,20 +36,18 @@ alloc_dynamic(int size) } static int -rdp_find_max(float *coordinates, unsigned char *pressures, float zoom, int coords_from, +rdp_find_max(float *xs, float *ys, unsigned char *pressures, float zoom, int coords_from, int segment_start, int segment_end) { - float EPS = 0.125 / zoom; + float EPS = 0.125f / zoom * 255.0f; -// __i32x4 a = wasm_i32x4_load16x4(coordinates); - int result = -1; float max_dist = 0.0f; - float ax = coordinates[coords_from + segment_start * 2 + 0]; - float ay = coordinates[coords_from + segment_start * 2 + 1]; - float bx = coordinates[coords_from + segment_end * 2 + 0]; - float by = coordinates[coords_from + segment_end * 2 + 1]; + float ax = xs[coords_from + segment_start]; + float ay = ys[coords_from + segment_start]; + float bx = xs[coords_from + segment_end]; + float by = ys[coords_from + segment_end]; unsigned char ap = pressures[coords_from / 2 + segment_start]; unsigned char bp = pressures[coords_from / 2 + segment_end]; @@ -56,12 +56,63 @@ rdp_find_max(float *coordinates, unsigned char *pressures, float zoom, int coord float dy = by - ay; float dist_ab = __builtin_sqrtf(dx * dx + dy * dy); - float dir_nx = dy / dist_ab; - float dir_ny = -dx / dist_ab; + float dir_nx = dy / dist_ab * 255.0f; + float dir_ny = -dx / dist_ab * 255.0f; + +#if 0 + v128_t scale_255 = wasm_f32x4_splat(1.0f / 255.0f); + v128_t EPSs = wasm_f32x4_splat(EPS); +#endif for (int i = segment_start + 1; i < segment_end; ++i) { - float px = coordinates[coords_from + i * 2 + 0]; - float py = coordinates[coords_from + i * 2 + 1]; +#if 0 + v128_t pxs = wasm_v128_load(coordinates_x + coords_from + i); + v128_t pxs = wasm_v128_load(coordinates_y + coords_from + i); + + v128_t pps = wasm_v128_load(pressures + coords_from + i); + + v128_t apxs = wasm_f32x4_sub(pxs, axs); + v128_t apys = wasm_f32x4_sub(pys, ays); + + v128_t dists = wasm_f32x4_add( + wasm_f32x4_add( + wasm_f32x4_mul(wasm_f32x4_abs(wasm_f32x4_sub(pps, aps)), scale_255), + wasm_f32x4_mul(wasm_f32x4_abs(wasm_f32x4_sub(pps, bps)), scale_255) + ), + wasm_f32x4_abs( + wasm_f32x4_add( + wasm_f32x4_mul(apxs, dir_nxs), + wasm_f32x4_mul(apys, dir_nys) + ) + ) + ); + + v128_t dist_mask = wasm_f32x4_gt(dists, EPSs); + v128_t max_mask = wasm_f32x4_gt(dists, max_dists); + v128_t final_mask = wasm_v128_and(dist_mask, max_mask); + + if (!wasm_v128_any_true(final_mask)) { + // fast path? hopefully? + continue; + } + + // Places max(0, 2) and max(1, 3) into lanes (0, 1) + v128_t max_02_13 = wasm_f32x4_max( + dists, + wasm_i32x4_shuffle(dists, dists, 2, 3, 2, 3) + ); + + // Places max(max(0, 2), max(1, 3)) into lane 0 + v128_t max_0123 = wasm_f32x4_max( + max_02_13, + wasm_i32x4_shuffle(max_02_13, max_02_13, 1, 1, 1, 1) + ); + + float final_max = wasm_f32x4_extract_lane(max_0123, 0); +#endif + + float px = xs[coords_from + i]; + float py = ys[coords_from + i]; unsigned char pp = pressures[coords_from + i]; @@ -69,7 +120,7 @@ rdp_find_max(float *coordinates, unsigned char *pressures, float zoom, int coord float apy = py - ay; float dist = __builtin_fabsf(apx * dir_nx + apy * dir_ny) - + __builtin_abs(pp - ap) / 255.0f + __builtin_abs(pp - bp) / 255.0f; + + __builtin_abs(pp - ap) + __builtin_abs(pp - bp); if (dist > EPS && dist > max_dist) { result = i; @@ -84,7 +135,8 @@ int do_lod(int *clipped_indices, int clipped_count, float zoom, int *stroke_coords_from, float *line_threshold, - float *coordinates, + float *xs, + float *ys, unsigned char *pressures, int coordinates_count) { @@ -93,7 +145,7 @@ do_lod(int *clipped_indices, int clipped_count, float zoom, } int *segments_from = alloc_dynamic((clipped_count + 1) * 4); - int *segments = alloc_dynamic(coordinates_count / 2 * 4); + int *segments = alloc_dynamic(coordinates_count * 4); int segments_head = 0; int stack[4096]; // TODO: what's a reasonable max size for this? @@ -105,7 +157,7 @@ do_lod(int *clipped_indices, int clipped_count, float zoom, int coords_from = stroke_coords_from[stroke_index]; int coords_to = stroke_coords_from[stroke_index + 1]; - int point_count = (coords_to - coords_from) / 2; + int point_count = coords_to - coords_from; // Basic CSR crap segments_from[i] = segments_head; @@ -134,7 +186,7 @@ do_lod(int *clipped_indices, int clipped_count, float zoom, if (type == 1) { segments[segments_head++] = start; } else { - int max = rdp_find_max(coordinates, pressures, zoom, coords_from, start, end); + int max = rdp_find_max(xs, ys, pressures, zoom, coords_from, start, end); if (max != -1) { segment_count += 1; @@ -180,13 +232,13 @@ do_lod(int *clipped_indices, int clipped_count, float zoom, for (int j = from; j < to; ++j) { int point_index = segments[j]; - float x = coordinates[base_stroke + point_index * 2 + 0]; - float y = coordinates[base_stroke + point_index * 2 + 1]; + float x = xs[base_stroke + point_index]; + float y = ys[base_stroke + point_index]; points[phead++] = x; points[phead++] = y; - pressures_res[ihead] = pressures[base_stroke / 2 + point_index]; + pressures_res[ihead] = pressures[base_stroke + point_index]; if (j != to - 1) { ids[ihead++] = stroke_index; diff --git a/client/wasm/lod.wasm b/client/wasm/lod.wasm index 64b42bf740ac1292484401323144888252d49603..3f921f67d489ff6c588c9350d064f27489403d5d 100755 GIT binary patch literal 1779 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