From 8536fd12234649d98e4a57d72aeb322e107cf754 Mon Sep 17 00:00:00 2001 From: Namit Bhutani Date: Wed, 3 Sep 2025 19:15:05 +0200 Subject: [PATCH] Sculpt: Compress position undo step data Stored undo step data for position changes in sculpt mode are now automatically compressed. Compression is run in background threads, reducing memory consumption during sculpting sessions while adding little performance overhead. For testing and benchmarks, memory usage is now available through `bpy.app.undo_memory_info()`. Undo memory usage is now tracked by the existing automated benchmark tests. Some changes to the web benchmark visualization present the data a bit better. ZSTD compression is run asynchronously in a backround task pool. Compression is only blocking if the data is requested immediately for undo/redo. Co-authored-by: Hans Goudey Pull Request: https://projects.blender.org/blender/blender/pulls/141310 --- source/blender/editors/include/ED_sculpt.hh | 5 + source/blender/editors/include/ED_undo.hh | 10 + .../editors/sculpt_paint/CMakeLists.txt | 1 + .../editors/sculpt_paint/sculpt_undo.cc | 207 ++++++++++++++++-- source/blender/editors/undo/ed_undo.cc | 22 ++ source/blender/python/intern/bpy_app.cc | 24 ++ tests/performance/api/graph.template.html | 61 ++++-- tests/performance/tests/sculpt.py | 11 +- 8 files changed, 294 insertions(+), 47 deletions(-) diff --git a/source/blender/editors/include/ED_sculpt.hh b/source/blender/editors/include/ED_sculpt.hh index 3b3de3d6ec3..c4c4cbd9801 100644 --- a/source/blender/editors/include/ED_sculpt.hh +++ b/source/blender/editors/include/ED_sculpt.hh @@ -8,6 +8,8 @@ #pragma once +#include + struct Depsgraph; struct Main; struct Mesh; @@ -16,6 +18,7 @@ struct RegionView3D; struct ReportList; struct Scene; struct UndoType; +struct UndoStep; struct bContext; struct wmKeyConfig; struct wmOperator; @@ -74,6 +77,8 @@ void geometry_end(Object &ob); void push_multires_mesh_begin(bContext *C, const char *str); void push_multires_mesh_end(bContext *C, const char *str); +size_t step_memory_size_get(UndoStep *step); + } // namespace undo namespace face_set { diff --git a/source/blender/editors/include/ED_undo.hh b/source/blender/editors/include/ED_undo.hh index c3f013bdbbd..00e6364dafe 100644 --- a/source/blender/editors/include/ED_undo.hh +++ b/source/blender/editors/include/ED_undo.hh @@ -148,3 +148,13 @@ MemFile *ED_undosys_stack_memfile_get_if_active(UndoStack *ustack); * (currently we only do that in #MemFileWriteData when writing a new step). */ void ED_undosys_stack_memfile_id_changed_tag(UndoStack *ustack, ID *id); +/** + * Get the total memory usage of all undo steps in the current undo stack. + * + * This function iterates through all undo steps and calculates their memory consumption. + * For sculpt undo steps, it uses the specialized sculpt memory calculation function. + * For other undo step types, it uses the generic `data_size` field. + * + * \return Total memory usage in bytes, or 0 if no undo stack is available. + */ +size_t ED_get_total_undo_memory(); diff --git a/source/blender/editors/sculpt_paint/CMakeLists.txt b/source/blender/editors/sculpt_paint/CMakeLists.txt index 9de1fb7799c..ba5ec9e7d07 100644 --- a/source/blender/editors/sculpt_paint/CMakeLists.txt +++ b/source/blender/editors/sculpt_paint/CMakeLists.txt @@ -16,6 +16,7 @@ set(INC ) set(INC_SYS + ${ZSTD_INCLUDE_DIRS} ) set(SRC diff --git a/source/blender/editors/sculpt_paint/sculpt_undo.cc b/source/blender/editors/sculpt_paint/sculpt_undo.cc index 20116933a31..db5f9648719 100644 --- a/source/blender/editors/sculpt_paint/sculpt_undo.cc +++ b/source/blender/editors/sculpt_paint/sculpt_undo.cc @@ -25,6 +25,7 @@ #include "sculpt_undo.hh" #include +#include #include "CLG_log.h" @@ -34,6 +35,7 @@ #include "BLI_map.hh" #include "BLI_memory_counter.hh" #include "BLI_string_utf8.h" +#include "BLI_task.h" #include "BLI_utildefines.h" #include "BLI_vector.hh" @@ -81,6 +83,12 @@ #include "sculpt_face_set.hh" #include "sculpt_intern.hh" +// #define DEBUG_TIME + +#ifdef DEBUG_TIME +# include "BLI_timeit.hh" +#endif + static CLG_LogRef LOG = {"undo.sculpt"}; namespace blender::ed::sculpt_paint::undo { @@ -180,6 +188,7 @@ struct NodeGeometry { }; struct Node; +struct PositionUndoStorage; struct StepData { private: @@ -255,7 +264,7 @@ struct StepData { /** Storage of per-node undo data after creation of the undo step is finished. */ Vector> nodes; - + std::unique_ptr position_step_storage; size_t undo_size; /** Whether processing code needs to handle the current data as an undo step. */ @@ -274,6 +283,121 @@ struct StepData { applied_ = false; } }; +namespace zstd { + +template Array compress(const Span src) +{ + Array dst(ZSTD_compressBound(src.size_in_bytes()), NoInitialization()); + const size_t dst_size = ZSTD_compress( + dst.data(), dst.size(), src.data(), src.size_in_bytes(), 12); + + if (ZSTD_isError(dst_size)) { + return Array(0, NoInitialization()); + } + + return dst.as_span().take_front(dst_size); +} + +template Array decompress(const Span src) +{ + const unsigned long long dst_size_in_bytes = ZSTD_getFrameContentSize(src.data(), src.size()); + + if (ELEM(dst_size_in_bytes, ZSTD_CONTENTSIZE_ERROR, ZSTD_CONTENTSIZE_UNKNOWN)) { + return Array(0, NoInitialization()); + } + + const int64_t dst_size = dst_size_in_bytes / sizeof(T); + Array dst(dst_size, NoInitialization()); + const size_t result = ZSTD_decompress( + dst.data(), dst.as_span().size_in_bytes(), src.data(), src.size()); + + if (ZSTD_isError(result)) { + return Array(0, NoInitialization()); + } + return dst; +} + +} // namespace zstd + +struct PositionUndoStorage : NonMovable { + Vector> nodes_to_compress; + + Array> compressed_indices; + Array> compressed_positions; + + Array unique_verts_nums; + + TaskPool *compression_task_pool; + std::atomic compression_ready = false; + std::atomic compression_started = false; + StepData *owner_step_data = nullptr; + + explicit PositionUndoStorage(StepData &step_data) + : nodes_to_compress(std::move(step_data.nodes)), owner_step_data(&step_data) + { + unique_verts_nums.reinitialize(nodes_to_compress.size()); + for (const int i : nodes_to_compress.index_range()) { + unique_verts_nums[i] = nodes_to_compress[i]->unique_verts_num; + } + + compression_task_pool = BLI_task_pool_create_background(this, TASK_PRIORITY_LOW); + compression_started = true; + + BLI_task_pool_push(compression_task_pool, compress_fn, this, false, nullptr); + } + + ~PositionUndoStorage() + { + if (compression_started.load() && compression_task_pool) { + BLI_task_pool_work_and_wait(compression_task_pool); + BLI_task_pool_free(compression_task_pool); + } + } + + void ensure_compression_complete() + { + if (!compression_ready.load(std::memory_order_acquire)) { + BLI_task_pool_work_and_wait(compression_task_pool); + } + } + + static void compress_fn(TaskPool * /*pool*/, void *task_data) + { +#ifdef DEBUG_TIME + SCOPED_TIMER(__func__); +#endif + auto *data = static_cast(task_data); + MutableSpan> nodes = data->nodes_to_compress; + const int nodes_num = nodes.size(); + + Array> compressed_indices(nodes.size(), NoInitialization()); + Array> compressed_data(nodes.size(), NoInitialization()); + threading::isolate_task([&]() { + threading::parallel_for(IndexRange(nodes_num), 1, [&](const IndexRange range) { + for (const int i : range) { + Array verts = zstd::compress(nodes[i]->vert_indices); + Array positions = zstd::compress(nodes[i]->position); + new (&compressed_indices[i]) Array(std::move(verts)); + new (&compressed_data[i]) Array(std::move(positions)); + nodes[i].reset(); + } + }); + }); + data->nodes_to_compress.clear_and_shrink(); + + size_t memory_size = 0; + for (const int i : IndexRange(nodes_num)) { + memory_size += compressed_indices[i].as_span().size_in_bytes(); + memory_size += compressed_data[i].as_span().size_in_bytes(); + } + + data->compressed_indices = std::move(compressed_indices); + data->compressed_positions = std::move(compressed_data); + data->owner_step_data->undo_size += memory_size; + + data->compression_ready.store(true, std::memory_order_release); + } +}; struct SculptUndoStep { UndoStep step; @@ -287,6 +411,21 @@ struct SculptUndoStep { SculptAttrRef active_color_end; }; +size_t step_memory_size_get(UndoStep *step) +{ + if (step->type != BKE_UNDOSYS_TYPE_SCULPT) { + return 0; + } + + SculptUndoStep *sculpt_step = reinterpret_cast(step); + + if (sculpt_step->data.position_step_storage) { + sculpt_step->data.position_step_storage->ensure_compression_complete(); + } + + return sculpt_step->data.undo_size; +} + static SculptUndoStep *get_active_step() { UndoStack *ustack = ED_undo_stack_get(); @@ -362,37 +501,48 @@ static void swap_indexed_data(MutableSpan full, const Span indices, Muta } static void restore_position_mesh(Object &object, - const Span> unodes, + PositionUndoStorage &undo_data, const MutableSpan modified_verts) { +#ifdef DEBUG_TIME + SCOPED_TIMER(__func__); +#endif + SculptSession &ss = *object.sculpt; Mesh &mesh = *static_cast(object.data); MutableSpan positions = mesh.vert_positions_for_write(); std::optional shape_key_data = ShapeKeyData::from_object(object); - threading::parallel_for(unodes.index_range(), 1, [&](const IndexRange range) { - for (const int node_i : range) { - Node &unode = *unodes[node_i]; - const Span verts = unode.vert_indices.as_span().take_front(unode.unique_verts_num); + undo_data.ensure_compression_complete(); - if (unode.orig_position.is_empty()) { + const int nodes_num = undo_data.unique_verts_nums.size(); + + threading::parallel_for(IndexRange(nodes_num), 1, [&](const IndexRange range) { + for (const int i : range) { + Array indices = zstd::decompress(undo_data.compressed_indices[i]); + Array node_positions = zstd::decompress(undo_data.compressed_positions[i]); + const int unique_verts_num = undo_data.unique_verts_nums[i]; + const Span verts = indices.as_span().take_front(unique_verts_num); + + if (!ss.deform_modifiers_active) { /* When original positions aren't written separately in the undo step, there are no * deform modifiers. Therefore the original and evaluated deform positions will be the * same, and modifying the positions from the original mesh is enough. */ swap_indexed_data( - unode.position.as_mutable_span().take_front(unode.unique_verts_num), verts, positions); + node_positions.as_mutable_span().take_front(unique_verts_num), verts, positions); } else { /* When original positions are stored in the undo step, undo/redo will cause a reevaluation * of the object. The evaluation will recompute the evaluated positions, so dealing with * them here is unnecessary. */ - MutableSpan undo_positions = unode.orig_position; + MutableSpan undo_positions = node_positions; if (shape_key_data) { MutableSpan active_data = shape_key_data->active_key_data; if (!shape_key_data->dependent_keys.is_empty()) { Array translations(verts.size()); - translations_from_new_positions(undo_positions, verts, active_data, translations); + translations_from_new_positions( + undo_positions.take_front(unique_verts_num), verts, active_data, translations); for (MutableSpan data : shape_key_data->dependent_keys) { apply_translations(translations, verts, data); } @@ -402,14 +552,18 @@ static void restore_position_mesh(Object &object, /* The basis key positions and the mesh positions are always kept in sync. */ scatter_data_mesh(undo_positions.as_span(), verts, positions); } - swap_indexed_data(undo_positions.take_front(unode.unique_verts_num), verts, active_data); + swap_indexed_data(undo_positions.take_front(unique_verts_num), verts, active_data); } else { /* There is a deform modifier, but no shape keys. */ - swap_indexed_data(undo_positions.take_front(unode.unique_verts_num), verts, positions); + swap_indexed_data(undo_positions.take_front(unique_verts_num), verts, positions); } } + modified_verts.fill_indices(verts, true); + + undo_data.compressed_indices[i] = zstd::compress(indices); + undo_data.compressed_positions[i] = zstd::compress(node_positions); } }); } @@ -896,7 +1050,7 @@ static void restore_list(bContext *C, Depsgraph *depsgraph, StepData &step_data) } const Mesh &mesh = *static_cast(object.data); Array modified_verts(mesh.verts_num, false); - restore_position_mesh(object, step_data.nodes, modified_verts); + restore_position_mesh(object, *step_data.position_step_storage, modified_verts); const IndexMask changed_nodes = IndexMask::from_predicate( node_mask, GrainSize(1), memory, [&](const int i) { @@ -1810,17 +1964,22 @@ void push_end_ex(Object &ob, const bool use_nested_undo) * just one positions array that has a different semantic meaning depending on whether there are * deform modifiers. */ - step_data->undo_size = threading::parallel_reduce( - step_data->nodes.index_range(), - 16, - 0, - [&](const IndexRange range, size_t size) { - for (const int i : range) { - size += node_size_in_bytes(*step_data->nodes[i]); - } - return size; - }, - std::plus()); + if (step_data->type == Type::Position) { + step_data->position_step_storage = std::make_unique(*step_data); + } + else { + step_data->undo_size = threading::parallel_reduce( + step_data->nodes.index_range(), + 16, + 0, + [&](const IndexRange range, size_t size) { + for (const int i : range) { + size += node_size_in_bytes(*step_data->nodes[i]); + } + return size; + }, + std::plus()); + } /* We could remove this and enforce all callers run in an operator using 'OPTYPE_UNDO'. */ wmWindowManager *wm = static_cast(G_MAIN->wm.first); diff --git a/source/blender/editors/undo/ed_undo.cc b/source/blender/editors/undo/ed_undo.cc index 00ac536d4dd..c6f4595f939 100644 --- a/source/blender/editors/undo/ed_undo.cc +++ b/source/blender/editors/undo/ed_undo.cc @@ -37,6 +37,7 @@ #include "ED_outliner.hh" #include "ED_render.hh" #include "ED_screen.hh" +#include "ED_sculpt.hh" #include "ED_undo.hh" #include "WM_api.hh" @@ -912,4 +913,25 @@ Vector ED_undo_editmode_bases_from_view_layer(const Scene *scene, ViewLa return bases; } +size_t ED_get_total_undo_memory() +{ + UndoStack *ustack = ED_undo_stack_get(); + if (!ustack) { + return 0; + } + + size_t total_memory = 0; + + for (UndoStep *us = static_cast(ustack->steps.first); us != nullptr; us = us->next) { + if (us->type == BKE_UNDOSYS_TYPE_SCULPT) { + total_memory += blender::ed::sculpt_paint::undo::step_memory_size_get(us); + } + else if (us->data_size > 0) { + total_memory += us->data_size; + } + } + + return total_memory; +} + /** \} */ diff --git a/source/blender/python/intern/bpy_app.cc b/source/blender/python/intern/bpy_app.cc index 4515614e9bc..54be5ad97d9 100644 --- a/source/blender/python/intern/bpy_app.cc +++ b/source/blender/python/intern/bpy_app.cc @@ -22,11 +22,13 @@ #include "bpy_app_opensubdiv.hh" #include "bpy_app_openvdb.hh" #include "bpy_app_sdl.hh" + #include "bpy_app_usd.hh" #include "bpy_app_translations.hh" #include "bpy_app_handlers.hh" +#include "bpy_capi_utils.hh" #include "bpy_driver.hh" #include "BPY_extern_python.hh" /* For #BPY_python_app_help_text_fn. */ @@ -46,6 +48,7 @@ #include "UI_interface_icons.hh" +#include "ED_undo.hh" #include "MEM_guardedalloc.h" #include "RNA_enum_types.hh" /* For `rna_enum_wm_job_type_items`. */ @@ -644,6 +647,23 @@ static PyObject *bpy_app_help_text(PyObject * /*self*/, PyObject *args, PyObject # pragma GCC diagnostic ignored "-Wcast-function-type" # endif #endif +PyDoc_STRVAR( + /* Wrap. */ + bpy_app_undo_memory_info_doc, + ".. staticmethod:: undo_memory_info()\n" + "\n" + " Get undo memory usage information.\n" + "\n" + " :return: 'total_memory'.\n" + " :rtype: int\n"); + +static PyObject *bpy_app_undo_memory_info(PyObject * /*self*/, PyObject * /*args*/) +{ + + size_t total_memory = ED_get_total_undo_memory(); + + return PyLong_FromSize_t(total_memory); +} static PyMethodDef bpy_app_methods[] = { {"is_job_running", @@ -654,6 +674,10 @@ static PyMethodDef bpy_app_methods[] = { (PyCFunction)bpy_app_help_text, METH_VARARGS | METH_KEYWORDS | METH_STATIC, bpy_app_help_text_doc}, + {"undo_memory_info", + (PyCFunction)bpy_app_undo_memory_info, + METH_NOARGS | METH_STATIC, + bpy_app_undo_memory_info_doc}, {nullptr, nullptr, 0, nullptr}, }; diff --git a/tests/performance/api/graph.template.html b/tests/performance/api/graph.template.html index a60df9b2ff7..8a3811eb8b7 100644 --- a/tests/performance/api/graph.template.html +++ b/tests/performance/api/graph.template.html @@ -39,6 +39,33 @@ return ndt; } + function drawChart(chartsQueue, index) { + index = index || 0; + if (index === chartsQueue.length) + return; + + var chartData = chartsQueue[index]; + var chart; + + if (chartData.chart_type == 'line') { + chart = new google.charts.Line(document.getElementById(chartData.id)); + } else { + chart = new google.charts.Bar(document.getElementById(chartData.id)); + } + + google.visualization.events.addOneTimeListener(chart, 'ready', function() { + /* Auto scale chart elements to display full SVG. */ + var allSvg = document.getElementsByTagName("svg"); + for (var svgIndex = 0; svgIndex < allSvg.length; svgIndex++) { + allSvg[svgIndex].setAttribute('height', allSvg[svgIndex].getBBox().height); + } + + drawChart(chartsQueue, index + 1); + }); + + chart.draw(chartData.data, chartData.options); + } + function draw_charts() { /* Load JSON data. */ @@ -54,7 +81,9 @@ charts_content_elem.removeChild(charts_content_elem.firstChild); } - /* Draw charts for each device. */ + var chartsQueue = []; + + /* Prepare UI and charts queue for each device. */ for (var i = 0; i < json_data.length; i++) { benchmark = json_data[i]; @@ -104,6 +133,7 @@ /* Create chart div. */ chart_div = document.createElement('div'); + chart_div.id = "chart-" + i; tab_div.appendChild(chart_div) /* Chart drawing options. */ @@ -113,25 +143,20 @@ height: 500, }; - /* Create chart. */ + /* Prepare chart data for queue. */ var data = new google.visualization.DataTable(benchmark["data"]); - if (benchmark['chart_type'] == 'line') { - var chart = new google.charts.Line(chart_div); - chart.draw(data, options); - } - else { - var chart = new google.charts.Bar(chart_div); - chart.draw(transposeDataTable(data), options); - } - - /* Auto scale chart elements to display full SVG. */ - google.visualization.events.addListener(chart, 'ready', function () { - var allSvg = document.getElementsByTagName("svg"); - for (var index = 0; index < allSvg.length; index++) { - allSvg[index].setAttribute('height', allSvg[index].getBBox().height); - } - }); + var chartData = { + id: chart_div.id, + data: benchmark['chart_type'] == 'line' ? data : transposeDataTable(data), + options: options, + chart_type: benchmark['chart_type'] + }; + + chartsQueue.push(chartData); } + + /* Start drawing charts sequentially. */ + drawChart(chartsQueue, 0); } diff --git a/tests/performance/tests/sculpt.py b/tests/performance/tests/sculpt.py index 5829ea5f93e..e325398dade 100644 --- a/tests/performance/tests/sculpt.py +++ b/tests/performance/tests/sculpt.py @@ -169,7 +169,6 @@ def _run_brush_test(args: dict): min_measurements = 5 max_measurements = 100 - measurements = [] while True: prepare_sculpt_scene(context, args['mode']) @@ -178,16 +177,18 @@ def _run_brush_test(args: dict): with context.temp_override(**context_override): if args.get('spatial_reorder', False): bpy.ops.mesh.reorder_vertices_spatial() + bpy.ops.ed.undo_push() start = time.time() bpy.ops.sculpt.brush_stroke(stroke=generate_stroke(context_override), override_location=True) + bpy.ops.ed.undo_push() measurements.append(time.time() - start) - + memory_info = bpy.app.undo_memory_info() if len(measurements) >= min_measurements and (time.time() - total_time_start) > timeout: break if len(measurements) >= max_measurements: break - return sum(measurements) / len(measurements) + return {'time': sum(measurements) / len(measurements), 'memory': memory_info} def _run_bvh_test(args: dict): @@ -277,7 +278,7 @@ class SculptBrushTest(api.Test): result, _ = env.run_in_blender(_run_brush_test, args, [self.filepath]) - return {'time': result} + return result class SculptBrushAfterSpatialReorderingTest(api.Test): @@ -301,7 +302,7 @@ class SculptBrushAfterSpatialReorderingTest(api.Test): result, _ = env.run_in_blender(_run_brush_test, args, [self.filepath]) - return {'time': result} + return result class SculptRebuildBVHTest(api.Test):