diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 60b39f8118..5078333c83 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -38,14 +38,6 @@ RUN GO_VERSION="1.23.8" && \ && tar -C /usr/local -xzf go${GO_VERSION}.linux-${GOARCH}.tar.gz \ && rm go${GO_VERSION}.linux-${GOARCH}.tar.gz -RUN git clone https://github.com/alibaba/yalantinglibs.git \ - && cd yalantinglibs \ - && mkdir -p build \ - && cd build \ - && cmake .. -DBUILD_EXAMPLES=OFF -DBUILD_BENCHMARK=OFF -DBUILD_UNIT_TESTS=OFF \ - && cmake --build . -j$(nproc) \ - && cmake --install . - ENV GOPROXY='https://goproxy.cn' ENV PATH=/usr/local/go/bin:$PATH diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 24dcbb753e..8e23479188 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -12,8 +12,29 @@ /mooncake-integration/transfer_engine @ShangmingCai @alogfans /mooncake-integration/store @ykwd @stmatengss @zxpdemonio /mooncake-pg @UNIDY2002 @ympcMark @yuechen-sys +/mooncake-reshard @ShangmingCai @stmatengss @Bo-Vincent @zxpdemonio /mooncake-store @ykwd @stmatengss @XucSh @YiXR -/mooncake-store/*/ha/ @Libotry @YiXR @00fish0 @Icedcoco +/mooncake-store/*/ha/ @Libotry @YiXR @00fish0 @Icedcoco @Aionw +/python/mooncake/async_store.py @ykwd @stmatengss @zxpdemonio +/python/tests/store/async_store_integration.py @ykwd @stmatengss @zxpdemonio +/python/tests/store/test_async_store.py @ykwd @stmatengss @zxpdemonio +/python/mooncake/buffer_pool.py @ykwd @stmatengss +/python/mooncake/ep.py @UNIDY2002 @ympcMark @yuechen-sys +/python/mooncake/mooncake_config.py @ykwd @stmatengss +/python/mooncake/mooncake_elastic_buffer.py @UNIDY2002 @ympcMark @yuechen-sys +/python/mooncake/mooncake_ep_buffer.py @UNIDY2002 @ympcMark @yuechen-sys +/python/tests/ep/ @UNIDY2002 @ympcMark @yuechen-sys +/python/mooncake/cli.py @ykwd @stmatengss @zxpdemonio +/python/mooncake/cli_client.py @ykwd @stmatengss @zxpdemonio +/python/mooncake/cli_bench.py @ShangmingCai @alogfans @stmatengss +/python/mooncake/transfer_engine_topology_dump.py @ShangmingCai @alogfans @stmatengss +/python/tests/integration/test_cli.py @ShangmingCai @stmatengss @zxpdemonio +/python/tests/unit/test_cli_modules.py @ShangmingCai @stmatengss @zxpdemonio +/python/tests/store/ @ykwd @stmatengss +/python/tests/unit/test_mooncake_config.py @ykwd @stmatengss +/python/mooncake/mooncake_ssd_*.py @ykwd @stmatengss +/python/mooncake/spdk_tgt_create.py @ykwd @stmatengss +/python/tests/ssd/ @ykwd @stmatengss /mooncake-transfer-engine @alogfans @doujiang24 @chestnut-Q @staryxchen /mooncake-transfer-engine/tent @alogfans @doujiang24 @chestnut-Q @staryxchen @00fish0 @dtcccc /mooncake-transfer-engine/*/transport/hip_transport/ @alogfans @amd-arozanov diff --git a/.github/actions/ctest-diagnostics/action.yml b/.github/actions/ctest-diagnostics/action.yml new file mode 100644 index 0000000000..eca7e3d6ae --- /dev/null +++ b/.github/actions/ctest-diagnostics/action.yml @@ -0,0 +1,50 @@ +name: Preserve CTest failure diagnostics +description: Summarize a failed CTest run and upload its logs and JUnit report + +inputs: + summary-title: + description: Heading to add to the GitHub Actions job summary + required: true + artifact-name: + description: Name of the uploaded diagnostics artifact + required: true + junit-report: + description: Path to the CTest JUnit XML report + required: true + failed-tests-log: + description: Path to CTest's LastTestsFailed.log + required: true + last-test-log: + description: Path to CTest's LastTest.log + required: true + retention-days: + description: Number of days to retain the diagnostics artifact + required: false + default: "14" + +runs: + using: composite + steps: + - name: Add CTest failure summary + env: + SUMMARY_TITLE: ${{ inputs.summary-title }} + FAILED_TESTS_LOG: ${{ inputs.failed-tests-log }} + LAST_TEST_LOG: ${{ inputs.last-test-log }} + shell: bash + run: | + "$GITHUB_ACTION_PATH/summarize.sh" \ + "$SUMMARY_TITLE" \ + "$FAILED_TESTS_LOG" \ + "$LAST_TEST_LOG" + + - name: Upload CTest diagnostics + if: ${{ always() }} + uses: actions/upload-artifact@v4 + with: + name: ${{ inputs.artifact-name }} + if-no-files-found: warn + retention-days: ${{ inputs.retention-days }} + path: | + ${{ inputs.junit-report }} + ${{ inputs.last-test-log }} + ${{ inputs.failed-tests-log }} diff --git a/.github/actions/ctest-diagnostics/summarize.sh b/.github/actions/ctest-diagnostics/summarize.sh new file mode 100755 index 0000000000..d1d0c4b1dc --- /dev/null +++ b/.github/actions/ctest-diagnostics/summarize.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash + +set -euo pipefail + +if [[ $# -ne 3 ]]; then + echo "Usage: $0 " >&2 + exit 2 +fi + +: "${GITHUB_STEP_SUMMARY:?GITHUB_STEP_SUMMARY must be set}" + +readonly SUMMARY_TITLE="$1" +readonly FAILED_TESTS_LOG="$2" +readonly LAST_TEST_LOG="$3" + +{ + echo "## $SUMMARY_TITLE" + echo + + if [[ -s "$FAILED_TESTS_LOG" ]]; then + echo "### Failed CTest targets" + echo '```text' + cat "$FAILED_TESTS_LOG" + echo '```' + fi + + if [[ -s "$LAST_TEST_LOG" ]]; then + failed_cases=$(grep -E '^\[ FAILED \]' "$LAST_TEST_LOG" | + sed -E \ + -e '/^\[ FAILED \] [0-9]+ tests?, listed below:$/d' \ + -e 's/ \([0-9]+ ms\)$//' | + sort -u || true) + if [[ -n "$failed_cases" ]]; then + echo "### Failed test cases" + echo '```text' + echo "$failed_cases" + echo '```' + fi + + failure_context=$(grep -n -B2 -A8 -E \ + -e ':[0-9]+: Failure$' \ + -e 'Assertion .* failed\.$' \ + -e '(ERROR|WARNING): (AddressSanitizer|LeakSanitizer|ThreadSanitizer|UndefinedBehaviorSanitizer)' \ + -e 'runtime error:|Segmentation fault|terminate called' \ + "$LAST_TEST_LOG" | sed -n '1,200p' || true) + if [[ -n "$failure_context" ]]; then + echo "### Failure context" + echo '```text' + echo "$failure_context" + echo '```' + fi + else + echo "CTest did not produce LastTest.log. Check the failed step for setup errors." + fi + + echo + echo "Download the CTest diagnostics artifact for the complete log and JUnit report." +} >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/actions/ctest-diagnostics/test.sh b/.github/actions/ctest-diagnostics/test.sh new file mode 100755 index 0000000000..745f0fb5f1 --- /dev/null +++ b/.github/actions/ctest-diagnostics/test.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash + +set -euo pipefail + +readonly ACTION_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +readonly FIXTURE_DIR="$ACTION_DIR/testdata" +readonly SUMMARY_FILE="$(mktemp)" + +trap 'rm -f "$SUMMARY_FILE"' EXIT + +GITHUB_STEP_SUMMARY="$SUMMARY_FILE" "$ACTION_DIR/summarize.sh" \ + "CTest failure fixture" \ + "$FIXTURE_DIR/last-tests-failed.txt" \ + "$FIXTURE_DIR/last-test.txt" + +diff -u "$FIXTURE_DIR/expected-summary.md" "$SUMMARY_FILE" diff --git a/.github/actions/ctest-diagnostics/testdata/expected-summary.md b/.github/actions/ctest-diagnostics/testdata/expected-summary.md new file mode 100644 index 0000000000..24cb45e6da --- /dev/null +++ b/.github/actions/ctest-diagnostics/testdata/expected-summary.md @@ -0,0 +1,43 @@ +## CTest failure fixture + +### Failed CTest targets +```text +1:google_test +2:native_assertion +3:tsan_test +``` +### Failed test cases +```text +[ FAILED ] FooTest.Bar +``` +### Failure context +```text +3-1/3 Testing: google_test +4-[ RUN ] FooTest.Bar +5:/workspace/foo_test.cpp:42: Failure +6-Expected equality of these values: +7- 1 +8- actual +9- Which is: 2 +10-[ FAILED ] FooTest.Bar (5 ms) +11-[==========] 1 test from 1 test suite ran. (5 ms total) +12-[ PASSED ] 0 tests. +13-[ FAILED ] 1 test, listed below: +-- +15- +16-2/3 Testing: native_assertion +17:native_assertion: /workspace/assert.cpp:17: int main(): Assertion 'ready' failed. +18-Subprocess aborted +19- +20-3/3 Testing: tsan_test +21:WARNING: ThreadSanitizer: data race (pid=123) +22- Write of size 4 at 0x7b0400000800 by thread T1: +23- #0 worker /workspace/worker.cpp:9 +24- +25-SUMMARY: ThreadSanitizer: data race /workspace/worker.cpp:9 in worker +26-ThreadSanitizer: reported 1 warnings +27- +28-End testing +``` + +Download the CTest diagnostics artifact for the complete log and JUnit report. diff --git a/.github/actions/ctest-diagnostics/testdata/last-test.txt b/.github/actions/ctest-diagnostics/testdata/last-test.txt new file mode 100644 index 0000000000..4331103b20 --- /dev/null +++ b/.github/actions/ctest-diagnostics/testdata/last-test.txt @@ -0,0 +1,28 @@ +Start testing: fixture +---------------------------------------------------------- +1/3 Testing: google_test +[ RUN ] FooTest.Bar +/workspace/foo_test.cpp:42: Failure +Expected equality of these values: + 1 + actual + Which is: 2 +[ FAILED ] FooTest.Bar (5 ms) +[==========] 1 test from 1 test suite ran. (5 ms total) +[ PASSED ] 0 tests. +[ FAILED ] 1 test, listed below: +[ FAILED ] FooTest.Bar + +2/3 Testing: native_assertion +native_assertion: /workspace/assert.cpp:17: int main(): Assertion 'ready' failed. +Subprocess aborted + +3/3 Testing: tsan_test +WARNING: ThreadSanitizer: data race (pid=123) + Write of size 4 at 0x7b0400000800 by thread T1: + #0 worker /workspace/worker.cpp:9 + +SUMMARY: ThreadSanitizer: data race /workspace/worker.cpp:9 in worker +ThreadSanitizer: reported 1 warnings + +End testing diff --git a/.github/actions/ctest-diagnostics/testdata/last-tests-failed.txt b/.github/actions/ctest-diagnostics/testdata/last-tests-failed.txt new file mode 100644 index 0000000000..dfb4965678 --- /dev/null +++ b/.github/actions/ctest-diagnostics/testdata/last-tests-failed.txt @@ -0,0 +1,3 @@ +1:google_test +2:native_assertion +3:tsan_test diff --git a/.github/actions/run-ctest/action.yml b/.github/actions/run-ctest/action.yml new file mode 100644 index 0000000000..03be26af77 --- /dev/null +++ b/.github/actions/run-ctest/action.yml @@ -0,0 +1,21 @@ +name: Run Mooncake CTest suite +description: Run the source-build CTest suite with shared runtime settings +inputs: + reserve-rpc-port: + description: Reserve the Mooncake client RPC port from ephemeral allocation + default: 'true' + junit-report: + description: Optional repository-relative JUnit output path + default: '' + label-exclude: + description: Optional CTest label regex to exclude + default: '' +runs: + using: composite + steps: + - shell: bash + env: + RESERVE_RPC_PORT: ${{ inputs.reserve-rpc-port }} + JUNIT_REPORT: ${{ inputs.junit-report }} + CTEST_LABEL_EXCLUDE: ${{ inputs.label-exclude }} + run: bash "$GITHUB_ACTION_PATH/run.sh" diff --git a/.github/actions/run-ctest/run.sh b/.github/actions/run-ctest/run.sh new file mode 100644 index 0000000000..5efd41a4cd --- /dev/null +++ b/.github/actions/run-ctest/run.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +set -e -o pipefail + +if [ "$RESERVE_RPC_PORT" = true ]; then + reserved_ports=$(sysctl -n net.ipv4.ip_local_reserved_ports) + sudo sysctl -w "net.ipv4.ip_local_reserved_ports=${reserved_ports:+$reserved_ports,}50052" +fi +args=(--parallel "$(nproc)" --output-on-failure) +if [ -n "${CTEST_LABEL_EXCLUDE:-}" ]; then + args+=(--label-exclude "$CTEST_LABEL_EXCLUDE") +fi +if [ -n "$JUNIT_REPORT" ]; then + report="$GITHUB_WORKSPACE/$JUNIT_REPORT" + mkdir -p "$(dirname "$report")" + args+=(--output-junit "$report") +fi +cd build +export LD_LIBRARY_PATH=${LD_LIBRARY_PATH:-}:/usr/local/lib +MC_METADATA_SERVER=http://127.0.0.1:8080/metadata \ +DEFAULT_KV_LEASE_TTL=500 \ + ctest "${args[@]}" diff --git a/.github/actions/setup-cuda-runtime/action.yml b/.github/actions/setup-cuda-runtime/action.yml new file mode 100644 index 0000000000..d914c363af --- /dev/null +++ b/.github/actions/setup-cuda-runtime/action.yml @@ -0,0 +1,8 @@ +name: Expose the CMake-selected CUDA driver runtime +description: Expose the CMake-selected CUDA driver runtime + +runs: + using: composite + steps: + - shell: bash + run: bash "$GITHUB_ACTION_PATH/run.sh" diff --git a/.github/actions/setup-cuda-runtime/run.sh b/.github/actions/setup-cuda-runtime/run.sh new file mode 100644 index 0000000000..0708a7f8eb --- /dev/null +++ b/.github/actions/setup-cuda-runtime/run.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +set -e -o pipefail + +cuda_driver_library=$(sed -n \ + 's/^CUDA_cuda_driver_LIBRARY:FILEPATH=//p' build/CMakeCache.txt) +if [ -z "$cuda_driver_library" ] || [ ! -f "$cuda_driver_library" ]; then + echo "::error::CMake did not resolve the CUDA driver library" + exit 1 +fi + +cuda_driver_dir=$(dirname "$cuda_driver_library") +if [ ! -e "$cuda_driver_dir/libcuda.so.1" ]; then + sudo ln -s "$(basename "$cuda_driver_library")" \ + "$cuda_driver_dir/libcuda.so.1" +fi +echo "LIBRARY_PATH=$cuda_driver_dir:${LIBRARY_PATH:-}" >> "$GITHUB_ENV" +echo "LD_LIBRARY_PATH=$cuda_driver_dir:${LD_LIBRARY_PATH:-}" >> "$GITHUB_ENV" diff --git a/.github/actions/setup-etcd/action.yml b/.github/actions/setup-etcd/action.yml new file mode 100644 index 0000000000..126e2bfda9 --- /dev/null +++ b/.github/actions/setup-etcd/action.yml @@ -0,0 +1,8 @@ +name: Install and start the CI etcd service +description: Install and start the CI etcd service + +runs: + using: composite + steps: + - shell: bash + run: bash "$GITHUB_ACTION_PATH/run.sh" diff --git a/.github/actions/setup-etcd/run.sh b/.github/actions/setup-etcd/run.sh new file mode 100644 index 0000000000..41d23095ea --- /dev/null +++ b/.github/actions/setup-etcd/run.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +set -e -o pipefail + +wget -q https://github.com/etcd-io/etcd/releases/download/v3.6.1/etcd-v3.6.1-linux-amd64.tar.gz +tar xzf etcd-v3.6.1-linux-amd64.tar.gz +sudo mv etcd-v3.6.1-linux-amd64/etcd* /usr/local/bin/ +etcd --advertise-client-urls http://127.0.0.1:2379 --listen-client-urls http://127.0.0.1:2379 & +sleep 3 +ETCDCTL_API=3 etcdctl --endpoints=http://127.0.0.1:2379 endpoint health diff --git a/.github/actions/setup-metadata-server/action.yml b/.github/actions/setup-metadata-server/action.yml new file mode 100644 index 0000000000..3e75b3bccd --- /dev/null +++ b/.github/actions/setup-metadata-server/action.yml @@ -0,0 +1,8 @@ +name: Start the CI HTTP metadata service +description: Start the CI HTTP metadata service + +runs: + using: composite + steps: + - shell: bash + run: bash "$GITHUB_ACTION_PATH/run.sh" diff --git a/.github/actions/setup-metadata-server/run.sh b/.github/actions/setup-metadata-server/run.sh new file mode 100644 index 0000000000..711738bef6 --- /dev/null +++ b/.github/actions/setup-metadata-server/run.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -e -o pipefail + +cd mooncake-transfer-engine/example/http-metadata-server-python +pip install aiohttp +python ./bootstrap_server.py & +echo "NIGHTLY_METADATA_SERVER_PID=$!" >> "$GITHUB_ENV" +sleep 2 diff --git a/.github/actions/setup-sccache/action.yml b/.github/actions/setup-sccache/action.yml new file mode 100644 index 0000000000..f9cb3af713 --- /dev/null +++ b/.github/actions/setup-sccache/action.yml @@ -0,0 +1,12 @@ +name: Set up sccache with GitHub Actions cache credentials +description: Set up sccache with GitHub Actions cache credentials + +runs: + using: composite + steps: + - uses: mozilla-actions/sccache-action@v0.0.9 + - uses: actions/github-script@v7 + with: + script: | + core.exportVariable('ACTIONS_RESULTS_URL', process.env.ACTIONS_RESULTS_URL || ''); + core.exportVariable('ACTIONS_RUNTIME_TOKEN', process.env.ACTIONS_RUNTIME_TOKEN || ''); diff --git a/.github/labeler.yml b/.github/labeler.yml index aa76d0be22..4e55945282 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -5,11 +5,29 @@ run-ci: store: - changed-files: - - any-glob-to-any-file: 'mooncake-store/**/*' + - any-glob-to-any-file: + - 'mooncake-store/**/*' + - 'python/mooncake/async_store.py' + - 'python/mooncake/cli.py' + - 'python/mooncake/cli_client.py' + - 'python/mooncake/buffer_pool.py' + - 'python/mooncake/mooncake_config.py' + - 'python/tests/store/**/*' + - 'python/tests/unit/test_mooncake_config.py' + - 'python/mooncake/mooncake_ssd_*.py' + - 'python/mooncake/spdk_tgt_create.py' + - 'python/tests/ssd/**/*' + +Mooncake Conductor: + - changed-files: + - any-glob-to-any-file: 'mooncake-conductor/**/*' Transfer Engine: - changed-files: - - any-glob-to-any-file: 'mooncake-transfer-engine/**/*' + - any-glob-to-any-file: + - 'mooncake-transfer-engine/**/*' + - 'python/mooncake/cli_bench.py' + - 'python/mooncake/transfer_engine_topology_dump.py' PyTorch Backend: - changed-files: @@ -17,11 +35,21 @@ PyTorch Backend: Mooncake EP: - changed-files: - - any-glob-to-any-file: 'mooncake-ep/**/*' + - any-glob-to-any-file: + - 'mooncake-ep/**/*' + - 'python/mooncake/ep.py' + - 'python/mooncake/mooncake_elastic_buffer.py' + - 'python/mooncake/mooncake_ep_buffer.py' + - 'python/tests/ep/**/*' Installation: - changed-files: - - any-glob-to-any-file: 'mooncake-wheel/**/*' + - any-glob-to-any-file: + - 'mooncake-wheel/**/*' + - 'python/mooncake/cli.py' + - 'python/mooncake/cli_client.py' + - 'python/mooncake/cli_bench.py' + - 'python/mooncake/transfer_engine_topology_dump.py' P2P Store: - changed-files: @@ -33,7 +61,9 @@ Integration: Common: - changed-files: - - any-glob-to-any-file: 'mooncake-common/**/*' + - any-glob-to-any-file: + - 'mooncake-common/**/*' + - 'mooncake-reshard/**/*' CI/Build: - changed-files: @@ -54,6 +84,8 @@ Tests: - any-glob-to-any-file: - 'scripts/test_*' - 'mooncake-wheel/tests/**/*' + - 'mooncake-reshard/tests/**/*' + - 'python/tests/**/*' - 'scripts/tone_tests/**/*' Ascend/NPU: diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index a926d40787..dae6b530d8 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -6,6 +6,8 @@ - [ ] Transfer Engine (`mooncake-transfer-engine`) - [ ] Mooncake Store (`mooncake-store`) +- [ ] Mooncake Conductor (`mooncake-conductor`) +- [ ] Reshard (`mooncake-reshard`) - [ ] Mooncake EP (`mooncake-ep`) - [ ] Mooncake PG (`mooncake-pg`) - [ ] Integration (`mooncake-integration`) @@ -45,7 +47,7 @@ - [ ] I have performed a self-review of my own code - [ ] I have formatted my code using `./scripts/code_format.sh` -- [ ] I have run `pre-commit run --all-files` and all hooks pass +- [ ] I have run pre-commit on the files changed in this PR and all hooks pass - [ ] I have updated the documentation (if applicable) - [ ] I have added tests to prove my changes are effective - [ ] For changes >500 LOC: I have filed an RFC issue diff --git a/.github/workflows/_build-efa-wheel.yaml b/.github/workflows/_build-efa-wheel.yaml index 730e7d87cc..ba03d3b2f7 100644 --- a/.github/workflows/_build-efa-wheel.yaml +++ b/.github/workflows/_build-efa-wheel.yaml @@ -11,9 +11,6 @@ on: # cuda | cuda13 | non-cuda type: string required: true - use-cuda: - type: boolean - required: true python-versions: # JSON array consumed by the called workflow's Python matrix. type: string @@ -25,13 +22,6 @@ on: cmake-args: type: string required: true - variant-flag: - # build_wheel.sh package variant, e.g. EFA_BUILD. - type: string - required: true - cuda-version: - type: string - default: '12.8.1' torch-cuda-arch-list: type: string default: '' @@ -52,9 +42,10 @@ jobs: BUILD_PROFILE: ${{ inputs.build-profile }} CMAKE_ARGS: ${{ inputs.cmake-args }} EFA_VARIANT: ${{ inputs.variant }} - USE_CUDA: ${{ inputs.use-cuda }} - VARIANT_FLAG: ${{ inputs.variant-flag }} - CUDA_VERSION: ${{ inputs.cuda-version }} + USE_CUDA: ${{ inputs.variant != 'non-cuda' }} + VARIANT_FLAG: ${{ fromJSON('{"cuda":"EFA_BUILD","cuda13":"EFA_CU13_BUILD","non-cuda":"EFA_NON_CUDA_BUILD"}')[inputs.variant] }} + CUDA_VERSION: ${{ inputs.variant == 'cuda13' && '13.0.2' || '12.8.1' }} + TORCH_CUDA_ARCH_LIST: ${{ inputs.torch-cuda-arch-list }} steps: - name: Validate build inputs @@ -64,19 +55,12 @@ jobs: *) echo "::error::Unknown EFA build profile: $BUILD_PROFILE"; exit 1 ;; esac - case "$EFA_VARIANT:$USE_CUDA:$VARIANT_FLAG" in - cuda:true:EFA_BUILD|cuda13:true:EFA_CU13_BUILD|non-cuda:false:EFA_NON_CUDA_BUILD) ;; - *) echo "::error::Inconsistent EFA variant inputs"; exit 1 ;; + case "$EFA_VARIANT" in + cuda|cuda13|non-cuda) ;; + *) echo "::error::Unknown EFA variant: $EFA_VARIANT"; exit 1 ;; esac shell: bash - - name: Configure CUDA architecture list - if: ${{ inputs.torch-cuda-arch-list != '' }} - env: - ARCH_LIST: ${{ inputs.torch-cuda-arch-list }} - run: echo "TORCH_CUDA_ARCH_LIST=$ARCH_LIST" >> "$GITHUB_ENV" - shell: bash - - name: Checkout source uses: actions/checkout@v4 with: @@ -91,10 +75,10 @@ jobs: uses: ./.github/actions/free-disk-space - name: Install CUDA Toolkit - if: ${{ inputs.use-cuda }} + if: ${{ inputs.variant != 'non-cuda' }} uses: Jimver/cuda-toolkit@v0.2.29 with: - cuda: ${{ inputs.cuda-version }} + cuda: ${{ env.CUDA_VERSION }} method: 'network' sub-packages: '["nvcc", "nvrtc-dev"]' non-cuda-sub-packages: '["libcusparse-dev", "libcublas-dev", "libcusolver-dev"]' @@ -201,7 +185,7 @@ jobs: shell: bash - name: Verify CUDA runtime dependency - if: ${{ inputs.use-cuda }} + if: ${{ inputs.variant != 'non-cuda' }} run: | WHL=$(ls mooncake-wheel/dist-py${{ steps.python-tag.outputs.python_version_tag }}/*.whl | head -1) inspect_dir=$(mktemp -d) diff --git a/.github/workflows/_build-wheel.yaml b/.github/workflows/_build-wheel.yaml index 11216c6c7d..c1ca05335c 100644 --- a/.github/workflows/_build-wheel.yaml +++ b/.github/workflows/_build-wheel.yaml @@ -62,17 +62,17 @@ jobs: variant_flag='' generator='' arch_list='8.0;9.0' - ep_versions='2.11.0;2.12.0;2.12.1;2.13.0' + ep_versions='2.11.0;2.12.0;2.12.1;2.13.0;2.14.0' build_nvlink=true - cmake_args='-DBUILD_UNIT_TESTS=OFF -DUSE_HTTP=ON -DUSE_ETCD=ON -DUSE_CUDA=ON -DWITH_EP=ON -DSTORE_USE_ETCD=ON -DENABLE_SCCACHE=ON -DCMAKE_BUILD_TYPE=Release' + cmake_args='-DBUILD_UNIT_TESTS=OFF -DUSE_HTTP=ON -DUSE_ETCD=ON -DUSE_CUDA=ON -DUSE_INTRA_NVLINK=ON -DWITH_EP=ON -DSTORE_USE_ETCD=ON -DENABLE_SCCACHE=ON -DCMAKE_BUILD_TYPE=Release' ;; cuda13:x86_64) variant_flag=CU13_BUILD generator='' - arch_list='8.0;9.0' - ep_versions='2.11.0;2.12.0;2.12.1;2.13.0' + arch_list='8.0;9.0;10.3' + ep_versions='2.11.0;2.12.0;2.12.1;2.13.0;2.14.0' build_nvlink=true - cmake_args='-DBUILD_UNIT_TESTS=OFF -DUSE_HTTP=ON -DUSE_ETCD=ON -DUSE_CUDA=ON -DWITH_EP=ON -DSTORE_USE_ETCD=ON -DENABLE_SCCACHE=ON -DCMAKE_BUILD_TYPE=Release' + cmake_args='-DBUILD_UNIT_TESTS=OFF -DUSE_HTTP=ON -DUSE_ETCD=ON -DUSE_CUDA=ON -DUSE_INTRA_NVLINK=ON -DWITH_EP=ON -DSTORE_USE_ETCD=ON -DENABLE_SCCACHE=ON -DCMAKE_BUILD_TYPE=Release' ;; non-cuda:x86_64) variant_flag=NON_CUDA_BUILD @@ -82,16 +82,21 @@ jobs: build_nvlink=false cmake_args='-DBUILD_UNIT_TESTS=OFF -DUSE_HTTP=ON -DUSE_ETCD=ON -DUSE_CUDA=OFF -DWITH_EP=OFF -DSTORE_USE_ETCD=ON -DENABLE_SCCACHE=ON -DCMAKE_BUILD_TYPE=Release' ;; - cuda:arm64|cuda13:arm64) + cuda:arm64) variant_flag='' - if [ "$BUILD_VARIANT" = cuda13 ]; then - variant_flag=CU13_BUILD - fi generator=Ninja arch_list='9.0' - ep_versions='' + ep_versions='2.11.0;2.12.0;2.12.1;2.13.0;2.14.0' build_nvlink=false - cmake_args='-DBUILD_UNIT_TESTS=OFF -DUSE_HTTP=ON -DUSE_CUDA=ON -DUSE_MNNVL=ON -DWITH_EP=OFF -DWITH_STORE_RUST=OFF -DENABLE_SCCACHE=ON -DCMAKE_BUILD_TYPE=Release' + cmake_args='-DBUILD_UNIT_TESTS=OFF -DUSE_HTTP=ON -DUSE_CUDA=ON -DUSE_MNNVL=ON -DWITH_EP=ON -DWITH_STORE_RUST=OFF -DENABLE_SCCACHE=ON -DCMAKE_BUILD_TYPE=Release' + ;; + cuda13:arm64) + variant_flag=CU13_BUILD + generator=Ninja + arch_list='9.0;10.3' + ep_versions='2.11.0;2.12.0;2.12.1;2.13.0;2.14.0' + build_nvlink=false + cmake_args='-DBUILD_UNIT_TESTS=OFF -DUSE_HTTP=ON -DUSE_CUDA=ON -DUSE_MNNVL=ON -DWITH_EP=ON -DWITH_STORE_RUST=OFF -DENABLE_SCCACHE=ON -DCMAKE_BUILD_TYPE=Release' ;; non-cuda:arm64) variant_flag=NON_CUDA_BUILD @@ -106,6 +111,7 @@ jobs: exit 1 ;; esac + cmake_args="$cmake_args -DENABLE_KV_EVENTS=ON" { echo "VARIANT_FLAG=$variant_flag" @@ -209,6 +215,7 @@ jobs: VERSION: ${{ env.VERSION }} - name: Smoke test repaired wheel + shell: bash run: | smoke_venv=$(mktemp -d) python -m venv "$smoke_venv" @@ -218,11 +225,20 @@ jobs: if [ "${VARIANT_FLAG:-}" = "NON_CUDA_BUILD" ]; then site_packages=$("$smoke_venv/bin/python" -c \ 'import sysconfig; print(sysconfig.get_paths()["purelib"])') - master="$site_packages/mooncake/mooncake_master" - if readelf -d "$master" | grep -Eq \ - 'Shared library: \[(libcuda|libcudart)\.so'; then - echo "Non-CUDA mooncake_master depends on CUDA" - readelf -d "$master" | grep 'Shared library:' + cuda_dependency_found=false + for package_path in "$site_packages"/mooncake*; do + [ -e "$package_path" ] || continue + while IFS= read -r -d '' file; do + cuda_dependencies=$(readelf -d "$file" 2>/dev/null | grep -E \ + 'Shared library: \[(libcuda|libcudart|libcublas|libcufft|libcurand|libcusolver|libcusparse|libcufile|libcupti|libnvrtc|libnvJitLink|libnvToolsExt|libnvfatbin|libnvidia|libnccl)\.so' || true) + if [ -n "$cuda_dependencies" ]; then + echo "::error file=$file::Non-CUDA wheel artifact depends on CUDA" + echo "$cuda_dependencies" + cuda_dependency_found=true + fi + done < <(find "$package_path" -type f -print0) + done + if [ "$cuda_dependency_found" = true ]; then exit 1 fi fi diff --git a/.github/workflows/ci-on-label.yml b/.github/workflows/ci-on-label.yml new file mode 100644 index 0000000000..f4d5929ca6 --- /dev/null +++ b/.github/workflows/ci-on-label.yml @@ -0,0 +1,53 @@ +name: Retrigger CI on run-ci label + +# Same-SHA retrigger for Build & Test. This is a separate workflow so labels +# other than `run-ci` (especially `run-e2e-ci`) cannot start or cancel +# `.github/workflows/ci.yml`. +# +# pull_request_target is required so fork PRs can rerun Actions. This +# workflow only calls the GitHub API; it does not check out PR code. +on: + pull_request_target: + branches: + - "main" + - "release/**" + types: [labeled] + +permissions: + actions: write + contents: read + +jobs: + retrigger: + if: > + github.event.label.name == 'run-ci' && + github.actor != 'github-actions[bot]' + runs-on: ubuntu-latest + steps: + - name: Re-run Build & Test for this SHA + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + SHA: ${{ github.event.pull_request.head.sha }} + shell: bash + run: | + set -euo pipefail + + run_json=$(gh api \ + "repos/${REPO}/actions/workflows/ci.yml/runs?head_sha=${SHA}&per_page=20") + run_id=$(echo "$run_json" | jq -r '.workflow_runs[0].id // empty') + status=$(echo "$run_json" | jq -r '.workflow_runs[0].status // empty') + + if [ -z "$run_id" ]; then + echo "No Build & Test run found for SHA ${SHA}." + echo "Open or push to the PR first so ci.yml has a run to rerun." + exit 1 + fi + + echo "Matched workflow run ${run_id} (status=${status})" + if [ "$status" != "completed" ]; then + echo "Build & Test is still ${status}; not starting a duplicate." + exit 0 + fi + + gh run rerun "$run_id" --repo "$REPO" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 52f9955b11..99c59bda48 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,163 +9,26 @@ on: branches: - "main" - "release/**" - types: [opened, synchronize, reopened, labeled] + # `labeled` is intentionally omitted. Auto-labeler already applies + # `run-ci`, so any new label (including `run-e2e-ci`) would retrigger + # this whole workflow and cancel the in-progress run. Same-SHA + # retrigger via the `run-ci` label lives in ci-on-label.yml. + types: [opened, synchronize, reopened] workflow_dispatch: {} permissions: contents: read concurrency: - group: ${{ github.workflow }}-${{ github.ref_name }}-${{ github.event.pull_request.number || github.sha }} + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true jobs: - build: - needs: [spell-check, clang-format, check-paths] - if: &run-ci-for-source-changes >- - (needs.check-paths.outputs.should-run-downstream == 'true' || - github.event_name == 'workflow_dispatch') && - (github.event_name == 'push' || - github.event_name == 'workflow_dispatch' || - github.event.action == 'opened' || - contains(github.event.pull_request.labels.*.name, 'run-ci')) - runs-on: ubuntu-22.04 - strategy: - matrix: - python-version: ['3.10', '3.12'] - env: - CI: "true" - SCCACHE_GHA_ENABLED: "true" - - steps: - - uses: actions/checkout@v4 - with: - persist-credentials: false - - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 - with: - python-version: ${{ matrix.python-version }} - - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable - - - name: Install and start etcd - run: | - wget https://github.com/etcd-io/etcd/releases/download/v3.6.1/etcd-v3.6.1-linux-amd64.tar.gz - tar xzf etcd-v3.6.1-linux-amd64.tar.gz - sudo mv etcd-v3.6.1-linux-amd64/etcd* /usr/local/bin/ - etcd --advertise-client-urls http://127.0.0.1:2379 --listen-client-urls http://127.0.0.1:2379 & - sleep 3 # Give etcd time to start - etcdctl --endpoints=http://127.0.0.1:2379 endpoint health - shell: bash - - - name: Free up disk space - uses: ./.github/actions/free-disk-space - - - name: Install CUDA Toolkit - uses: Jimver/cuda-toolkit@v0.2.24 - with: - cuda: '12.8.1' - linux-local-args: '["--toolkit"]' - method: 'network' - sub-packages: '["nvcc"]' - - - name: Install build utilities - run: | - sudo apt-get update - sudo apt-get install -y ninja-build - - - name: Test HugeTLB sizing helper - run: | - python3 scripts/test_hicache_hugepage_requirements.py - shell: bash - - - name: Run sccache-cache - uses: mozilla-actions/sccache-action@v0.0.9 - - - name: Configure sccache - uses: actions/github-script@v7 - with: - script: | - core.exportVariable('ACTIONS_RESULTS_URL', process.env.ACTIONS_RESULTS_URL || ''); - core.exportVariable('ACTIONS_RUNTIME_TOKEN', process.env.ACTIONS_RUNTIME_TOKEN || ''); - - - name: Configure project - run: | - sudo apt update -y - sudo bash -x dependencies.sh -y - mkdir build - cd build - cmake -G Ninja .. -DUSE_HTTP=ON -DUSE_CXL=ON -DUSE_UB=ON -DUSE_ETCD=ON -DSTORE_USE_ETCD=ON -DENABLE_ASAN=ON -DENABLE_SCCACHE=ON -DCMAKE_BUILD_TYPE=Debug -DENABLE_DEBUG_SYMBOLS=OFF - shell: bash - - - name: Build project - run: | - cd build - cmake --build . - sudo cmake --install . - shell: bash - - - name: Build nvlink_allocator.so - run: | - mkdir -p build/mooncake-transfer-engine/nvlink-allocator - cd mooncake-transfer-engine/nvlink-allocator - export LIBRARY_PATH=/usr/local/cuda/lib64/stubs:$LIBRARY_PATH - bash build.sh ../../build/mooncake-transfer-engine/nvlink-allocator/ - shell: bash - - - name: Run sccache stat for check - if: ${{ env.SCCACHE_PATH != '' }} - shell: bash - run: ${SCCACHE_PATH} --show-stats - - - name: Start Metadata Server - run: | - cd mooncake-transfer-engine/example/http-metadata-server-python - pip install aiohttp - python ./bootstrap_server.py & - shell: bash - - - name: Run Mooncake Store Rust smoke test and benchmark - env: - MOONCAKE_STORE_CLUSTER_ID: ci_rust_test_cluster - MOONCAKE_STORE_RUST_LINK_ASAN: "1" - run: ./scripts/ci/run_store_rust_smoke.sh - shell: bash - - - name: Run Go store binding integration tests - env: - MOONCAKE_STORE_CLUSTER_ID: ci_go_test_cluster - MOONCAKE_STORE_GO_LINK_COMMON: "1" - MOONCAKE_STORE_GO_SANITIZED: "1" - run: ./scripts/ci/run_store_go_integration.sh - shell: bash - - - name: Test (in build env) - run: | - cd build - export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:/usr/local/lib - ldconfig -v || echo "always continue" - MC_METADATA_SERVER=http://127.0.0.1:8080/metadata DEFAULT_KV_LEASE_TTL=500 ctest -j --output-on-failure - shell: bash - - - name: Drain HTTP E2E test - if: matrix.python-version == '3.12' - run: | - cd build - export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:/usr/local/lib - # Keep the sanitizer gate on the C++ integration test. The Python - # drain script is manual/nightly only because pybind + ASan teardown in - # a Python host process is not stable. - DEFAULT_KV_LEASE_TTL=500 ./mooncake-store/tests/task_integration_test --gtest_filter='TaskExecutorIntegrationTest.DrainJobCompleteFlow' - shell: bash - # Build the artifact tested below through the exact release wheel path. The # Ubuntu jobs remain consumer tests, not an alternate wheel build environment. build-wheel: needs: [spell-check, clang-format, check-paths] - if: *run-ci-for-source-changes + if: &run-ci-for-source-changes needs.check-paths.outputs.src == 'true' uses: ./.github/workflows/_build-wheel.yaml with: python-versions: '["3.10", "3.12"]' @@ -173,17 +36,16 @@ jobs: version-override: 0.0.0.dev0 test-wheel-ubuntu: - needs: [spell-check, clang-format, build-wheel] - if: >- - needs.build-wheel.result == 'success' && - (github.event_name == 'push' || - github.event_name == 'workflow_dispatch' || - github.event.action == 'opened' || - contains(github.event.pull_request.labels.*.name, 'run-ci')) + needs: [build-wheel] strategy: matrix: - ubuntu-version: [ubuntu-22.04, ubuntu-24.04] - python-version: ['3.10', '3.12'] + include: + - ubuntu-version: ubuntu-22.04 + python-version: '3.12' + artifact: mooncake-wheel-ci-py312 + - ubuntu-version: ubuntu-24.04 + python-version: '3.10' + artifact: mooncake-wheel-ci-py310 runs-on: ${{ matrix.ubuntu-version }} steps: - uses: actions/checkout@v4 @@ -195,16 +57,10 @@ jobs: with: python-version: ${{ matrix.python-version }} - - name: Generate Python version tag - id: generate_tag_test - run: | - echo "python_version_tag=$(echo ${{ matrix.python-version }} | tr -d '.')" >> $GITHUB_OUTPUT - shell: bash - - name: Download wheel artifact uses: actions/download-artifact@v4 with: - name: mooncake-wheel-ci-py${{ steps.generate_tag_test.outputs.python_version_tag }} + name: ${{ matrix.artifact }} path: mooncake-wheel/dist - name: Verify wheel file exists @@ -231,35 +87,47 @@ jobs: bash scripts/test_installation.sh shell: bash - - name: Run tests with ssd + - name: Run tensor snapshot regressions + env: + MASTER_SERVER: "127.0.0.1:50061" + MC_METADATA_SERVER: "P2PHANDSHAKE" + LOCAL_HOSTNAME: "127.0.0.1" + DEFAULT_KV_LEASE_TTL: "500" run: | - # Reserve port 50052 (mooncake_client RPC port) so the kernel never - # auto-allocates it as ephemeral source port for other outbound - # connections in the test suite. Without this, a random Python test - # connection can pick src_port=50052, leave a TIME_WAIT on - # :50052 for 60s, and block mooncake_client's bind to - # 0.0.0.0:50052 even with SO_REUSEADDR (Linux only relaxes - # TIME_WAIT+bind conflict for same-IP or loopback). - sudo sysctl -w net.ipv4.ip_local_reserved_ports=50052 source test_env/bin/activate - MC_STORE_MEMCPY=false TEST_SSD_OFFLOAD_IN_EVICT=true ./scripts/run_tests.sh - rm -rf /tmp/mooncake_test_ssd - deactivate - shell: bash - - - name: Start Mooncake Master + export LD_LIBRARY_PATH=${LD_LIBRARY_PATH:-}:/usr/local/lib + mooncake_master --port 50061 --default_kv_lease_ttl=500 \ + >"$RUNNER_TEMP/tensor-snapshot-master.log" 2>&1 & + master_pid=$! + trap 'kill "$master_pid" 2>/dev/null || true; wait "$master_pid" 2>/dev/null || true' EXIT + python - <<'PY' + import socket + import time + for attempt in range(100): + try: + with socket.create_connection(("127.0.0.1", 50061), timeout=0.2): + break + except OSError: + time.sleep(0.1) + else: + raise RuntimeError("Tensor snapshot test master did not start") + PY + python mooncake-wheel/tests/test_tensor_read_snapshot.py -v + python mooncake-wheel/tests/test_cuda_fabric_compat.py -v + shell: bash + + - name: Run all PG CPU tests with latest PyTorch + if: matrix.ubuntu-version == 'ubuntu-22.04' && matrix.python-version == '3.12' + env: + MC_FORCE_TCP: "true" run: | source test_env/bin/activate - mkdir -p /tmp/mooncake_storage - mooncake_master \ - --eviction_high_watermark_ratio=0.95 \ - --cluster_id=ci_test_cluster \ - --port 50051 \ - --enable_http_metadata_server=true & - sleep 3 + export LD_LIBRARY_PATH=${LD_LIBRARY_PATH:-}:/usr/local/lib + python -m unittest discover -s mooncake-pg/tests -k CPU -v shell: bash - - name: Run Python Tensor API Performance Test (CI check) + - name: Run Store API and RPC smoke tests + if: matrix.ubuntu-version == 'ubuntu-22.04' && matrix.python-version == '3.12' env: MOONCAKE_MASTER: "127.0.0.1:50051" MOONCAKE_TE_META_DATA_SERVER: "http://127.0.0.1:8080/metadata" @@ -267,96 +135,134 @@ jobs: LOCAL_HOSTNAME: "127.0.0.1" run: | source test_env/bin/activate - python scripts/test_tensor_api.py -n 1 + export LD_LIBRARY_PATH=${LD_LIBRARY_PATH:-}:/usr/local/lib + python -m pip install safetensors + + reserved_ports=$(sysctl -n net.ipv4.ip_local_reserved_ports) + sudo sysctl -w \ + "net.ipv4.ip_local_reserved_ports=${reserved_ports:+$reserved_ports,}50052" + + ./scripts/ci/run_store_api_smoke.sh + ./scripts/ci/run_rpc_smoke.sh shell: bash - - name: Run Python Async API Test (CI check) - env: - MOONCAKE_MASTER: "127.0.0.1:50051" - MOONCAKE_TE_META_DATA_SERVER: "http://127.0.0.1:8080/metadata" - MOONCAKE_PROTOCOL: "tcp" - LOCAL_HOSTNAME: "127.0.0.1" + - name: Run SSD offload and promotion end-to-end tests + if: matrix.ubuntu-version == 'ubuntu-22.04' && matrix.python-version == '3.12' run: | source test_env/bin/activate - python scripts/test_async_store.py + ./scripts/ci/run_ssd_offload_smoke.sh shell: bash - - name: Test Mooncake Copy/Move API - env: - MOONCAKE_MASTER: "127.0.0.1:50051" - MOONCAKE_TE_META_DATA_SERVER: "http://127.0.0.1:8080/metadata" - MOONCAKE_PROTOCOL: "tcp" - LOCAL_HOSTNAME: "127.0.0.1" + unit-tests: + name: CTest unit tests (Python 3.12) + needs: [check-paths] + if: *run-ci-for-source-changes + runs-on: ubuntu-22.04 + env: + CI: "true" + SCCACHE_GHA_ENABLED: "true" + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Set up Python 3.12 + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Install and start etcd + uses: ./.github/actions/setup-etcd + + - name: Free up disk space + uses: ./.github/actions/free-disk-space + + - name: Install CUDA Toolkit + uses: Jimver/cuda-toolkit@v0.2.24 + with: + cuda: '12.8.1' + linux-local-args: '["--toolkit"]' + method: 'network' + sub-packages: '["nvcc"]' + + - name: Install build utilities and dependencies run: | - source test_env/bin/activate - python scripts/test_copy_move_api.py + sudo apt-get update + sudo apt-get install -y ninja-build + sudo bash -x dependencies.sh -y + echo "/usr/local/go/bin" >> "$GITHUB_PATH" shell: bash - - name: Run Python Drain HTTP E2E Test (CI check) - env: - MOONCAKE_MASTER: "127.0.0.1:50051" - MOONCAKE_TE_META_DATA_SERVER: "http://127.0.0.1:8080/metadata" - MOONCAKE_PROTOCOL: "tcp" - LOCAL_HOSTNAME: "127.0.0.1" + - name: Set up compiler cache + uses: ./.github/actions/setup-sccache + + - name: Configure project with unit tests run: | - source test_env/bin/activate - export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:/usr/local/lib - python scripts/test_drain_http_api.py --timeout-sec 90 + cmake -S . -B build -G Ninja \ + -DUSE_HTTP=ON \ + -DUSE_CXL=ON \ + -DUSE_UB=ON \ + -DUSE_ETCD=ON \ + -DUSE_CUDA=ON \ + -DUSE_MNNVL=ON \ + -DWITH_P2P_STORE=ON \ + -DWITH_CONDUCTOR=ON \ + -DSTORE_USE_ETCD=ON \ + -DCMAKE_BUILD_TYPE=Release \ + -DBUILD_UNIT_TESTS=ON \ + -DENABLE_SCCACHE=ON shell: bash - - name: Run RPC Communicator Bandwidth Test - run: | - source test_env/bin/activate - python mooncake-transfer-engine/tests/rpc_communicator_test.py server --url 127.0.0.1:9004 --data-size 1 & - SERVER_PID=$! - sleep 5 - timeout 10 python mooncake-transfer-engine/tests/rpc_communicator_test.py client --url 127.0.0.1:9004 --threads 2 --data-size 1 || true - kill $SERVER_PID 2>/dev/null || true - wait $SERVER_PID 2>/dev/null || true - - - name: Test Mooncake PyTorch Backend (CPU Only) - env: - MC_FORCE_TCP: "true" + - name: Configure CUDA driver runtime + uses: ./.github/actions/setup-cuda-runtime + + - name: Build project and unit tests run: | - source test_env/bin/activate - python mooncake-pg/tests/test_pg_collectives.py + cmake --build build --parallel 128 + sudo -E cmake --install build shell: bash - - name: Test PyTorch 2.13 Single-Buffer Collectives (CPU Only) - if: matrix.ubuntu-version == 'ubuntu-22.04' && matrix.python-version == '3.10' + - name: Run Mooncake Store Rust unit tests env: - MC_FORCE_TCP: "true" + MOONCAKE_BUILD_DIR: ${{ github.workspace }}/build + MOONCAKE_STORE_LIB_DIR: ${{ github.workspace }}/build/mooncake-store/src + MOONCAKE_STORE_INCLUDE_DIR: ${{ github.workspace }}/mooncake-store/include run: | - source test_env/bin/activate - python -m pip install --force-reinstall "torch==2.13.0" \ - --index-url https://download.pytorch.org/whl/cu126 \ - --extra-index-url https://pypi.org/simple - python mooncake-pg/tests/test_pg_collectives.py \ - TestMooncakePGCollectivesCPU.test_all_gather_into_tensor \ - TestMooncakePGCollectivesCPU.test_reduce_scatter_sum + export LD_LIBRARY_PATH="$GITHUB_WORKSPACE/build/mooncake-asio:$GITHUB_WORKSPACE/build/mooncake-store/src:$GITHUB_WORKSPACE/build/mooncake-store/src/cachelib_memory_allocator:$GITHUB_WORKSPACE/build/mooncake-transfer-engine/src:$GITHUB_WORKSPACE/build/mooncake-transfer-engine/src/common/base:$GITHUB_WORKSPACE/build/mooncake-common/etcd:/usr/local/lib:${LD_LIBRARY_PATH:-}" + cargo test --manifest-path mooncake-store/rust/Cargo.toml --lib shell: bash - - name: Test Safetensor Functions - run: | - source test_env/bin/activate - pip install safetensors - python -m unittest mooncake-wheel.tests.test_safetensor_functions - shell: bash + - name: Start Metadata Server + uses: ./.github/actions/setup-metadata-server + + - name: Run all CTest unit tests + id: ctest + uses: ./.github/actions/run-ctest + with: + junit-report: build/test-results/ctest.xml + label-exclude: '^nvlink_gpu$' + + - name: Preserve CTest diagnostics + if: ${{ always() && steps.ctest.outcome == 'failure' }} + uses: ./.github/actions/ctest-diagnostics + with: + summary-title: PR CTest failure + artifact-name: ctest-diagnostics-pr + junit-report: build/test-results/ctest.xml + failed-tests-log: build/Testing/Temporary/LastTestsFailed.log + last-test-log: build/Testing/Temporary/LastTest.log build-flags: - needs: [spell-check, clang-format, check-paths] + needs: [check-paths] if: *run-ci-for-source-changes runs-on: ubuntu-22.04 - strategy: - matrix: - python-version: ['3.10', '3.12'] env: CI: "true" - TORCH_CUDA_ARCH_LIST: "8.0;9.0" SCCACHE_GHA_ENABLED: "true" PIP_NO_CACHE_DIR: "1" - MAX_JOBS: "2" - EP_TORCH_VERSIONS: "2.11.0;2.12.0;2.12.1;2.13.0" CMAKE_RELWITHDEBINFO_FLAGS: "-O2 -DNDEBUG" steps: @@ -364,10 +270,10 @@ jobs: with: persist-credentials: false - - name: Set up Python ${{ matrix.python-version }} + - name: Set up Python 3.12 uses: actions/setup-python@v5 with: - python-version: ${{ matrix.python-version }} + python-version: '3.12' - name: Free up disk space uses: ./.github/actions/free-disk-space @@ -381,15 +287,8 @@ jobs: sub-packages: '["nvcc", "nvrtc-dev"]' non-cuda-sub-packages: '["libcusparse-dev", "libcublas-dev", "libcusolver-dev"]' - - name: Run sccache-cache - uses: mozilla-actions/sccache-action@v0.0.9 - - - name: Configure sccache - uses: actions/github-script@v7 - with: - script: | - core.exportVariable('ACTIONS_RESULTS_URL', process.env.ACTIONS_RESULTS_URL || ''); - core.exportVariable('ACTIONS_RUNTIME_TOKEN', process.env.ACTIONS_RUNTIME_TOKEN || ''); + - name: Set up compiler cache + uses: ./.github/actions/setup-sccache - name: Install dependencies run: | @@ -399,9 +298,6 @@ jobs: df -h shell: bash - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable - - name: Build transfer engine only run: | cd mooncake-transfer-engine @@ -433,149 +329,75 @@ jobs: df -h shell: bash - - name: Configure project with all settings are ON - run: | - mkdir build - cd build - # ENABLE_DEBUG_SYMBOLS=OFF alone still leaves CMake's RelWithDebInfo -g. - cmake -G Ninja .. \ - -DUSE_ETCD=ON \ - -DUSE_CXL=ON \ - -DUSE_REDIS=ON \ - -DUSE_HTTP=ON \ - -DWITH_STORE=ON \ - -DWITH_P2P_STORE=ON \ - -DWITH_METRICS=ON \ - -DBUILD_UNIT_TESTS=ON \ - -DBUILD_EXAMPLES=ON \ - -DENABLE_SCCACHE=ON \ - -DUSE_CUDA=ON \ - -DUSE_MNNVL=OFF \ - -DUSE_UB=OFF \ - -DCMAKE_EXE_LINKER_FLAGS="-L/usr/local/cuda/lib64/stubs" \ - "-DCMAKE_C_FLAGS_RELWITHDEBINFO=${CMAKE_RELWITHDEBINFO_FLAGS}" \ - "-DCMAKE_CXX_FLAGS_RELWITHDEBINFO=${CMAKE_RELWITHDEBINFO_FLAGS}" \ - -DENABLE_DEBUG_SYMBOLS=OFF - shell: bash - # TODO: lack USE_NVMEOF,USE_MNNVL - - - name: Build project with all settings are ON - run: | - export LD_LIBRARY_PATH=/usr/local/cuda/lib64/stubs:$LD_LIBRARY_PATH - export LIBRARY_PATH=/usr/local/cuda/lib64/stubs:$LIBRARY_PATH - cd build - cmake --build . - sudo cmake --install . - df -h - shell: bash - - - name: Configure project with unit tests and examples - run: | - cd build - cmake -G Ninja .. -DBUILD_UNIT_TESTS=ON -DBUILD_EXAMPLES=ON -DWITH_STORE_RUST=ON -DENABLE_SCCACHE=ON -DENABLE_DEBUG_SYMBOLS=OFF - shell: bash + spell-check: + name: Spell Check with Typos + runs-on: ubuntu-22.04 + steps: + - name: Checkout Actions Repository + uses: actions/checkout@v4 + with: + persist-credentials: false + - name: Spell Check Repo + uses: crate-ci/typos@v1.30.2 - - name: Build project with unit tests and examples - run: | - export LD_LIBRARY_PATH=/usr/local/cuda/lib64/stubs:$LD_LIBRARY_PATH - export LIBRARY_PATH=/usr/local/cuda/lib64/stubs:$LIBRARY_PATH - cd build - cmake --build . - sudo cmake --install . - shell: bash + reshard-type-check: + name: Check reshard manifest types + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false - - name: Check Mooncake Store Rust bindings, examples, and tests - run: | - # libcuda.so.1 (SONAME of the CUDA stub) must be findable at runtime. - # The toolkit stubs dir only ships libcuda.so; create the versioned symlink. - if [ -f /usr/local/cuda/lib64/stubs/libcuda.so ] && \ - [ ! -e /usr/local/cuda/lib64/stubs/libcuda.so.1 ]; then - sudo ln -s libcuda.so /usr/local/cuda/lib64/stubs/libcuda.so.1 - fi - export LD_LIBRARY_PATH=/usr/local/cuda/lib64/stubs:$LD_LIBRARY_PATH - cd mooncake-store/rust - export MOONCAKE_BUILD_DIR=$GITHUB_WORKSPACE/build - cargo test --lib - MOONCAKE_STORE_LIB_DIR=$GITHUB_WORKSPACE/build/mooncake-store/src \ - MOONCAKE_STORE_INCLUDE_DIR=$GITHUB_WORKSPACE/mooncake-store/include \ - cargo test --examples --tests --no-run - cargo clean - shell: bash + - name: Set up Python 3.12 + uses: actions/setup-python@v5 + with: + python-version: '3.12' - - name: Verify Mooncake Store Rust dlopen bindings and packaging + - name: Check canonical reshard contract types run: | - cd mooncake-store/rust - # 1. Committed dlopen bindings must stay in sync with store_c.h. - cargo run --locked --example generate_dlopen_bindings - git diff --exit-code -- src/generated/ffi_dlopen_bindings.rs - # 2. The published dlopen crate must build with no header/bindgen: package - # it, extract, and check the dlopen feature against the packaged files. - cargo package --no-verify --allow-dirty - crate=$(ls target/package/mooncake_store-*.crate | head -1) - dest=$(mktemp -d) - tar xzf "$crate" -C "$dest" - (cd "$dest"/mooncake_store-* && cargo check --no-default-features --features dlopen) + python -m pip install --disable-pip-version-check pyright==1.1.411 + bash scripts/check_reshard_types.sh shell: bash - - name: Configure project - run: | - cd build - rm -r */tests - cmake -G Ninja .. \ - -DBUILD_UNIT_TESTS=OFF \ - -DBUILD_EXAMPLES=OFF \ - -DUSE_HTTP=ON \ - -DENABLE_SCCACHE=ON \ - -DUSE_CXL=ON \ - -DWITH_EP=ON \ - "-DEP_TORCH_VERSIONS=${EP_TORCH_VERSIONS}" \ - -DENABLE_DEBUG_SYMBOLS=OFF - shell: bash + ci-script-tests: + name: Test CI scripts + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false - - name: Build project - run: | - export LD_LIBRARY_PATH=/usr/local/cuda/lib64/stubs:$LD_LIBRARY_PATH - export LIBRARY_PATH=/usr/local/cuda/lib64/stubs:$LIBRARY_PATH - cd build - cmake --build . - sudo cmake --install . - shell: bash + - name: Set up Python 3.12 + uses: actions/setup-python@v5 + with: + python-version: '3.12' - - name: Build nvlink_allocator.so + - name: Run focused release gate tests run: | - mkdir -p build/mooncake-transfer-engine/nvlink-allocator - cd mooncake-transfer-engine/nvlink-allocator - export PATH=/usr/local/nvidia/bin:/usr/local/nvidia/lib64:$PATH - export LD_LIBRARY_PATH=/usr/local/cuda/lib64/stubs:$LD_LIBRARY_PATH - export LIBRARY_PATH=/usr/local/cuda/lib64/stubs:$LIBRARY_PATH - bash build.sh ../../build/mooncake-transfer-engine/nvlink-allocator/ - shell: bash - - - name: Run sccache stat for check - if: ${{ env.SCCACHE_PATH != '' }} + python -m pip install --disable-pip-version-check \ + packaging pytest==8.3.5 pyyaml + python -m pytest -q mooncake-wheel/tests/test_testpypi_wheel_gate.py shell: bash - run: ${SCCACHE_PATH} --show-stats + - name: Test CI service and smoke scripts + run: python -m unittest discover -s scripts/ci/tests -v - spell-check: - name: Spell Check with Typos - if: &run-ci >- - (github.event_name == 'push' || - github.event_name == 'workflow_dispatch' || - github.event.action == 'opened' || - contains(github.event.pull_request.labels.*.name, 'run-ci')) + tone-script-tests: + name: Test external CI lifecycle helpers runs-on: ubuntu-22.04 steps: - - name: Checkout Actions Repository - uses: actions/checkout@v4 + - uses: actions/checkout@v4 with: persist-credentials: false - - name: Spell Check Repo - uses: crate-ci/typos@v1.30.2 + + - name: Run focused shell regression tests + run: | + bash scripts/tone_tests/tests/test_common_cleanup.sh + python3 scripts/tone_tests/tests/test_hicache_model_selection.py + shell: bash clang-format: name: Check code format - if: *run-ci runs-on: ubuntu-22.04 steps: - name: Checkout Actions Repository @@ -618,59 +440,86 @@ jobs: ./scripts/code_format.sh --check --changed-lines --base "${BASE_REF}" shell: bash + python-lint: + name: Check Python with Ruff + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Set up Python 3.12 + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install Ruff + run: python -m pip install --disable-pip-version-check ruff==0.6.9 + + - name: Check changed Python files + run: | + if [ "${{ github.event_name }}" = "pull_request" ]; then + base_ref="origin/${{ github.base_ref }}" + elif [ "${{ github.event.before }}" = \ + "0000000000000000000000000000000000000000" ]; then + base_ref="origin/${{ github.event.repository.default_branch }}" + elif [ -n "${{ github.event.before }}" ]; then + base_ref="${{ github.event.before }}" + else + base_ref="HEAD^" + fi + + mapfile -d '' python_files < <( + git diff --name-only --diff-filter=ACMR -z "$base_ref"...HEAD -- \ + '*.py' \ + ':(exclude)extern/**' \ + ':(exclude)FAST25-release/**' + ) + if [ "${#python_files[@]}" -eq 0 ]; then + echo "No changed Python files to check." + exit 0 + fi + + ruff check "${python_files[@]}" + ruff format --check "${python_files[@]}" + shell: bash + docs-check: name: Check Sphinx docs build - if: *run-ci + needs: check-paths + if: needs.check-paths.outputs.docs == 'true' runs-on: ubuntu-22.04 steps: - uses: actions/checkout@v4 with: - fetch-depth: 2 persist-credentials: false - - uses: dorny/paths-filter@v3 - id: filter - with: - filters: | - docs: - - 'docs/**' - - 'docs/requirements-docs.txt' - - name: Set up Python - if: steps.filter.outputs.docs == 'true' uses: actions/setup-python@v5 with: python-version: '3.12' - name: Install documentation dependencies - if: steps.filter.outputs.docs == 'true' run: | python -m pip install --upgrade pip pip install -r docs/requirements-docs.txt - name: Build docs with strict mode - if: steps.filter.outputs.docs == 'true' - run: | - cd docs - make html SPHINXOPTS=-W - shell: bash - + working-directory: docs + run: make html SPHINXOPTS=-W check-paths: - if: *run-ci runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read outputs: - should-run-downstream: ${{ steps.dispatch-override.outputs.src || steps.filter.outputs.src }} - should-run-tent: ${{ steps.dispatch-override.outputs.tent || steps.filter.outputs.tent }} + src: ${{ github.event_name == 'workflow_dispatch' && 'true' || steps.filter.outputs.src }} + tent: ${{ github.event_name == 'workflow_dispatch' && 'true' || steps.filter.outputs.tent }} + docs: ${{ github.event_name == 'workflow_dispatch' && 'true' || steps.filter.outputs.docs }} steps: - # workflow_dispatch has no PR/push diff context — skip paths-filter and default to true - - name: Default to true for workflow_dispatch - id: dispatch-override - if: github.event_name == 'workflow_dispatch' - run: | - echo "src=true" >> $GITHUB_OUTPUT - echo "tent=true" >> $GITHUB_OUTPUT - uses: actions/checkout@v4 if: github.event_name != 'workflow_dispatch' with: @@ -680,10 +529,14 @@ jobs: if: github.event_name != 'workflow_dispatch' id: filter with: + # PRs use their base branch; pushes compare against the previous push + # on this ref, including release branches. + base: ${{ github.event_name == 'push' && github.ref || '' }} filters: | src: - 'mooncake-*/**' - 'extern/**' + - 'cmake/**' - 'CMakeLists.txt' - 'dependencies.sh' - 'scripts/**' @@ -691,9 +544,19 @@ jobs: - '.github/workflows/**' tent: - 'mooncake-transfer-engine/**' + - 'mooncake-store/src/transfer_task.cpp' + - 'mooncake-store/tests/transfer_task_test.cpp' + - 'mooncake-store/tests/CMakeLists.txt' - 'mooncake-common/**' + - 'extern/**' + - 'cmake/**' - 'CMakeLists.txt' - 'dependencies.sh' + - 'scripts/ci/run_transfer_engine_rust_smoke.sh' + - '.github/actions/**' + - '.github/workflows/ci.yml' + docs: + - 'docs/**' - '.github/workflows/ci.yml' build-wheel-cu13: @@ -702,7 +565,7 @@ jobs: uses: ./.github/workflows/_build-wheel.yaml with: variant: cuda13 - python-versions: '["3.10", "3.12"]' + python-versions: '["3.12"]' artifact-prefix: mooncake-wheel-cu130 version-override: 0.0.0.dev0 @@ -714,31 +577,13 @@ jobs: build-wheel-rocm: needs: [spell-check, clang-format, check-paths] - if: >- - (needs.check-paths.outputs.should-run-downstream == 'true' || - github.event_name == 'workflow_dispatch') && - (github.event_name == 'push' || - github.event_name == 'workflow_dispatch' || - github.event.action == 'opened' || - contains(github.event.pull_request.labels.*.name, 'run-ci')) + if: needs.check-paths.outputs.src == 'true' uses: ./.github/workflows/ci_rocm.yml secrets: inherit - integration-test: - needs: [build, build-wheel-cu13, check-paths] - if: needs.check-paths.outputs.should-run-downstream == 'true' - uses: ./.github/workflows/integration-test.yml - secrets: inherit - tent-ci: needs: [spell-check, clang-format, check-paths] - if: >- - (needs.check-paths.outputs.should-run-tent == 'true' || - github.event_name == 'workflow_dispatch') && - (github.event_name == 'push' || - github.event_name == 'workflow_dispatch' || - github.event.action == 'opened' || - contains(github.event.pull_request.labels.*.name, 'run-ci')) + if: needs.check-paths.outputs.tent == 'true' runs-on: ubuntu-22.04 strategy: fail-fast: false @@ -749,7 +594,7 @@ jobs: need_cuda: true metrics_flags: '' - name: cuda-off - cmake_flags: '-DUSE_CUDA=OFF' + cmake_flags: '-DUSE_CUDA=OFF -DUSE_EVENT_DRIVEN_COMPLETION=ON' need_cuda: false metrics_flags: '' - name: cuda-off-metrics-on @@ -785,15 +630,8 @@ jobs: method: 'network' sub-packages: '["nvcc", "nvrtc-dev"]' - - name: Run sccache-cache - uses: mozilla-actions/sccache-action@v0.0.9 - - - name: Configure sccache - uses: actions/github-script@v7 - with: - script: | - core.exportVariable('ACTIONS_RESULTS_URL', process.env.ACTIONS_RESULTS_URL || ''); - core.exportVariable('ACTIONS_RUNTIME_TOKEN', process.env.ACTIONS_RUNTIME_TOKEN || ''); + - name: Set up compiler cache + uses: ./.github/actions/setup-sccache - name: Install dependencies run: | @@ -803,6 +641,9 @@ jobs: df -h shell: bash + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + - name: Configure project with TENT run: | mkdir build-tent @@ -821,27 +662,76 @@ jobs: sudo cmake --install . shell: bash + # Check the Transfer Engine Rust crate against the freshly built C++ + # libraries. This is a compile/link check, so it is safe to run on every + # matrix leg (including the cuda-on leg, which only verifies that the + # USE_CUDA code paths compile). + - name: Check Transfer Engine Rust bindings, examples, and tests + run: | + if [ -f /usr/local/cuda/lib64/stubs/libcuda.so ] && \ + [ ! -e /usr/local/cuda/lib64/stubs/libcuda.so.1 ]; then + sudo ln -s libcuda.so /usr/local/cuda/lib64/stubs/libcuda.so.1 + fi + if [ "${{ matrix.need_cuda }}" = "true" ]; then + export LD_LIBRARY_PATH=/usr/local/cuda/lib64/stubs:$LD_LIBRARY_PATH + fi + cd mooncake-transfer-engine/rust + export MOONCAKE_BUILD_DIR=$GITHUB_WORKSPACE/build-tent + export MOONCAKE_TE_LIB_DIR=$GITHUB_WORKSPACE/build-tent/mooncake-transfer-engine/src + export MOONCAKE_TE_INCLUDE_DIR=$GITHUB_WORKSPACE/mooncake-transfer-engine/include + cargo test --lib + cargo test --examples --tests --no-run + cargo clean + shell: bash + + - name: Test Store completion with TENT + if: matrix.name == 'cuda-off' + run: | + ctest --test-dir build-tent --output-on-failure --no-tests=error \ + -R '^transfer_task_tent_completion_test$' + shell: bash + # Only run tests on the cuda-off leg. GitHub runners have no real GPU; # with USE_CUDA=ON tent's cuda_probe hits the CUDA stub library at # runtime and drives some dispatch paths past the fake objects the # unit tests rely on, causing false failures. cuda-on still validates # that every #ifdef USE_CUDA branch compiles. - name: Test (TENT) + id: ctest if: '!matrix.need_cuda' run: | cd build-tent - ctest --test-dir mooncake-transfer-engine/tent/tests -j --output-on-failure + mkdir -p test-results + ctest --test-dir mooncake-transfer-engine/tent/tests -j \ + --output-on-failure \ + --output-junit "$GITHUB_WORKSPACE/build-tent/test-results/ctest.xml" shell: bash - - name: Smoke test TENT UB benchmark CLI - if: matrix.name == 'ub-mock' + - name: Preserve TENT CTest diagnostics + if: ${{ always() && steps.ctest.outcome == 'failure' }} + uses: ./.github/actions/ctest-diagnostics + with: + summary-title: TENT CTest failure (${{ matrix.name }}) + artifact-name: ctest-diagnostics-tent-${{ matrix.name }} + junit-report: build-tent/test-results/ctest.xml + failed-tests-log: build-tent/mooncake-transfer-engine/tent/tests/Testing/Temporary/LastTestsFailed.log + last-test-log: build-tent/mooncake-transfer-engine/tent/tests/Testing/Temporary/LastTest.log + + - name: Start Metadata Server + if: '!matrix.need_cuda' run: | - cd build-tent - help_output="$(./mooncake-transfer-engine/benchmark/tebench \ - --backend=tent --xport_type=ub --tent_transport_hint=ub \ - --help 2>&1)" - grep -q 'iouring|ub|sunrise_link' <<< "${help_output}" - grep -q 'ascend|ub|sunrise_link' <<< "${help_output}" + cd mooncake-transfer-engine/example/http-metadata-server-python + pip install aiohttp + python ./bootstrap_server.py & + sleep 2 + shell: bash + + - name: Run Transfer Engine Rust smoke test + if: '!matrix.need_cuda' + env: + MOONCAKE_BUILD_DIR: ${{ github.workspace }}/build-tent + MOONCAKE_TE_RUST_LINK_ASAN: "0" + run: ./scripts/ci/run_transfer_engine_rust_smoke.sh shell: bash - name: Run sccache stat for check @@ -853,31 +743,65 @@ jobs: name: CI Gate if: always() needs: + - check-paths - spell-check - clang-format + - python-lint - docs-check - - build + - reshard-type-check + - ci-script-tests + - tone-script-tests - build-wheel + - unit-tests - build-flags - test-wheel-ubuntu - build-wheel-cu13 - build-wheel-efa - build-wheel-rocm - tent-ci - - integration-test runs-on: ubuntu-latest steps: - - name: Check required job results - run: | - failing=$(echo "$NEEDS_JSON" | jq -r ' - to_entries[] | - select(.value.result != "success" and .value.result != "skipped") | - "\(.key): \(.value.result)"') - if [ -n "$failing" ]; then - echo "::error::The following jobs failed or were cancelled:" - echo "$failing" - exit 1 - fi - echo "All checks passed or were acceptably skipped." + # Each path-gated group must succeed when selected and be skipped otherwise. + # A failed selector must never turn skipped builds into a successful gate. + - name: Check unconditional jobs env: NEEDS_JSON: ${{ toJSON(needs) }} + PASSED: >- + ${{ needs.check-paths.result == 'success' && + needs.spell-check.result == 'success' && + needs.clang-format.result == 'success' && + needs.python-lint.result == 'success' && + needs.reshard-type-check.result == 'success' && + needs.ci-script-tests.result == 'success' && + needs.tone-script-tests.result == 'success' }} + run: | + echo "$NEEDS_JSON" + test "$PASSED" = true + + - name: Check source jobs + env: + EXPECTED: ${{ needs.check-paths.outputs.src == 'true' && 'success' || 'skipped' }} + RESULTS: >- + ${{ needs.build-wheel.result }} + ${{ needs.unit-tests.result }} + ${{ needs.build-flags.result }} + ${{ needs.test-wheel-ubuntu.result }} + ${{ needs.build-wheel-cu13.result }} + ${{ needs.build-wheel-efa.result }} + ${{ needs.build-wheel-rocm.result }} + run: | + for result in $RESULTS; do + test "$result" = "$EXPECTED" || exit 1 + done + + - name: Check TENT jobs + env: + EXPECTED: ${{ needs.check-paths.outputs.tent == 'true' && 'success' || 'skipped' }} + RESULT: ${{ needs.tent-ci.result }} + run: test "$RESULT" = "$EXPECTED" + + - name: Check documentation job + env: + EXPECTED: ${{ needs.check-paths.outputs.docs == 'true' && 'success' || 'skipped' }} + RESULT: ${{ needs.docs-check.result }} + run: test "$RESULT" = "$EXPECTED" diff --git a/.github/workflows/ci_ascend.yml b/.github/workflows/ci_ascend.yml index 13f79a244f..1f2b6d1ea2 100644 --- a/.github/workflows/ci_ascend.yml +++ b/.github/workflows/ci_ascend.yml @@ -45,12 +45,13 @@ jobs: fetch-depth: 1 persist-credentials: false - - name: Retry checkout via GitHub mirror + # Mirror is only a git transport fallback. Keep retry on actions/checkout so + # pull_request_target fork checks still run; do not fetch with raw git. + - name: Configure GitHub mirror rewrite if: steps.checkout_code.outcome == 'failure' shell: bash env: ASCEND_GITHUB_MIRROR_URLS: 'https://ghfast.top/' - CHECKOUT_REF: ${{ inputs.checkout_ref || github.sha }} run: | set -euo pipefail @@ -83,27 +84,21 @@ jobs: workdir="${GITHUB_WORKSPACE}" git config --global --add safe.directory "$workdir" + find "$workdir" -mindepth 1 -maxdepth 1 -exec rm -rf {} + - for base in "${candidates[@]}"; do - mirror_url="${base}https://github.com/${GITHUB_REPOSITORY}.git" - echo "Retrying checkout with ${mirror_url}" - - find "$workdir" -mindepth 1 -maxdepth 1 -exec rm -rf {} + - git init "$workdir" - git -C "$workdir" remote add origin "$mirror_url" - - if git -C "$workdir" fetch --depth=1 origin "$CHECKOUT_REF" && \ - git -C "$workdir" checkout --force --detach FETCH_HEAD; then - echo "Mirror checkout succeeded via ${base}" - exit 0 - fi - - echo "Mirror checkout failed via ${base}" - rm -rf "$workdir/.git" - done + # insteadOf only rewrites github.com fetches; checkout still runs + # assertSafePrCheckout before any git network I/O. + mirror_base="${candidates[0]}" + echo "Rewriting https://github.com/ to ${mirror_base}https://github.com/" + git config --global url."${mirror_base}https://github.com/".insteadOf "https://github.com/" - echo "Direct GitHub checkout failed and all mirror retries failed" - exit 1 + - name: Retry checkout via GitHub mirror + if: steps.checkout_code.outcome == 'failure' + uses: actions/checkout@v4 + with: + ref: ${{ inputs.checkout_ref || github.sha }} + fetch-depth: 1 + persist-credentials: false - name: Configure CMake shell: bash diff --git a/.github/workflows/ci_efa.yml b/.github/workflows/ci_efa.yml index 6bf8967c72..0a448c1cf4 100644 --- a/.github/workflows/ci_efa.yml +++ b/.github/workflows/ci_efa.yml @@ -11,31 +11,19 @@ jobs: matrix: include: - variant: cuda - use_cuda: "ON" - build_env: "EFA_BUILD" - cuda-version: "12.8.1" python-version: "3.12" - variant: cuda13 - use_cuda: "ON" - build_env: "EFA_CU13_BUILD" - cuda-version: "13.0.2" python-version: "3.12" - variant: non-cuda - use_cuda: "OFF" - build_env: "EFA_NON_CUDA_BUILD" - cuda-version: "12.8.1" python-version: "3.10" uses: ./.github/workflows/_build-efa-wheel.yaml with: variant: ${{ matrix.variant }} - use-cuda: ${{ matrix.use_cuda == 'ON' }} python-versions: ${{ format('["{0}"]', matrix.python-version) }} build-profile: ci cmake-args: >- -DUSE_ETCD=ON -DUSE_HTTP=ON -DWITH_STORE=ON -DWITH_METRICS=ON -DBUILD_UNIT_TESTS=OFF -DBUILD_EXAMPLES=ON -DBUILD_BENCHMARK=ON -DENABLE_DEBUG_SYMBOLS=OFF - variant-flag: ${{ matrix.build_env }} - cuda-version: ${{ matrix.cuda-version }} torch-cuda-arch-list: '8.0;9.0' artifact-prefix: mooncake-wheel-efa-${{ matrix.variant }}-ubuntu diff --git a/.github/workflows/ci_hygon.yml b/.github/workflows/ci_hygon.yml new file mode 100644 index 0000000000..8887d09905 --- /dev/null +++ b/.github/workflows/ci_hygon.yml @@ -0,0 +1,358 @@ +name: 'Hygon HCU PR Build & Test' + +on: + pull_request_target: + branches: [main] + types: [labeled] + +permissions: + contents: read + +concurrency: + group: hcu-upstream-pr-${{ github.event.pull_request.number }} + cancel-in-progress: false + +jobs: + build-wheel: + name: Build wheel + if: >- + github.repository == 'kvcache-ai/Mooncake' && + github.event.label.name == 'run-hygon-ci' + runs-on: [self-hosted, Linux, X64, hcu1] + timeout-minutes: 120 + container: + image: harbor.sourcefind.cn:5443/hcu/admin/base/dtk:26.04-ubuntu22.04-mpi5.0-gcc11.4-cmake3.29-py3.10 + options: --mount type=bind,source=/etc/hygon-ci,target=/etc/hygon-ci,readonly + env: + CI: "false" + FREE_BUILD_DIR: "0" + steps: + - name: Checkout PR source + uses: actions/checkout@v4 + with: + allow-unsafe-pr-checkout: true + repository: ${{ github.event.pull_request.head.repo.full_name }} + ref: ${{ github.event.pull_request.head.sha }} + fetch-depth: 1 + persist-credentials: false + submodules: recursive + path: pr-source + + - name: Mark workspace safe for git + run: git config --global --add safe.directory "$GITHUB_WORKSPACE/pr-source" + + - name: Record tested commit + shell: bash + working-directory: pr-source + run: | + git rev-parse HEAD + printf 'HCU source commit: `%s`\n' "$(git rev-parse HEAD)" >> "$GITHUB_STEP_SUMMARY" + + - name: Load HCU CI configuration + shell: bash + run: | + set -Eeuo pipefail + config_file=/etc/hygon-ci/config.env + test -r "${config_file}" || { + echo "ERROR: ${config_file} is not readable" >&2 + exit 1 + } + set -a + source "${config_file}" + set +a + for name in DTK_PKG_URL PIP_INDEX_URL; do + value="${!name:-}" + test -n "${value}" || { + echo "ERROR: ${name} is missing from ${config_file}" >&2 + exit 1 + } + echo "::add-mask::${value}" + printf '%s=%s\n' "${name}" "${value}" >> "${GITHUB_ENV}" + done + + - name: Install DTK + shell: bash + run: | + set -Eeuo pipefail + cd /opt + wget -q "${DTK_PKG_URL}" + tarball="$(basename "${DTK_PKG_URL}")" + tar -xzf "${tarball}" + dtk_dir="$(find /opt -mindepth 1 -maxdepth 1 -type d -name 'dtk-*' -print -quit)" + test -n "${dtk_dir}" + ln -s "${dtk_dir}" dtk + + - name: Configure and build Mooncake + shell: bash + working-directory: pr-source + run: | + set -Eeuo pipefail + export GOPROXY="${GOPROXY:-https://goproxy.cn,https://goproxy.io,direct}" + set +u + source /opt/dtk/env.sh + set -u + + bash dependencies.sh -y + mkdir -p build + cd build + cmake .. -DUSE_HTTP=ON -DCMAKE_BUILD_TYPE=Release + cmake --build . -j"$(nproc)" + cmake --install . + + - name: Build wheel + shell: bash + working-directory: pr-source + run: | + set -Eeuo pipefail + set +u + source /opt/dtk/env.sh + set -u + + python3 -m pip install patchelf==0.14.5 + PYTHON_VERSION=3.10 OUTPUT_DIR=dist bash scripts/build_wheel.sh + + wheel_count="$(find mooncake-wheel/dist -maxdepth 1 -type f -name '*.whl' | wc -l)" + if [ "${wheel_count}" -ne 1 ]; then + echo "Expected exactly one wheel, found ${wheel_count}" >&2 + find mooncake-wheel/dist -maxdepth 1 -type f -print || true + exit 1 + fi + ls -lh mooncake-wheel/dist/*.whl + + - name: Upload wheel + uses: actions/upload-artifact@v4 + with: + name: wheel + path: pr-source/mooncake-wheel/dist/*.whl + if-no-files-found: error + retention-days: 3 + + - name: Cleanup build environment + if: always() + shell: bash + run: | + set -u + rm -rf /opt/dtk + if [ -n "${DTK_PKG_URL:-}" ]; then + tarball="$(basename "${DTK_PKG_URL}")" + rm -f "/opt/${tarball}" + fi + rm -rf pr-source/build pr-source/mooncake-wheel/dist + + test-transfer-engine-cross-node: + name: Transfer Engine cross-node test + needs: build-wheel + runs-on: [self-hosted, Linux, X64, hcu1] + timeout-minutes: 60 + env: + TEST_IMAGE: harbor.sourcefind.cn:5443/hcu/admin/base/dtk:26.04-ubuntu22.04-mpi5.0-gcc11.4-cmake3.29-py3.10 + steps: + - name: Checkout authorized PR test scripts + uses: actions/checkout@v4 + with: + allow-unsafe-pr-checkout: true + repository: ${{ github.event.pull_request.head.repo.full_name }} + ref: ${{ github.event.pull_request.head.sha }} + fetch-depth: 1 + persist-credentials: false + path: ci-control + + - name: Download wheel + uses: actions/download-artifact@v4 + with: + name: wheel + path: ${{ runner.temp }}/artifacts/${{ github.run_id }}-${{ github.run_attempt }}/wheel + + - name: Load HCU CI configuration + shell: bash + run: | + set -Eeuo pipefail + config_file=/etc/hygon-ci/config.env + test -r "${config_file}" || { + echo "ERROR: ${config_file} is not readable" >&2 + exit 1 + } + set -a + source "${config_file}" + set +a + for name in DTK_PKG_URL PIP_INDEX_URL TARGET_HOST INITIATOR_HOST TARGET_FILTER INITIATOR_FILTER; do + value="${!name:-}" + test -n "${value}" || { + echo "ERROR: ${name} is missing from ${config_file}" >&2 + exit 1 + } + echo "::add-mask::${value}" + printf '%s=%s\n' "${name}" "${value}" >> "${GITHUB_ENV}" + done + for host in "${TARGET_HOST}" "${INITIATOR_HOST}"; do + getent ahostsv4 "${host}" | awk '{print $1}' | sort -u | while read -r address; do + test -z "${address}" || echo "::add-mask::${address}" + done + done + + - name: Run Transfer Engine cross-node test + shell: bash + run: | + set -Eeuo pipefail + artifact_dir="${RUNNER_TEMP}/artifacts/${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}/wheel" + mapfile -t wheels < <(find "${artifact_dir}" -maxdepth 1 -type f -name '*.whl' -print) + if [ "${#wheels[@]}" -ne 1 ]; then + echo "Expected exactly one wheel, found ${#wheels[@]}" >&2 + exit 1 + fi + bash ci-control/scripts/hygon/test_transfer_engine_cross_node.sh "${wheels[0]}" + + - name: Redact Transfer Engine cross-node test logs + id: redact-transfer-engine-logs + if: always() + shell: bash + run: | + set -Eeuo pipefail + python3 ci-control/scripts/hygon/redact_ci_logs.py \ + "${RUNNER_TEMP}/transfer-engine-logs" \ + "${RUNNER_TEMP}/artifacts/${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}/transfer-engine-logs-redacted" + + - name: Upload Transfer Engine cross-node test logs + if: always() && steps.redact-transfer-engine-logs.outcome == 'success' + uses: actions/upload-artifact@v4 + with: + name: transfer-engine-logs + path: ${{ runner.temp }}/artifacts/${{ github.run_id }}-${{ github.run_attempt }}/transfer-engine-logs-redacted/*.log + if-no-files-found: warn + retention-days: 7 + + test-mooncake-store-cross-node: + name: Mooncake Store cross-node test + needs: test-transfer-engine-cross-node + runs-on: [self-hosted, Linux, X64, hcu1] + timeout-minutes: 60 + env: + TEST_IMAGE: harbor.sourcefind.cn:5443/hcu/admin/base/dtk:26.04-ubuntu22.04-mpi5.0-gcc11.4-cmake3.29-py3.10 + steps: + - name: Checkout authorized PR test scripts + uses: actions/checkout@v4 + with: + allow-unsafe-pr-checkout: true + repository: ${{ github.event.pull_request.head.repo.full_name }} + ref: ${{ github.event.pull_request.head.sha }} + fetch-depth: 1 + persist-credentials: false + path: ci-control + + - name: Download wheel + uses: actions/download-artifact@v4 + with: + name: wheel + path: ${{ runner.temp }}/artifacts/${{ github.run_id }}-${{ github.run_attempt }}/wheel + + - name: Load HCU CI configuration + shell: bash + run: | + set -Eeuo pipefail + config_file=/etc/hygon-ci/config.env + test -r "${config_file}" || { + echo "ERROR: ${config_file} is not readable" >&2 + exit 1 + } + set -a + source "${config_file}" + set +a + for name in DTK_PKG_URL PIP_INDEX_URL TARGET_HOST INITIATOR_HOST TARGET_FILTER INITIATOR_FILTER; do + value="${!name:-}" + test -n "${value}" || { + echo "ERROR: ${name} is missing from ${config_file}" >&2 + exit 1 + } + echo "::add-mask::${value}" + printf '%s=%s\n' "${name}" "${value}" >> "${GITHUB_ENV}" + done + for host in "${TARGET_HOST}" "${INITIATOR_HOST}"; do + getent ahostsv4 "${host}" | awk '{print $1}' | sort -u | while read -r address; do + test -z "${address}" || echo "::add-mask::${address}" + done + done + + - name: Run mooncake store cross-node test + shell: bash + run: | + set -Eeuo pipefail + artifact_dir="${RUNNER_TEMP}/artifacts/${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}/wheel" + mapfile -t wheels < <(find "${artifact_dir}" -maxdepth 1 -type f -name '*.whl' -print) + if [ "${#wheels[@]}" -ne 1 ]; then + echo "Expected exactly one wheel, found ${#wheels[@]}" >&2 + exit 1 + fi + + bash ci-control/scripts/hygon/test_mooncake_store_cross_node.sh "${wheels[0]}" + + - name: Redact mooncake store cross-node test logs + id: redact-store-logs + if: always() + shell: bash + run: | + set -Eeuo pipefail + python3 ci-control/scripts/hygon/redact_ci_logs.py \ + "${RUNNER_TEMP}/mooncake-store-logs" \ + "${RUNNER_TEMP}/artifacts/${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}/mooncake-store-logs-redacted" + + - name: Upload mooncake store cross-node test logs + if: always() && steps.redact-store-logs.outcome == 'success' + uses: actions/upload-artifact@v4 + with: + name: mooncake-store-logs + path: ${{ runner.temp }}/artifacts/${{ github.run_id }}-${{ github.run_attempt }}/mooncake-store-logs-redacted/*.log + if-no-files-found: warn + retention-days: 7 + + hcu-pr-gate: + name: HCU PR gate + if: >- + always() && + github.repository == 'kvcache-ai/Mooncake' && + github.event.label.name == 'run-hygon-ci' + needs: + - build-wheel + - test-transfer-engine-cross-node + - test-mooncake-store-cross-node + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Require build and cross-node tests to pass + shell: bash + env: + BUILD_RESULT: ${{ needs.build-wheel.result }} + RDMA_TEST_RESULT: ${{ needs.test-transfer-engine-cross-node.result }} + STORE_TEST_RESULT: ${{ needs.test-mooncake-store-cross-node.result }} + run: | + set -Eeuo pipefail + printf 'Build wheel: %s\n' "${BUILD_RESULT}" + printf 'Transfer-Engine test: %s\n' "${RDMA_TEST_RESULT}" + printf 'Mooncake Store benchmark test: %s\n' "${STORE_TEST_RESULT}" + + if [ "${BUILD_RESULT}" != "success" ] || \ + [ "${RDMA_TEST_RESULT}" != "success" ] || \ + [ "${STORE_TEST_RESULT}" != "success" ]; then + echo "::error::HCU PR validation failed or was skipped." + exit 1 + fi + + echo "The wheel built successfully and all HCU tests passed." + + cleanup-label: + name: Remove HCU CI request label + needs: hcu-pr-gate + if: >- + always() && + github.repository == 'kvcache-ai/Mooncake' && + github.event.label.name == 'run-hygon-ci' + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + pull-requests: write + steps: + - name: Remove request label + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: gh pr edit "$PR_NUMBER" --remove-label run-hygon-ci diff --git a/.github/workflows/ci_rocm.yml b/.github/workflows/ci_rocm.yml index c445be1230..c042eb1dc0 100644 --- a/.github/workflows/ci_rocm.yml +++ b/.github/workflows/ci_rocm.yml @@ -3,11 +3,26 @@ name: 'Build Wheel (ROCm)' # ROCm/HIP CI parity with the standard CUDA wheel CI: build the AMD ROCm wheel # on PRs so packaging regressions are caught. Runs inside the ROCm dev image so # hipcc / HIP headers / hsa-runtime are available; no GPU is needed to compile. -# On-device transfer tests (test_transfer_on_hip.py) require AMD hardware and -# run outside GitHub-hosted runners. +# On-device external-PD tests consume these artifacts later on the trusted +# self-hosted ROCm runner in integration-test-rocm.yml. on: - workflow_call: {} + workflow_call: + inputs: + checkout_ref: + description: 'Git revision to build (used by trusted E2E dispatches)' + required: false + type: string + python_versions: + description: 'JSON array of Python versions to build' + required: false + default: '["3.10", "3.12"]' + type: string + version_override: + description: 'Optional wheel version override for nightly snapshots' + required: false + default: '' + type: string jobs: build-wheel-rocm: @@ -17,9 +32,10 @@ jobs: # so it also loads on the ROCm 7.0 image variants. container: rocm/dev-ubuntu-22.04:7.2.3-complete strategy: + fail-fast: false matrix: # 3.10 covers the SGLang ROCm image; 3.12 covers vLLM ROCm. - python-version: ['3.10', '3.12'] + python-version: ${{ fromJSON(inputs.python_versions) }} env: HIP_BUILD: "1" SCCACHE_GHA_ENABLED: "true" @@ -37,15 +53,35 @@ jobs: apt-get update -y apt-get install -y --no-install-recommends git ca-certificates - - uses: actions/checkout@v4 + - name: Check out source + uses: actions/checkout@v4 with: + ref: ${{ inputs.checkout_ref || github.sha }} persist-credentials: false - submodules: recursive + submodules: false - name: Mark repository as safe run: git config --global --add safe.directory "$GITHUB_WORKSPACE" || true shell: bash + - name: Check out submodules + shell: bash + run: | + set -eo pipefail + git submodule sync --recursive + for attempt in 1 2 3; do + if git -c http.version=HTTP/1.1 submodule update \ + --init --recursive --depth=1 --jobs=2; then + exit 0 + fi + if [ "$attempt" -eq 3 ]; then + echo "Submodule checkout failed after $attempt attempts" >&2 + exit 1 + fi + echo "Submodule checkout attempt $attempt failed; retrying..." >&2 + sleep $((attempt * 10)) + done + - name: Install toolchain and Python ${{ matrix.python-version }} shell: bash run: | @@ -96,12 +132,15 @@ jobs: rm -rf build && mkdir build && cd build cmake -G Ninja .. \ -DUSE_HIP=ON -DUSE_CUDA=OFF -DWITH_EP=OFF \ + -DENABLE_MULTI_PROTOCOL=ON \ -DUSE_HTTP=ON -DUSE_ETCD=ON -DSTORE_USE_ETCD=ON \ -DBUILD_UNIT_TESTS=OFF -DENABLE_SCCACHE=ON \ -DCMAKE_BUILD_TYPE=Release \ -DPython3_EXECUTABLE="${PYTHON_BIN}" - name: Build project + env: + MAX_JOBS: "2" shell: bash run: | set -eo pipefail @@ -110,7 +149,7 @@ jobs: # Retry to ride out transient Go module (proxy.golang.org) fetch errors # during the etcd-wrapper build; Go caches modules, so retries resume. n=0 - until cmake --build . -j"$(nproc)"; do + until cmake --build . -j"${MAX_JOBS}"; do n=$((n+1)) if [ "$n" -ge 3 ]; then echo "Build failed after $n attempts"; exit 1; fi echo "Build attempt $n failed; retrying in 15s..."; sleep 15 @@ -127,6 +166,17 @@ jobs: shell: bash run: echo "python_version_tag=$(echo ${{ matrix.python-version }} | tr -d '.')" >> "$GITHUB_OUTPUT" + - name: Apply wheel version override + if: ${{ inputs.version_override != '' }} + shell: bash + env: + VERSION_OVERRIDE: ${{ inputs.version_override }} + run: | + set -euo pipefail + sed -i "s/^version = .*/version = \"${VERSION_OVERRIDE}\"/" \ + mooncake-wheel/pyproject.toml + grep '^version = ' mooncake-wheel/pyproject.toml + - name: Build Python wheel shell: bash run: | @@ -147,7 +197,8 @@ jobs: mooncake-wheel/dist-rocm-py${{ steps.generate_tag.outputs.python_version_tag }}/*.whl # ROCm runtime is excluded from the wheel and bound at load time. export LD_LIBRARY_PATH="/opt/rocm/lib:/usr/local/lib:${LD_LIBRARY_PATH:-}" - site="$("$smoke_venv/bin/python" -c 'import mooncake,os;print(os.path.dirname(mooncake.__file__))')" + "$smoke_venv/bin/python" scripts/tone_tests/python/verify_rocm_wheel.py + site="$("$smoke_venv/bin/python" -c 'import mooncake, os; print(os.path.dirname(mooncake.__file__))')" "$site/mooncake_master" --version - name: Upload Python wheel artifact diff --git a/.github/workflows/e2e-ci.yml b/.github/workflows/e2e-ci.yml index b6abfee2e8..d3ec8215e3 100644 --- a/.github/workflows/e2e-ci.yml +++ b/.github/workflows/e2e-ci.yml @@ -37,6 +37,14 @@ jobs: checkout_ref: ${{ inputs.pr_sha || github.event.pull_request.head.sha }} secrets: inherit + build-wheel-rocm: + if: > + github.event_name == 'workflow_dispatch' || + github.event.label.name == 'run-e2e-ci' + uses: ./.github/workflows/ci_rocm.yml + with: + checkout_ref: ${{ inputs.pr_sha || github.event.pull_request.head.sha }} + integration-test: if: > github.event_name == 'workflow_dispatch' || @@ -47,6 +55,17 @@ jobs: pr_number: ${{ inputs.pr_number || github.event.pull_request.number }} secrets: inherit + rocm-integration-test: + if: > + github.event_name == 'workflow_dispatch' || + github.event.label.name == 'run-e2e-ci' + needs: + - build-wheel-rocm + uses: ./.github/workflows/integration-test-rocm.yml + with: + checkout_ref: ${{ inputs.pr_sha || github.event.pull_request.head.sha }} + secrets: inherit + e2e-gate: name: E2E Gate if: > @@ -55,7 +74,9 @@ jobs: github.event.label.name == 'run-e2e-ci') needs: - ascend-test + - build-wheel-rocm - integration-test + - rocm-integration-test runs-on: ubuntu-latest steps: - name: Check E2E results diff --git a/.github/workflows/integration-test-rocm.yml b/.github/workflows/integration-test-rocm.yml new file mode 100644 index 0000000000..2dea302e85 --- /dev/null +++ b/.github/workflows/integration-test-rocm.yml @@ -0,0 +1,167 @@ +name: 'Integration test (ROCm)' + +on: + workflow_call: + inputs: + checkout_ref: + description: 'Git revision whose ROCm wheel and integration tests are exercised' + required: false + type: string + run_sglang: + description: 'Run SGLang cases in addition to vLLM' + required: false + default: true + type: boolean + +jobs: + test-rocm-integration: + name: ROCm MI350X external PD + runs-on: [self-hosted, Linux, X64, amd, rocm, gfx950, mooncake-pd] + timeout-minutes: 390 + concurrency: + group: mooncake-rocm-mi350x + cancel-in-progress: false + env: + REGISTRY_ADDR_SGLANG: 'lmsysorg/sglang:v0.5.18-rocm720-mi35x@sha256:6d68cd19206716cb3f1e31e2ad89cd0852d7ae614a792773c30a4277f8955c72' + REGISTRY_ADDR_VLLM: 'vllm/vllm-openai-rocm:v0.21.0@sha256:98a77b20df03adeb1cfc0ced009b4df6dd52b0a994ab99a32421f30876a9ae0c' + steps: + # Pytest previously ran as root in a bind-mounted container and left + # caches that the non-root runner could not remove on the next checkout. + - name: Remove root-owned Python test caches + shell: bash + run: | + set -euo pipefail + docker run --rm --network=none \ + -v "${GITHUB_WORKSPACE}:/workspace" \ + --entrypoint /bin/rm \ + "${REGISTRY_ADDR_VLLM}" \ + -rf \ + /workspace/scripts/tone_tests/python/.pytest_cache \ + /workspace/scripts/tone_tests/python/__pycache__ + + - name: Check out tested revision + uses: actions/checkout@v4 + with: + ref: ${{ inputs.checkout_ref || github.sha }} + persist-credentials: false + clean: true + + - name: Download Python 3.10 ROCm wheel + if: inputs.run_sglang + uses: actions/download-artifact@v4 + with: + name: mooncake-wheel-rocm-ubuntu-py310 + path: ${{ runner.temp }}/mooncake-wheel-rocm-py310 + + - name: Download Python 3.12 ROCm wheel + uses: actions/download-artifact@v4 + with: + name: mooncake-wheel-rocm-ubuntu-py312 + path: ${{ runner.temp }}/mooncake-wheel-rocm-py312 + + - name: Run two-node ROCm external PD suite + shell: bash + env: + WHEEL_DIR_SGLANG: ${{ runner.temp }}/mooncake-wheel-rocm-py310 + WHEEL_DIR_VLLM: ${{ runner.temp }}/mooncake-wheel-rocm-py312 + run: | + set -euo pipefail + [ "$(id -u)" -ne 0 ] || { + echo "The self-hosted runner must not run as root" >&2 + exit 1 + } + [ -r /etc/mooncake-ci/runner.env ] || { + echo "Missing controller profile: /etc/mooncake-ci/runner.env" >&2 + exit 1 + } + set -a + # shellcheck disable=SC1091 + source /etc/mooncake-ci/runner.env + set +a + export REGISTRY_ADDR_SGLANG REGISTRY_ADDR_VLLM + export WHEEL_DIR_SGLANG WHEEL_DIR_VLLM + + required_vars=( + CI_ACCELERATOR LOCAL_IP REMOTE_IP REMOTE_SSH_TARGET + MODEL_CACHE MOONCAKE_RUNTIME_CACHE MOONCAKE_CI_TIER + MOONCAKE_GPU_INDICES + MOONCAKE_RENDER_DEVICES MOONCAKE_CPUSET_CPUS + MOONCAKE_CPUSET_MEMS MOONCAKE_SSH_CONFIG + MOONCAKE_LOCK_FILE REMOTE_TEST_DIR MOONCAKE_RDMA_DEVICES + MOONCAKE_RDMA_NETDEVS MOONCAKE_TRANSFER_DEVICE + MOONCAKE_GID_INDEX MOONCAKE_SGLANG_BASE_GPU_ID + MOONCAKE_EPD_ENCODER_GPU_ID MOONCAKE_EPD_PREFILL_GPU_ID + MOONCAKE_EPD_DECODE_GPU_ID MOONCAKE_VLLM_VISIBLE_DEVICES + MOONCAKE_SGLANG_MEM_FRACTION_STATIC AINIC_VERSION + ) + for variable in "${required_vars[@]}"; do + [ -n "${!variable:-}" ] || { + echo "Missing controller setting: $variable" >&2 + exit 1 + } + done + [ "$CI_ACCELERATOR" = rocm ] + export SSH_CMD="ssh -F ${MOONCAKE_SSH_CONFIG}" + + for command in docker flock python3 realpath rocm-smi rsync ssh; do + command -v "$command" >/dev/null || { + echo "Missing required command: $command" >&2 + exit 1 + } + done + test -c /dev/kfd + test -d /sys/class/kfd/kfd/topology/nodes + test -d /sys/class/kfd/kfd/proc + ssh -F "${MOONCAKE_SSH_CONFIG}" "${REMOTE_SSH_TARGET}" ' + set -eu + for command in docker python3 rocm-smi; do + command -v "$command" >/dev/null || { + echo "Missing required remote command: $command" >&2 + exit 1 + } + done + test -c /dev/kfd + test -d /sys/class/kfd/kfd/topology/nodes + test -d /sys/class/kfd/kfd/proc + ' + [ -w "$(dirname "$MOONCAKE_LOCK_FILE")" ] || { + echo "Runner cannot write the Mooncake lock directory" >&2 + exit 1 + } + + exec 9>"${MOONCAKE_LOCK_FILE}" + flock -n 9 || { + echo "The MI350X Mooncake allocation is already in use" >&2 + exit 1 + } + + cleanup_best_effort() { + local exit_code=$? + trap - EXIT + if [ -f scripts/tone_tests/run/.shrc ]; then + set +e + # shellcheck disable=SC1091 + source scripts/tone_tests/run/.shrc + # shellcheck disable=SC1091 + source scripts/tone_tests/scripts/common.sh + cleanup_test_env double || true + fi + exit "$exit_code" + } + trap cleanup_best_effort EXIT + trap 'exit 130' INT + trap 'exit 143' TERM + if ${{ inputs.run_sglang }}; then + bash scripts/tone_tests/scripts/run_test.sh run-all + else + bash scripts/tone_tests/scripts/run_test.sh run-all VLLM + fi + + - name: Upload ROCm external PD logs + if: always() + uses: actions/upload-artifact@v4 + with: + name: rocm-external-pd-logs-${{ github.run_id }}-${{ github.run_attempt }} + path: scripts/tone_tests/run/logs + if-no-files-found: warn + retention-days: 14 diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml index 4d5cb8ad52..3888e68237 100644 --- a/.github/workflows/integration-test.yml +++ b/.github/workflows/integration-test.yml @@ -11,6 +11,19 @@ on: description: 'PR number (passed from parent workflow for workflow_dispatch)' required: false type: string + source_run_id: + description: 'Workflow run containing the wheel artifact' + required: false + type: string + artifact_name: + description: 'Exact wheel artifact name' + required: false + type: string + testpypi_version: + description: 'Exact TestPyPI CUDA 13 package version to test instead of a CI artifact' + required: false + default: '' + type: string jobs: test-sglang-integration: @@ -18,64 +31,117 @@ jobs: env: tone_user_name: ${{ secrets.TONE_USER_NAME }} steps: + - name: Require T-one credentials for gated integration + if: ${{ inputs.testpypi_version != '' || inputs.source_run_id != '' || inputs.artifact_name != '' }} + env: + TONE_USER_NAME: ${{ secrets.TONE_USER_NAME }} + TONE_USER_TOKEN: ${{ secrets.TONE_USER_TOKEN }} + run: | + if [ -z "$TONE_USER_NAME" ] || [ -z "$TONE_USER_TOKEN" ]; then + echo "TONE_USER_NAME and TONE_USER_TOKEN are required for gated integration" + exit 1 + fi + + - name: Set up Python for TestPyPI download + if: ${{ inputs.testpypi_version != '' }} + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Download the exact indexed CUDA 13 wheel + if: ${{ inputs.testpypi_version != '' }} + env: + TESTPYPI_VERSION: ${{ inputs.testpypi_version }} + run: | + python -m pip download \ + --index-url https://test.pypi.org/simple \ + --no-cache-dir \ + --no-deps \ + --only-binary=:all: \ + --pre \ + --dest integration-wheel \ + "mooncake-transfer-engine-cuda13==${TESTPYPI_VERSION}" + + - name: Stage the indexed wheel for T-one + if: ${{ inputs.testpypi_version != '' }} + id: testpypi-wheel + uses: actions/upload-artifact@v4 + with: + name: mooncake-testpypi-tone-${{ inputs.testpypi_version }} + path: integration-wheel/*.whl + if-no-files-found: error + retention-days: 14 + - name: trigger T-one test if: ${{ env.tone_user_name != '' }} + env: + TESTPYPI_ARTIFACT_ID: ${{ steps.testpypi-wheel.outputs.artifact-id }} run: | # Priority: explicit inputs > PR event context > push SHA SHA="${{ inputs.pr_sha || github.event.pull_request.head.sha || github.sha }}" PR_ID="${{ inputs.pr_number || github.event.pull_request.number }}" + SOURCE_RUN_ID="${{ inputs.source_run_id }}" + ARTIFACT_NAME="${{ inputs.artifact_name }}" if [ "${{ github.event_name }}" = "push" ]; then SHA="${{ github.sha }}" PR_ID="" fi echo "PR_ID=${PR_ID}" + artifact_id="$TESTPYPI_ARTIFACT_ID" max_attempts=120 attempt=1 - while [ $attempt -le $max_attempts ]; do + while [ -z "$artifact_id" ] && [ $attempt -le $max_attempts ]; do echo "Attempt $attempt: Fetching artifact..." echo "Target SHA=${SHA}" artifact_id="" - run_id="" - if curl -L -fs -o runs.json -H "Accept: application/vnd.github+json" -H "X-GitHub-Api-Version: 2022-11-28" "https://api.github.com/repos/${{ github.repository }}/actions/runs?head_sha=${SHA}&per_page=100"; then - if jq empty runs.json >/dev/null 2>&1; then - run_id=$(jq -r '.workflow_runs[] | select((.path == ".github/workflows/ci.yml") or (.name == "Build & Test (Linux)")) | .id' runs.json | head -n 1) + run_id="$SOURCE_RUN_ID" + if [ -z "$run_id" ]; then + if curl -L -fs -o runs.json -H "Accept: application/vnd.github+json" -H "X-GitHub-Api-Version: 2022-11-28" "https://api.github.com/repos/${{ github.repository }}/actions/runs?head_sha=${SHA}&per_page=100"; then + if jq empty runs.json >/dev/null 2>&1; then + run_id=$(jq -r '.workflow_runs[] | select((.path == ".github/workflows/ci.yml") or (.name == "Build & Test (Linux)")) | .id' runs.json | head -n 1) + else + echo "Failed to download workflow run list. Retrying..." + fi else - echo "Failed to download workflow run list. Retrying..." + echo "Failed to fetch workflow runs. Retrying..." fi - if [ -n "$run_id" ]; then - echo "Matched workflow run id $run_id" - if curl -L -fs -o artifact.json -H "Accept: application/vnd.github+json" -H "X-GitHub-Api-Version: 2022-11-28" "https://api.github.com/repos/${{ github.repository }}/actions/runs/${run_id}/artifacts?per_page=100"; then - if jq empty artifact.json >/dev/null 2>&1; then - artifact_id=$(jq -r '.artifacts[] | select(.name | contains("py312") ) | select(.name | contains("mooncake") ) | select(.name | contains("cu130") ) | .id' artifact.json | head -n 1) - if [ -z "$artifact_id" ]; then - echo "Available artifacts in workflow run $run_id:" - jq -r '.artifacts[].name' artifact.json || true - fi + fi + + if [ -n "$run_id" ]; then + echo "Matched workflow run id $run_id" + if curl -L -fs -o artifact.json -H "Accept: application/vnd.github+json" -H "X-GitHub-Api-Version: 2022-11-28" "https://api.github.com/repos/${{ github.repository }}/actions/runs/${run_id}/artifacts?per_page=100"; then + if jq empty artifact.json >/dev/null 2>&1; then + if [ -n "$ARTIFACT_NAME" ]; then + artifact_id=$(jq -r --arg name "$ARTIFACT_NAME" '.artifacts[] | select(.name == $name) | .id' artifact.json | head -n 1) else - echo "Failed to download artifact list. Retrying..." + artifact_id=$(jq -r '.artifacts[] | select(.name | contains("py312") ) | select(.name | contains("mooncake") ) | select(.name | contains("cu130") ) | .id' artifact.json | head -n 1) + fi + if [ -z "$artifact_id" ]; then + echo "Available artifacts in workflow run $run_id:" + jq -r '.artifacts[].name' artifact.json || true fi else - echo "Failed to fetch artifacts for workflow run $run_id. Retrying..." + echo "Failed to download artifact list. Retrying..." fi else - echo "Failed to find Build & Test workflow run for SHA $SHA. Retrying..." + echo "Failed to fetch artifacts for workflow run $run_id. Retrying..." + fi + else + echo "Failed to find Build & Test workflow run for SHA $SHA. Retrying..." + if jq empty runs.json >/dev/null 2>&1; then echo "Available workflow runs for SHA:" jq -r '.workflow_runs[] | "\(.id) \(.name) \(.path) \(.status) \(.conclusion)"' runs.json || true fi - if [ -n "$artifact_id" ]; then - echo "Successfully fetched expected artifact id $artifact_id" - break - else - echo "Failed to fetch expected artifact. Retrying..." - if [ $attempt -lt $max_attempts ]; then - sleep $((attempt * 60 < 600 ? attempt * 60 : 600)) - fi - fi + fi + + if [ -n "$artifact_id" ]; then + echo "Successfully fetched expected artifact id $artifact_id" + break else - echo "Failed to fetch workflow runs. Retrying..." + echo "Failed to fetch expected artifact. Retrying..." if [ $attempt -lt $max_attempts ]; then - sleep $((attempt * 60)) + sleep $((attempt * 60 < 600 ? attempt * 60 : 600)) fi fi attempt=$((attempt + 1)) @@ -86,6 +152,9 @@ jobs: fi ENV_INFO="ARTIFACT_ID=${artifact_id} GIT_REPO=${{ github.repository }}" + if [ -n "${{ inputs.testpypi_version }}" ]; then + ENV_INFO="${ENV_INFO} BRANCH=${{ github.ref_name }}" + fi if [ -n "$PR_ID" ]; then ENV_INFO="${ENV_INFO} PR_ID=${PR_ID}" fi diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index ec31a8082c..e657a6fbd1 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -20,10 +20,10 @@ concurrency: # ── Workflow Design Notes ────────────────────────────────────────────────────── # 1. version-stamp computes a forward-looking nightly version (next patch + .devYYYYMMDD) # that sorts AFTER the current release per PEP 440, e.g. "0.3.12.dev20260720". -# 2. Build jobs pass the nightly version via `version-override` to _build-wheel.yaml -# so each wheel is stamped with the correct dev version for TestPyPI. +# 2. Build jobs pass the nightly version to their reusable wheel workflows so +# each wheel is stamped with the correct dev version for TestPyPI. # 3. Specialized platform and packaging checks run nightly instead of on every PR. -# 4. publish-testpypi uploads all nightly-* artifacts to TestPyPI. +# 4. publish-testpypi uploads standard and ROCm nightly artifacts to TestPyPI. # 5. nightly-gate aggregates ALL build + test results; notify-failure files an issue. # ────────────────────────────────────────────────────────────────────────────── @@ -74,14 +74,38 @@ jobs: - variant: non-cuda architecture: x86_64 artifact-prefix: nightly-non-cuda-x86 + - variant: non-cuda + architecture: arm64 + artifact-prefix: nightly-non-cuda-arm64 uses: ./.github/workflows/_build-wheel.yaml with: variant: ${{ matrix.variant }} architecture: ${{ matrix.architecture }} - python-versions: '["3.10", "3.12"]' artifact-prefix: ${{ matrix.artifact-prefix }} version-override: ${{ needs.version-stamp.outputs.nightly_version }} + tone-sglang-integration: + needs: build-wheels + uses: ./.github/workflows/integration-test.yml + with: + pr_sha: ${{ github.sha }} + source_run_id: ${{ github.run_id }} + artifact_name: nightly-cuda13-x86-py312 + secrets: inherit + + build-wheel-rocm: + needs: version-stamp + uses: ./.github/workflows/ci_rocm.yml + with: + version_override: ${{ needs.version-stamp.outputs.nightly_version }} + + rocm-integration-test: + needs: build-wheel-rocm + uses: ./.github/workflows/integration-test-rocm.yml + with: + checkout_ref: ${{ github.sha }} + secrets: inherit + build-musa: runs-on: ubuntu-22.04 container: registry.mthreads.com/mcconline/inference/pytorch:2.9.1.post1-py3.10-musa5.2.0-mp31-devel-ubuntu22.04-amd64 @@ -108,7 +132,10 @@ jobs: run: | cd build source ~/.bashrc - cmake --build . --parallel 1 + # Limit concurrency of Mooncake PG's setup.py build to avoid OOM + export MAX_JOBS=2 + # Does not affect the Ninja system + cmake --build . cmake --install . shell: bash @@ -130,7 +157,7 @@ jobs: run: | docker build -f docker/mooncake.Dockerfile \ --build-arg PYTHON_VERSION=3.10 \ - --build-arg EP_TORCH_VERSIONS="2.13.0" \ + --build-arg EP_TORCH_VERSIONS="2.14.0" \ --build-arg CLEAN_BUILD_ARTIFACTS=1 \ -t mooncake:from-source . @@ -139,7 +166,7 @@ jobs: secrets: inherit publish-testpypi: - needs: [version-stamp, build-wheels, nightly-test] + needs: [version-stamp, build-wheels, build-wheel-rocm, nightly-test] if: ${{ !(github.event_name == 'workflow_dispatch' && github.event.inputs.skip_publish == 'true') }} runs-on: ubuntu-22.04 environment: nightly @@ -150,13 +177,21 @@ jobs: run: | echo "Publishing nightly version: ${{ needs.version-stamp.outputs.nightly_version }}" - - name: Download all nightly wheel artifacts + - name: Download standard nightly wheel artifacts uses: actions/download-artifact@v4 with: path: dist-all pattern: 'nightly-*' - - name: Collect and rename wheels with nightly version + # Keep the established artifact names because integration-test-rocm.yml + # consumes them from the same run. + - name: Download ROCm nightly wheel artifacts + uses: actions/download-artifact@v4 + with: + path: dist-all + pattern: 'mooncake-wheel-rocm-ubuntu-*' + + - name: Collect wheels for nightly publication run: | mkdir -p dist-publish find dist-all -name "*.whl" -exec cp {} dist-publish/ \; @@ -201,13 +236,7 @@ jobs: uses: dtolnay/rust-toolchain@stable - name: Install and start etcd - run: | - wget -q https://github.com/etcd-io/etcd/releases/download/v3.6.1/etcd-v3.6.1-linux-amd64.tar.gz - tar xzf etcd-v3.6.1-linux-amd64.tar.gz - sudo mv etcd-v3.6.1-linux-amd64/etcd* /usr/local/bin/ - etcd --advertise-client-urls http://127.0.0.1:2379 --listen-client-urls http://127.0.0.1:2379 & - sleep 3 - ETCDCTL_API=3 etcdctl --endpoints=http://127.0.0.1:2379 endpoint health + uses: ./.github/actions/setup-etcd - name: Free up disk space run: | @@ -227,15 +256,8 @@ jobs: sudo apt-get update sudo apt-get install -y ninja-build - - name: Run sccache-cache - uses: mozilla-actions/sccache-action@v0.0.9 - - - name: Configure sccache - uses: actions/github-script@v7 - with: - script: | - core.exportVariable('ACTIONS_RESULTS_URL', process.env.ACTIONS_RESULTS_URL || ''); - core.exportVariable('ACTIONS_RUNTIME_TOKEN', process.env.ACTIONS_RUNTIME_TOKEN || ''); + - name: Set up compiler cache + uses: ./.github/actions/setup-sccache - name: Configure project (Release, no ASAN) run: | @@ -245,13 +267,16 @@ jobs: mkdir build && cd build cmake -G Ninja .. \ -DUSE_HTTP=ON -DUSE_CXL=ON -DUSE_UB=ON -DUSE_ETCD=ON -DUSE_CUDA=ON \ + -DWITH_P2P_STORE=ON \ -DSTORE_USE_ETCD=ON -DCMAKE_BUILD_TYPE=Release \ -DBUILD_UNIT_TESTS=ON -DENABLE_SCCACHE=ON + - name: Configure CUDA driver runtime + uses: ./.github/actions/setup-cuda-runtime + - name: Build project run: | cd build - export LIBRARY_PATH=/usr/local/cuda/lib64/stubs:${LIBRARY_PATH:-} cmake --build . -j$(nproc) sudo -E cmake --install . @@ -259,23 +284,13 @@ jobs: run: | mkdir -p build/mooncake-transfer-engine/nvlink-allocator cd mooncake-transfer-engine/nvlink-allocator - export LIBRARY_PATH=/usr/local/cuda/lib64/stubs:$LIBRARY_PATH bash build.sh ../../build/mooncake-transfer-engine/nvlink-allocator/ - name: Start Metadata Server - run: | - cd mooncake-transfer-engine/example/http-metadata-server-python - pip install aiohttp - python ./bootstrap_server.py & - sleep 2 + uses: ./.github/actions/setup-metadata-server - name: Run CTest unit tests - run: | - cd build - export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:/usr/local/lib - MC_METADATA_SERVER=http://127.0.0.1:8080/metadata \ - DEFAULT_KV_LEASE_TTL=500 \ - ctest --parallel $(nproc) --output-on-failure + uses: ./.github/actions/run-ctest - name: Run Mooncake Store Rust smoke test env: @@ -283,32 +298,99 @@ jobs: MOONCAKE_STORE_RUST_LINK_ASAN: "0" run: ./scripts/ci/run_store_rust_smoke.sh + - name: Run Transfer Engine Rust smoke test + env: + MOONCAKE_TE_RUST_LINK_ASAN: "0" + run: ./scripts/ci/run_transfer_engine_rust_smoke.sh + + - name: Check Mooncake Store Rust bindings, examples, and tests + env: + MOONCAKE_BUILD_DIR: ${{ github.workspace }}/build + MOONCAKE_STORE_LIB_DIR: ${{ github.workspace }}/build/mooncake-store/src + MOONCAKE_STORE_INCLUDE_DIR: ${{ github.workspace }}/mooncake-store/include + run: | + export LD_LIBRARY_PATH="$GITHUB_WORKSPACE/build/mooncake-asio:$GITHUB_WORKSPACE/build/mooncake-store/src:$GITHUB_WORKSPACE/build/mooncake-store/src/cachelib_memory_allocator:$GITHUB_WORKSPACE/build/mooncake-transfer-engine/src:$GITHUB_WORKSPACE/build/mooncake-transfer-engine/src/common/base:$GITHUB_WORKSPACE/build/mooncake-common/etcd:/usr/local/lib:${LD_LIBRARY_PATH:-}" + cd mooncake-store/rust + cargo test --lib + cargo test --examples --tests --no-run + cargo clean + + - name: Verify Mooncake Store Rust dlopen bindings and packaging + env: + MOONCAKE_STORE_INCLUDE_DIR: ${{ github.workspace }}/mooncake-store/include + run: | + cd mooncake-store/rust + cargo run --locked --example generate_dlopen_bindings + git diff --exit-code -- src/generated/ffi_dlopen_bindings.rs + cargo package --no-verify --allow-dirty + crate=$(ls target/package/mooncake_store-*.crate | head -1) + dest=$(mktemp -d) + tar xzf "$crate" -C "$dest" + (cd "$dest"/mooncake_store-* && cargo check --no-default-features --features dlopen) + - name: Run Go store binding integration tests env: MOONCAKE_STORE_CLUSTER_ID: nightly_go_cluster - MOONCAKE_STORE_GO_LINK_COMMON: "0" MOONCAKE_STORE_GO_SANITIZED: "0" run: ./scripts/ci/run_store_go_integration.sh - name: Build and install Python wheel for integration tests run: | + python -m venv "${RUNNER_TEMP}/mooncake-nightly-venv" + echo "${RUNNER_TEMP}/mooncake-nightly-venv/bin" >> "$GITHUB_PATH" + source "${RUNNER_TEMP}/mooncake-nightly-venv/bin/activate" export LD_LIBRARY_PATH=${LD_LIBRARY_PATH:-}:/usr/local/lib export CUDA_HOME=/usr/local/cuda PYTHON_VERSION=3.12 OUTPUT_DIR=dist ./scripts/build_wheel.sh - pip install mooncake-wheel/dist/*.whl + python -m pip install mooncake-wheel/dist/*.whl - name: Run Python integration tests (full suite) env: MC_METADATA_SERVER: http://127.0.0.1:8080/metadata RUN_TESTS_METADATA_SERVER_MODE: external DEFAULT_KV_LEASE_TTL: "500" - TEST_SSD_OFFLOAD_IN_EVICT: "1" - TEST_PROMOTION_ON_HIT: "1" TEST_CXL: "1" run: | export LD_LIBRARY_PATH=${LD_LIBRARY_PATH:-}:/usr/local/lib bash scripts/run_tests.sh + - name: Run Python API end-to-end tests + env: + MOONCAKE_MASTER: "127.0.0.1:50051" + MOONCAKE_TE_META_DATA_SERVER: "http://127.0.0.1:8080/metadata" + MOONCAKE_PROTOCOL: "tcp" + LOCAL_HOSTNAME: "127.0.0.1" + run: | + export LD_LIBRARY_PATH=${LD_LIBRARY_PATH:-}:/usr/local/lib + kill "$NIGHTLY_METADATA_SERVER_PID" 2>/dev/null || true + for _ in {1..50}; do + if ! kill -0 "$NIGHTLY_METADATA_SERVER_PID" 2>/dev/null; then + break + fi + sleep 0.1 + done + if kill -0 "$NIGHTLY_METADATA_SERVER_PID" 2>/dev/null; then + kill -KILL "$NIGHTLY_METADATA_SERVER_PID" + fi + mkdir -p /tmp/mooncake_storage + mooncake_master \ + --default_kv_lease_ttl=500 \ + --eviction_high_watermark_ratio=0.95 \ + --cluster_id=nightly_test_cluster \ + --port 50051 \ + --enable_http_metadata_server=true & + master_pid=$! + trap 'kill "$master_pid" 2>/dev/null || true; wait "$master_pid" 2>/dev/null || true' EXIT + sleep 3 + python -m unittest mooncake-wheel.tests.test_weight_snapshot_api + python python/tests/store/async_store_integration.py + python scripts/test_copy_move_api.py + python scripts/test_drain_http_api.py --timeout-sec 90 + + - name: Run RPC Communicator Bandwidth Test + run: ./scripts/ci/run_rpc_smoke.sh + shell: bash + nightly-coverage: runs-on: ubuntu-22.04 env: @@ -331,13 +413,7 @@ jobs: uses: dtolnay/rust-toolchain@stable - name: Install and start etcd - run: | - wget -q https://github.com/etcd-io/etcd/releases/download/v3.6.1/etcd-v3.6.1-linux-amd64.tar.gz - tar xzf etcd-v3.6.1-linux-amd64.tar.gz - sudo mv etcd-v3.6.1-linux-amd64/etcd* /usr/local/bin/ - etcd --advertise-client-urls http://127.0.0.1:2379 --listen-client-urls http://127.0.0.1:2379 & - sleep 3 - ETCDCTL_API=3 etcdctl --endpoints=http://127.0.0.1:2379 endpoint health + uses: ./.github/actions/setup-etcd - name: Free up disk space uses: ./.github/actions/free-disk-space @@ -355,15 +431,11 @@ jobs: sudo apt-get update sudo apt-get install -y lcov gcovr ninja-build - - name: Run sccache-cache - uses: mozilla-actions/sccache-action@v0.0.9 + - name: Test HugeTLB sizing helper + run: python3 scripts/test_hicache_hugepage_requirements.py - - name: Configure sccache - uses: actions/github-script@v7 - with: - script: | - core.exportVariable('ACTIONS_RESULTS_URL', process.env.ACTIONS_RESULTS_URL || ''); - core.exportVariable('ACTIONS_RUNTIME_TOKEN', process.env.ACTIONS_RUNTIME_TOKEN || ''); + - name: Set up compiler cache + uses: ./.github/actions/setup-sccache - name: Configure project with coverage support run: | @@ -380,19 +452,29 @@ jobs: sudo cmake --install . - name: Start Metadata Server - run: | - cd mooncake-transfer-engine/example/http-metadata-server-python - pip install aiohttp - python ./bootstrap_server.py & - sleep 2 + uses: ./.github/actions/setup-metadata-server - name: Run CTest with coverage - run: | - cd build - export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:/usr/local/lib - MC_METADATA_SERVER=http://127.0.0.1:8080/metadata \ - DEFAULT_KV_LEASE_TTL=500 \ - ctest --parallel $(nproc) --output-on-failure + uses: ./.github/actions/run-ctest + with: + reserve-rpc-port: 'false' + + - name: Run Mooncake Store Rust sanitizer smoke test + env: + MOONCAKE_STORE_CLUSTER_ID: nightly_asan_rust_cluster + MOONCAKE_STORE_RUST_LINK_ASAN: "1" + run: ./scripts/ci/run_store_rust_smoke.sh + + - name: Run Transfer Engine Rust sanitizer smoke test + env: + MOONCAKE_TE_RUST_LINK_ASAN: "1" + run: ./scripts/ci/run_transfer_engine_rust_smoke.sh + + - name: Run Go store binding sanitizer integration tests + env: + MOONCAKE_STORE_CLUSTER_ID: nightly_asan_go_cluster + MOONCAKE_STORE_GO_SANITIZED: "1" + run: ./scripts/ci/run_store_go_integration.sh - name: Generate coverage report id: coverage @@ -453,6 +535,9 @@ jobs: needs: - version-stamp - build-wheels + - tone-sglang-integration + - build-wheel-rocm + - rocm-integration-test - build-musa - build-docker - ascend-test diff --git a/.github/workflows/pre-release.yaml b/.github/workflows/pre-release.yaml index 2a07c4b4ad..ccd01b6aab 100644 --- a/.github/workflows/pre-release.yaml +++ b/.github/workflows/pre-release.yaml @@ -1,11 +1,8 @@ name: Pre-Release -# Dry run of the release pipelines: build wheels through the same _build-wheel.yaml -# the Release / Release Non-CUDA / Release CUDA 13 workflows use, validate the -# artifacts, but do not create a GitHub Release or publish to PyPI. -# -# Trigger by pushing a pre-release tag, for example: -# git tag v1.0.0-rc1 && git push origin v1.0.0-rc1 +# Build the same core wheel matrix as the stable release workflows and attach +# the artifacts to a GitHub Pre-release. Also publish the exact artifacts to +# TestPyPI, then consume them in smoke and T-one tests. No production PyPI upload. on: push: tags: @@ -15,7 +12,40 @@ on: - 'v*-pre*' jobs: + version-stamp: + name: Normalize pre-release version + if: github.repository == 'kvcache-ai/Mooncake' + runs-on: ubuntu-22.04 + permissions: + contents: read + outputs: + package_version: ${{ steps.version.outputs.package_version }} + steps: + - name: Checkout release gate + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install version parser + run: python -m pip install packaging + + - name: Normalize tag as a PEP 440 pre-release + id: version + env: + RELEASE_TAG: ${{ github.ref_name }} + run: | + normalized=$(python scripts/ci/testpypi_wheel_gate.py \ + normalize-version --tag "$RELEASE_TAG") + echo "package_version=${normalized}" >> "$GITHUB_OUTPUT" + echo "Normalized ${RELEASE_TAG} to ${normalized}" + build: + needs: version-stamp strategy: fail-fast: false matrix: @@ -43,13 +73,46 @@ jobs: variant: ${{ matrix.variant }} architecture: ${{ matrix.architecture }} artifact-prefix: ${{ matrix.artifact-prefix }} + version-override: ${{ needs.version-stamp.outputs.package_version }} - validate-release: - name: Validate release artifacts + publish-github-release: + name: Upload wheels to GitHub Pre-release needs: build runs-on: ubuntu-22.04 + concurrency: + group: pre-release-github-${{ github.ref_name }} + cancel-in-progress: false + permissions: + contents: write + steps: + - name: Download all pre-release wheel artifacts + uses: actions/download-artifact@v4 + with: + path: mooncake-wheel/dist-release + pattern: mooncake-wheel*pre-release* + merge-multiple: true + + - name: Upload wheels to GitHub Pre-release + uses: softprops/action-gh-release@v3 + with: + tag_name: ${{ github.ref_name }} + prerelease: true + make_latest: false + files: mooncake-wheel/dist-release/*.whl + fail_on_unmatched_files: true + + publish-testpypi: + name: Validate and publish wheels to TestPyPI + needs: [version-stamp, build] + runs-on: ubuntu-22.04 + environment: nightly + concurrency: + group: pre-release-testpypi-${{ needs.version-stamp.outputs.package_version }} + cancel-in-progress: false permissions: contents: read + env: + PACKAGE_VERSION: ${{ needs.version-stamp.outputs.package_version }} steps: - name: Checkout source uses: actions/checkout@v4 @@ -60,42 +123,173 @@ jobs: path: mooncake-wheel/dist-all pattern: mooncake-wheel*pre-release* - - name: Prepare wheels for validation + - name: Collect wheels for publication run: | mkdir -p mooncake-wheel/dist-release - find mooncake-wheel/dist-all -name "*.whl" -exec cp {} mooncake-wheel/dist-release/ \; + find mooncake-wheel/dist-all -name "*.whl" \ + -exec cp {} mooncake-wheel/dist-release/ \; echo "Pre-release tag: ${GITHUB_REF_NAME}" + echo "Package version: ${PACKAGE_VERSION}" echo "Collected wheels:" ls -la mooncake-wheel/dist-release/ - wheel_count=$(find mooncake-wheel/dist-release -name "*.whl" | wc -l) - echo "wheel_count=${wheel_count}" >> "$GITHUB_ENV" - if [ "${wheel_count}" -lt 24 ]; then - echo "Expected at least 24 wheels (4 Python versions x 3 variants x 2 architectures), found ${wheel_count}" - exit 1 - fi - name: Set up Python uses: actions/setup-python@v5 with: python-version: '3.12' + - name: Install publication tools + run: python -m pip install packaging twine + + - name: Require TestPyPI credentials + env: + TESTPYPI_API_TOKEN: ${{ secrets.TESTPYPI_API_TOKEN }} + run: | + if [ -z "$TESTPYPI_API_TOKEN" ]; then + echo "TESTPYPI_API_TOKEN is required" + exit 1 + fi + - name: Validate wheels with twine + run: twine check mooncake-wheel/dist-release/*.whl + + - name: Validate empty or resumable TestPyPI upload state run: | - pip install twine - twine check mooncake-wheel/dist-release/*.whl + python scripts/ci/testpypi_wheel_gate.py validate-upload-state \ + --directory mooncake-wheel/dist-release \ + --version "$PACKAGE_VERSION" - - name: Upload validated wheels as workflow artifacts - uses: actions/upload-artifact@v4 + - name: Publish exact wheels to TestPyPI + env: + TWINE_USERNAME: __token__ + TWINE_PASSWORD: ${{ secrets.TESTPYPI_API_TOKEN }} + run: >- + twine upload --repository testpypi --skip-existing + mooncake-wheel/dist-release/*.whl + + - name: Verify every published wheel hash + run: | + python scripts/ci/testpypi_wheel_gate.py wait-upload-state \ + --directory mooncake-wheel/dist-release \ + --version "$PACKAGE_VERSION" + + consume-testpypi: + name: Consume ${{ matrix.package }} on ${{ matrix.architecture }} + needs: [version-stamp, publish-testpypi] + runs-on: ${{ matrix.architecture == 'aarch64' && 'ubuntu-22.04-arm' || 'ubuntu-22.04' }} + permissions: + contents: read + strategy: + fail-fast: false + matrix: + package: + - mooncake-transfer-engine + - mooncake-transfer-engine-cuda13 + - mooncake-transfer-engine-non-cuda + architecture: + - x86_64 + - aarch64 + env: + PACKAGE_VERSION: ${{ needs.version-stamp.outputs.package_version }} + TARGET_PACKAGE: ${{ matrix.package }} + steps: + - name: Install CUDA runtime and driver stub + if: ${{ matrix.package != 'mooncake-transfer-engine-non-cuda' }} + env: + CUDA_PACKAGE_VERSION: ${{ matrix.package == 'mooncake-transfer-engine-cuda13' && '13-0' || '12-8' }} + run: | + set -euo pipefail + if [ "$(uname -m)" = x86_64 ]; then + repository_architecture=x86_64 + else + repository_architecture=sbsa + fi + keyring=$(mktemp --suffix=.deb) + curl --fail --location --retry 3 \ + "https://developer.download.nvidia.com/compute/cuda/repos/ubuntu2204/${repository_architecture}/cuda-keyring_1.1-1_all.deb" \ + --output "$keyring" + sudo dpkg -i "$keyring" + sudo apt-get update + sudo apt-get install -y \ + "cuda-cudart-${CUDA_PACKAGE_VERSION}" \ + "cuda-driver-dev-${CUDA_PACKAGE_VERSION}" + + - name: Set up Python + uses: actions/setup-python@v5 with: - name: mooncake-wheels-pre-release-${{ github.ref_name }} - path: mooncake-wheel/dist-release/*.whl - retention-days: 14 + python-version: '3.12' - - name: Pre-release validation summary + - name: Download, install, and smoke the indexed wheel run: | - echo "## Pre-Release validation passed" >> "$GITHUB_STEP_SUMMARY" - echo "" >> "$GITHUB_STEP_SUMMARY" - echo "- Tag: \`${GITHUB_REF_NAME}\`" >> "$GITHUB_STEP_SUMMARY" - echo "- Wheels built: ${wheel_count}" >> "$GITHUB_STEP_SUMMARY" - echo "- PyPI publish: skipped (pre-release dry run)" >> "$GITHUB_STEP_SUMMARY" - echo "- GitHub Release upload: skipped (pre-release dry run)" >> "$GITHUB_STEP_SUMMARY" + set -euo pipefail + sudo apt-get update + sudo apt-get install -y \ + libcurl4 libibverbs1 rdma-core librdmacm1 libnuma1 liburing2 + + download_dir=$(mktemp -d) + + python -m pip download \ + --index-url https://test.pypi.org/simple \ + --no-cache-dir \ + --no-deps \ + --only-binary=:all: \ + --pre \ + --dest "$download_dir" \ + "${TARGET_PACKAGE}==${PACKAGE_VERSION}" + + # Install the wheel downloaded above. Its ordinary dependencies are + # resolved from production PyPI, never TestPyPI. + python -m pip install \ + --index-url https://pypi.org/simple \ + "$download_dir"/*.whl + + if [ "$TARGET_PACKAGE" != mooncake-transfer-engine-non-cuda ]; then + cuda_stub= + for candidate in /usr/local/cuda*/lib64/stubs/libcuda.so \ + /usr/local/cuda*/targets/*/lib/stubs/libcuda.so; do + if [ -f "$candidate" ]; then + cuda_stub="$candidate" + break + fi + done + if [ -z "$cuda_stub" ]; then + echo "CUDA driver stub libcuda.so was not found" + exit 1 + fi + stub_dir=$(mktemp -d) + ln -s "$cuda_stub" "$stub_dir/libcuda.so.1" + export LD_LIBRARY_PATH="$stub_dir:${LD_LIBRARY_PATH:-}" + for directory in /usr/local/cuda*/lib64 \ + /usr/local/cuda*/targets/*/lib; do + if [ -d "$directory" ]; then + export LD_LIBRARY_PATH="$directory:$LD_LIBRARY_PATH" + fi + done + fi + + python - "$TARGET_PACKAGE" "$PACKAGE_VERSION" <<'PY' + import importlib.metadata + import sys + + package, expected = sys.argv[1:] + installed = importlib.metadata.version(package) + if installed != expected: + raise SystemExit( + f"installed {package} version {installed}, expected {expected}" + ) + + import mooncake + import mooncake.engine + import mooncake.http_metadata_server + import mooncake.store + PY + mooncake_http_metadata_server --help >/dev/null + mooncake_master --version + + tone-integration: + name: Validate T-one integration with TestPyPI wheel + needs: [version-stamp, consume-testpypi] + uses: ./.github/workflows/integration-test.yml + with: + testpypi_version: ${{ needs.version-stamp.outputs.package_version }} + secrets: inherit diff --git a/.github/workflows/publish-master-image.yaml b/.github/workflows/publish-master-image.yaml index 9a62b6e6b1..0e941e67fc 100644 --- a/.github/workflows/publish-master-image.yaml +++ b/.github/workflows/publish-master-image.yaml @@ -6,20 +6,20 @@ name: Publish Master Image # # Two CUDA flavors are published from the same version input, because the wheel ships as # two PyPI projects that are versioned in lockstep: -# cuda12 -> mooncake-transfer-engine -> : (and :latest) -# cuda13 -> mooncake-transfer-engine-cuda13 -> :-cuda13 (and :latest-cuda13) -# The cuda12 tag stays unsuffixed so existing pulls keep working. Each flavor is built -# from its own Dockerfile (docker/master.Dockerfile, docker/master-cuda13.Dockerfile); -# those two files must differ only in the CUDA-flavor lines, which `prepare` enforces. +# cuda12 -> mooncake-transfer-engine -> :-cuda12 +# cuda13 -> mooncake-transfer-engine-cuda13 -> :-cuda13 +# CUDA 13 is the default flavor: after smoke, : aliases :-cuda13. The +# floating flavor tags are :cuda12 and :cuda13; :latest aliases :cuda13 when requested. +# Each flavor is built from its own Dockerfile (docker/master.Dockerfile, +# docker/master-cuda13.Dockerfile); those two files must differ only in the CUDA-flavor +# lines, which `prepare` enforces. # # Publish flow: validate input -> check the Dockerfiles are in sync -> per flavor: confirm -# amd64+arm64 wheels exist -> build multi-arch and push the :[-cuda13] tag -> -# smoke-test amd64 AND arm64 -> only then promote :latest[-cuda13] (when requested). -# :latest is copied from the smoked : digest, so the default pull tag can never -# point at an image that failed smoke. A failed smoke still leaves the : tag -# public; since this is manual, delete it from Docker Hub by hand. The flavors run as -# independent matrix legs with fail-fast disabled, so one flavor failing never cancels -# the other mid-push. +# amd64+arm64 wheels exist -> build multi-arch and push the :- tag -> +# smoke-test amd64 AND arm64 -> only then promote aliases (when requested). A failed smoke +# still leaves the fixed flavor tag public; since this is manual, delete it from Docker Hub +# by hand. The flavors run as independent matrix legs with fail-fast disabled, so one flavor +# failing never cancels the other mid-push. on: workflow_dispatch: inputs: @@ -36,8 +36,8 @@ on: - both - cuda12 - cuda13 - tag_latest: - description: "Also move :latest (per flavor) to this build. Only enable when publishing the newest release (a backfill/retry of an older wheel must NOT move :latest)." + update_floating_tags: + description: "Move :cuda12/:cuda13 and the CUDA 13 default :latest. Enable only for the newest release." required: false default: false type: boolean @@ -103,13 +103,11 @@ jobs: "flavor": "cuda12", "package": "mooncake-transfer-engine", "dockerfile": "docker/master.Dockerfile", - "tag_suffix": "", }, "cuda13": { "flavor": "cuda13", "package": "mooncake-transfer-engine-cuda13", "dockerfile": "docker/master-cuda13.Dockerfile", - "tag_suffix": "-cuda13", }, } sel = os.environ["FLAVORS"] @@ -134,8 +132,8 @@ jobs: # inputs.*/matrix.* placed in env (not interpolated into shell source) — injection-safe. MOONCAKE_VERSION: ${{ inputs.mooncake_version }} MOONCAKE_PACKAGE: ${{ matrix.package }} - IMAGE_TAG: ${{ inputs.mooncake_version }}${{ matrix.tag_suffix }} - TAG_SUFFIX: ${{ matrix.tag_suffix }} + FLAVOR: ${{ matrix.flavor }} + IMAGE_TAG: ${{ inputs.mooncake_version }}-${{ matrix.flavor }} # Pinned for the official image: provenance must always be the public PyPI index. # Debug builds against another index belong in a separate workflow that does NOT # push official kvcacheai/mooncake tags. @@ -193,11 +191,11 @@ jobs: username: kvcacheai password: ${{ secrets.DOCKERHUB_TOKEN }} - # Build multi-arch and push ONLY the immutable :[-cuda13] tag. :latest is - # promoted in a later step, after smoke passes. provenance/sbom disabled to keep the - # index free of unknown/unknown attestation entries (they would otherwise show up in - # `imagetools inspect`). - - name: Build and push :${{ matrix.tag_suffix }} (multi-arch) + # Build multi-arch and push ONLY the immutable :- tag. Default and + # floating aliases are promoted in later steps, after smoke passes. provenance/sbom + # disabled to keep the index free of unknown/unknown attestation entries (they would + # otherwise show up in `imagetools inspect`). + - name: Build and push :-${{ matrix.flavor }} (multi-arch) id: build uses: docker/build-push-action@v6 with: @@ -209,12 +207,13 @@ jobs: build-args: | MOONCAKE_VERSION=${{ inputs.mooncake_version }} PIP_INDEX_URL=${{ env.PIP_INDEX_URL }} - tags: ${{ env.REPO }}:${{ inputs.mooncake_version }}${{ matrix.tag_suffix }} + tags: ${{ env.REPO }}:${{ inputs.mooncake_version }}-${{ matrix.flavor }} push: true - # Smoke tests run AFTER the : push (a failed smoke leaves : public - # — delete it from Docker Hub by hand; this manual workflow wires no auto-cleanup). - # :latest is NOT pushed yet, so a bad build can never poison the default pull tag. + # Smoke tests run AFTER the :- push (a failed smoke leaves the fixed + # flavor tag public — delete it from Docker Hub by hand; this manual workflow wires no + # auto-cleanup). Default and floating aliases are NOT pushed yet, so a bad build can + # never poison them. # We exercise real entrypoints, not just imports: `mooncake_master --version` runs the # compiled binary through the console-script/cli.py path and exits 0 (gflags # SetVersionString) — note `--help` would exit 1 under gflags, so --version is used. @@ -243,13 +242,35 @@ jobs: echo "arm64 ok" ' - # Promote :latest[-cuda13] ONLY after both arches pass smoke, and only when explicitly - # requested. imagetools create copies the tested : manifest by digest - # (no rebuild), so :latest can never point at an image that didn't pass smoke. - # Each flavor promotes its own latest tag: cuda12 -> :latest, cuda13 -> :latest-cuda13. - - name: Promote :latest${{ matrix.tag_suffix }} (post-smoke) - if: ${{ inputs.tag_latest }} + # CUDA 13 is the default flavor. Create the unsuffixed version alias only after both + # arches pass smoke, using the build output digest rather than a mutable source tag. + - name: Promote : (CUDA 13 default) + if: ${{ matrix.flavor == 'cuda13' }} + env: + BUILD_DIGEST: ${{ steps.build.outputs.digest }} run: | docker buildx imagetools create \ - --tag "${REPO}:latest${TAG_SUFFIX}" \ - "${REPO}:${IMAGE_TAG}" + --tag "${REPO}:${MOONCAKE_VERSION}" \ + "${REPO}@${BUILD_DIGEST}" + + # Promote the latest successful build for this CUDA flavor only when explicitly + # requested. A CUDA 12 backfill cannot move the default :latest tag. + - name: Promote :${{ matrix.flavor }} (post-smoke) + if: ${{ inputs.update_floating_tags }} + env: + BUILD_DIGEST: ${{ steps.build.outputs.digest }} + run: | + docker buildx imagetools create \ + --tag "${REPO}:${FLAVOR}" \ + "${REPO}@${BUILD_DIGEST}" + + # Keep exactly one default floating tag. It follows CUDA 13, matching the unsuffixed + # version alias above and Docker's meaning of :latest as the repository default. + - name: Promote :latest (CUDA 13 default, post-smoke) + if: ${{ inputs.update_floating_tags && matrix.flavor == 'cuda13' }} + env: + BUILD_DIGEST: ${{ steps.build.outputs.digest }} + run: | + docker buildx imagetools create \ + --tag "${REPO}:latest" \ + "${REPO}@${BUILD_DIGEST}" diff --git a/.github/workflows/release-efa-cuda13.yaml b/.github/workflows/release-efa-cuda13.yaml index dcbfe7434f..4115a4bb89 100644 --- a/.github/workflows/release-efa-cuda13.yaml +++ b/.github/workflows/release-efa-cuda13.yaml @@ -8,18 +8,16 @@ on: # Publishes mooncake-transfer-engine-efa-cuda13, the CUDA 13-aware AWS EFA wheel. jobs: build: + if: ${{ !contains(github.ref_name, '-') }} permissions: contents: write uses: ./.github/workflows/_build-efa-wheel.yaml with: variant: cuda13 - use-cuda: true - cuda-version: '13.0.2' python-versions: '["3.10", "3.11", "3.12", "3.13"]' build-profile: release cmake-args: >- -DUSE_HTTP=ON -DUSE_ETCD=ON -DWITH_EP=OFF -DSTORE_USE_ETCD=ON - variant-flag: EFA_CU13_BUILD artifact-prefix: mooncake-wheel-efa-cuda13 publish-release: diff --git a/.github/workflows/release-efa-non-cuda.yaml b/.github/workflows/release-efa-non-cuda.yaml index 6ea27b00d6..e7825ae26d 100644 --- a/.github/workflows/release-efa-non-cuda.yaml +++ b/.github/workflows/release-efa-non-cuda.yaml @@ -8,17 +8,16 @@ on: # Publishes mooncake-transfer-engine-efa-non-cuda, the CPU/DRAM AWS EFA wheel. jobs: build: + if: ${{ !contains(github.ref_name, '-') }} permissions: contents: write uses: ./.github/workflows/_build-efa-wheel.yaml with: variant: non-cuda - use-cuda: false python-versions: '["3.10", "3.11", "3.12", "3.13"]' build-profile: release cmake-args: >- -DUSE_HTTP=ON -DUSE_ETCD=ON -DWITH_EP=OFF -DSTORE_USE_ETCD=ON - variant-flag: EFA_NON_CUDA_BUILD artifact-prefix: mooncake-wheel-efa-non-cuda publish-release: diff --git a/.github/workflows/release-efa.yaml b/.github/workflows/release-efa.yaml index c10d46bc89..ad3d30681c 100644 --- a/.github/workflows/release-efa.yaml +++ b/.github/workflows/release-efa.yaml @@ -8,18 +8,16 @@ on: # Publishes mooncake-transfer-engine-efa, the CUDA 12-aware AWS EFA wheel. jobs: build: + if: ${{ !contains(github.ref_name, '-') }} permissions: contents: write uses: ./.github/workflows/_build-efa-wheel.yaml with: variant: cuda - use-cuda: true - cuda-version: '12.8.1' python-versions: '["3.10", "3.11", "3.12", "3.13"]' build-profile: release cmake-args: >- -DUSE_HTTP=ON -DUSE_ETCD=ON -DWITH_EP=OFF -DSTORE_USE_ETCD=ON - variant-flag: EFA_BUILD artifact-prefix: mooncake-wheel-efa publish-release: diff --git a/.github/workflows/release-rocm.yaml b/.github/workflows/release-rocm.yaml index 060285da36..5f989ec0f3 100644 --- a/.github/workflows/release-rocm.yaml +++ b/.github/workflows/release-rocm.yaml @@ -56,6 +56,18 @@ jobs: fetch-depth: 0 submodules: recursive + # workflow_dispatch may build a tag older than the verification helper. + # Keep release policy tied to the trusted workflow revision instead of + # relying on files from the historical source tree. + - name: Check out release verification helper + uses: actions/checkout@v4 + with: + ref: ${{ github.workflow_sha }} + path: .release-ci + persist-credentials: false + sparse-checkout: scripts/tone_tests/python/verify_rocm_wheel.py + sparse-checkout-cone-mode: false + - name: Mark repository as safe shell: bash run: git config --global --add safe.directory "$GITHUB_WORKSPACE" || true @@ -131,12 +143,15 @@ jobs: rm -rf build && mkdir build && cd build cmake -G Ninja .. \ -DUSE_HIP=ON -DUSE_CUDA=OFF -DWITH_EP=OFF \ + -DENABLE_MULTI_PROTOCOL=ON \ -DUSE_HTTP=ON -DUSE_ETCD=ON -DSTORE_USE_ETCD=ON \ -DBUILD_UNIT_TESTS=OFF -DENABLE_DEBUG_SYMBOLS=OFF \ -DCMAKE_BUILD_TYPE=Release \ -DPython3_EXECUTABLE="${PYTHON_BIN}" - name: Build project + env: + MAX_JOBS: "2" shell: bash run: | set -eo pipefail @@ -145,7 +160,7 @@ jobs: # Retry to ride out transient Go module (proxy.golang.org) fetch errors # during the etcd-wrapper build; Go caches modules, so retries resume. n=0 - until cmake --build . -j"$(nproc)"; do + until cmake --build . -j"${MAX_JOBS}"; do n=$((n+1)) if [ "$n" -ge 3 ]; then echo "Build failed after $n attempts"; exit 1; fi echo "Build attempt $n failed; retrying in 15s..."; sleep 15 @@ -187,13 +202,16 @@ jobs: # Install the actual per-matrix release artifact (cp310-cp313) into a # fresh venv and exercise the packaged binary, so packaging/ELF-layout # failures in any version are caught before this wheel reaches PyPI. - # No GPU is needed for `mooncake_master --version`. + # Import both native bindings as well as exercising the packaged + # binary. No GPU is needed for these load-time checks. smoke_venv=$(mktemp -d) "${PYTHON_BIN}" -m venv "$smoke_venv" "$smoke_venv/bin/python" -m pip install --no-deps \ mooncake-wheel/dist-rocm-py${{ steps.generate_tag_release.outputs.python_version_tag }}/*.whl export LD_LIBRARY_PATH="/opt/rocm/lib:/usr/local/lib:${LD_LIBRARY_PATH:-}" - site="$("$smoke_venv/bin/python" -c 'import mooncake,os;print(os.path.dirname(mooncake.__file__))')" + "$smoke_venv/bin/python" \ + .release-ci/scripts/tone_tests/python/verify_rocm_wheel.py + site="$("$smoke_venv/bin/python" -c 'import mooncake, os; print(os.path.dirname(mooncake.__file__))')" "$site/mooncake_master" --version - name: Upload Python wheel artifact diff --git a/.gitmodules b/.gitmodules index acc910c38d..aecfec5a1c 100644 --- a/.gitmodules +++ b/.gitmodules @@ -2,7 +2,3 @@ path = extern/pybind11 url = https://github.com/pybind/pybind11.git branch = stable -[submodule "extern/yalantinglibs"] - path = extern/yalantinglibs - url = https://github.com/alibaba/yalantinglibs.git - branch = v0.5.7 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e29bb9c9be..06600a0e65 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,8 @@ # Pre-commit hooks configuration for Mooncake # Install: pip install -r requirements-dev.txt && pre-commit install -# Run manually on all files: pre-commit run --all-files +# Staged files: pre-commit run +# PR-changed files: git fetch origin main && pre-commit run --files $(git diff --name-only --diff-filter=ACMR origin/main...HEAD) +# Full-repo (intentional cleanup only): pre-commit run --all-files # Format all C/C++ files explicitly: ./scripts/code_format.sh --all # Note: clang-format should already be available (installed via system packages or dependencies.sh) # Exclusions: build artifacts, vendored extern code, generated wheels. @@ -60,7 +62,7 @@ repos: hooks: - id: codespell exclude: '^(extern/|FAST25-release/)' - args: ['--ignore-words-list=te,mooncake,KVCache,cann,hsa'] + args: ['--ignore-words-list=te,mooncake,KVCache,cann,hsa,crate'] - repo: https://github.com/cheshirekow/cmake-format-precommit rev: v0.6.13 diff --git a/.typos.toml b/.typos.toml index 4843e506f2..687e48866c 100644 --- a/.typos.toml +++ b/.typos.toml @@ -18,4 +18,8 @@ extend-exclude = [ # DeepEP-derived elastic kernel headers keep upstream identifiers such as # `ue8m0x4`; exclude the imported header block from spelling checks. "mooncake-ep/include/elastic/*", + # Conductor hash golden vectors are generated digests and pickle byte + # dumps; hex runs collide with dictionary words. The generator scripts + # stay checked. + "mooncake-conductor/tests/fixtures/*.json", ] diff --git a/AGENTS.md b/AGENTS.md index f7332c2d0a..6ab75b16b9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,8 +18,10 @@ checklist, and AI assistance disclosure. - For AI-assisted changes, make sure the human submitter has reviewed every changed line and can defend the change end-to-end. -- Run pre-commit locally on the files touched by the change before handoff when - the toolchain is available. If broader hooks or `pre-commit run --all-files` - rewrite unrelated files, do not include those unrelated edits in the PR. +- Before handoff, run pre-commit on the files touched by the change when the + toolchain is available (see `CONTRIBUTING.md` for the PR-scoped + `pre-commit run --files ...` command). Do not use + `pre-commit run --all-files` for routine PRs; if it rewrites unrelated + files, leave those edits out of the PR. - Keep PRs lean: review `git diff` before staging, and include only changes required for the requested task. diff --git a/CMakeLists.txt b/CMakeLists.txt index a82f333c77..fe9c2569d6 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -4,21 +4,30 @@ project(mooncake CXX C) # indicates cmake is invoked from top-level dir set(GLOBAL_CONFIG "true") +if(SKBUILD) + add_subdirectory(python) +endif() + include(mooncake-common/FindJsonCpp.cmake) include(mooncake-common/FindGLOG.cmake) include(mooncake-common/common.cmake) # unit test -if (BUILD_UNIT_TESTS) +if(BUILD_UNIT_TESTS) enable_testing() endif() option(WITH_TE "build mooncake transfer engine and sample code" ON) option(WITH_STORE "build mooncake store library and sample code" ON) option(WITH_STORE_GO "build Go bindings for mooncake store" OFF) +option(WITH_CONDUCTOR "build mooncake conductor service" OFF) option(WITH_P2P_STORE "build p2p store library and sample code" OFF) -option(WITH_RUST_EXAMPLE "build the Rust interface and sample code for the transfer engine" OFF) +option(WITH_RUST_EXAMPLE + "build the Rust interface and sample code for the transfer engine" OFF) option(WITH_STORE_RUST "build the Rust bindings for the Mooncake Store" ON) -option(WITH_STORE_C_SHARED "build a self-contained libmooncake_store.so exposing only the store_c.h C ABI (for dlopen consumers)" OFF) +option( + WITH_STORE_C_SHARED + "build a self-contained libmooncake_store.so exposing only the store_c.h C ABI (for dlopen consumers)" + OFF) option(WITH_EP "build mooncake with expert parallelism support" OFF) option(USE_NOF "build mooncake store with NoF SSD pool support" OFF) option(MOONCAKE_ENABLE_TEST_FAILPOINTS @@ -30,48 +39,82 @@ option(MOONCAKE_ENABLE_OPLOG_PERF_METRICS "Enable detailed batch OpLog performance metrics" OFF) include(${CMAKE_CURRENT_SOURCE_DIR}/mooncake-common/SetupPython.cmake) -add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/extern/pybind11) +if(SKBUILD) + set(PYBIND11_FINDPYTHON ON) + find_package(pybind11 CONFIG REQUIRED) +else() + add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/extern/pybind11) +endif() execute_process( - COMMAND ${PYTHON_EXECUTABLE} -c "import sys; print(sys.path[-1])" - OUTPUT_VARIABLE PYTHON_SYS_PATH -) + COMMAND ${PYTHON_EXECUTABLE} -c "import sys; print(sys.path[-1])" + OUTPUT_VARIABLE PYTHON_SYS_PATH) string(STRIP ${PYTHON_SYS_PATH} PYTHON_SYS_PATH) -if (USE_ETCD) +if(USE_ETCD) add_compile_definitions(USE_ETCD) - if (USE_ETCD_LEGACY) + if(USE_ETCD_LEGACY) add_compile_definitions(USE_ETCD_LEGACY) - message(STATUS "etcd as metadata server support is enabled (etcd-cpp-api-v3)") + message( + STATUS "etcd as metadata server support is enabled (etcd-cpp-api-v3)") else() message(STATUS "etcd as metadata server support is enabled (go package)") endif() endif() option(STORE_USE_ETCD "build mooncake store with etcd" OFF) -if (STORE_USE_ETCD) +if(STORE_USE_ETCD) add_compile_definitions(STORE_USE_ETCD) endif() option(STORE_USE_REDIS "build mooncake store with redis" OFF) -if (STORE_USE_REDIS) +if(STORE_USE_REDIS) add_compile_definitions(STORE_USE_REDIS) endif() -option(STORE_USE_K8S_LEASE "build mooncake store with K8s Lease leader election" OFF) -if (STORE_USE_K8S_LEASE) - if (STORE_USE_ETCD) - message(FATAL_ERROR "STORE_USE_K8S_LEASE and STORE_USE_ETCD cannot be enabled together because both build Go c-shared HA backends.") +option(ENABLE_KV_EVENTS + "Build master KV events ZMQ publisher (requires libzmq when ON)" OFF) +option(STORE_USE_K8S_LEASE + "build mooncake store with K8s Lease leader election" OFF) +if(STORE_USE_K8S_LEASE) + if(STORE_USE_ETCD) + message( + FATAL_ERROR + "STORE_USE_K8S_LEASE and STORE_USE_ETCD cannot be enabled together because both build Go c-shared HA backends." + ) endif() - if (USE_ETCD AND NOT USE_ETCD_LEGACY) - message(FATAL_ERROR "STORE_USE_K8S_LEASE cannot be enabled with non-legacy USE_ETCD because both build Go c-shared libraries in the same process.") + if(USE_ETCD AND NOT USE_ETCD_LEGACY) + message( + FATAL_ERROR + "STORE_USE_K8S_LEASE cannot be enabled with non-legacy USE_ETCD because both build Go c-shared libraries in the same process." + ) endif() add_compile_definitions(STORE_USE_K8S_LEASE) endif() -if (USE_NOF) +if(USE_NOF) add_compile_definitions(USE_NOF) else() message(STATUS "USE_NOF=OFF, NoF SSD pool support is disabled") endif() -option(STORE_USE_JEMALLOC "Use jemalloc in mooncake store master" OFF) +# The matching STORE_USE_JEMALLOC compile definition is scoped to the master and +# client executables in mooncake-store/src, since only they link the allocator. +option(STORE_USE_JEMALLOC + "Use jemalloc in the mooncake store master and client binaries" OFF) + +include(${CMAKE_CURRENT_SOURCE_DIR}/cmake/Dependencies.cmake) +if(WITH_STORE) + mooncake_provide_zstd() + mooncake_provide_xxhash() + if(ENABLE_KV_EVENTS) + mooncake_provide_libzmq() + endif() +endif() +if(WITH_STORE OR (WITH_TE AND USE_TENT)) + mooncake_provide_liburing() +endif() +if((WITH_TE AND USE_REDIS) OR (WITH_STORE AND STORE_USE_REDIS)) + mooncake_provide_hiredis(REQUIRED) +elseif(WITH_TE AND USE_TENT) + mooncake_provide_hiredis() +endif() # Define ASIO macros before building targets that include ASIO headers. add_compile_definitions(ASIO_SEPARATE_COMPILATION ASIO_DYN_LINK) @@ -81,19 +124,24 @@ include_directories(mooncake-common/etcd) include_directories(mooncake-common/k8s-lease) include_directories(mooncake-common/include) -if (WITH_TE) +if(WITH_TE) add_subdirectory(mooncake-transfer-engine) include_directories(mooncake-transfer-engine/include) endif() -if (WITH_STORE) +if(WITH_STORE) message(STATUS "Mooncake Store will be built") add_subdirectory(mooncake-store) include_directories(mooncake-store/include) endif() -if (WITH_STORE_RUST) - if (NOT WITH_STORE) +if(WITH_CONDUCTOR) + message(STATUS "Mooncake Conductor will be built") + add_subdirectory(mooncake-conductor) +endif() + +if(WITH_STORE_RUST) + if(NOT WITH_STORE) message(FATAL_ERROR "WITH_STORE_RUST=ON requires WITH_STORE=ON") endif() message(STATUS "Mooncake Store Rust bindings will be built") @@ -101,115 +149,140 @@ if (WITH_STORE_RUST) endif() option(EP_USE_IDE "Enable intelligent indexing for IDEs" OFF) -if (WITH_EP) +if(WITH_EP) add_subdirectory(mooncake-pg) include_directories(mooncake-pg/include) - if (EP_USE_IDE) + if(EP_USE_IDE) message(WARNING "EP_USE_IDE enabled. DO NOT USE IN PRODUCTION!") add_subdirectory(mooncake-ep) include_directories(mooncake-ep/include) add_library( mooncake_pg_torch_ide OBJECT mooncake-pg/torch/src/mooncake_backend.cpp - mooncake-pg/torch/src/pg_py.cpp - mooncake-pg/torch/src/work_handles.cpp) + mooncake-pg/torch/src/pg_py.cpp mooncake-pg/torch/src/work_handles.cpp) # Reuse Mooncake EP's dependency include paths for clangd indexing. target_include_directories( mooncake_pg_torch_ide - PRIVATE mooncake-pg/torch/include - mooncake-pg/include - $) - else () - message(STATUS "WITH_EP enabled: building Mooncake EP and PG Python extensions") + PRIVATE mooncake-pg/torch/include mooncake-pg/include + $) + else() + message( + STATUS + "WITH_EP enabled: building Mooncake EP natively and PG via setup.py") if(USE_CUDA) find_package(CUDAToolkit REQUIRED) message(STATUS "Detected CUDA version: ${CUDAToolkit_VERSION}") endif() - # EP_TORCH_VERSIONS: semicolon-separated list of PyTorch versions to build for. - # Can be set via -DEP_TORCH_VERSIONS="2.9.1;2.8.0" or the EP_TORCH_VERSIONS env var. - # Empty means build with the currently-installed torch. + add_subdirectory(mooncake-ep) + include_directories(mooncake-ep/include) + + # EP_TORCH_VERSIONS: semicolon-separated list of PyTorch versions to build + # for. Can be set via -DEP_TORCH_VERSIONS="2.9.1;2.8.0" or the + # EP_TORCH_VERSIONS env var. Empty means build with the currently-installed + # torch. if(NOT EP_TORCH_VERSIONS) set(EP_TORCH_VERSIONS "$ENV{EP_TORCH_VERSIONS}") endif() - set(EP_TORCH_VERSIONS "${EP_TORCH_VERSIONS}" CACHE STRING - "PyTorch versions for EP/PG extensions, semicolon-separated (empty = use currently-installed torch)") + set(EP_TORCH_VERSIONS + "${EP_TORCH_VERSIONS}" + CACHE + STRING + "PyTorch versions for EP/PG extensions, semicolon-separated (empty = use currently-installed torch)" + ) + + if(SKBUILD AND NOT EP_TORCH_VERSIONS) + execute_process( + COMMAND ${Python3_EXECUTABLE} -c "import torch" + RESULT_VARIABLE _mooncake_python_has_torch + OUTPUT_QUIET ERROR_QUIET) + if(NOT _mooncake_python_has_torch EQUAL 0) + message( + FATAL_ERROR + "WITH_EP requires EP_TORCH_VERSIONS in an isolated scikit-build-core build because PyTorch is not installed in the build environment" + ) + endif() + endif() # TORCH_CUDA_ARCH_LIST forwarded to the torch CUDA extension build. if(NOT TORCH_CUDA_ARCH_LIST) set(TORCH_CUDA_ARCH_LIST "$ENV{TORCH_CUDA_ARCH_LIST}") endif() if(NOT TORCH_CUDA_ARCH_LIST) - set(TORCH_CUDA_ARCH_LIST "8.0;9.0") + if(CUDAToolkit_VERSION VERSION_GREATER_EQUAL "13.0") + set(TORCH_CUDA_ARCH_LIST "8.0;9.0;10.3") + else() + set(TORCH_CUDA_ARCH_LIST "8.0;9.0") + endif() endif() - set(TORCH_CUDA_ARCH_LIST "${TORCH_CUDA_ARCH_LIST}" CACHE STRING - "CUDA arch list for EP/PG extension builds (e.g. \"8.0;9.0\")") + set(TORCH_CUDA_ARCH_LIST + "${TORCH_CUDA_ARCH_LIST}" + CACHE STRING + "CUDA arch list for EP/PG extension builds (e.g. \"8.0;9.0\")") - # Staging directory: EP/PG .so files are placed here during make and later - # injected into the wheel AFTER auditwheel, so patchelf never touches the - # CUDA fatbins (which would cause cudaErrorInvalidKernelImage at runtime). + # Keep generated Torch extensions and device libraries in backend-owned + # build directories. The legacy release path injects the staged device + # artifacts after auditwheel; scikit-build-core installs them directly into + # its private wheel tree. set(EP_PG_STAGING_DIR "${CMAKE_BINARY_DIR}/ep_pg_staging") + set(EP_PG_BUILD_DIR "${CMAKE_BINARY_DIR}/ep_pg_build") + file(REMOVE_RECURSE "${EP_PG_STAGING_DIR}" "${EP_PG_BUILD_DIR}") + file(MAKE_DIRECTORY "${EP_PG_STAGING_DIR}" "${EP_PG_BUILD_DIR}") - # Convert semicolon-separated lists to pipe-separated strings so they survive - # CMake's COMMAND list-splitting (semicolons are CMake list separators). + # Convert semicolon-separated lists to pipe-separated strings so they + # survive CMake's COMMAND list-splitting (semicolons are CMake list + # separators). string(REPLACE ";" "|" _ep_torch_versions_pipe "${EP_TORCH_VERSIONS}") string(REPLACE ";" "|" _torch_cuda_arch_list_pipe "${TORCH_CUDA_ARCH_LIST}") - add_custom_target(mooncake_ep_ext ALL - COMMAND ${CMAKE_COMMAND} -E make_directory "${EP_PG_STAGING_DIR}" - COMMAND ${CMAKE_COMMAND} - "-DSOURCE_DIR=${CMAKE_CURRENT_SOURCE_DIR}/mooncake-ep" - "-DEP_CUDA_MAJOR=${CUDAToolkit_VERSION_MAJOR}" - "-DEP_CUDA_MINOR=${CUDAToolkit_VERSION_MINOR}" - "-DEP_TORCH_VERSIONS=${_ep_torch_versions_pipe}" - "-DTORCH_CUDA_ARCH_LIST=${_torch_cuda_arch_list_pipe}" - "-DSTAGING_DIR=${EP_PG_STAGING_DIR}" - "-DENGINE_SO_PATH=$" - "-DPython3_EXECUTABLE=${Python3_EXECUTABLE}" - "-DEP_USE_MUSA=$,1,0>" - "-DEP_USE_MACA=$,1,0>" - -P "${CMAKE_CURRENT_SOURCE_DIR}/mooncake-ep/BuildEpExt.cmake" - COMMENT "Building Mooncake EP Python extension(s)" - DEPENDS engine - VERBATIM - ) + if(USE_CUDA) + add_custom_target( + mooncake_ep_device_stage ALL + COMMAND ${CMAKE_COMMAND} -E make_directory "${EP_PG_STAGING_DIR}" + COMMAND ${CMAKE_COMMAND} -E copy_if_different + "$" "${EP_PG_STAGING_DIR}/" + COMMENT "Staging Mooncake EP CUDA device library" + DEPENDS mooncake_ep_device + VERBATIM) + endif() - add_custom_target(mooncake_pg_ext ALL + add_custom_target( + mooncake_pg_ext ALL COMMAND ${CMAKE_COMMAND} -E make_directory "${EP_PG_STAGING_DIR}" - COMMAND ${CMAKE_COMMAND} + COMMAND + ${CMAKE_COMMAND} "-DSOURCE_DIR=${CMAKE_CURRENT_SOURCE_DIR}/mooncake-pg/torch" "-DEP_CUDA_MAJOR=${CUDAToolkit_VERSION_MAJOR}" "-DEP_CUDA_MINOR=${CUDAToolkit_VERSION_MINOR}" "-DEP_TORCH_VERSIONS=${_ep_torch_versions_pipe}" - "-DSTAGING_DIR=${EP_PG_STAGING_DIR}" + "-DSTAGING_DIR=${EP_PG_STAGING_DIR}" "-DBUILD_DIR=${EP_PG_BUILD_DIR}" "-DPG_CORE_SO_PATH=$" "-DPG_DEVICE_SO_PATH=$" "-DPython3_EXECUTABLE=${Python3_EXECUTABLE}" "-DEP_USE_MUSA=$,1,0>" - "-DEP_USE_MACA=$,1,0>" - -P "${CMAKE_CURRENT_SOURCE_DIR}/mooncake-pg/torch/BuildPgExt.cmake" + "-DEP_USE_MACA=$,1,0>" -P + "${CMAKE_CURRENT_SOURCE_DIR}/mooncake-pg/torch/BuildPgExt.cmake" COMMENT "Building Mooncake PG Python extension(s)" - DEPENDS mooncake_pg mooncake_pg_device mooncake_ep_ext - VERBATIM - ) - endif () + DEPENDS mooncake_pg mooncake_pg_device + VERBATIM) + endif() endif() add_subdirectory(mooncake-integration) -if (WITH_STORE_GO AND WITH_STORE) +if(WITH_STORE_GO AND WITH_STORE) add_custom_target(build_store_go DEPENDS mooncake_store transfer_engine) add_custom_command( - TARGET build_store_go - COMMAND bash build.sh ${CMAKE_BINARY_DIR} ${CMAKE_CURRENT_BINARY_DIR} ${USE_ETCD} ${USE_REDIS} ${USE_HTTP} ${USE_ETCD_LEGACY} - WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/mooncake-store/go - ) + TARGET build_store_go + COMMAND bash build.sh ${CMAKE_BINARY_DIR} ${CMAKE_CURRENT_BINARY_DIR} + ${USE_ETCD} ${USE_REDIS} ${USE_HTTP} ${USE_ETCD_LEGACY} + WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/mooncake-store/go) set_property(TARGET build_store_go PROPERTY EXCLUDE_FROM_ALL FALSE) message(STATUS "Mooncake Store Go bindings will be built") endif() -if (WITH_P2P_STORE) +if(WITH_P2P_STORE) add_subdirectory(mooncake-p2p-store) message(STATUS "P2P Store will be built") endif() diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 68d0741220..d29ca43570 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -22,6 +22,7 @@ Prefer one of the following documented prefixes: - ``[Bugfix]`` for bug fixes. - ``[CI/Build]`` for build or continuous integration improvements. - ``[Doc]`` for documentation fixes and improvements. +- ``[Conductor]`` for changes in the ``mooncake-conductor``. - ``[Integration]`` for changes in the ``mooncake-integration``. - ``[P2PStore]`` for changes in the ``mooncake-p2p-store``. - ``[Store]`` for changes in the ``mooncake-store``. @@ -34,9 +35,12 @@ when they better match the change scope: ``[Bug fix]``, ``[Build]``, ``[CI]``, ``[Docs]``, ``[EP]``, ``[Feature]``, ``[MUSA]``, ``[PG]``, ``[TE]``, ``[TENT]``, and ``[Wheel]``. -### RFC Discussion +### Notes for Large Changes -For major architectural changes (>500 LOC excluding tests), we would expect a GitHub issue (RFC) discussing the technical design and justification. +Please keep changes as concise as possible. For major architectural changes +(>500 LOC excluding kernel/data/config/test), we expect a GitHub issue (RFC) +that discusses the technical design and justification. Otherwise, the PR may be +tagged with `rfc-required` and might not be reviewed until an RFC is provided. ### Development Workflow & Pre-commit Hooks @@ -65,9 +69,21 @@ committing again. Use `./scripts/code_format.sh --all` only when intentionally formatting the whole project. #### Usage -Run hooks on all files (the first run installs hook environments). The C/C++ -hook remains limited to staged line ranges; use `./scripts/code_format.sh --all` -for an intentional whole-project C/C++ format: +After `pre-commit install`, hooks run on each commit. To run them manually on +staged files (the first run may install hook environments): +```bash +pre-commit run +``` +Before opening a PR, run hooks only on files changed against the PR base +(default `origin/main`). The C/C++ hook remains limited to staged or changed +line ranges: +```bash +git fetch origin main +pre-commit run --files $(git diff --name-only --diff-filter=ACMR origin/main...HEAD) +``` +Use full-repo checks only for intentional whole-project cleanup; do not fold +unrelated rewrites into a feature PR. Prefer `./scripts/code_format.sh --all` +for a deliberate whole-project C/C++ format: ```bash pre-commit run --all-files ``` diff --git a/MAINTAINERS.md b/MAINTAINERS.md index 297963d94c..24260e376e 100644 --- a/MAINTAINERS.md +++ b/MAINTAINERS.md @@ -12,6 +12,7 @@ Current list of codeowners on this project: | ---------------------------- | ---------------------------- | ---------------------------- | ---------------------------- | | | | | | | | | | | -| | | | | +| | | | | +| | | | | Want to include your company logo? Just open a Pull Request! \ No newline at end of file diff --git a/README.md b/README.md index 5d0cb1f2ee..afd83df98c 100644 --- a/README.md +++ b/README.md @@ -24,9 +24,8 @@ [![PyPI Non-CUDA](https://img.shields.io/static/v1?label=pypi&message=non-CUDA&color=00BFFF)](https://pypi.org/project/mooncake-transfer-engine-non-cuda/) [![PyPI NPU](https://img.shields.io/static/v1?label=pypi&message=NPU&color=F87171)](https://pypi.org/project/mooncake-transfer-engine-npu/) [![PyPI MUSA](https://img.shields.io/static/v1?label=pypi&message=MUSA&color=F97316)](https://pypi.org/project/mooncake-transfer-engine-musa/) - [![PyPI EFA CUDA 12](https://img.shields.io/static/v1?label=pypi&message=EFA%20%2B%20CUDA%2012&color=F59E0B)](https://pypi.org/project/mooncake-transfer-engine-efa/) - [![PyPI EFA CUDA 13](https://img.shields.io/static/v1?label=pypi&message=EFA%20%2B%20CUDA%2013&color=F59E0B)](https://pypi.org/project/mooncake-transfer-engine-efa-cuda13/) - [![PyPI EFA Non-CUDA](https://img.shields.io/static/v1?label=pypi&message=EFA%20non-CUDA&color=F59E0B)](https://pypi.org/project/mooncake-transfer-engine-efa-non-cuda/) + [![PyPI EFA](https://img.shields.io/static/v1?label=pypi&message=EFA&color=F59E0B)](https://pypi.org/project/mooncake-transfer-engine-efa/) + [![PyPI ROCm](https://img.shields.io/static/v1?label=pypi&message=ROCm&color=black)](https://pypi.org/project/mooncake-transfer-engine-rocm/)
@@ -35,16 +34,18 @@ Under real workloads, Mooncake’s innovative architecture enables Kimi to handl

🔄 Updates

-- **Jul 2, 2026**: [DSpark](https://x.com/mgoin_/status/2072785822231728363) scales fully online training on a GB300 NVL72 system with Speculators and Mooncake: 9 vLLM nodes serve the GLM 5.2 FP8 verifier through Mooncake RDMA Store to 6 FSDP training nodes (DP=24), achieving 125k prefill tokens/s and 1.5 steps/s. +- **Aug 24, 2026**: [AgentX / InferenceX v3](https://newsletter.semianalysis.com/p/agentx-inferencexv3-does-cuda-moat) highlights Mooncake's contributions to agentic inference workloads, including ROCm wheels, CI, and packaging improvements. [Mooncake optimization details](https://inferencex.semianalysis.com/agentx/optimizations/mooncake). +- **Aug 20, 2026**: Mooncake is integrated into [Miles](https://github.com/radixark/miles) as a rollout data-transfer backend for the fragmented, heterogeneous data moving between rollout and training in disaggregated RL. Blogs: [KVCache.AI](https://kvcache.ai/blog/mooncake-rl-rollout-data-transfer/), [lmsys](https://www.lmsys.org/blog/2026-08-20-miles-mooncake-rollout-data-transfer). +- **Aug 17, 2026**: Mooncake is integrated into [Speculators](https://github.com/vllm-project/speculators) as a distributed backend for multi-node online training, efficiently moves hidden-state between vLLM inference workers and Speculators trainers through RDMA, eliminating the need for massive hidden-state storage in offline training. [Benchmark on GB300 NVL72](https://x.com/mgoin_/status/2072785822231728363). - **May 7, 2026**: 🚀 [vLLM officially features Mooncake Store](https://vllm.ai/blog/mooncake-store) — a deep dive into how Mooncake's distributed KVCache engine supercharges vLLM inference with high-throughput, memory-efficient, cross-instance KV cache sharing! - **Apr 29, 2026**: SGLang introduces [RDMA-based P2P weight transfer for large-scale distributed RL](https://lmsys.org/blog/2026-04-29-p2p-update/) using Mooncake TransferEngine, achieving 7x faster weight updates for the 1T-parameter Kimi-K2 model (53s → 7.2s) with zero-copy RDMA transfer across thousands of GPUs. - **Mar 19, 2026**: [TorchSpec: Speculative Decoding Training at Scale](https://pytorch.org/blog/torchspec-speculative-decoding-training-at-scale) is [open sourced](https://github.com/torchspec-project/TorchSpec), using Mooncake to decouple inference and training via efficient hidden states management. -- **Mar 5, 2026**: [LightX2V](https://github.com/ModelTC/LightX2V/pull/893) now supports disaggregated deployment based on Mooncake, enabling encoder/transformer service decoupling with Mooncake Transfer Engine for high-performance cross-device and cross-machine data transfer. Details in [blog](https://light-ai.top/LightX2V-BLOG/posts/Disaggregation/). -- **Feb 25, 2026**: [SGLang](https://github.com/sgl-project/sglang) merged [Encoder Global Cache Manager](https://github.com/sgl-project/sglang/pull/16137), introducing a Mooncake-powered global multimodal embedding cache that enables cross-instance sharing of ViT embeddings to avoid redundant GPU computation.
More +- **Mar 5, 2026**: [LightX2V](https://github.com/ModelTC/LightX2V/pull/893) now supports disaggregated deployment based on Mooncake, enabling encoder/transformer service decoupling with Mooncake Transfer Engine for high-performance cross-device and cross-machine data transfer. Details in [blog](https://light-ai.top/LightX2V-BLOG/posts/Disaggregation/). +- **Feb 25, 2026**: [SGLang](https://github.com/sgl-project/sglang) merged [Encoder Global Cache Manager](https://github.com/sgl-project/sglang/pull/16137), introducing a Mooncake-powered global multimodal embedding cache that enables cross-instance sharing of ViT embeddings to avoid redundant GPU computation. - **Feb 24, 2026**: [vLLM-Omni](https://docs.vllm.ai/projects/vllm-omni/en/latest/design/feature/disaggregated_inference/) introduces disaggregated inference connectors with support for both `MooncakeStoreConnector` and `MooncakeTransferEngineConnector` for multi-node omni-modality pipelines. - **Feb 12, 2026**: [Mooncake Joins PyTorch Ecosystem](https://pytorch.org/blog/mooncake-joins-pytorch-ecosystem/) We are thrilled to announce that Mooncake has officially joined the PyTorch Ecosystem! - **Jan 28, 2026**: [FlexKV](https://github.com/taco-project/FlexKV), a distributed KV store and cache system from Tencent and NVIDIA in collaboration with the community, now supports [distributed KVCache reuse](https://github.com/taco-project/FlexKV/blob/main/docs/dist_reuse/README_en.md) with the Mooncake Transfer Engine. @@ -214,6 +215,29 @@ pip install mooncake-transfer-engine pip install mooncake-transfer-engine-cuda13 ``` +#### Master image + +The multi-architecture master image publishes explicit CUDA flavor tags: + +```bash +# Fixed versions, recommended for production +docker pull kvcacheai/mooncake:0.3.14-cuda12 +docker pull kvcacheai/mooncake:0.3.14-cuda13 + +# CUDA 13 is the default flavor +docker pull kvcacheai/mooncake:0.3.14 +docker pull kvcacheai/mooncake:latest + +# Floating flavor aliases +docker pull kvcacheai/mooncake:cuda12 +docker pull kvcacheai/mooncake:cuda13 +``` + +The unqualified `:` and `:latest` tags point to CUDA 13 starting with the first +release published by the new tagging workflow. Existing releases keep their original contents; +use `:cuda12` or `:-cuda12` when CUDA 12 is required. The former `:latest-cuda13` tag +is deprecated and is not part of the new naming contract. + In addition to CUDA, Mooncake also supports other accelerator backends, along with flexible installation and deployment options. See the guides below for details: - [Quick Start](https://kvcache-ai.github.io/Mooncake/getting_started/quick-start.html) diff --git a/benchmarks/storage_benchmark_v1/benchmark.py b/benchmarks/storage_benchmark_v1/benchmark.py index f840732393..a28f5493f6 100644 --- a/benchmarks/storage_benchmark_v1/benchmark.py +++ b/benchmarks/storage_benchmark_v1/benchmark.py @@ -24,9 +24,11 @@ # Data Structures # ============================================================================ + @dataclass class KVCacheRequest: """KVCache request from trace""" + timestamp: float hash_ids: List[int] input_length: int @@ -37,6 +39,7 @@ class KVCacheRequest: # Trace Replay # ============================================================================ + class TraceReplay: """Trace replay handler""" @@ -46,17 +49,19 @@ def __init__(self, trace_path: str): def load_all(self) -> List[KVCacheRequest]: """Load all requests from trace file""" requests = [] - with open(self.trace_path, 'r', encoding='utf-8') as f: + with open(self.trace_path, "r", encoding="utf-8") as f: for line in f: line = line.strip() if line: req = json.loads(line) - requests.append(KVCacheRequest( - timestamp=req.get('timestamp', 0), - hash_ids=req.get('hash_ids', []), - input_length=req.get('input_length', 0), - output_length=req.get('output_length', 0), - )) + requests.append( + KVCacheRequest( + timestamp=req.get("timestamp", 0), + hash_ids=req.get("hash_ids", []), + input_length=req.get("input_length", 0), + output_length=req.get("output_length", 0), + ) + ) return requests @@ -64,16 +69,23 @@ def load_all(self) -> List[KVCacheRequest]: # Storage Benchmark # ============================================================================ + class StorageBenchmark: """KVCache storage benchmark Processes KVCache requests using layout-generated access patterns. """ - def __init__(self, storage_dir: str, model_config: dict, - page_size_tokens: int = 512, - max_pages: int = 100000, - fsync_mode: str = 'none', fsync_batch_size: int = 100): + def __init__( + self, + storage_dir: str, + model_config: dict, + page_size_tokens: int = 512, + max_pages: int = 100000, + file_mode: str = "single", + fsync_mode: str = "none", + fsync_batch_size: int = 100, + ): """Initialize benchmark Args: @@ -81,6 +93,8 @@ def __init__(self, storage_dir: str, model_config: dict, model_config: Model configuration dict page_size_tokens: Tokens per page (default: 512) max_pages: Maximum number of pages + file_mode: 'single' = one big data.bin with slot offsets (default); + 'per-file' = one file per page fsync_mode: When to fsync ('none', 'batch', 'always', 'end') fsync_batch_size: Number of writes between fsync in batch mode """ @@ -93,19 +107,20 @@ def __init__(self, storage_dir: str, model_config: dict, storage_dir=storage_dir, page_size=self.page_size_bytes, max_pages=max_pages, + file_mode=file_mode, fsync_mode=fsync_mode, - fsync_batch_size=fsync_batch_size + fsync_batch_size=fsync_batch_size, ) # Statistics self.stats = { - 'total_requests': 0, - 'total_tokens': 0, - 'read_pages': 0, - 'write_pages': 0, - 'page_hits': 0, - 'request_io_latencies_ms': [], - 'request_wall_latencies_ms': [], + "total_requests": 0, + "total_tokens": 0, + "read_pages": 0, + "write_pages": 0, + "page_hits": 0, + "request_io_latencies_ms": [], + "request_wall_latencies_ms": [], } def process_request(self, req: KVCacheRequest) -> float: @@ -117,8 +132,8 @@ def process_request(self, req: KVCacheRequest) -> float: Returns: Total latency in milliseconds """ - self.stats['total_requests'] += 1 - self.stats['total_tokens'] += req.input_length + req.output_length + self.stats["total_requests"] += 1 + self.stats["total_tokens"] += req.input_length + req.output_length request_start = time.perf_counter() io_latency_ms = 0.0 @@ -127,47 +142,57 @@ def process_request(self, req: KVCacheRequest) -> float: for access in self.layout.get_operations(req): if self.storage.exists(access.page_id): # Page exists, perform READ - io_latency_ms += self.storage.read( + latency = self.storage.read( access.page_id, offset_in_page=access.offset_in_page, - length=access.length + length=access.length, ) - self.stats['read_pages'] += 1 - self.stats['page_hits'] += 1 + if latency is None: + continue + io_latency_ms += latency + self.stats["read_pages"] += 1 + self.stats["page_hits"] += 1 else: # Page doesn't exist, perform WRITE - io_latency_ms += self.storage.write( + latency = self.storage.write( access.page_id, offset_in_page=access.offset_in_page, - length=access.length + length=access.length, ) - self.stats['write_pages'] += 1 + if latency is None: + continue + io_latency_ms += latency + self.stats["write_pages"] += 1 wall_latency_ms = (time.perf_counter() - request_start) * 1000.0 - self.stats['request_io_latencies_ms'].append(io_latency_ms) - self.stats['request_wall_latencies_ms'].append(wall_latency_ms) + self.stats["request_io_latencies_ms"].append(io_latency_ms) + self.stats["request_wall_latencies_ms"].append(wall_latency_ms) return io_latency_ms def get_stats(self) -> Dict: """Get statistics""" storage_stats = self.storage.get_stats() - request_io_latencies = self.stats['request_io_latencies_ms'] - request_wall_latencies = self.stats['request_wall_latencies_ms'] + request_io_latencies = self.stats["request_io_latencies_ms"] + request_wall_latencies = self.stats["request_wall_latencies_ms"] - total_pages = self.stats['read_pages'] + self.stats['write_pages'] + total_pages = self.stats["read_pages"] + self.stats["write_pages"] return { - 'total_requests': self.stats['total_requests'], - 'total_tokens': self.stats['total_tokens'], - 'total_pages': total_pages, - 'read_pages': self.stats['read_pages'], - 'write_pages': self.stats['write_pages'], - 'page_hits': self.stats['page_hits'], - 'page_hit_rate': self.stats['read_pages'] / total_pages if total_pages > 0 else 0, - 'write_ratio': self.stats['write_pages'] / total_pages if total_pages > 0 else 0, - 'request_io_latency': latency_stats(request_io_latencies), - 'request_wall_latency': latency_stats(request_wall_latencies), - 'storage': storage_stats, + "total_requests": self.stats["total_requests"], + "total_tokens": self.stats["total_tokens"], + "total_pages": total_pages, + "read_pages": self.stats["read_pages"], + "write_pages": self.stats["write_pages"], + "page_hits": self.stats["page_hits"], + "page_hit_rate": self.stats["read_pages"] / total_pages + if total_pages > 0 + else 0, + "write_ratio": self.stats["write_pages"] / total_pages + if total_pages > 0 + else 0, + "request_io_latency": latency_stats(request_io_latencies), + "request_wall_latency": latency_stats(request_wall_latencies), + "storage": storage_stats, } def __enter__(self): @@ -185,6 +210,7 @@ def close(self, force_sync: bool = True): # Benchmark Runner # ============================================================================ + def get_max_page_id(requests: List[KVCacheRequest]) -> int: max_id = 0 for req in requests: @@ -194,16 +220,17 @@ def get_max_page_id(requests: List[KVCacheRequest]) -> int: def parse_csv_floats(value: str) -> List[float]: - return [float(item.strip()) for item in value.split(',') if item.strip()] + return [float(item.strip()) for item in value.split(",") if item.strip()] -def wait_for_replay_time(req: KVCacheRequest, base_timestamp: float, - start_time: float, replay_scale: float): +def wait_for_replay_time( + req: KVCacheRequest, base_timestamp: float, start_time: float, replay_scale: float +): if replay_scale <= 0 or req.timestamp == 0: return - target_time = (start_time + - max(0.0, req.timestamp - base_timestamp) / - (1000.0 * replay_scale)) + target_time = start_time + max(0.0, req.timestamp - base_timestamp) / ( + 1000.0 * replay_scale + ) delay = target_time - time.perf_counter() if delay > 0: time.sleep(delay) @@ -211,7 +238,7 @@ def wait_for_replay_time(req: KVCacheRequest, base_timestamp: float, def latency_stats(values: List[float]) -> Dict[str, float]: if not values: - return {'avg_ms': 0, 'p50_ms': 0, 'p95_ms': 0, 'p99_ms': 0} + return {"avg_ms": 0, "p50_ms": 0, "p95_ms": 0, "p99_ms": 0} sorted_values = sorted(values) @@ -222,51 +249,46 @@ def get_percentile(p: float) -> float: lower = int(rank) upper = min(lower + 1, len(sorted_values) - 1) weight = rank - lower - return (sorted_values[lower] * (1.0 - weight) + - sorted_values[upper] * weight) + return sorted_values[lower] * (1.0 - weight) + sorted_values[upper] * weight return { - 'avg_ms': statistics.mean(values), - 'p50_ms': get_percentile(0.50), - 'p95_ms': get_percentile(0.95), - 'p99_ms': get_percentile(0.99), + "avg_ms": statistics.mean(values), + "p50_ms": get_percentile(0.50), + "p95_ms": get_percentile(0.95), + "p99_ms": get_percentile(0.99), } def snapshot_thread_stats(benchmark: StorageBenchmark) -> Dict[str, Any]: storage = benchmark.storage - total_pages = benchmark.stats['read_pages'] + benchmark.stats['write_pages'] + total_pages = benchmark.stats["read_pages"] + benchmark.stats["write_pages"] return { - 'total_requests': benchmark.stats['total_requests'], - 'total_tokens': benchmark.stats['total_tokens'], - 'read_pages': benchmark.stats['read_pages'], - 'write_pages': benchmark.stats['write_pages'], - 'page_hits': benchmark.stats['page_hits'], - 'request_io_latencies_ms': list( - benchmark.stats['request_io_latencies_ms'] - ), - 'request_wall_latencies_ms': list( - benchmark.stats['request_wall_latencies_ms'] - ), - 'read_bytes': storage.stats['read_bytes'], - 'write_bytes': storage.stats['write_bytes'], - 'read_time_s': storage.stats['read_time_s'], - 'write_time_s': storage.stats['write_time_s'], - 'read_latencies_ms': list(storage.stats['read_latencies_ms']), - 'write_latencies_ms': list(storage.stats['write_latencies_ms']), - 'sync_count': storage.stats['sync_count'], - 'max_pages': storage.max_pages, - 'written_pages': len(storage._written_pages), - 'total_pages': total_pages, + "total_requests": benchmark.stats["total_requests"], + "total_tokens": benchmark.stats["total_tokens"], + "read_pages": benchmark.stats["read_pages"], + "write_pages": benchmark.stats["write_pages"], + "page_hits": benchmark.stats["page_hits"], + "request_io_latencies_ms": list(benchmark.stats["request_io_latencies_ms"]), + "request_wall_latencies_ms": list(benchmark.stats["request_wall_latencies_ms"]), + "read_bytes": storage.stats["read_bytes"], + "write_bytes": storage.stats["write_bytes"], + "read_time_s": storage.stats["read_time_s"], + "write_time_s": storage.stats["write_time_s"], + "read_latencies_ms": list(storage.stats["read_latencies_ms"]), + "write_latencies_ms": list(storage.stats["write_latencies_ms"]), + "sync_count": storage.stats["sync_count"], + "max_pages": storage.max_pages, + "written_pages": len(storage._written_pages), + "total_pages": total_pages, } def aggregate_thread_stats(thread_stats: List[Dict[str, Any]]) -> Dict: - total_requests = sum(s['total_requests'] for s in thread_stats) - total_tokens = sum(s['total_tokens'] for s in thread_stats) - read_pages = sum(s['read_pages'] for s in thread_stats) - write_pages = sum(s['write_pages'] for s in thread_stats) - page_hits = sum(s['page_hits'] for s in thread_stats) + total_requests = sum(s["total_requests"] for s in thread_stats) + total_tokens = sum(s["total_tokens"] for s in thread_stats) + read_pages = sum(s["read_pages"] for s in thread_stats) + write_pages = sum(s["write_pages"] for s in thread_stats) + page_hits = sum(s["page_hits"] for s in thread_stats) total_pages = read_pages + write_pages request_io_latencies = [] @@ -274,74 +296,83 @@ def aggregate_thread_stats(thread_stats: List[Dict[str, Any]]) -> Dict: read_latencies = [] write_latencies = [] for stats in thread_stats: - request_io_latencies.extend(stats['request_io_latencies_ms']) - request_wall_latencies.extend(stats['request_wall_latencies_ms']) - read_latencies.extend(stats['read_latencies_ms']) - write_latencies.extend(stats['write_latencies_ms']) + request_io_latencies.extend(stats["request_io_latencies_ms"]) + request_wall_latencies.extend(stats["request_wall_latencies_ms"]) + read_latencies.extend(stats["read_latencies_ms"]) + write_latencies.extend(stats["write_latencies_ms"]) - read_bytes = sum(s['read_bytes'] for s in thread_stats) - write_bytes = sum(s['write_bytes'] for s in thread_stats) - read_time = sum(s['read_time_s'] for s in thread_stats) - write_time = sum(s['write_time_s'] for s in thread_stats) + read_bytes = sum(s["read_bytes"] for s in thread_stats) + write_bytes = sum(s["write_bytes"] for s in thread_stats) + read_time = sum(s["read_time_s"] for s in thread_stats) + write_time = sum(s["write_time_s"] for s in thread_stats) return { - 'total_requests': total_requests, - 'total_tokens': total_tokens, - 'total_pages': total_pages, - 'read_pages': read_pages, - 'write_pages': write_pages, - 'page_hits': page_hits, - 'page_hit_rate': read_pages / total_pages if total_pages > 0 else 0, - 'write_ratio': write_pages / total_pages if total_pages > 0 else 0, - 'request_io_latency': latency_stats(request_io_latencies), - 'request_wall_latency': latency_stats(request_wall_latencies), - 'storage': { - 'read': { - 'count': read_pages, - 'mb': read_bytes / 1024 / 1024, - 'time_s': read_time, + "total_requests": total_requests, + "total_tokens": total_tokens, + "total_pages": total_pages, + "read_pages": read_pages, + "write_pages": write_pages, + "page_hits": page_hits, + "page_hit_rate": read_pages / total_pages if total_pages > 0 else 0, + "write_ratio": write_pages / total_pages if total_pages > 0 else 0, + "request_io_latency": latency_stats(request_io_latencies), + "request_wall_latency": latency_stats(request_wall_latencies), + "storage": { + "read": { + "count": read_pages, + "mb": read_bytes / 1024 / 1024, + "time_s": read_time, **latency_stats(read_latencies), }, - 'write': { - 'count': write_pages, - 'mb': write_bytes / 1024 / 1024, - 'time_s': write_time, + "write": { + "count": write_pages, + "mb": write_bytes / 1024 / 1024, + "time_s": write_time, **latency_stats(write_latencies), }, - 'sync_count': sum(s['sync_count'] for s in thread_stats), - 'max_pages': sum(s['max_pages'] for s in thread_stats), - 'written_pages': sum(s['written_pages'] for s in thread_stats), - 'page_hits': page_hits, - 'page_misses': write_pages, + "sync_count": sum(s["sync_count"] for s in thread_stats), + "max_pages": sum(s["max_pages"] for s in thread_stats), + "written_pages": sum(s["written_pages"] for s in thread_stats), + "page_hits": page_hits, + "page_misses": write_pages, }, } -def print_progress(done: int, total: int, start_time: float, - stats: Dict, req: KVCacheRequest = None, - suffix: str = ""): +def print_progress( + done: int, + total: int, + start_time: float, + stats: Dict, + req: KVCacheRequest = None, + suffix: str = "", +): elapsed = time.perf_counter() - start_time qps = done / elapsed if elapsed > 0 else 0 - storage = stats.get('storage', {}) - read_stats = storage.get('read', {}) - write_stats = storage.get('write', {}) - read_time = read_stats.get('time_s', 0) - write_time = write_stats.get('time_s', 0) - read_mbps = read_stats.get('mb', 0) / read_time if read_time > 0 else 0 - write_mbps = write_stats.get('mb', 0) / write_time if write_time > 0 else 0 + storage = stats.get("storage", {}) + read_stats = storage.get("read", {}) + write_stats = storage.get("write", {}) + read_time = read_stats.get("time_s", 0) + write_time = write_stats.get("time_s", 0) + read_mbps = read_stats.get("mb", 0) / read_time if read_time > 0 else 0 + write_mbps = write_stats.get("mb", 0) / write_time if write_time > 0 else 0 if req is None: req_info = "" else: - req_info = (f" ids={len(req.hash_ids):3d} " - f"tokens={req.input_length + req.output_length:6d} |") + req_info = ( + f" ids={len(req.hash_ids):3d} " + f"tokens={req.input_length + req.output_length:6d} |" + ) - print(f" [{done:5d}/{total}]{req_info} QPS={qps:7.2f} | " - f"R={stats['read_pages']:6d} " - f"({read_stats.get('avg_ms', 0):6.2f}ms, {read_mbps:6.1f}MB/s) | " - f"W={stats['write_pages']:6d} " - f"({write_stats.get('avg_ms', 0):6.2f}ms, {write_mbps:6.1f}MB/s)" - f"{suffix}") + print( + f" [{done:5d}/{total}]{req_info} QPS={qps:7.2f} | " + f"R={stats['read_pages']:6d} " + f"({read_stats.get('avg_ms', 0):6.2f}ms, {read_mbps:6.1f}MB/s) | " + f"W={stats['write_pages']:6d} " + f"({write_stats.get('avg_ms', 0):6.2f}ms, {write_mbps:6.1f}MB/s)" + f"{suffix}" + ) def should_print_progress(done: int, total: int, progress_interval: int) -> bool: @@ -350,10 +381,12 @@ def should_print_progress(done: int, total: int, progress_interval: int) -> bool return progress_interval > 0 and done % progress_interval == 0 -def run_single_thread(benchmark: StorageBenchmark, - requests: List[KVCacheRequest], - replay_scale: float, - progress_interval: int) -> Dict[str, Any]: +def run_single_thread( + benchmark: StorageBenchmark, + requests: List[KVCacheRequest], + replay_scale: float, + progress_interval: int, +) -> Dict[str, Any]: start_time = time.perf_counter() base_timestamp = requests[0].timestamp if requests else 0 completed = 0 @@ -363,19 +396,22 @@ def run_single_thread(benchmark: StorageBenchmark, benchmark.process_request(req) completed += 1 if should_print_progress(completed, len(requests), progress_interval): - print_progress(completed, len(requests), start_time, - benchmark.get_stats(), req) + print_progress( + completed, len(requests), start_time, benchmark.get_stats(), req + ) return { - 'completed': completed, - 'elapsed': time.perf_counter() - start_time, - 'stats': benchmark.get_stats(), + "completed": completed, + "elapsed": time.perf_counter() - start_time, + "stats": benchmark.get_stats(), } -def run_multi_thread(benchmarks: List[StorageBenchmark], - requests: List[KVCacheRequest], - replay_scale: float) -> Dict[str, Any]: +def run_multi_thread( + benchmarks: List[StorageBenchmark], + requests: List[KVCacheRequest], + replay_scale: float, +) -> Dict[str, Any]: start_time = time.perf_counter() base_timestamp = requests[0].timestamp if requests else 0 total_requests = len(requests) * len(benchmarks) @@ -397,24 +433,36 @@ def run_worker(thread_id: int): for future in as_completed(futures): worker_stats = future.result() thread_stats.append(worker_stats) - completed += worker_stats['total_requests'] - print_progress(completed, total_requests, start_time, - aggregate_thread_stats(thread_stats), - suffix=" | completed worker") + completed += worker_stats["total_requests"] + print_progress( + completed, + total_requests, + start_time, + aggregate_thread_stats(thread_stats), + suffix=" | completed worker", + ) return { - 'completed': completed, - 'elapsed': time.perf_counter() - start_time, - 'stats': aggregate_thread_stats(thread_stats), + "completed": completed, + "elapsed": time.perf_counter() - start_time, + "stats": aggregate_thread_stats(thread_stats), } -def run_benchmark(trace_path: str, storage_dir: str, model_config: dict, - max_requests: int = None, max_pages: int = None, - page_size_tokens: int = 512, - fsync_mode: str = 'none', fsync_batch_size: int = 100, - threads: int = 1, replay_scale: float = 0.0, - progress_interval: int = 100) -> Dict: +def run_benchmark( + trace_path: str, + storage_dir: str, + model_config: dict, + max_requests: int = None, + max_pages: int = None, + page_size_tokens: int = 512, + file_mode: str = "single", + fsync_mode: str = "none", + fsync_batch_size: int = 100, + threads: int = 1, + replay_scale: float = 0.0, + progress_interval: int = 100, +) -> Dict: """Run benchmark Args: @@ -424,6 +472,8 @@ def run_benchmark(trace_path: str, storage_dir: str, model_config: dict, max_requests: Maximum number of requests (None = all) max_pages: Maximum number of pages (None = auto-calculate) page_size_tokens: Tokens per page + file_mode: 'single' = one big data.bin with slot offsets (default); + 'per-file' = one file per page fsync_mode: When to fsync fsync_batch_size: Number of writes between fsync threads: Benchmark client worker threads @@ -438,8 +488,13 @@ def run_benchmark(trace_path: str, storage_dir: str, model_config: dict, print(f"Model: {model_config['name']}") print(f"Layers: {model_config['num_layers']}") print(f"Page size: {page_size_tokens} tokens") + print(f"File mode: {file_mode}") print(f"Threads: {threads}") - print(f"Fast-forward: {replay_scale:g}x" if replay_scale > 0 else "Fast-forward: unpaced") + print( + f"Fast-forward: {replay_scale:g}x" + if replay_scale > 0 + else "Fast-forward: unpaced" + ) print(f"{'='*80}") # Load trace @@ -472,7 +527,7 @@ def run_benchmark(trace_path: str, storage_dir: str, model_config: dict, max_size_gb = max_pages * page_size_bytes / (1024**3) trace_size_gb = max_pages_needed * page_size_bytes / (1024**3) - print(f"\n[Storage Configuration]") + print("\n[Storage Configuration]") print(f" Max page_id in trace: {max_page_id:,}") print(f" Pages needed (trace): {max_pages_needed:,}") print(f" Trace storage size: {trace_size_gb:.2f} GB") @@ -487,12 +542,18 @@ def run_benchmark(trace_path: str, storage_dir: str, model_config: dict, shortfall = max_pages_needed - max_pages shortfall_gb = shortfall * page_size_bytes / (1024**3) compression_ratio = max_pages / max_pages_needed - print(f"\n ⚠️ Storage insufficient: {shortfall:,} pages shortfall ({shortfall_gb:.2f} GB)") - print(f" ⚠️ Consider increasing --max-pages to at least {max_pages_needed:,} for full simulation") + print( + f"\n ⚠️ Storage insufficient: {shortfall:,} pages shortfall ({shortfall_gb:.2f} GB)" + ) + print( + f" ⚠️ Consider increasing --max-pages to at least {max_pages_needed:,} for full simulation" + ) else: surplus = max_pages - max_pages_needed surplus_pct = (surplus / max_pages) * 100 if max_pages > 0 else 0 - print(f" ✓ Direct mapping: all {max_pages_needed:,} logical pages uniquely mapped") + print( + f" ✓ Direct mapping: all {max_pages_needed:,} logical pages uniquely mapped" + ) try: if threads <= 1: @@ -501,62 +562,76 @@ def run_benchmark(trace_path: str, storage_dir: str, model_config: dict, model_config=model_config, page_size_tokens=page_size_tokens, max_pages=max_pages, + file_mode=file_mode, fsync_mode=fsync_mode, - fsync_batch_size=fsync_batch_size + fsync_batch_size=fsync_batch_size, ) as benchmark: - result = run_single_thread(benchmark, requests, replay_scale, - progress_interval) + result = run_single_thread( + benchmark, requests, replay_scale, progress_interval + ) else: with ExitStack() as stack: benchmarks = [ - stack.enter_context(StorageBenchmark( - storage_dir=str(Path(storage_dir) / f"thread_{thread_id}"), - model_config=model_config, - page_size_tokens=page_size_tokens, - max_pages=max_pages, - fsync_mode=fsync_mode, - fsync_batch_size=fsync_batch_size - )) + stack.enter_context( + StorageBenchmark( + storage_dir=str(Path(storage_dir) / f"thread_{thread_id}"), + model_config=model_config, + page_size_tokens=page_size_tokens, + max_pages=max_pages, + file_mode=file_mode, + fsync_mode=fsync_mode, + fsync_batch_size=fsync_batch_size, + ) + ) for thread_id in range(threads) ] result = run_multi_thread(benchmarks, requests, replay_scale) except KeyboardInterrupt: print(f"\n\n{'='*80}") - print(f"Interrupted! Showing partial results:") + print("Interrupted! Showing partial results:") print(f"{'='*80}") - result = result if 'result' in locals() else { - 'completed': 0, - 'elapsed': 0, - 'stats': {}, - } - print_results([{ - 'trace_file': Path(trace_path).name, - 'total_requests': result['completed'], - 'io_time_s': result['elapsed'], - 'requests_per_second': ( - result['completed'] / result['elapsed'] - if result['elapsed'] > 0 else 0 - ), - 'model': model_config['name'], - 'fsync_mode': fsync_mode, - 'threads': threads, - 'replay_scale': replay_scale, - **result['stats'], - }]) + result = ( + result + if "result" in locals() + else { + "completed": 0, + "elapsed": 0, + "stats": {}, + } + ) + print_results( + [ + { + "trace_file": Path(trace_path).name, + "total_requests": result["completed"], + "io_time_s": result["elapsed"], + "requests_per_second": ( + result["completed"] / result["elapsed"] + if result["elapsed"] > 0 + else 0 + ), + "model": model_config["name"], + "fsync_mode": fsync_mode, + "threads": threads, + "replay_scale": replay_scale, + **result["stats"], + } + ] + ) sys.exit(0) return { - 'trace_file': Path(trace_path).name, - 'total_requests': result['completed'], - 'io_time_s': result['elapsed'], - 'requests_per_second': ( - result['completed'] / result['elapsed'] if result['elapsed'] > 0 else 0 + "trace_file": Path(trace_path).name, + "total_requests": result["completed"], + "io_time_s": result["elapsed"], + "requests_per_second": ( + result["completed"] / result["elapsed"] if result["elapsed"] > 0 else 0 ), - 'model': model_config['name'], - 'fsync_mode': fsync_mode, - 'threads': threads, - 'replay_scale': replay_scale, - **result['stats'], + "model": model_config["name"], + "fsync_mode": fsync_mode, + "threads": threads, + "replay_scale": replay_scale, + **result["stats"], } @@ -564,23 +639,26 @@ def run_benchmark(trace_path: str, storage_dir: str, model_config: dict, # Output Formatting # ============================================================================ + def format_storage_stats(stats: Dict, title: str = "Storage"): """Format storage statistics with clear read/write separation""" - storage = stats.get('storage', {}) - read_stats = storage.get('read', {}) - write_stats = storage.get('write', {}) - request_wall = stats.get('request_wall_latency', {}) - request_io = stats.get('request_io_latency', {}) + storage = stats.get("storage", {}) + read_stats = storage.get("read", {}) + write_stats = storage.get("write", {}) + request_wall = stats.get("request_wall_latency", {}) + request_io = stats.get("request_io_latency", {}) output = [] output.append(f"\n[{title}]") # General info - output.append(f"\n[General]") + output.append("\n[General]") output.append(f" Model: {stats.get('model', 'N/A')}") output.append(f" Threads: {stats.get('threads', 1)}") - replay_scale = stats.get('replay_scale', 0) - output.append(f" Fast-forward: {f'{replay_scale:g}x' if replay_scale else 'unpaced'}") + replay_scale = stats.get("replay_scale", 0) + output.append( + f" Fast-forward: {f'{replay_scale:g}x' if replay_scale else 'unpaced'}" + ) output.append(f" Requests: {stats.get('total_requests', 0):,}") output.append(f" Tokens: {stats.get('total_tokens', 0):,}") output.append(f" Total I/O Time: {stats.get('io_time_s', 0):.3f} s") @@ -588,48 +666,48 @@ def format_storage_stats(stats: Dict, title: str = "Storage"): output.append(f" Hit Rate: {stats.get('page_hit_rate', 0):.2%}") # Request Stats - output.append(f"\n[Request Wall Latency]") + output.append("\n[Request Wall Latency]") output.append(f" Avg: {request_wall.get('avg_ms', 0):.3f} ms") output.append(f" P50: {request_wall.get('p50_ms', 0):.3f} ms") output.append(f" P95: {request_wall.get('p95_ms', 0):.3f} ms") output.append(f" P99: {request_wall.get('p99_ms', 0):.3f} ms") - output.append(f"\n[Request Storage I/O Latency]") + output.append("\n[Request Storage I/O Latency]") output.append(f" Avg: {request_io.get('avg_ms', 0):.3f} ms") output.append(f" P50: {request_io.get('p50_ms', 0):.3f} ms") output.append(f" P95: {request_io.get('p95_ms', 0):.3f} ms") output.append(f" P99: {request_io.get('p99_ms', 0):.3f} ms") # Read Stats - output.append(f"\n[Read Operations]") + output.append("\n[Read Operations]") output.append(f" Count: {read_stats.get('count', 0):,}") output.append(f" Data Volume: {read_stats.get('mb', 0):.2f} MB") - read_time = read_stats.get('time_s', 0) - read_mbps = read_stats.get('mb', 0) / read_time if read_time > 0 else 0 + read_time = read_stats.get("time_s", 0) + read_mbps = read_stats.get("mb", 0) / read_time if read_time > 0 else 0 output.append(f" Total Time: {read_time:.3f} s") output.append(f" Bandwidth: {read_mbps:.2f} MB/s") - output.append(f" Latency:") + output.append(" Latency:") output.append(f" Avg: {read_stats.get('avg_ms', 0):.3f} ms") output.append(f" P50: {read_stats.get('p50_ms', 0):.3f} ms") output.append(f" P95: {read_stats.get('p95_ms', 0):.3f} ms") output.append(f" P99: {read_stats.get('p99_ms', 0):.3f} ms") # Write Stats - output.append(f"\n[Write Operations]") + output.append("\n[Write Operations]") output.append(f" Count: {write_stats.get('count', 0):,}") output.append(f" Data Volume: {write_stats.get('mb', 0):.2f} MB") - write_time = write_stats.get('time_s', 0) - write_mbps = write_stats.get('mb', 0) / write_time if write_time > 0 else 0 + write_time = write_stats.get("time_s", 0) + write_mbps = write_stats.get("mb", 0) / write_time if write_time > 0 else 0 output.append(f" Total Time: {write_time:.3f} s") output.append(f" Bandwidth: {write_mbps:.2f} MB/s") - output.append(f" Latency:") + output.append(" Latency:") output.append(f" Avg: {write_stats.get('avg_ms', 0):.3f} ms") output.append(f" P50: {write_stats.get('p50_ms', 0):.3f} ms") output.append(f" P95: {write_stats.get('p95_ms', 0):.3f} ms") output.append(f" P99: {write_stats.get('p99_ms', 0):.3f} ms") # Storage Info - output.append(f"\n[Storage Info]") + output.append("\n[Storage Info]") output.append(f" Max Pages: {storage.get('max_pages', 0):,}") output.append(f" Written Pages: {storage.get('written_pages', 0):,}") output.append(f" Sync Count: {storage.get('sync_count', 0):,}") @@ -650,43 +728,101 @@ def print_results(results: List[Dict]): # CLI Entry Point # ============================================================================ + def main(): """Main entry point""" parser = argparse.ArgumentParser( - description='Mooncake KVCache Storage Benchmark', + description="Mooncake KVCache Storage Benchmark", formatter_class=argparse.RawDescriptionHelpFormatter, ) - parser.add_argument('--trace-dir', type=str, default='../../FAST25-release/traces', - help='Trace files directory') - parser.add_argument('--scenario', type=str, choices=['conversation', 'synthetic', 'toolagent', 'all'], - default='toolagent', help='Test scenario') - parser.add_argument('--storage-dir', type=str, default='/tmp/mooncake_bench', - help='Storage directory') - parser.add_argument('--model', type=str, default='glm5', choices=['glm5', 'kimi-k2.6'], - help='Model preset') - parser.add_argument('--page-size-tokens', type=int, default=512, - help='Page size in tokens (default: 512)') - parser.add_argument('--max-requests', type=int, default=None, - help='Maximum number of requests') - parser.add_argument('--max-pages', type=int, default=2000, - help='Maximum number of pages') - parser.add_argument('--fsync-mode', type=str, choices=['batch', 'always', 'end', 'none'], - default='none', help='When to fsync') - parser.add_argument('--fsync-batch-size', type=int, default=100, - help='Number of writes between fsync') - parser.add_argument('--threads', type=int, default=1, - help='Number of benchmark client worker threads') - parser.add_argument('--replay-scales', type=str, default='0', - help='Comma-separated trace fast-forward speeds; 0 means unpaced') - parser.add_argument('--progress-interval', type=int, default=100, - help='Print progress every N requests; 0 disables per-request progress') + parser.add_argument( + "--trace-dir", + type=str, + default="../../FAST25-release/traces", + help="Trace files directory", + ) + parser.add_argument( + "--scenario", + type=str, + choices=["conversation", "synthetic", "toolagent", "all"], + default="toolagent", + help="Test scenario", + ) + parser.add_argument( + "--storage-dir", + type=str, + default="/tmp/mooncake_bench", + help="Storage directory", + ) + parser.add_argument( + "--model", + type=str, + default="glm5", + choices=["glm5", "kimi-k2.6"], + help="Model preset", + ) + parser.add_argument( + "--page-size-tokens", + type=int, + default=512, + help="Page size in tokens (default: 512)", + ) + parser.add_argument( + "--file-mode", + type=str, + choices=["single", "per-file"], + default="single", + help="Storage layout: single = one data.bin with slot " + "offsets (default); per-file = one file per page. " + "Per-file writes open with O_TRUNC and replace the " + "whole page file", + ) + parser.add_argument( + "--max-requests", type=int, default=None, help="Maximum number of requests" + ) + parser.add_argument( + "--max-pages", type=int, default=2000, help="Maximum number of pages" + ) + parser.add_argument( + "--fsync-mode", + type=str, + choices=["batch", "always", "end", "none"], + default="none", + help="When to fsync. With --file-mode per-file, end does " + "zero fsyncs and batch fsyncs only the last file in " + "each batch; durability is weaker than single", + ) + parser.add_argument( + "--fsync-batch-size", + type=int, + default=100, + help="Number of writes between fsync", + ) + parser.add_argument( + "--threads", + type=int, + default=1, + help="Number of benchmark client worker threads", + ) + parser.add_argument( + "--replay-scales", + type=str, + default="0", + help="Comma-separated trace fast-forward speeds; 0 means unpaced", + ) + parser.add_argument( + "--progress-interval", + type=int, + default=100, + help="Print progress every N requests; 0 disables per-request progress", + ) args = parser.parse_args() if args.threads < 1: - parser.error('--threads must be at least 1') + parser.error("--threads must be at least 1") if args.progress_interval < 0: - parser.error('--progress-interval must be non-negative') + parser.error("--progress-interval must be non-negative") print(f"\n{'='*80}") print(f"{'Mooncake KVCache Storage Benchmark':^80}") @@ -696,16 +832,20 @@ def main(): print(f"Model: {args.model} ({model_config['num_layers']} layers)") replay_scales = parse_csv_floats(args.replay_scales) if not replay_scales: - parser.error('--replay-scales must include at least one value') + parser.error("--replay-scales must include at least one value") if any(scale < 0 for scale in replay_scales): - parser.error('--replay-scales values must be non-negative') + parser.error("--replay-scales values must be non-negative") # Determine scenarios - scenarios = ['conversation', 'synthetic', 'toolagent'] if args.scenario == 'all' else [args.scenario] + scenarios = ( + ["conversation", "synthetic", "toolagent"] + if args.scenario == "all" + else [args.scenario] + ) trace_files = { - 'conversation': 'conversation_trace.jsonl', - 'synthetic': 'synthetic_trace.jsonl', - 'toolagent': 'toolagent_trace.jsonl' + "conversation": "conversation_trace.jsonl", + "synthetic": "synthetic_trace.jsonl", + "toolagent": "toolagent_trace.jsonl", } # Run benchmarks @@ -725,11 +865,12 @@ def main(): args.max_requests, args.max_pages, args.page_size_tokens, + args.file_mode, args.fsync_mode, args.fsync_batch_size, args.threads, replay_scale, - args.progress_interval + args.progress_interval, ) results.append(result) else: @@ -742,5 +883,6 @@ def main(): print("Error: No trace files were successfully processed.", file=sys.stderr) sys.exit(1) -if __name__ == '__main__': + +if __name__ == "__main__": main() diff --git a/benchmarks/storage_benchmark_v1/doc/README.md b/benchmarks/storage_benchmark_v1/doc/README.md index 8b53be660a..642dc87405 100644 --- a/benchmarks/storage_benchmark_v1/doc/README.md +++ b/benchmarks/storage_benchmark_v1/doc/README.md @@ -24,9 +24,10 @@ python benchmark.py --scenario conversation \ | `--storage-dir` | `/tmp/mooncake_bench` | Directory for storage files | | `--model` | `glm5` | Model preset: `glm5` or `kimi-k2.6` | | `--page-size-tokens` | `512` | Page size in tokens | +| `--file-mode` | `single` | Storage layout: `single` = one `data.bin` with slot offsets; `per-file` = one file per page. Per-file writes open with `O_TRUNC` and replace the whole page file | | `--max-requests` | `None` | Maximum number of requests to process | | `--max-pages` | `2000` | Maximum number of pages (creates modulo mapping if trace is larger) | -| `--fsync-mode` | `none` | When to fsync: `none`, `batch`, `always`, or `end` | +| `--fsync-mode` | `none` | When to fsync: `none`, `batch`, `always`, or `end`. With `--file-mode per-file`, `end`/`batch` are weaker than in `single` (see File Mode) | | `--fsync-batch-size` | `100` | Number of writes between fsync in batch mode | | `--threads` | `1` | Number of benchmark client worker threads | | `--replay-scales` | `0` | Comma-separated trace fast-forward speeds; `0` means unpaced | @@ -58,11 +59,46 @@ python benchmark.py --scenario toolagent \ ``` With `--threads > 1`, each benchmark client thread uses an independent storage -file under `thread_N/data.bin`, similar to running multiple clients at the same +directory under `thread_N/`, similar to running multiple clients at the same time. Final results aggregate the per-thread counters and latency samples. For strict single-client trace-order read/write and hit-rate accounting, use `--threads 1`. +### File Mode + +`--file-mode` selects the on-disk layout: + +- `single` (default): one preallocated `data.bin`. Each page is a slot at + `physical_page_id * page_size`. Partial writes keep the rest of the file. +- `per-file`: one `page_.bin` per page. Each operation opens, reads or + writes, then closes that file. A write opens with `O_WRONLY | O_CREAT | + O_TRUNC`, so it replaces the entire page file and does not preserve earlier + bytes in that file. + +With `--threads > 1`, each thread still uses its own `thread_N/` directory. +In `single` mode that directory contains `data.bin`; in `per-file` mode it +contains `page_*.bin`. + +Per-file mode does not delete leftover `page_*.bin` files or `thread_N/` +directories between runs. A re-run with a smaller `--max-pages` or fewer +`--threads` can leave orphaned files. Within a run this is harmless +(`_written_pages` starts empty and writes use `O_TRUNC`); remove them +manually from `--storage-dir` if disk space matters. + +`--fsync-mode none` and `always` are comparable across file modes. Combined +with `--file-mode per-file`, `end` and `batch` are weaker than in `single` +and should not be compared as the same durability cost: + +- `end`: `write()` never fsyncs, and `close()` only fsyncs the persistent + `data.bin` fd, which per-file never opens. Expected result: zero fsyncs, + write latency and `Sync Count` look like `none`. +- `batch`: `_pending_syncs` counts across files, but `os.fsync(fd)` runs + only on the current per-op fd. Expected result: one file per batch is + synced (the Nth write); the other N-1 files are already closed, and the + trailing partial batch is never flushed. `Sync Count` still increments, + but most writes are not durable, so write latency is much closer to + `none` than to `single` + `batch`. + ## Output Format ### Progress Output diff --git a/benchmarks/storage_benchmark_v1/storage/disk.py b/benchmarks/storage_benchmark_v1/storage/disk.py index b54de98a64..388d45ddd1 100644 --- a/benchmarks/storage_benchmark_v1/storage/disk.py +++ b/benchmarks/storage_benchmark_v1/storage/disk.py @@ -15,8 +15,9 @@ def calc_percentiles(data): """Calculate latency percentiles""" if not data: - return {'avg_ms': 0, 'p50_ms': 0, 'p95_ms': 0, 'p99_ms': 0} + return {"avg_ms": 0, "p50_ms": 0, "p95_ms": 0, "p99_ms": 0} import statistics + sorted_data = sorted(data) def get_percentile(p): @@ -29,10 +30,10 @@ def get_percentile(p): return sorted_data[lower] * (1.0 - weight) + sorted_data[upper] * weight return { - 'avg_ms': statistics.mean(data), - 'p50_ms': get_percentile(50), - 'p95_ms': get_percentile(95), - 'p99_ms': get_percentile(99), + "avg_ms": statistics.mean(data), + "p50_ms": get_percentile(50), + "p95_ms": get_percentile(95), + "p99_ms": get_percentile(99), } @@ -51,15 +52,26 @@ class DiskHashTable(Storage): This allows simulating large traces with limited storage. """ - def __init__(self, storage_dir: str, page_size: int, - max_pages: int = 100000, - fsync_mode: str = 'batch', fsync_batch_size: int = 100): + def __init__( + self, + storage_dir: str, + page_size: int, + max_pages: int = 100000, + file_mode: str = "single", + fsync_mode: str = "batch", + fsync_batch_size: int = 100, + ): """Initialize disk hash table Args: storage_dir: Storage directory page_size: Size of each entry (page) in bytes max_pages: Maximum number of entries (creates circular mapping if trace is larger) + file_mode: 'single' = one big data.bin with slot offsets (default); + 'per-file' = one file per page (open/read-write/close per op). + Per-file writes use O_TRUNC, so a partial-page + (offset_in_page > 0 or length < page_size) write + replaces the whole file and discards the rest of the page. fsync_mode: When to fsync ('batch', 'always', 'end', 'none') fsync_batch_size: Writes between fsync """ @@ -67,25 +79,27 @@ def __init__(self, storage_dir: str, page_size: int, self.page_size = page_size self.max_pages = max_pages self.max_page_id = max_pages - 1 + self.file_mode = file_mode self.fsync_mode = fsync_mode self.fsync_batch_size = fsync_batch_size self.storage_dir.mkdir(parents=True, exist_ok=True) self.storage_file = self.storage_dir / "data.bin" - self._allocate_file() + if self.file_mode == "single": + self._allocate_file() self.fd = None self._buffer = os.urandom(page_size) self.stats = { - 'read_count': 0, - 'write_count': 0, - 'read_bytes': 0, - 'write_bytes': 0, - 'read_latencies_ms': [], - 'write_latencies_ms': [], - 'read_time_s': 0.0, - 'write_time_s': 0.0, - 'sync_count': 0, - 'hit': 0, - 'miss': 0, + "read_count": 0, + "write_count": 0, + "read_bytes": 0, + "write_bytes": 0, + "read_latencies_ms": [], + "write_latencies_ms": [], + "read_time_s": 0.0, + "write_time_s": 0.0, + "sync_count": 0, + "hit": 0, + "miss": 0, } self._pending_syncs = 0 self._written_pages: set = set() @@ -95,30 +109,39 @@ def _allocate_file(self): file_size = self.max_pages * self.page_size if not self.storage_file.exists(): print(f" [Storage] Creating file: {self.storage_file}") - print(f" [Storage] Requested size: {file_size / (1024**3):.2f} GB ({self.max_pages:,} pages × {self.page_size} bytes = {file_size:,} bytes)") + print( + f" [Storage] Requested size: {file_size / (1024**3):.2f} GB ({self.max_pages:,} pages × {self.page_size} bytes = {file_size:,} bytes)" + ) # Use fallocate for efficient preallocation (Linux) fd = os.open(self.storage_file, os.O_RDWR | os.O_CREAT | os.O_TRUNC, 0o644) try: # Try fallocate first (Linux specific, much faster) try: import fcntl + fcntl.fallocate(fd, 0, file_size) method = "fallocate" except (ImportError, AttributeError, OSError): # Fallback to seek+write method os.lseek(fd, file_size - 1, os.SEEK_SET) - os.write(fd, b'\0') + os.write(fd, b"\0") os.fsync(fd) method = "seek+write" finally: os.close(fd) - actual_size = self.storage_file.stat().st_size if self.storage_file.exists() else 0 - print(f" [Storage] Pre-allocated {actual_size / (1024**3):.2f} GB using {method}") + actual_size = ( + self.storage_file.stat().st_size if self.storage_file.exists() else 0 + ) + print( + f" [Storage] Pre-allocated {actual_size / (1024**3):.2f} GB using {method}" + ) else: actual_size = self.storage_file.stat().st_size actual_pages = actual_size // self.page_size print(f" [Storage] Reusing existing file: {self.storage_file}") - print(f" [Storage] Current file size: {actual_size / (1024**3):.2f} GB ({actual_pages:,} pages × {self.page_size} bytes = {actual_size:,} bytes)") + print( + f" [Storage] Current file size: {actual_size / (1024**3):.2f} GB ({actual_pages:,} pages × {self.page_size} bytes = {actual_size:,} bytes)" + ) def _get_fd(self): if self.fd is None: @@ -127,7 +150,9 @@ def _get_fd(self): if self.storage_file.exists(): actual_size = self.storage_file.stat().st_size actual_pages = actual_size // self.page_size - print(f" [Storage] File size: {actual_size / (1024**3):.2f} GB ({actual_pages:,} pages × {self.page_size} bytes = {actual_size:,} bytes)") + print( + f" [Storage] File size: {actual_size / (1024**3):.2f} GB ({actual_pages:,} pages × {self.page_size} bytes = {actual_size:,} bytes)" + ) return self.fd # ======================================================================== @@ -147,6 +172,22 @@ def _map_page_id(self, page_id: int) -> int: """ return page_id % self.max_pages + def _open_page(self, page_id: int, write: bool): + """Resolve a page access to (fd, base_offset, owned). + + single: reuse the persistent data.bin fd, base = physical * page_size. + per-file: open page_.bin for this op, base = 0, owned = True + (caller must close fd when done). Writes use O_TRUNC, so a + partial-page(offset_in_page > 0 or length < page_size) + write replaces the whole file. + """ + physical_page_id = self._map_page_id(page_id) + if self.file_mode == "per-file": + path = self.storage_dir / f"page_{physical_page_id}.bin" + flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC if write else os.O_RDONLY + return os.open(str(path), flags, 0o644), 0, True + return self._get_fd(), physical_page_id * self.page_size, False + def read(self, page_id: int, offset_in_page: int = 0, length: int = None) -> float: """Read entry from disk @@ -163,29 +204,40 @@ def read(self, page_id: int, offset_in_page: int = 0, length: int = None) -> flo # Validate parameters if offset_in_page < 0 or offset_in_page >= self.page_size: - raise ValueError(f"offset_in_page {offset_in_page} out of range [0, {self.page_size})") + raise ValueError( + f"offset_in_page {offset_in_page} out of range [0, {self.page_size})" + ) if length <= 0 or offset_in_page + length > self.page_size: - raise ValueError(f"length {length} invalid with offset_in_page {offset_in_page} (page_size={self.page_size})") + raise ValueError( + f"length {length} invalid with offset_in_page {offset_in_page} (page_size={self.page_size})" + ) - # Map to physical page_id and calculate offset - physical_page_id = self._map_page_id(page_id) - offset = physical_page_id * self.page_size + offset_in_page start = time.perf_counter() - + fd = None + owned = False try: - fd = self._get_fd() - os.pread(fd, length, offset) + fd, base, owned = self._open_page(page_id, write=False) + os.pread(fd, length, base + offset_in_page) + if owned: + close_fd, fd = fd, None + os.close(close_fd) latency = (time.perf_counter() - start) * 1000.0 - self.stats['read_count'] += 1 - self.stats['read_bytes'] += length - self.stats['read_latencies_ms'].append(latency) - self.stats['read_time_s'] += latency / 1000.0 - self.stats['hit'] += 1 + self.stats["read_count"] += 1 + self.stats["read_bytes"] += length + self.stats["read_latencies_ms"].append(latency) + self.stats["read_time_s"] += latency / 1000.0 + self.stats["hit"] += 1 return latency except OSError as e: - print(f"Read error (page_id={page_id}, physical_page_id={physical_page_id}): {e}") - return 0.0 + print(f"Read error (page_id={page_id}): {e}") + return None + finally: + if owned and fd is not None: + try: + os.close(fd) + except OSError: + pass def write(self, page_id: int, offset_in_page: int = 0, length: int = None) -> float: """Write entry to disk @@ -203,47 +255,61 @@ def write(self, page_id: int, offset_in_page: int = 0, length: int = None) -> fl # Validate parameters if offset_in_page < 0 or offset_in_page >= self.page_size: - raise ValueError(f"offset_in_page {offset_in_page} out of range [0, {self.page_size})") + raise ValueError( + f"offset_in_page {offset_in_page} out of range [0, {self.page_size})" + ) if length <= 0 or offset_in_page + length > self.page_size: - raise ValueError(f"length {length} invalid with offset_in_page {offset_in_page} (page_size={self.page_size})") + raise ValueError( + f"length {length} invalid with offset_in_page {offset_in_page} (page_size={self.page_size})" + ) - # Map to physical page_id and calculate offset - physical_page_id = self._map_page_id(page_id) - offset = physical_page_id * self.page_size + offset_in_page start = time.perf_counter() - + fd = None + owned = False try: - fd = self._get_fd() + fd, base, owned = self._open_page(page_id, write=True) # Use corresponding portion of buffer - os.pwrite(fd, self._buffer[:length], offset) + os.pwrite(fd, self._buffer[:length], base + offset_in_page) write_done = time.perf_counter() # Fsync - if self.fsync_mode == 'always': + if self.fsync_mode == "always": os.fsync(fd) - self.stats['sync_count'] += 1 + self.stats["sync_count"] += 1 self._pending_syncs = 0 - latency = (time.perf_counter() - start) * 1000.0 - elif self.fsync_mode == 'batch': + elif self.fsync_mode == "batch": self._pending_syncs += 1 if self._pending_syncs >= self.fsync_batch_size: os.fsync(fd) - self.stats['sync_count'] += 1 + self.stats["sync_count"] += 1 self._pending_syncs = 0 - latency = (write_done - start) * 1000.0 + if owned: + close_fd, fd = fd, None + os.close(close_fd) + + if self.fsync_mode == "always" or owned: + latency = (time.perf_counter() - start) * 1000.0 else: latency = (write_done - start) * 1000.0 self._written_pages.add(page_id) - self.stats['write_count'] += 1 - self.stats['write_bytes'] += length - self.stats['write_latencies_ms'].append(latency) - self.stats['write_time_s'] += latency / 1000.0 - self.stats['miss'] += 1 + self.stats["write_count"] += 1 + self.stats["write_bytes"] += length + self.stats["write_latencies_ms"].append(latency) + self.stats["write_time_s"] += latency / 1000.0 + self.stats["miss"] += 1 return latency except OSError as e: - print(f"Write error (page_id={page_id}, offset_in_page={offset_in_page}, length={length}): {e}") - return 0.0 + print( + f"Write error (page_id={page_id}, offset_in_page={offset_in_page}, length={length}): {e}" + ) + return None + finally: + if owned and fd is not None: + try: + os.close(fd) + except OSError: + pass # ======================================================================== # Storage interface methods @@ -278,30 +344,30 @@ def get_stats(self) -> Dict[str, Any]: def calc_stats(latencies): if not latencies: - return {'avg_ms': 0, 'p50_ms': 0, 'p95_ms': 0, 'p99_ms': 0} + return {"avg_ms": 0, "p50_ms": 0, "p95_ms": 0, "p99_ms": 0} return { - 'avg_ms': statistics.mean(latencies), + "avg_ms": statistics.mean(latencies), **calc_percentiles(latencies), } return { - 'read': { - 'count': self.stats['read_count'], - 'mb': self.stats['read_bytes'] / 1024 / 1024, - 'time_s': self.stats['read_time_s'], - **calc_stats(self.stats['read_latencies_ms']) + "read": { + "count": self.stats["read_count"], + "mb": self.stats["read_bytes"] / 1024 / 1024, + "time_s": self.stats["read_time_s"], + **calc_stats(self.stats["read_latencies_ms"]), }, - 'write': { - 'count': self.stats['write_count'], - 'mb': self.stats['write_bytes'] / 1024 / 1024, - 'time_s': self.stats['write_time_s'], - **calc_stats(self.stats['write_latencies_ms']) + "write": { + "count": self.stats["write_count"], + "mb": self.stats["write_bytes"] / 1024 / 1024, + "time_s": self.stats["write_time_s"], + **calc_stats(self.stats["write_latencies_ms"]), }, - 'sync_count': self.stats['sync_count'], - 'max_pages': self.max_pages, - 'written_pages': len(self._written_pages), - 'page_hits': self.stats['hit'], - 'page_misses': self.stats['miss'], + "sync_count": self.stats["sync_count"], + "max_pages": self.max_pages, + "written_pages": len(self._written_pages), + "page_hits": self.stats["hit"], + "page_misses": self.stats["miss"], } # ======================================================================== @@ -310,11 +376,11 @@ def calc_stats(latencies): def close(self, force_sync: bool = True): """Close file""" - if force_sync and self.fsync_mode in ['end', 'batch']: + if force_sync and self.fsync_mode in ["end", "batch"]: if self.fd is not None: try: os.fsync(self.fd) - self.stats['sync_count'] += 1 + self.stats["sync_count"] += 1 except OSError: pass diff --git a/cmake/Dependencies.cmake b/cmake/Dependencies.cmake new file mode 100644 index 0000000000..a9ccc3e787 --- /dev/null +++ b/cmake/Dependencies.cmake @@ -0,0 +1,74 @@ +# Build entry points own these targets and call each provider at most once. +# Consumer directories only link the resulting Mooncake::* targets. + +function(mooncake_provide_zstd) + find_path(MOONCAKE_ZSTD_INCLUDE_DIR zstd.h) + find_library(MOONCAKE_ZSTD_LIBRARY zstd) + if(NOT MOONCAKE_ZSTD_INCLUDE_DIR OR NOT MOONCAKE_ZSTD_LIBRARY) + message(FATAL_ERROR "zstd development files not found") + endif() + add_library(Mooncake::zstd UNKNOWN IMPORTED) + set_target_properties( + Mooncake::zstd + PROPERTIES IMPORTED_LOCATION "${MOONCAKE_ZSTD_LIBRARY}" + INTERFACE_INCLUDE_DIRECTORIES "${MOONCAKE_ZSTD_INCLUDE_DIR}") +endfunction() + +function(mooncake_provide_xxhash) + find_path(MOONCAKE_XXHASH_INCLUDE_DIR xxhash.h) + find_library(MOONCAKE_XXHASH_LIBRARY NAMES xxhash libxxhash) + if(NOT MOONCAKE_XXHASH_INCLUDE_DIR OR NOT MOONCAKE_XXHASH_LIBRARY) + message(FATAL_ERROR "xxHash development files not found") + endif() + add_library(Mooncake::xxhash UNKNOWN IMPORTED) + set_target_properties( + Mooncake::xxhash + PROPERTIES IMPORTED_LOCATION "${MOONCAKE_XXHASH_LIBRARY}" + INTERFACE_INCLUDE_DIRECTORIES "${MOONCAKE_XXHASH_INCLUDE_DIR}") +endfunction() + +function(mooncake_provide_liburing) + find_path(MOONCAKE_LIBURING_INCLUDE_DIR liburing.h) + find_library(MOONCAKE_LIBURING_LIBRARY uring) + if(MOONCAKE_LIBURING_INCLUDE_DIR AND MOONCAKE_LIBURING_LIBRARY) + add_library(Mooncake::liburing UNKNOWN IMPORTED) + set_target_properties( + Mooncake::liburing + PROPERTIES IMPORTED_LOCATION "${MOONCAKE_LIBURING_LIBRARY}" + INTERFACE_INCLUDE_DIRECTORIES + "${MOONCAKE_LIBURING_INCLUDE_DIR}") + endif() +endfunction() + +function(mooncake_provide_libzmq) + find_path(MOONCAKE_LIBZMQ_INCLUDE_DIR zmq.h) + find_library(MOONCAKE_LIBZMQ_LIBRARY NAMES zmq libzmq) + if(NOT MOONCAKE_LIBZMQ_INCLUDE_DIR OR NOT MOONCAKE_LIBZMQ_LIBRARY) + message(FATAL_ERROR "libzmq development files not found") + endif() + add_library(Mooncake::libzmq UNKNOWN IMPORTED) + set_target_properties( + Mooncake::libzmq + PROPERTIES IMPORTED_LOCATION "${MOONCAKE_LIBZMQ_LIBRARY}" + INTERFACE_INCLUDE_DIRECTORIES "${MOONCAKE_LIBZMQ_INCLUDE_DIR}") +endfunction() + +function(mooncake_provide_hiredis) + cmake_parse_arguments(ARG "REQUIRED" "" "" ${ARGN}) + find_path(MOONCAKE_HIREDIS_INCLUDE_DIR hiredis/hiredis.h) + find_library(MOONCAKE_HIREDIS_LIBRARY hiredis) + if(ARG_REQUIRED) + if(NOT MOONCAKE_HIREDIS_INCLUDE_DIR OR NOT MOONCAKE_HIREDIS_LIBRARY) + message(FATAL_ERROR "hiredis development files not found") + endif() + endif() + + if(MOONCAKE_HIREDIS_INCLUDE_DIR AND MOONCAKE_HIREDIS_LIBRARY) + add_library(Mooncake::hiredis UNKNOWN IMPORTED) + set_target_properties( + Mooncake::hiredis + PROPERTIES IMPORTED_LOCATION "${MOONCAKE_HIREDIS_LIBRARY}" + INTERFACE_INCLUDE_DIRECTORIES + "${MOONCAKE_HIREDIS_INCLUDE_DIR}") + endif() +endfunction() diff --git a/dependencies.sh b/dependencies.sh index c1e42a772c..d218894c53 100755 --- a/dependencies.sh +++ b/dependencies.sh @@ -49,6 +49,19 @@ check_success() { fi } +# Detect ScaleFabric SHCA (shca-tools). +has_shca_tools() { + if command -v dpkg-query >/dev/null 2>&1; then + dpkg-query -W -f='${Status}' shca-tools 2>/dev/null | grep -q "install ok installed" + return $? + fi + if command -v rpm >/dev/null 2>&1; then + rpm -q shca-tools >/dev/null 2>&1 + return $? + fi + return 1 +} + read_os_release_value() { local key="$1" awk -F= -v key="$key" ' @@ -72,7 +85,7 @@ detect_os() { elif [ -f /etc/redhat-release ]; then OS="centos" else - print_error "Cannot detect OS. Supported OS: Ubuntu, Debian, CentOS, RHEL, Rocky, AlmaLinux, EulerOS, and openEuler." + print_error "Cannot detect OS. Supported OS: Ubuntu, Debian, CentOS, RHEL, Rocky, AlmaLinux, EulerOS, openEuler, and Kylin." fi echo -e "${GREEN}Detected OS: $OS ${OS_VERSION:-unknown}${NC}" @@ -110,7 +123,7 @@ echo -e "${YELLOW}Mooncake Dependencies Installer${NC}" echo -e "This script will install all required dependencies for Mooncake." echo -e "The following components will be installed:" echo -e " - System packages (build tools, libraries)" -echo -e " - Git submodules (including pybind11 and yalantinglibs)" +echo -e " - Git submodules (pybind11)" echo -e " - Go $GOVER" if [ "$INSTALL_SPDK" = true ]; then echo -e " - SPDK (for NVMe-oF support)" @@ -135,7 +148,7 @@ print_section "Updating package lists" if [ "$OS" = "ubuntu" ] || [ "$OS" = "debian" ]; then apt-get update check_success "Failed to update package lists" -elif [ "$OS" = "centos" ] || [ "$OS" = "rhel" ] || [ "$OS" = "rocky" ] || [ "$OS" = "almalinux" ] || [ "$OS" = "euleros" ] || [ "$OS" = "openeuler" ]; then +elif [ "$OS" = "centos" ] || [ "$OS" = "rhel" ] || [ "$OS" = "rocky" ] || [ "$OS" = "almalinux" ] || [ "$OS" = "euleros" ] || [ "$OS" = "openeuler" ] || [ "$OS" = "kylin" ]; then yum install -y dnf-plugins-core epel-release || true yum config-manager --set-enabled powertools || yum config-manager --set-enabled crb || true yum clean all @@ -183,6 +196,15 @@ if [ "$OS" = "ubuntu" ] || [ "$OS" = "debian" ]; then libc6-dev \ libc-bin" + # ScaleFabric SHCA (shca-tools) ships its own libibverbs headers/libs; installing + # libibverbs-dev conflicts with it. libboost-all-dev pulls OpenMPI/libfabric, + # which also depend on distro ibverbs and fail on SHCA systems. + if has_shca_tools; then + SYSTEM_PACKAGES=$(echo $SYSTEM_PACKAGES | sed 's/libibverbs-dev//g') + SYSTEM_PACKAGES=$(echo $SYSTEM_PACKAGES | sed "s/libboost-all-dev/libboost-dev/g") + echo -e "${GREEN}shca-tools package detected. Adjusting system packages accordingly; build with -DUSE_SHCA=ON to enable SHCA support.${NC}" + fi + apt-get install -y $SYSTEM_PACKAGES check_success "Failed to install system packages" @@ -208,6 +230,7 @@ elif [ "$OS" = "centos" ] || [ "$OS" = "rhel" ] || [ "$OS" = "rocky" ] || [ "$OS liburing-devel \ jemalloc-devel \ msgpack-devel \ + zeromq-devel \ libzstd-devel \ pkgconf-pkg-config \ elfutils-libelf-devel \ @@ -215,8 +238,68 @@ elif [ "$OS" = "centos" ] || [ "$OS" = "rhel" ] || [ "$OS" = "rocky" ] || [ "$OS xxhash-devel \ libbsd-devel" + # Same SHCA conflict on RHEL-family: skip rdma-core-devel when shca-tools is present. + if has_shca_tools; then + SYSTEM_PACKAGES=$(echo $SYSTEM_PACKAGES | sed 's/rdma-core-devel//g') + echo -e "${GREEN}shca-tools package detected. Skipping rdma-core-devel (provided by shca-tools).${NC}" + fi + yum install -y $SYSTEM_PACKAGES check_success "Failed to install system packages" + +elif [ "$OS" = "kylin" ]; then + SYSTEM_PACKAGES="@development \ + cmake \ + ninja-build \ + git \ + wget \ + rdma-core-devel \ + glog-devel \ + gflags-devel \ + jsoncpp-devel \ + libunwind-devel \ + numactl-devel \ + python3-devel \ + boost-devel \ + openssl-devel \ + protobuf-devel \ + yaml-cpp-devel \ + libcurl-devel \ + hiredis-devel \ + liburing-devel \ + jemalloc-devel \ + msgpack-devel \ + libzstd-devel \ + pkgconf-pkg-config \ + elfutils-libelf-devel \ + patchelf \ + xxhash-devel \ + libbsd-devel" + + if [ -z "${KYLIN_EPKL_URL:-}" ]; then + KYLIN_NKVERS_OUTPUT="" + if command -v nkvers >/dev/null 2>&1; then + KYLIN_NKVERS_OUTPUT=$(nkvers 2>/dev/null) + fi + + KYLIN_VERSION=${KYLIN_VERSION:-$(printf '%s\n' "$KYLIN_NKVERS_OUTPUT" | sed -nE 's/.*release[[:space:]]+(V[0-9]+).*/\1/p' | head -n 1)} + KYLIN_VERSION=${KYLIN_VERSION:-$OS_VERSION} + case "$KYLIN_VERSION" in + V*) ;; + [0-9]*) KYLIN_VERSION="V${KYLIN_VERSION}" ;; + esac + + KYLIN_EPKL_RELEASE=${KYLIN_EPKL_RELEASE:-$(printf '%s\n' "$KYLIN_NKVERS_OUTPUT" | sed -nE 's/.*release[[:space:]]+V[0-9]+[[:space:]]+([0-9]{4})\/.*/\1/p' | head -n 1)} + if [ -z "$KYLIN_VERSION" ] || [ -z "$KYLIN_EPKL_RELEASE" ]; then + print_error "Cannot detect Kylin release. Set KYLIN_EPKL_URL or KYLIN_EPKL_RELEASE explicitly." + fi + + KYLIN_EPKL_URL="https://eps-server.openkylin.top/NS/${KYLIN_VERSION}/${KYLIN_EPKL_RELEASE}/EPKL/main/$(uname -m)/" + fi + dnf --repofrompath="kylin-epkl,$KYLIN_EPKL_URL" \ + --setopt=kylin-epkl.gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-kylin \ + --enablerepo=kylin-epkl install -y $SYSTEM_PACKAGES + check_success "Failed to install system packages" else print_error "Unsupported OS: $OS" fi @@ -244,31 +327,6 @@ else exit 1 fi -# Build and install yalantinglibs from submodule -print_section "Installing yalantinglibs" -cd "${REPO_ROOT}/extern/yalantinglibs" -check_success "Failed to change to yalantinglibs submodule directory" - -mkdir -p build -check_success "Failed to create build directory" -cd build -check_success "Failed to change to build directory" - -echo "Configuring yalantinglibs..." -cmake .. -DBUILD_EXAMPLES=OFF -DBUILD_BENCHMARK=OFF -DBUILD_UNIT_TESTS=OFF -check_success "Failed to configure yalantinglibs" - -echo "Building yalantinglibs (using $(nproc) cores)..." -cmake --build . -j$(nproc) -check_success "Failed to build yalantinglibs" - -echo "Installing yalantinglibs..." -cmake --install . -check_success "Failed to install yalantinglibs" - -print_success "yalantinglibs installed successfully" -cd "${REPO_ROOT}" - print_section "Verifying essential build tools" # Verify getconf and ldd (required for glibc version detection in build_wheel.sh) @@ -373,6 +431,11 @@ fi if [ "$INSTALL_SPDK" = true ]; then print_section "Installing SPDK" + if [ "$OS" = "ubuntu" ] || [ "$OS" = "debian" ]; then + apt-get install -y libelf-dev + check_success "Failed to install NoF dependencies" + fi + cd "${REPO_ROOT}/extern" check_success "Failed to change to extern directory" @@ -403,8 +466,8 @@ if [ "$INSTALL_SPDK" = true ]; then # Install SPDK dependencies echo "Installing SPDK dependencies..." - ./scripts/pkgdep.sh - check_success "Failed to install SPDK dependencies" + ./scripts/pkgdep.sh --rdma + check_success "Failed to install SPDK RDMA dependencies" # Configure SPDK with RDMA support echo "Configuring SPDK with RDMA support..." @@ -440,7 +503,6 @@ print_section "Installation Complete" echo -e "${GREEN}All dependencies have been successfully installed!${NC}" echo -e "The following components were installed:" echo -e " ${GREEN}✓${NC} System packages" -echo -e " ${GREEN}✓${NC} yalantinglibs" echo -e " ${GREEN}✓${NC} Git submodules" echo -e " ${GREEN}✓${NC} Go $GOVER" if [ "$INSTALL_SPDK" = true ]; then diff --git a/docker/mooncake.Dockerfile b/docker/mooncake.Dockerfile index c0a9c8d760..ddebfa48dd 100644 --- a/docker/mooncake.Dockerfile +++ b/docker/mooncake.Dockerfile @@ -15,8 +15,8 @@ ENV DEBIAN_FRONTEND=noninteractive \ ARG PYTHON_VERSION=3.10 ARG PYPA_INDEX_URL=https://bootstrap.pypa.io ARG CMAKE_BUILD_TYPE=Release -ARG EP_TORCH_VERSIONS="2.13.0" -ARG TORCH_CUDA_ARCH_LIST="8.0;9.0" +ARG EP_TORCH_VERSIONS="2.14.0" +ARG TORCH_CUDA_ARCH_LIST="" # CI can opt in to removing /workspace/build from the builder layer. ARG CLEAN_BUILD_ARTIFACTS=0 @@ -49,7 +49,7 @@ RUN apt-get update && \ WORKDIR /workspace COPY . /workspace -# Install Mooncake dependencies (yalantinglibs, Go, etc.) +# Install Mooncake dependencies (submodules, Go, etc.) RUN bash dependencies.sh -y # Configure and build the wheel in one layer, then remove build/ only after diff --git a/docker/musa.Dockerfile b/docker/musa.Dockerfile index d33f65d263..1aff6ce363 100644 --- a/docker/musa.Dockerfile +++ b/docker/musa.Dockerfile @@ -32,7 +32,7 @@ ENV PYTHON_VERSION=${PYTHON_VERSION} \ WORKDIR /workspace COPY . /workspace -# Install Mooncake dependencies (submodules, yalantinglibs, Go, etc.) +# Install Mooncake dependencies (submodules, Go, etc.) RUN bash dependencies.sh -y # Configure & build Mooncake diff --git a/docker/xpu.Dockerfile b/docker/xpu.Dockerfile new file mode 100644 index 0000000000..24dfc50e2e --- /dev/null +++ b/docker/xpu.Dockerfile @@ -0,0 +1,87 @@ +# syntax=docker/dockerfile:1.7 + +############################################################################### +# Mooncake Intel XPU (oneAPI SYCL) development / build image. +# +# USE_XPU is a native, direct-link SYCL build: the XPU platform sources include +# and link libsycl directly, so the whole Transfer Engine build +# must use the Intel DPC++ compiler (icpx). +# +# Base image: intel/pytorch:xpu. It already ships the Intel GPU runtime +# (level-zero / libze_loader, the OpenCL ICD, and the graphics compute runtime) +# plus a torch build with XPU support, but -- like vLLM's Dockerfile.xpu, which +# only consumes prebuilt torch-xpu wheels -- it does NOT include a SYCL +# compiler. We add one via Intel's oneAPI apt repo +# (intel-oneapi-compiler-dpcpp-cpp -> icpx). The result is a single image that +# can BUILD the native XPU platform *and* run it against a real Intel GPU (or +# fall back to the OpenCL CPU device for tent_xpu_platform_test on a GPU-less +# host), with torch available for XPU integration work. +# +# Build: +# docker build -f docker/xpu.Dockerfile -t mooncake-xpu:dev . +# Run the XPU platform test inside the image: +# docker run --rm --device /dev/dri mooncake-xpu:dev \ +# bash -lc '. /opt/intel/oneapi/setvars.sh >/dev/null && \ +# ctest --test-dir build-xpu -R tent_xpu_platform_test --output-on-failure' +############################################################################### + +ARG BASE_IMAGE=intel/pytorch:xpu-2.13.0-ubuntu24.04-20260907 + +FROM ${BASE_IMAGE} + +ENV DEBIAN_FRONTEND=noninteractive \ + PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 \ + PATH="/usr/local/go/bin:${PATH}" + +ARG CMAKE_BUILD_TYPE=RelWithDebInfo +# Pin the DPC++ compiler version to keep image builds reproducible. +ARG DPCPP_VERSION=2026.1 + +# Add Intel's oneAPI apt repository (the base image only carries the GPU +# *runtime* repo, intel-gpu-*, not the compiler), then install the DPC++ +# compiler (icpx) plus the build utilities Mooncake needs. The base image +# provides cmake but not ninja/git/RDMA dev headers. +RUN apt-get update && \ + apt-get install -y --no-install-recommends ca-certificates curl gpg && \ + curl -fsSL https://apt.repos.intel.com/intel-gpg-keys/GPG-PUB-KEY-INTEL-SW-PRODUCTS.PUB \ + | gpg --dearmor -o /usr/share/keyrings/oneapi-archive-keyring.gpg && \ + echo "deb [signed-by=/usr/share/keyrings/oneapi-archive-keyring.gpg] https://apt.repos.intel.com/oneapi all main" \ + > /etc/apt/sources.list.d/oneAPI.list && \ + apt-get update && \ + apt-get install -y --no-install-recommends \ + intel-oneapi-compiler-dpcpp-cpp-${DPCPP_VERSION} \ + build-essential \ + cmake \ + git \ + ninja-build \ + pkg-config \ + libibverbs-dev \ + librdmacm-dev \ + libnuma-dev && \ + rm -rf /var/lib/apt/lists/* + +WORKDIR /workspace +COPY . /workspace + +# Install Mooncake dependencies (submodules, Go, system libs, etc.). +RUN bash dependencies.sh -y + +# Configure and build the Transfer Engine with the native XPU platform. icpx is +# selected as the C/C++ compiler because USE_XPU links SYCL directly; +# common.cmake enforces this and fails fast otherwise. Store/P2P components are +# disabled to keep the DPC++ build focused on the XPU transfer path. +RUN . /opt/intel/oneapi/setvars.sh >/dev/null && \ + cmake -G Ninja -S . -B build-xpu \ + -DCMAKE_C_COMPILER=icx \ + -DCMAKE_CXX_COMPILER=icpx \ + -DCMAKE_BUILD_TYPE=${CMAKE_BUILD_TYPE} \ + -DUSE_TENT=ON \ + -DUSE_XPU=ON \ + -DBUILD_UNIT_TESTS=ON \ + -DWITH_STORE=OFF \ + -DWITH_STORE_RUST=OFF \ + -DWITH_P2P_STORE=OFF && \ + cmake --build build-xpu --target tent_xpu_platform_test -j "$(nproc)" + +CMD ["bash"] diff --git a/docs/source/api-reference/cpp/index.md b/docs/source/api-reference/cpp/index.md index 089c657c8b..022c3a08b8 100644 --- a/docs/source/api-reference/cpp/index.md +++ b/docs/source/api-reference/cpp/index.md @@ -4,7 +4,7 @@ |--------|-------------| | [Transfer Engine C++ API](transfer-engine) | `TransferEngine` class — memory registration, batch transfer, segment management, RDMA transport | | [TENT C++ API](tent) | `mooncake::tent::TransferEngine` — next-gen transfer engine with automatic transport selection and fault tolerance | -| [Mooncake Store Client C++ API](mooncake-store) | `Client` class — `Put`/`Get`/`Remove`/`Replicate` operations, `BufferAllocatorBase` interface | +| [Mooncake Store Client C++ API](mooncake-store) | `Client` class — `Put`/`Get`/`Remove`/`Replicate` operations | :::{toctree} :maxdepth: 1 diff --git a/docs/source/api-reference/cpp/mooncake-store.md b/docs/source/api-reference/cpp/mooncake-store.md index 779655148c..49d9ca4ed8 100644 --- a/docs/source/api-reference/cpp/mooncake-store.md +++ b/docs/source/api-reference/cpp/mooncake-store.md @@ -26,7 +26,7 @@ tl::expected Get(const std::string& object_key, std::vector& slices); ``` -`Get` retrieves the value of `object_key` into the provided `slices`. The returned data is guaranteed to be complete and correct. Each slice must reference local DRAM/VRAM memory that has been pre-registered with `registerLocalMemory(addr, len)` (not the global segments that contribute to the distributed memory pool). When persistence is enabled and the requested data is not found in the distributed memory pool, `Get` will fall back to loading the data from SSD. +`Get` retrieves the value of `object_key` into the provided `slices`. The returned data is guaranteed to be complete and correct. Each slice must reference local DRAM/VRAM memory that has been pre-registered with `registerLocalMemory(addr, len)` (not the global segments that contribute to the distributed memory pool). The master returns the readable replica list and the client selects a complete replica. Depending on the selected replica, the data may be read from memory, NoF SSD, legacy shared-filesystem `DISK`, client-owned `LOCAL_DISK`, or the configured descriptor-based DFS backend. ### Put @@ -36,24 +36,45 @@ tl::expected Put(const ObjectKey& key, const ReplicateConfig& config); ``` -`Put` stores the value associated with `key` in the distributed memory pool. The `config` parameter allows specifying the required number of replicas as well as the preferred segment for storing the value. When persistence is enabled, `Put` also asynchronously triggers a persistence operation to SSD. +`Put` stores the value associated with `key` in the configured replica tiers. The `config` parameter controls the number of memory, NoF, and DFS replicas as well as placement preferences. Legacy `DISK` persistence and client-owned `LOCAL_DISK` SSD offload remain asynchronous. When `dfs_replica_num` is `1`, `Put` waits for the requested DFS `WriteAt` operation before returning success; this does not provide an additional `fsync` durability guarantee. -**Replication Guarantees and Best Effort Behavior:** +**Memory Replication Guarantees and Best Effort Behavior:** - Each slice of an object is guaranteed to be replicated to different segments, ensuring distribution across separate storage nodes - Different slices from different objects may be placed in the same segment - Replication operates on a best-effort basis: if insufficient space is available for all requested replicas, the object will still be written with as many replicas as possible -The data structure details of `ReplicateConfig` are as follows: +Requests with `dfs_replica_num == 1` use reliable multi-replica mode: allocation and every requested transfer must succeed, otherwise `Put` fails and allocated replicas are revoked. + +```{warning} +Descriptor-based DFS is a work-in-progress feature for development and evaluation. It is not covered by the Store's production fault-tolerance, HA, durability, or multi-tenant guarantees. +``` + +The DFS-related replica-count fields of `ReplicateConfig` are as follows: ```C++ struct ReplicateConfig { - size_t replica_num{1}; // Total number of replicas for the object - bool with_soft_pin{false}; // Whether to enable soft pin mechanism for this object - bool with_hard_pin{false}; // Whether to enable hard pin (never evicted) - std::string preferred_segment{}; // Preferred segment for allocation + size_t replica_num{1}; // Memory replicas + size_t nof_replica_num{0}; // NoF SSD replicas + size_t dfs_replica_num{0}; // Shared DFS replicas (0 or 1) + SoftPinAction soft_pin_action{SoftPinAction::PRESERVE}; + std::optional soft_pin_ttl_ms{}; // ENABLE override; omitted uses the Master default + bool with_hard_pin{false}; // Whether to enable hard pin (never evicted) + std::string preferred_segment{}; // Preferred segment for allocation + // Other placement, data-type, and grouping fields are omitted. }; ``` +`dfs_replica_num` may currently be `0` or `1`. When it is `1`, `replica_num >= 1` is required, so DFS-only placement is not supported. DFS replicas currently support only the `default` tenant and require the master and client DFS backends to be configured with the same shared root and shard layout. See the {ref}`DFS deployment documentation ` for setup and lifecycle limitations. Native C++ clients must initialize a `DistributedStorageBackend` and attach it with `SetDfsStorageBackend()` before issuing DFS reads or writes. DFS descriptors are carried by each `PutStart`, `UpsertStart`, or query response; there is no client-side descriptor cache. The Python/RealClient setup path attaches the backend through `FileStorage`. + +Soft pinning starts when the first replica becomes readable and has a fixed +lifetime: reads do not extend it. `PRESERVE` keeps the committed deadline on an +Upsert, `ENABLE` starts a new lifetime, and `DISABLE` removes it when the write +commits. `soft_pin_ttl_ms` is valid only with `ENABLE`; zero commits ordinary +cache, and values above the Master's configured maximum are rejected. +Soft-pin state is not persisted in snapshots or the HA OpLog; after recovery or +Standby promotion, restored objects are ordinary cache until a later write +explicitly enables soft pinning again. + ### Upsert ```C++ @@ -70,8 +91,11 @@ std::vector> BatchUpsert( `Upsert` inserts `key` if it does not exist and updates the existing object if it does. It uses the same replication configuration model as `Put`, while allowing the store to reuse existing placement for in-place updates when the -current layout permits it. `BatchUpsert` performs the same operation for -multiple keys using a shared replication configuration. +current layout permits it. If either the existing object or the new request has +a DFS replica, a same-size update requires the requested memory, NoF, and DFS +replica counts to match the existing topology. A different-size update releases +the old placement and allocates a new topology. `BatchUpsert` performs the same +operation for multiple keys using a shared replication configuration. ### Remove diff --git a/docs/source/api-reference/cpp/tent.md b/docs/source/api-reference/cpp/tent.md index 22b56a0c68..46b68c7e56 100644 --- a/docs/source/api-reference/cpp/tent.md +++ b/docs/source/api-reference/cpp/tent.md @@ -106,6 +106,64 @@ export MC_USE_TENT=1 When this variable is set, the `mooncake::TransferEngine` class internally delegates to `mooncake::tent::TransferEngine`. Most TE APIs are translated automatically. APIs that have no TENT equivalent (e.g., `installTransport`, `getMetadata`) become no-ops or return placeholder values. +Passing a NIC priority matrix through `installTransport(..., args)` is **not** supported under TENT. Configure custom topology via `MC_TENT_CONF` or `MC_CUSTOM_TOPO_JSON` instead (see below). + +(custom-nic-priority-matrix)= +### Custom NIC Priority Matrix + +TENT accepts the same classic Transfer Engine priority-matrix JSON format: + +```json +{ + "cpu:0": [["mlx5_0"], ["mlx5_1"]], + "cpu:1": [["mlx5_1"], ["mlx5_0"]], + "cuda:0": [["mlx5_0"], ["mlx5_1"]] +} +``` + +Keys must match discovered location names (`cpu:N`, `cuda:N`, `hip:N` on AMD). +TENT accepts the legacy AMD prefix `rocm:N` when parsing matrices so existing +configs keep working — such keys are canonicalized to `hip:N` on load, and +specifying both `rocm:N` and `hip:N` for the same device is rejected as a +conflict. Discovery and dumps emit `hip:N`, matching classic TE. + +Preferred HCAs map to topology rank 0; available/fallback HCAs map to rank 1. + +Configuration priority (highest first): + +1. Inline matrix in `MC_TENT_CONF` / `Config`: `topology/priority_matrix` +2. File path: `topology/custom_json_path` (also set by `MC_CUSTOM_TOPO_JSON`) +3. Automatic topology discovery + +**Inline example (`MC_TENT_CONF`):** + +```json +{ + "topology": { + "priority_matrix": { + "cpu:0": [["mlx5_0"], ["mlx5_1"]], + "cuda:0": [["mlx5_0"], ["mlx5_1"]] + } + } +} +``` + +**Path example:** + +```json +{ + "topology": { + "custom_json_path": "/etc/mooncake/nic_priority_matrix.json" + } +} +``` + +```bash +export MC_CUSTOM_TOPO_JSON=/etc/mooncake/nic_priority_matrix.json +``` + +A topology file may also use TENT's native `{"nics":[...],"mems":[...]}` format when loaded via `custom_json_path` / `MC_CUSTOM_TOPO_JSON`. If loading or parsing fails, TENT falls back to auto-discovery. + ## Core APIs ### Core Usage Path (C++) @@ -484,7 +542,7 @@ Status receiveNotification(std::vector& notifi_list); Receives pending notifications from peers. -- `notifi_list`: Output vector of received notifications. +- `notifi_list`: Output vector of received notifications. The vector is cleared on entry, so each call reports only what that call delivered; a caller reusing one buffer across polls does not see the previous batch again. (Note that the classic Transfer Engine's `getNotifies()` appends instead of replacing.) - Return value: `Status::OK()` on success; otherwise a non-OK status. - Typical use: Polling loop to trigger follow-up actions on received data. @@ -541,7 +599,7 @@ using Location = std::string; const static std::string kWildcardLocation = "*"; ``` -Location strings identify device affinity: `"cpu:0"`, `"cuda:0"`, `"cuda:1"`, etc. Use `"*"` for automatic detection. +Location strings identify device affinity: `"cpu:0"`, `"cuda:0"`, `"hip:0"`, etc. Use `"*"` for automatic detection. On AMD GPUs the canonical prefix is `hip:` (same as classic TE). The legacy TENT prefix `rocm:` is still accepted when parsing locations and custom NIC matrices. ### TransportType diff --git a/docs/source/api-reference/cpp/transfer-engine.md b/docs/source/api-reference/cpp/transfer-engine.md index d1fa4f8838..a438e064c0 100644 --- a/docs/source/api-reference/cpp/transfer-engine.md +++ b/docs/source/api-reference/cpp/transfer-engine.md @@ -199,6 +199,20 @@ Unregisters the region. - `update_metadata`: Whether to publish the unregistration to the metadata service. - Return value: If successful, returns 0; otherwise, returns a negative value. +#### TransferEngine::allocateSharedMemory + +```cpp +void* allocateSharedMemory(size_t length); +void* allocateSharedMemory(size_t length, const SharedMemoryOptions& opt); +int freeSharedMemory(void* addr); +``` + +Allocates a shared-memory region that `ShmTransport` can export to same-host peers. Ordinary `malloc` cannot be advertised this way. Requires `ShmTransport` (`MC_FORCE_SHM=1` or `installTransport("shm")`). Call `registerLocalMemory` on the returned pointer before remote access; a sub-range inside that allocation, or a length larger than the allocation, returns an error. Classic Transfer Engine only; TENT returns `nullptr` / `ERR_NOT_IMPLEMENTED`. Combining SHM with RDMA/TCP on one engine requires `-DENABLE_MULTI_PROTOCOL=ON`. Without that flag, `installTransport("shm")` logs a WARNING if it replaces an existing rdma/tcp segment protocol. + +The no-options overload (and `SharedMemoryOptions{}`) creates a POSIX shm object in `/dev/shm` named `/mooncake__xxxxxxxx`, mode `0600` (same UID). With `opt.use_hugepage` and `hugepage_size` of 2MB, 512MB, or 1GB, TE creates a file on a matching hugetlbfs mount (`opt.hugetlbfs_path`, or `/dev/hugepages`, `/dev/hugepages-512M`, `/dev/hugepages-1G`) named `/mooncake__xxxxxxxx`. Hugepage `length` must already be a multiple of that page size; TE does not round up, and it does not fall back to tmpfs if the mount is missing or the page size mismatches. `freeSharedMemory` only accepts pointers returned by `allocateSharedMemory`; a `malloc` pointer is rejected without unregistering other transports. + +`freeSharedMemory` and `ShmTransport`'s destructor unlink the object. Crash or `SIGKILL` skips that. POSIX leftovers sit in `/dev/shm` until reboot or manual `shm_unlink`. Hugetlbfs leftovers stay on the mount and **keep hugepages reserved** until the file is unlinked or the node reboots — worse than tmpfs leftovers. There is no automatic startup reaper: several processes share a mount, so deleting every `mooncake_*` on start would remove live peers' files. After a crash, remove only files whose encoded pid is gone, for example `rm /dev/hugepages/mooncake__*` (and the 512MB/1GB mounts if used). Peers detect an unlinked object on the next transfer, drop the orphaned mmap, and refetch metadata once; if realloc changes the virtual address, the initiator must use the new `BufferDesc.addr`. + #### TransferEngine::registerLocalMemoryBatch ```cpp @@ -240,6 +254,36 @@ int closeSegment(SegmentHandle segment_id); - `segment_id`: The unique identifier of the segment. - Return value: If successful, returns 0; otherwise, returns a negative value. +#### TransferEngine::getSegmentBuffers + +```cpp +struct SegmentBufferInfo { + uint64_t addr; + uint64_t length; + std::string location; +}; + +int getSegmentBuffers(SegmentHandle handle, + std::vector& buffers); +``` + +Returns a snapshot of the memory buffers published in a segment, using the +selected classic or TENT backend. This supports buffer inspection without +accessing `getMetadata()`, which is unavailable under TENT. + +- `handle`: A segment handle returned by `openSegment()`. +- `buffers`: Replaces any existing contents with buffer addresses, lengths in + bytes, and backend-reported location labels. Classic copies `BufferDesc::name` + (some transports, such as TCP, use a server name); TENT copies the native + location. Entries preserve the backend descriptor's order, which is not + guaranteed to be sorted by address. TENT internal buffers are excluded. +- Return value: `0` on success, including a segment with no memory buffers; + `ERR_METADATA` if the descriptor cannot be retrieved; `ERR_NOT_IMPLEMENTED` + for file segments. On error, `buffers` is empty. +- The call may fetch remote metadata and block. It does not close the handle, + register memory, or keep the reported buffers alive; the caller must coordinate + buffer lifetime with the owning peer. + #### TransferEngine::removeLocalSegment ```cpp @@ -399,7 +443,7 @@ Transport* installTransport(const std::string& proto, void** args); Installs a transport backend explicitly. -- `proto`: Transport protocol name, such as `rdma`, `tcp`, or `nvmeof`. +- `proto`: Transport protocol name, such as `rdma`, `tcp`, `nvmeof`, or `shm`. - `args`: Transport-specific arguments. > Note: In TENT, `installTransport` is not exposed (removed from the public API, including compatibility surfaces). Transport selection is internal to TENT. @@ -426,6 +470,18 @@ Returns a transport instance by protocol name, mainly for advanced inspection or TransferEngine can send and receive lightweight notifications across segments to coordinate data movement. +Notifications are sent only when requested through the APIs below. For classic TE: + +- `MC_RDMA_NOTIFY_ENABLED=1` (default) enables RDMA notifications for both `rdma` and + `rdma_twosided`; set it to `0` before initialization to use TCP. +- Ordinary `rdma` supports up to 65528 combined bytes of `name` and `msg`. Unsupported + peers or larger messages fall back to TCP when `MC_RDMA_NOTIFY_OOB_FALLBACK=1` + (default); set it to `0` to return an error instead. +- A successful ordinary RDMA send indicates local submission, without confirming + remote receipt. Connection setup or backpressure may block; applications must + ensure delivery before shutting down either engine. +- Ordinary RDMA notifications use 32 MiB of registered host memory per endpoint. + #### TransferEngine::submitTransferWithNotify ```cpp diff --git a/docs/source/api-reference/http/http-service.md b/docs/source/api-reference/http/http-service.md index 8c8ee90fdd..0050adcd50 100644 --- a/docs/source/api-reference/http/http-service.md +++ b/docs/source/api-reference/http/http-service.md @@ -260,6 +260,29 @@ Basic health check endpoint for service availability verification. curl http://localhost:8080/health ``` +#### `/version` +Report the master version. Always available, including while the master is in +standby. + +**Method**: `GET` +**Content-Type**: `application/json; charset=utf-8` +**Response**: JSON object with: +- `version` (string): Store version used for RPC handshake compatibility +- `display_version` (string): Human-readable release plus short git hash + +**Example**: +```bash +curl http://localhost:8080/version +``` + +```json +{"version":"2.0.0","display_version":"0.3.12.post1 (git: f9e8311f)"} +``` + +Real clients expose the same `/version` payload on their own client HTTP port +when `enable_client_http_server` is on. See +[Client Metrics Endpoint](../../getting_started/observability.md#client-metrics-endpoint). + ## Store REST API Endpoints The following endpoints are served by the Python store REST service, which wraps @@ -280,9 +303,10 @@ mc_store_rest_server --config /path/to/mooncake_config.json --port 8080 ``` ### `/api/mount_shm` -Mount a named shared memory object as one or more Mooncake store segments. If -the requested size exceeds the maximum registration size, the service may split -the region and return multiple segment ids. +Mount a named shared memory object as one or more Mooncake store segments. +Protocols with a registration-size limit split oversized regions and return +multiple segment ids. Protocols without such a Store-level limit, such as TCP +and RDMA, use a single segment regardless of `max_mr_size`. **Method**: `POST` **Content-Type**: `application/json` @@ -366,9 +390,10 @@ curl -X POST http://localhost:8080/api/unmount_shm \ ### `/api/mount` Allocate memory inside the store process and mount it as one or more Mooncake -store segments. If the requested size exceeds the maximum registration size, -the service may split it and return multiple segment ids. The response includes -the actual allocated size after alignment. +store segments. Protocols with a registration-size limit split oversized +requests and return multiple segment ids. Protocols without such a Store-level +limit, such as TCP and RDMA, use a single segment regardless of `max_mr_size`. +The response includes the actual allocated size after alignment. **Method**: `POST` **Content-Type**: `application/json` @@ -440,3 +465,54 @@ curl -X POST http://localhost:8080/api/unmount \ -d '{"segment_ids": ["00000000-0000-0000-0000-000000000002"], "grace_period_seconds": 30}' ``` + +### `/api/unmount_local_disk` +Deregister this store's SSD offload tier from the master before the process +goes away. Intended for a shutdown hook. + +The master stops naming this store as the owner of the keys it offloaded, so a +reader gets a clean miss instead of a peer that is about to disappear. Without +this, a `LOCAL_DISK` segment leaves the master only when the client expires — +one `client_ttl` after the store stops pinging — and reads that pick up the +stale owner in that window block on the connect retries (see +`MC_RPC_CONNECT_TIMEOUT_MS`) before missing. + +The call then holds for `grace_period_seconds` before returning. Unlike a memory +replica, which the NIC serves without help from the store process, a disk +replica is read and pushed by that process, so it has to stay alive for the +reads the master handed out before the deregistration. Offloading is stopped for +good when this is called; the store is expected to exit afterwards. + +Returns success and does nothing when SSD offload is not enabled on this store. +Safe to call more than once. + +**Method**: `POST` +**Content-Type**: `application/json` + +**Request Body**: +```json +{ + "grace_period_seconds": 30 +} +``` + +`grace_period_seconds` is optional and defaults to `0`, which returns as soon as +the master has dropped the segment. Must be a non-negative integer no greater +than 3600 (1 hour); a malformed body or an out-of-range value gets a `400` +without touching the store, so a mistake here (seconds where milliseconds were +meant, say) cannot block a preStop hook for hours. + +**Success Response**: +```json +{ + "status": "success" +} +``` + +**Example** — as a Kubernetes preStop hook, with a +`terminationGracePeriodSeconds` longer than the grace period: +```bash +curl -X POST http://localhost:8080/api/unmount_local_disk \ + -H "Content-Type: application/json" \ + -d '{"grace_period_seconds": 30}' +``` diff --git a/docs/source/api-reference/python/ep-backend.md b/docs/source/api-reference/python/ep-backend.md index a3c12a38dd..c2d10f290d 100644 --- a/docs/source/api-reference/python/ep-backend.md +++ b/docs/source/api-reference/python/ep-backend.md @@ -1,14 +1,13 @@ -# Mooncake EP & Mooncake Backend (PG) +# Mooncake EP & Mooncake PG ## Overview Mooncake provides two closely related components for fault-tolerant MoE inference: -- **Mooncake Backend (PG)** is a `torch.distributed` ProcessGroup backend. It - registers the `mooncake` accelerator backend and the `mooncake-cpu` backend, - implements common collective and point-to-point APIs, tracks active ranks, and - exposes elastic recovery helpers. +- **Mooncake PG** is a `torch.distributed` ProcessGroup backend. It registers + the `mooncake` accelerator backend and the `mooncake-cpu` backend, implements + collective and point-to-point APIs, and exposes dynamic-membership helpers. - **Mooncake EP** is an expert-parallel dispatch/combine runtime for latency-sensitive MoE inference. It follows the DeepEP low-latency programming model while adding rank activeness awareness and Mooncake transport support. @@ -17,8 +16,9 @@ The usual integration pattern is to initialize a Mooncake process group first, then construct a Mooncake EP `Buffer` from that group. The process group is used both for regular collectives and for exchanging EP bootstrap metadata. -For implementation details, see the [Mooncake Backend (PG) design guide](../../design/mooncake-backend-pg.md) -and the [Mooncake EP design guide](../../design/mooncake-ep.md). +For implementation details, see the +[Mooncake PG design guide](../../design/mooncake-backend-pg.md) and the +[Mooncake EP design guide](../../design/mooncake-ep.md). ## Installation and build notes @@ -35,7 +35,7 @@ match the active `torch.__version__`. If the current PyTorch version does not match a built extension, import will fail with a message such as `Mooncake PG was not built against torch==...`. -## Mooncake Backend (PG) quick start +## Mooncake PG quick start ### CUDA backend @@ -54,14 +54,10 @@ local_rank = int(os.environ.get("LOCAL_RANK", rank)) torch.cuda.set_device(local_rank) device = torch.device("cuda", local_rank) -# Backend-level active-rank mask. Use int32 and place it on the backend device. -active_ranks = torch.ones(world_size, dtype=torch.int32, device=device) - dist.init_process_group( backend="mooncake", rank=rank, world_size=world_size, - pg_options=pg.MooncakeBackendOptions(active_ranks), ) x = torch.tensor([rank + 1], dtype=torch.int32, device=device) @@ -77,18 +73,20 @@ torchrun --nproc-per-node=2 pg_quickstart.py ### CPU backend -Use `backend="mooncake-cpu"` and put `active_ranks` on CPU: +Use `backend="mooncake-cpu"`: ```python -active_ranks = torch.ones(world_size, dtype=torch.int32) dist.init_process_group( backend="mooncake-cpu", rank=rank, world_size=world_size, - pg_options=pg.MooncakeBackendOptions(active_ranks), ) ``` +`pg_options` is optional for a fixed-size group using the default failure +handling. Pass `MooncakeBackendOptions` when reserving additional group +capacity, joining as an extension, or selecting a non-default failure mode. + ### Selecting network devices To explicitly restrict Mooncake to a list of NIC / HCA devices, call @@ -103,27 +101,38 @@ pg.set_device_filter(["mlx5_1", "mlx5_2"]) For test and benchmark commands, the same setting is commonly passed through `MOONCAKE_PGTEST_DEVICE_FILTERS=mlx5_1,mlx5_2`. -## Mooncake Backend (PG) API reference +## Mooncake PG Torch API reference ### `MooncakeBackendOptions` ```python +pg.MooncakeBackendOptions(max_group_size) +pg.MooncakeBackendOptions(max_group_size, is_extension) +pg.MooncakeBackendOptions( + max_group_size, + is_extension, + auto_deactivate_on_failure, + auto_sync_on_failure, +) + +# Explicit active-rank mirror overloads pg.MooncakeBackendOptions(active_ranks) pg.MooncakeBackendOptions(active_ranks, is_extension) -pg.MooncakeBackendOptions(active_ranks, is_extension, max_world_size) +pg.MooncakeBackendOptions(active_ranks, is_extension, max_group_size) ``` Arguments: -- `active_ranks`: `torch.int32` tensor used as the backend-level rank-health - mask. For `mooncake`, it must be on the accelerator device; for - `mooncake-cpu`, it must be on CPU. When `max_world_size` is set, size this - tensor to `max_world_size`, not the current visible world size. +- `max_group_size`: fixed in-group slot capacity. It must be at least the + initially declared group size and cannot be increased later. +- `active_ranks`: optional contiguous `torch.int32` storage used as a mirror of + committed PG membership. Its initial contents are ignored. Size it to + `max_group_size`; it may be on CPU or GPU. - `is_extension`: set to `True` for a replacement or joining process that will enter an existing group through `join_group()`. -- `max_world_size`: optional upper bound for reserved rank slots. It lets - healthy ranks reserve inactive future ranks while keeping - `dist.get_world_size()` equal to the current active size. +- `auto_deactivate_on_failure` and `auto_sync_on_failure`: select automatic or + framework-managed failure handling. Both default to `True`; auto-sync + requires auto-deactivation. ### Utility functions @@ -133,15 +142,17 @@ Arguments: | `pg.set_device_filter(filters)` | Restrict NIC/HCA selection. | Call before `init_process_group()`. | | `pg.set_transfer_engine(engine)` | Reuse an external `TransferEngine`. | The engine must outlive all process groups. | | `pg.get_active_ranks(backend)` | Return the backend active-rank tensor. | Used by EP fallback and recovery paths. | -| `pg.get_num_synced_ranks(backend)` | Return the number of ranks synchronized by the backend. | Diagnostic helper. | -| `pg.extend_group_size_to(backend, size)` | Reserve additional inactive ranks. | Newly extended ranks do not participate until recovered. | -| `pg.get_peer_state(backend, ranks)` | Check whether candidate ranks have published peer metadata. | Collective among healthy ranks. | -| `pg.recover_ranks(backend, ranks)` | Activate ready ranks and publish extension state. | Requires peer metadata to be ready. | -| `pg.join_group(backend)` | Joiner-side blocking call for extension ranks. | Used after `is_extension=True` initialization. | +| `pg.get_num_synced_ranks(backend)` | Return the number of locally activatable group slots. | Diagnostic helper. | +| `pg.get_peer_state(backend, ranks)` | Read locally mirrored activation readiness. | A lightweight, communication-free query. | +| `pg.activate_ranks(backend, ranks)` | Propose activation through the Coordinator. | A single call from any online rank is sufficient. | +| `pg.recover_ranks(backend, ranks)` | Propose activation through the Coordinator. | Compatibility alias to `activate_ranks`. | +| `pg.deactivate_ranks(backend, ranks)` | Propose deactivation through the Coordinator. | A single call from any online rank is sufficient. | +| `pg.join_group(backend)` | Confirm readiness for activation and remain blocked until activation actually occurs. | Used for scale-up, replacement, and in-place rejoin. | +| `pg.sync_after_failure(backend)` | Report current link observations, wait for reconciliation, and apply the latest group view. | Called automatically when `auto_sync_on_failure=True`; it may also be called manually. | ### Supported distributed operations -Mooncake Backend implements the following `torch.distributed` APIs. Support may +Mooncake PG implements the following `torch.distributed` APIs. Support may depend on device type, dtype, PyTorch version, and whether the current backend is `mooncake` or `mooncake-cpu`; run the PG tests on the target environment before production use. @@ -150,29 +161,27 @@ production use. | --- | --- | --- | | Collectives | `all_reduce`, `broadcast`, `all_gather`, `all_gather_into_tensor`, `reduce_scatter_tensor`, `all_to_all`, `barrier`, `reduce`, `gather`, `scatter` | Active ranks participate; inactive ranks are skipped by backend internals. | | Async work | `dist.all_reduce(..., async_op=True)` | Wait on the returned work object, then synchronize the device stream as needed. | -| P2P | `isend`, `irecv`, `batch_isend_irecv` | Single-tensor P2P is routed through the Mooncake P2P shim. | +| P2P | `isend`, `irecv`, `batch_isend_irecv` | Single-tensor P2P is routed through the Mooncake backend shim. | ## Elastic recovery protocol -Mooncake PG supports a two-sided recovery protocol. Existing healthy ranks poll -for replacement rank readiness, then activate those ranks. Replacement ranks -start in extension mode, publish metadata, and wait until healthy ranks recover -them. +Mooncake PG separates join preparation from membership activation. A joining or +recovering rank completes local warmup, calls `join_group()`, and waits. An +existing rank may poll local readiness and then issue the activation proposal. +The Coordinator validates and distributes the resulting membership. ### Healthy-rank side ```python from mooncake import pg -active_ranks = torch.tensor([1, 1, 0], dtype=torch.int32, device=device) dist.init_process_group( backend="mooncake", rank=rank, world_size=2, pg_options=pg.MooncakeBackendOptions( - active_ranks, + 3, # max_group_size False, # is_extension - 3, # max_world_size ), ) @@ -191,31 +200,35 @@ pg.recover_ranks(backend, join_ranks) ```python from mooncake import pg -active_ranks = torch.tensor([1, 1, 1], dtype=torch.int32, device=device) dist.init_process_group( backend="mooncake", rank=2, world_size=3, pg_options=pg.MooncakeBackendOptions( - active_ranks, + 3, # max_group_size True, # is_extension - 3, # max_world_size ), ) backend = dist.group.WORLD + +# Collectives are local-only before join_group. Use this +# window for framework-specific preparation, for example: +# capture_cuda_graphs() +# warm_up_model() + pg.join_group(backend) ``` Important semantics: -- `get_peer_state()` is collective among the current healthy ranks. Call it in a - consistent order across those ranks. -- New ranks are inactive after `extend_group_size_to()` and become collective - participants only after `recover_ranks()`. -- A joining rank initialized with `is_extension=True` starts with local-only - behavior and blocks in `join_group()` until the corresponding healthy ranks - publish recovery state. +- `get_peer_state()` is a local best-effort readiness query, not a collective. +- Capacity must be reserved with `max_group_size` when founding members create + the group. A joining registration appends inactive slots within that capacity. +- A joining rank starts with local-only collective behavior until `join_group`. + The join call then blocks until a Coordinator-approved activation commits. +- A single `activate_ranks()` call, or its `recover_ranks()` alias, from any + online rank is sufficient; redundant equivalent calls are safe. - Subgroups must be created in the same order on healthy and joining processes, following PyTorch `new_group()` ordering rules. @@ -399,27 +412,132 @@ matching dispatch `handle` and pass the resulting tensor back to `combine()` wit Reconnects EP peers after backend membership changes. Call it after PG recovery updates rank activeness so EP transport metadata and QPs can be refreshed. +## Default NCCL backend for `ElasticBuffer` + +`mooncake.mooncake_elastic_buffer.ElasticBuffer` now defaults to +`transport="auto"`. Auto mode uses NCCL when the extension was built with the +NCCL Device API and the inferred EP topology is supported by the compiled NCCL +kernels. Existing constructor calls require no changes. If NCCL cannot be +used, auto mode falls back to IPC + IBGDA and retains the previous backend +behavior. As before, the requested workload must have a compiled elastic kernel +shape. + +NCCL support is opt-in. Build with +`-DWITH_EP=ON -DUSE_CUDA=ON -DUSE_NCCL_DEVICE=ON`; the option defaults to +`OFF`. NCCL-enabled EP extensions currently link directly to `libnccl`, so +importing `mooncake.ep` requires a matching NCCL runtime even when the NCCL +transport is not selected. Keep the option disabled for deployments that must +remain compatible with older NCCL runtimes. + +No application-side communicator bootstrap is required. Auto mode creates one +NCCL unique ID on process-group rank zero and broadcasts it to the group: + +```python +import torch.distributed as dist + +from mooncake.mooncake_elastic_buffer import ElasticBuffer + +# Run this program with torchrun so rank metadata is available. +dist.init_process_group(backend="nccl") +buffer = ElasticBuffer( + dist.group.WORLD, + num_max_tokens_per_rank=128, + hidden=4096, + num_topk=8, +) +print(f"Mooncake EP selected {buffer.transport}") + +try: + # Call buffer.dispatch(...) and buffer.combine(...). + pass +finally: + # Deterministic collective cleanup is recommended when NCCL was selected. + buffer.destroy() + +dist.destroy_process_group() +``` + +For a controlled rollout, pass `transport="ibgda"` or set +`MOONCAKE_EP_TRANSPORT=ibgda`. Explicit `transport="nccl"` disables automatic +fallback and reports an error if NCCL support is unavailable. The +`explicitly_destroy` argument remains optional for compatibility with the +DeepEP API; calling `destroy()` collectively is still the most predictable way +to release NCCL symmetric windows before destroying the process group. + +The NCCL backend currently has the following constraints: + +- It requires NCCL 2.30.4 or newer with Device API and GIN support. The NCCL + headers used to build Mooncake must exactly match the loaded `libnccl`. + Rebuild Mooncake after an NCCL upgrade. If PyTorch would load another NCCL + first, configure or preload the matching runtime before initializing the + process group. +- Process-group ranks must form contiguous, equal-sized NCCL LSA teams. The + compiled kernels support one team of two or eight GPUs (`1x2` or `1x8`), two + teams of four or eight GPUs (`2x4` or `2x8`), and four teams of four GPUs + (`4x4`). Cross-team communication uses hybrid mode and rail GIN. Auto mode + selects IPC + IBGDA for other shapes. +- Groups with more than one rank request GIN resources, including runs whose + data path remains inside one LSA team. +- Logical membership remains fixed. After every process observes that Mooncake + PG has restored every original logical rank slot, rebuild between EP + iterations. Each surviving process + calls `update_ep_member()` on its existing buffer while each replacement + process constructs an `ElasticBuffer` with the same arguments. Healthy + reconfiguration, with no replaced process, calls `update_ep_member()` on + every rank. These calls are one coordinated operation; do not start a new EP + operation until all calls return. +- Reconfiguration rebuilds the host and device communicators, GIN resources, + symmetric window, and buffer allocation while preserving survivor Python + buffer objects. The recovered placement must still match a supported + topology. All dispatch handles and views created before the update are + invalid and must not be reused. Until the replacement is ready, the update + temporarily owns two complete NCCL generations: communicators, symmetric + windows, EP buffer allocations, and exclusive GIN contexts. Deployments near + memory, GIN-context, or QP limits must reserve capacity for both generations. +- `update_ep_member()` does not complete an operation interrupted by failure or + run with missing logical ranks. Retry interrupted work after PG recovery. +- A rank-local failure before the internal status collective is established + (for example, mismatched configuration/runtime or failure to allocate its + minimal CUDA control resources) is not recoverable in place and may require + restarting the process group. Use identical NCCL/CUDA configuration on every + rank. + ## Active-rank tensors: PG vs EP There are two active-rank tensors in the API surface: -- **PG active-rank mask**: passed to `pg.MooncakeBackendOptions`. This is the - backend-level health mask used by collective and recovery logic. +- **PG active-rank mask**: passed to `pg.MooncakeBackendOptions`. This mirrors + the Coordinator's committed membership. - **EP active-rank tensor**: passed to `Buffer.dispatch()` and `Buffer.combine()`. It is also rank-level (`[num_ranks]`, `torch.int32`) and may be updated by EP kernels when timeout detection marks a peer as failed. -In simple integrations these tensors often carry the same health information, -but they are passed through different API layers. Keep their dtype, device, and -shape consistent with the process group world size or reserved `max_world_size`. +Their values may coincide in a simple integration, but their semantics are not +interchangeable: PG membership is configuration, while EP may update its mask +from kernel-level timeout observations. Keep the mapping, dtype, device, and +capacity consistent when propagating committed PG membership into EP. ## Tests and examples - PG collectives: `mooncake-pg/tests/test_pg_collectives.py` - PG elastic recovery and subgroup extension: `mooncake-pg/tests/test_pg_elastic.py` - PG benchmark harness: `mooncake-pg/benchmark/README.md` -- EP correctness and failure simulation: `mooncake-ep/tests/test_ep_grid.py` -- Wheel-level EP example: `mooncake-wheel/tests/test_mooncake_ep.py` +- EP correctness and failure simulation: `python/tests/ep/test_ep_grid.py` +- EP wrapper example: `python/tests/ep/test_mooncake_ep.py` +- NCCL EP rank-replacement recovery: `python/tests/ep/test_elastic_buffer_recovery.py` + +Run the NCCL EP recovery tests from the repository root with two visible CUDA +devices and NCCL-enabled EP/PG extensions: + +```bash +python -m pytest -q python/tests/ep/test_elastic_buffer_recovery.py +``` + +These tests check dispatch/combine before and after worker replacement, reject +stale EP handles, and cover reserved PG capacity and a `mooncake-cpu` control +group. The EP data path remains on the GPUs when the control group uses CPU +tensors. The tests reuse the PG worker harness and honor +`MOONCAKE_PGTEST_DEVICE_FILTERS` for NIC selection. See [PG/EP troubleshooting](../../troubleshooting/pg-ep-troubleshooting.md) for common setup and runtime issues. diff --git a/docs/source/api-reference/python/mooncake-store.md b/docs/source/api-reference/python/mooncake-store.md index fe6d71d3da..05d3a4923a 100644 --- a/docs/source/api-reference/python/mooncake-store.md +++ b/docs/source/api-reference/python/mooncake-store.md @@ -277,6 +277,33 @@ Zero-copy operations require registered memory buffers. For repeated reads and w #### register_buffer() Register a memory buffer for direct RDMA access. +For a contiguous PyTorch CPU tensor, including one created with +`pin_memory=True`, pass its address and **byte size**: + +```python +import torch + +# Assumes store.setup(...) or store.setup_dummy(...) has already succeeded. +tensor = torch.empty(1024, dtype=torch.float32, pin_memory=True) +tensor.fill_(1) +ptr = tensor.data_ptr() +size = tensor.numel() * tensor.element_size() +assert store.register_buffer(ptr, size) == 0 +try: + assert store.put_from("pinned_tensor", ptr, size) == 0 + tensor.zero_() + assert store.get_into("pinned_tensor", ptr, size) == size + assert torch.all(tensor == 1) +finally: + assert store.unregister_buffer(ptr) == 0 +``` + +Keep the tensor alive and its storage unchanged until all operations finish and +the buffer is unregistered. `put_from` stores raw bytes; it does not serialize +the tensor's shape or dtype. With `setup_dummy()`, external CPU buffers use +shared-memory staging and read copy-back across the RealClient process boundary. +Pinned allocation alone does not make this path zero-copy. + #### unregister_buffer() Unregister a previously registered buffer. @@ -602,23 +629,70 @@ config = ReplicateConfig() #### replica_num **Type:** `int` **Default:** `1` -**Description:** Specifies the total number of replicas to create for the stored object. +**Description:** Specifies the number of memory replicas to create for the +stored object. ```python config = ReplicateConfig() -config.replica_num = 3 # Store 3 copies of the data +config.replica_num = 3 # Store 3 memory replicas ``` -#### with_soft_pin -**Type:** `bool` -**Default:** `False` -**Description:** Enables soft pinning for the stored object. Soft pinned objects are prioritized to remain in memory during eviction - they are only evicted when memory is insufficient and no other objects are eligible for eviction. This is useful for frequently accessed or important objects like system prompts. +#### nof_replica_num +**Type:** `int` +**Default:** `0` +**Description:** Specifies the number of replicas to create in the configured +NVMe-oF SSD pool. + +```python +config = ReplicateConfig() +config.replica_num = 1 +config.nof_replica_num = 1 +``` + +#### dfs_replica_num +**Type:** `int` +**Default:** `0` +**Status:** **Work in progress; development and evaluation only.** +**Description:** Requests an additional replica in the configured shared +distributed filesystem. The supported values are currently `0` and `1`. When +set to `1`, `replica_num` must be at least `1`, so DFS-only placement is not +supported. DFS replicas currently support only the `default` tenant. + +```python +config = ReplicateConfig() +config.replica_num = 1 +config.dfs_replica_num = 1 +``` + +Writes that request a DFS replica return success after the DFS `WriteAt` +operation completes, but without an additional `fsync` durability guarantee. +The master and client DFS backends must be enabled and configured with the same +absolute shared-root path and shard layout. See the +{ref}`DFS deployment documentation ` for the required environment +variables and current limitations. + +For a same-size `upsert`, if either the existing object or the new request has +a DFS replica, the requested memory, NoF, and DFS replica counts must match the +existing topology. A different-size update allocates a new topology. + +#### soft_pin_action +**Type:** `SoftPinAction` +**Default:** `SoftPinAction.PRESERVE` +**Description:** Controls the soft-pin transition committed when the first replica becomes readable. `PRESERVE` keeps an existing deadline during Upsert, `ENABLE` starts a fixed soft-pin lifetime, and `DISABLE` removes it. Reads do not extend the lifetime. ```python +from mooncake.store import ReplicateConfig, SoftPinAction + config = ReplicateConfig() -config.with_soft_pin = True # Keep this object in memory longer +config.soft_pin_action = SoftPinAction.ENABLE +config.soft_pin_ttl_ms = 60_000 # Optional; omitted uses the Master default ``` +`soft_pin_ttl_ms` is valid only with `ENABLE`. The Master rejects TTLs above +`max_kv_soft_pin_ttl`; a value of zero commits the object as ordinary cache. +Soft-pin state is not persisted in snapshots or the HA OpLog. Restored objects +therefore become ordinary cache after recovery or Standby promotion. + #### with_hard_pin **Type:** `bool` **Default:** `False` @@ -648,13 +722,13 @@ config.preferred_segment = self.get_hostname() ``` #### prefer_alloc_in_same_node -**Type:** `str` -**Default:** `""` (empty string) -**Description:** Enables the preference for allocating data on the same node. Currently, this only supports `batch_put_from_multi_buffers`. Additionally, it does not support disk segments, and the `replica_num` can only be set to 1. +**Type:** `bool` +**Default:** `False` +**Description:** Enables host-aware local-first allocation for this request, using the writer host identity and the normal ordered remote fallback. This can be used with direct multi-buffer writes and tensor write APIs to avoid staging when the selected segment is local and local memcpy is enabled. It does not support disk segments, and the `replica_num` can only be set to 1. Tensor APIs keep their default staging behavior unless this flag is explicitly enabled. ```python config = ReplicateConfig() -config.prefer_alloc_in_same_node = "True" +config.prefer_alloc_in_same_node = True ``` #### group_ids @@ -686,233 +760,104 @@ store.put("key-a", b"value-a", config) --- -## Unified Parallel Tensor IO API - -Mooncake Store also provides a unified tensor IO family for tensors that are stored either as full objects or as explicitly identified parallel shards. - -This API family is the long-term interface for TP / DP / EP / PP-aware tensor IO: +(choosing-a-parallel-tensor-io-api)= +## Choosing a Parallel Tensor IO API -- write and upsert use `TensorParallelism` -- reads use `ReadTarget` -- legacy TP-only APIs remain available as compatibility wrappers +Use the API that matches the object being stored. The single-axis TP methods +and the manifest-backed weight snapshot API have different storage contracts. -### ParallelAxis +| Requirement | Public API | Contract | +| --- | --- | --- | +| Store and retrieve a complete tensor | `put_tensor()` / `get_tensor()` | One ordinary Store tensor object. | +| Split a full tensor and read a TP shard | `put_tensor_with_tp()` / `get_tensor_with_tp()` | Legacy single-axis TP tensor objects; batch and registered-buffer variants are also available. | +| Save weights and restore into a different TP/DP/EP/PP placement | `begin_weight_snapshot()` and `WeightStore.load_manifest()` / `plan_load()` / `load()` | Immutable manifest-managed fragments, with framework-supplied placement and runtime bindings. | +| Use `put/get_tensor_with_cp`, `*_with_dp`, `*_with_ep`, or `*_with_pp` | No such public convenience methods | DP/EP/PP weight placement is expressed through the manifest API; CP is not a supported axis. | +| Supply an arbitrary parallel strategy through `*_with_config` | No such public tensor API | `ReplicateConfig` controls Store replication and placement policy, not tensor parallel topology. | -`ParallelAxis` describes one axis in a shard identity. +For example, with an already initialized `MooncakeDistributedStore`, the +legacy TP write accepts the **full** tensor and writes all shards. `tp_rank` +on this write does not select a single shard to persist: ```python -axis = mooncake.store.ParallelAxis() -axis.kind = "tp" # one of: "tp", "dp", "ep", "pp" -axis.rank = 0 -axis.size = 8 -axis.split_dim = 1 # used for layout-sharding axes such as TP -axis.expert_id = 3 # optional, for EP -axis.stage_id = 1 # optional, for PP -``` - -**Fields:** -- `kind`: Parallelism axis kind. -- `rank`: Current shard rank on that axis. -- `size`: Total number of shards on that axis. -- `split_dim`: Optional tensor split dimension for layout-sharding axes. -- `expert_id`: Optional expert identifier for EP layouts. -- `stage_id`: Optional pipeline stage identifier for PP layouts. - -### TensorParallelism - -`TensorParallelism` is an ordered list of axes that identifies the stored or requested shard. - -```python -parallelism = mooncake.store.TensorParallelism() -parallelism.axes = [ - tp_axis, -] -``` - -Examples: -- TP shard: `axes=[TP(...)]` -- DP + TP shard: `axes=[DP(...), TP(...)]` -- PP + TP shard: `axes=[PP(...), TP(...)]` -- EP shard: `axes=[EP(...)]` - -### ReadTarget - -`ReadTarget` tells Mooncake whether the caller wants the stored form, a specific shard view, or the reconstructed full tensor. - -```python -target = mooncake.store.ReadTarget() -target.mode = "full" # one of: "as_stored", "shard", "full" -target.parallelism = None # required for target shard reads -``` - -**Fields:** -- `mode`: Read materialization mode. -- `parallelism`: Optional `TensorParallelism`. Required when `mode="shard"`. - -### put_tensor_with_parallelism() - -Store a tensor using the unified parallelism model. - -```python -def put_tensor_with_parallelism( - self, - key: str, - tensor, - parallelism: mooncake.store.TensorParallelism | None = None, - config: ReplicateConfig | None = None, - writer_partition = None, -) -> int -``` - -Use `parallelism=None` to store a full tensor object. Provide `TensorParallelism` to store a shard-scoped object. - -`writer_partition` is an optional write-side shorthand for full-tensor inputs that should be stored as one shard. It describes the writer's `(rank, size, split_dim)` and is mutually exclusive with `parallelism`; do not provide both in one call. - -For TP-containing multi-axis layouts, the caller may pass the full source tensor; Mooncake derives and persists the uniform shard selected by the requested TP rank/layout. That applies to layouts such as `dp_tp`, `pp_tp`, and `ep_tp`. - -Plain single-axis TP remains shard-input for compatibility. - -Pure DP still does not imply a split axis by itself. - -### batch_put_tensor_with_parallelism() - -Batch version of unified tensor writes. - -```python -def batch_put_tensor_with_parallelism( - self, - keys: list[str], - tensors: list, - parallelisms: list[mooncake.store.TensorParallelism | None] | None = None, - config: ReplicateConfig | None = None, - writer_partitions = None, -) -> list[int] -``` - -`writer_partitions` is an optional write-side convenience input for batch full-tensor writes that should be partitioned into stored shards. Each entry describes the target shard write as `(rank, size, split_dim)`. - -Use `writer_partitions` when the caller has full tensors and wants Mooncake to derive the stored shard objects from writer-side partition info instead of constructing full `TensorParallelism` objects per element. TP-containing `parallelisms` can now express the same full-tensor-input behavior too; `writer_partitions` remains the lighter explicit write-side shorthand. - -### get_tensor_with_parallelism() - -Read a tensor through the unified read path. - -```python -def get_tensor_with_parallelism( - self, - key: str, - target: mooncake.store.ReadTarget | None = None, -) -``` - -Typical modes: -- `target=None` or `mode="as_stored"`: return the stored local object. -- `mode="shard"`: return the target shard described by `target.parallelism`. -- `mode="full"`: reconstruct and return the full tensor. - -### batch_get_tensor_with_parallelism() - -Batch version of unified tensor reads. +import torch -```python -def batch_get_tensor_with_parallelism( - self, - keys: list[str], - targets: list[mooncake.store.ReadTarget | None] | None = None, -) -> list +tensor = torch.linspace(0, 23, 24, dtype=torch.float32).reshape(4, 6) +assert store.put_tensor_with_tp("tp-example", tensor, tp_size=2, split_dim=1) == 0 +shard = store.get_tensor_with_tp("tp-example", tp_rank=1, tp_size=2, split_dim=1) +assert torch.equal(shard, tensor[:, 3:]) ``` -### get_tensor_with_parallelism_into() / batch_get_tensor_with_parallelism_into() +### Parallel configuration boundaries -Zero-copy unified read forms. The destination buffers must resolve to Store-managed registered memory, either from `BufferPool`/the setup-time local buffer or from an explicit `register_buffer()` call. +The model-weight API uses typed `ParallelTopology`, `WeightPlacementManifest`, +and `WeightRuntimeBindingManifest` values supplied by the framework adapter. +It does not provide a factory that generates a separate put/get method family +for each axis, or accept an arbitrary strategy dictionary. -```python -def get_tensor_with_parallelism_into( - self, - key: str, - buffer_ptr: int, - size: int, - target: mooncake.store.ReadTarget | None = None, -) -``` +- TP and EP can describe logical splits. EP splits the leading logical expert + dimension; TP names an explicit logical dimension. +- DP describes replicas or ownership. PP describes framework-provided tensor + or layer ownership. Neither implies a tensor split dimension. +- CP (context parallelism) is absent from the current topology and axis types. + A sequence-dimension slice through the TP API does not establish CP topology + support or CP-aware KV-cache resharding. +- Combining supported axes still requires complete logical coverage and + compatible source/target tensor descriptors. The planner is copy-only; it + does not convert dtype, quantization, packing, or model semantics. -```python -def batch_get_tensor_with_parallelism_into( - self, - keys: list[str], - buffer_ptrs: list[int], - sizes: list[int], - targets: list[mooncake.store.ReadTarget | None] | None = None, -) -> list -``` +See the [manifest contracts](../../design/mooncake-reshard/reshard-manifest.md), +[weight reshard planner](../../design/mooncake-reshard/model-weight-reshard-planner.md), and +[Store upload planning](../../design/mooncake-reshard/model-weight-store-upload-planning.md) +for the configuration and execution boundaries. -### upsert_tensor_with_parallelism() +## Model Weight Snapshot API -Unified upsert form for tensor objects. +Heterogeneous model-weight snapshots use the manifest-backed Reshard API. +The framework adapter owns model semantics and exports a complete source +placement plus live runtime bindings. Mooncake Store persists the resulting +payloads and the immutable stored manifest. ```python -def upsert_tensor_with_parallelism( - self, - key: str, - tensor, - parallelism: mooncake.store.TensorParallelism | None = None, - config: ReplicateConfig | None = None, - writer_partition = None, -) -> int -``` - -The write semantics match `put_tensor_with_parallelism()`, including full-tensor input for TP-containing layouts and the mutually exclusive `writer_partition` shorthand. - -### batch_upsert_tensor_with_parallelism() - -Batch unified upsert form. +from mooncake.reshard.weight.store import WeightStore -```python -def batch_upsert_tensor_with_parallelism( - self, - keys: list[str], - tensors: list, - parallelisms: list[mooncake.store.TensorParallelism | None] | None = None, - config: ReplicateConfig | None = None, - writer_partitions = None, -) -> list[int] +weight_store = WeightStore(store) +session = weight_store.begin_weight_snapshot(descriptor, adapter) +session.write_tensor(tensor_id, tensor) +manifest = session.commit() ``` -The write semantics match `put_tensor_with_parallelism()`, including full-tensor input for TP-containing layouts. +`MooncakeDistributedStore.begin_weight_snapshot(descriptor, adapter)` provides +the same session for callers that already hold the native Store object. -### *_from zero-copy write variants +`write_tensor()` validates the adapter-selected source fragments against the +session placement and runtime bindings. `commit()` publishes one +`StoredWeightManifest` after complete durable coverage. Restore uses +`WeightStore.load_manifest()`, `plan_load()`, and `load()` with the target +placement and runtime binding manifests. -The unified write and upsert family also has `_from` variants for registered-memory inputs, including: +### Breaking Change and Migration -- `put_tensor_with_parallelism_from(...)` -- `batch_put_tensor_with_parallelism_from(...)` -- `upsert_tensor_with_parallelism_from(...)` -- `batch_upsert_tensor_with_parallelism_from(...)` +PR [#3772](https://github.com/kvcache-ai/Mooncake/pull/3772) removed the public +`*_with_parallelism` API family and the +associated `ParallelAxis`, `TensorParallelism`, and `ReadTarget` helper types. +Applications that create heterogeneous model-weight snapshots migrate their +write path to `begin_weight_snapshot()`, `write_tensor()`, and `commit()`. +The writer creates manifest-managed payload fragments and one +`StoredWeightManifest`; it does not create ordinary Store tensor objects. -These APIs accept Store-managed registered buffer pointers that contain serialized tensor objects in the current Mooncake tensor format: - -```text -[TensorObjectHeader + layout metadata][tensor data] -``` +Applications restore a snapshot through `load_manifest()`, `plan_load()`, and +`load()` with the target placement and runtime binding manifests. The existing +single-axis TP APIs named `*_with_tp` remain separate compatibility APIs. -As with other zero-copy APIs, every source pointer must resolve to Store-managed registered memory, either from `BufferPool`/the setup-time local buffer or from an explicit `register_buffer()` call. - -### Compatibility wrappers - -Legacy TP-only methods such as: - -- `put_tensor_with_tp(...)` -- `batch_put_tensor_with_tp(...)` -- `get_tensor_with_tp(...)` -- `batch_get_tensor_with_tp(...)` -- corresponding `_into`, `_from`, and upsert variants - -remain supported for compatibility, but they are wrapper-style APIs around the unified parallel tensor IO model. Prefer the unified `*_with_parallelism` family for new code and new documentation examples. +When `commit()` reports a manifest publication failure after Store records the +commit decision, the writer remains open and preserves its payloads. Retry +`commit()` on that writer to complete manifest publication. --- ## Non-Zero-Copy API (Simple Usage) + For simpler use cases, use the standard API without memory registration: ### Basic Operations @@ -1054,30 +999,57 @@ def setup( self, local_hostname: str, metadata_server: str, - global_segment_size: int = 16777216, - local_buffer_size: int = 1073741824, - protocol: str = "tcp", - rdma_devices: str = "", + global_segment_size: int, + local_buffer_size: int, + protocol: str, + rdma_devices: str, master_server_addr: str, engine: Optional[TransferEngine] = None, enable_ssd_offload: bool = False, ssd_offload_path: str = "", tenant_id: str = "default", + enable_client_http_server: bool = False, + client_http_port: int = 9300, ) -> int ``` +The positional overload requires every argument through +`master_server_addr`. To use defaults for those fields, pass a configuration +dictionary instead: + +```python +def setup(self, config: Dict[str, object]) -> int +``` + +The dictionary overload requires `local_hostname` and `metadata_server`. Its +other keys are optional; the defaults are `16777216` (16 MiB) for both +`global_segment_size` and `local_buffer_size`, `"tcp"` for `protocol`, an empty +string for `rdma_devices`, and `"127.0.0.1:50051"` for +`master_server_addr`. It also accepts `ipc_socket_path` and the optional +configuration fields listed below. The `engine` argument is available only in +the positional overload. + **Parameters:** - `local_hostname` (str): **Required**. Local hostname and port (e.g., "localhost" or "localhost:12345") - `metadata_server` (str): **Required**. Metadata connection string, e.g. `"P2PHANDSHAKE"` or `"http://localhost:8080/metadata"`. -- `global_segment_size` (int): Memory segment size in bytes for mounting. -- `local_buffer_size` (int): Local buffer size in bytes. -- `protocol` (str): Network protocol, usually `"tcp"`, `"rdma"`, `"efa"`, `"cxl"`, or `"ascend"` depending on the build. -- `rdma_devices` (str): RDMA/EFA device name(s), e.g. `"mlx5_0"` or `"mlx5_0,mlx5_1"`. Leave empty to auto-discover NICs unless `MC_MS_AUTO_DISC=0`; always empty for TCP. -- `master_server_addr` (str): **Required**. Master server address (e.g., "localhost:50051") +- `global_segment_size` (int): **Required by the positional overload**. Memory segment size in bytes for mounting. +- `local_buffer_size` (int): **Required by the positional overload**. Local buffer size in bytes. +- `protocol` (str): **Required by the positional overload**. Network protocol, usually `"tcp"`, `"rdma"`, `"efa"`, `"cxl"`, or `"ascend"` depending on the build. +- `rdma_devices` (str): **Required by the positional overload**. RDMA/EFA device name(s), e.g. `"mlx5_0"` or `"mlx5_0,mlx5_1"`. Leave empty to auto-discover NICs unless `MC_MS_AUTO_DISC=0`; always empty for TCP. +- `master_server_addr` (str): **Required by the positional overload**. Master server address (e.g., "localhost:50051") - `engine` (Optional[TransferEngine]): Existing Transfer Engine instance to reuse. Defaults to `None`. -- `enable_ssd_offload` (bool): Enable client-side SSD offload support. Defaults to `False`. -- `ssd_offload_path` (str): SSD offload directory. When provided, overrides the storage path environment configuration. +- `enable_ssd_offload` (bool): Initialize client-side `FileStorage`. With a + normal file backend this enables SSD offload; with + `MOONCAKE_OFFLOAD_STORAGE_BACKEND_DESCRIPTOR=distributed_storage_backend`, + it initializes the DFS backend and is required for DFS reads and writes. + Defaults to `False`. +- `ssd_offload_path` (str): FileStorage directory. When provided, it overrides + `MOONCAKE_OFFLOAD_FILE_STORAGE_PATH`. With the distributed backend, DFS shard + data is stored under `MOONCAKE_DFS_ROOT_DIR`, but this separate directory is + still validated during FileStorage initialization. - `tenant_id` (str): Tenant namespace for object keys. Defaults to `"default"`. +- `enable_client_http_server` (bool): Enable the client-local `/health`, `/metrics`, `/metrics/summary`, and `/version` HTTP endpoints. Defaults to `False`. +- `client_http_port` (int): Port for the client-local HTTP endpoints. Defaults to `9300`. **Store segment pinned memory:** CUDA-enabled builds can register Store-managed host segments as pinned memory when `MC_STORE_PIN_MEMORY_MAX_BYTES` is set to a @@ -1135,8 +1107,9 @@ store.setup_dummy(1024*1024*256, 1024*1024*64, "localhost:8080") Dummy clients do not own Store segments. They use a local shared-memory buffer that is mapped by a real client process at `server_address`. Tensor APIs that stage through this SHM buffer are supported, including tensor put/get, -`*_tensor_from`, `*_tensor_into`, tensor upsert/pub, TP wrappers, and unified -parallelism write wrappers. +`*_tensor_from`, `*_tensor_into`, tensor upsert/pub, and the single-axis TP +wrappers. Model-weight snapshots use the explicit snapshot API described +above. The real client owns the SHM buffer allocator. This keeps tensor writes and regular object writes from allocating overlapping offsets when they run @@ -1539,6 +1512,67 @@ for key, exists in zip(keys, results): --- +#### probe_key() +Point-in-time existence check that grants no read lease. + +Unlike `is_exist()`, a successful probe does not extend the object's lease, +so probed objects remain eligible for eviction. A `1` result only means the +object existed at the time of the call; it may be evicted before a +subsequent `get`, and callers must treat a following miss as normal. This is +suited for speculative scans (e.g., probing candidate keys to estimate +prefix reuse) where the caller reads back only a subset of the probed keys. + +```python +def probe_key(self, key: str) -> int +``` + +**Parameters:** +- `key` (str): Object identifier to check + +**Returns:** +- `int`: + - `1`: Object existed at the time of the call + - `0`: Object didn't exist + - `-1`: Error occurred + +**Example:** +```python +exists = store.probe_key("my_key") +if exists == 1: + print("Object existed at probe time (no lease granted)") +elif exists == 0: + print("Object not found") +else: + print("Error checking existence") +``` + +--- + +#### batch_probe_key() +Point-in-time existence check for multiple objects in a single batch +operation, granting no read leases. + +```python +def batch_probe_key(self, keys: List[str]) -> List[int] +``` + +**Parameters:** +- `keys` (List[str]): List of object identifiers to check + +**Returns:** +- `List[int]`: List of existence results (1=existed at probe time, +0=not exists, -1=error) + +**Example:** +```python +keys = ["key1", "key2", "key3"] +results = store.batch_probe_key(keys) +candidates = [key for key, exists in zip(keys, results) if exists == 1] +print("Probed candidates (unprotected from eviction):", candidates) +``` + +--- + #### get_size() Get the size of a stored object in bytes. @@ -2141,7 +2175,7 @@ def pub_tensor(self, key: str, tensor: torch.Tensor, config: ReplicateConfig = N **Example:** ```python import torch -from mooncake.store import ReplicateConfig +from mooncake.store import ReplicateConfig, SoftPinAction # Create a tensor tensor = torch.randn(100, 100) @@ -2149,7 +2183,7 @@ tensor = torch.randn(100, 100) # Create replication config config = ReplicateConfig() config.replica_num = 3 -config.with_soft_pin = True +config.soft_pin_action = SoftPinAction.ENABLE # Publish tensor with replication settings result = store.pub_tensor("my_tensor", tensor, config) @@ -2555,13 +2589,13 @@ shared-memory staging buffer. **Example:** ```python import torch -from mooncake.store import ReplicateConfig +from mooncake.store import ReplicateConfig, SoftPinAction tensor = torch.randn(100, 100) config = ReplicateConfig() config.replica_num = 2 -config.with_soft_pin = True +config.soft_pin_action = SoftPinAction.ENABLE result = store.upsert_pub_tensor("my_tensor", tensor, config) if result == 0: @@ -2910,6 +2944,207 @@ store.unregister_buffer(target_tensor.data_ptr()) ```
+--- + +### Session-based ranged multi-buffer transfer + +For layerwise KV load/save, resolve Master metadata once per object, then transfer +object-byte ranges across multiple buffers without re-querying Master on every layer. + +Typical flow: + +- Get: `batch_get_session_start` → `batch_get_into_multi_buffer_ranges` (per layer) → `batch_get_session_end` +- Put: `batch_put_session_start` → `batch_put_from_multi_buffer_ranges` (per layer) → `batch_put_session_end` / `batch_put_session_revoke` + +Get sessions cache a filtered `QueryResult` (one complete MEMORY or DFS replica +plus its lease). The MEMORY path remains zero-copy. DFS replicas are read into +request-scoped host staging and then scattered to host or device destinations. +If a key has no complete MEMORY or DFS replica, for example because it has only +LOCAL_DISK, DISK, or NOF replicas, `batch_get_session_start` returns +`INVALID_REPLICA` for that key and does not open a session. +For device destinations, DFS staging first uses the fixed-capacity pinned restore +arena configured by `MC_STORE_PINNED_RESTORE_ARENA_SIZE_BYTES`; if that arena is +unavailable or exhausted, it falls back to the regular client buffer allocator. +Host-only reads use the regular client buffer allocator. Range calls only check +the cached lease locally (zero Master RPCs). Put sessions reserve object space +via Master `BatchPutStart` and finalize with `BatchPutEnd`. + +Put sessions write MEMORY replicas only. `nof_replica_num > 0` is accepted only for +flexible dual-replica configs (`replica_num == 1` and `nof_replica_num == 1`), where +`batch_put_session_end` finalizes MEMORY and revokes the unused NoF reservation. +Reliable multi-replica NoF configs are rejected at session start. `end` / `revoke` +seal the session (no further range writes) and wait for in-flight range transfers +before talking to Master. + +⚠️ **Store-managed Buffer Required**: All destination buffers must resolve to +Store-managed registered memory. DFS session reads use temporary staging; the +staging allocation is released after the synchronous read and scatter finish. + +#### batch_get_session_start() + +Query replicas once and open a get session for the given keys. + +```python +def batch_get_session_start(self, keys: List[str]) -> List[int] +``` + +**Parameters:** +- `keys` (List[str]): Object identifiers + +**Returns:** +- `List[int]`: Per-key status (0 = success, negative = error) + +#### batch_get_into_multi_buffer_ranges() + +Ranged get into multiple buffers using an active get session (no Master RPC). + +```python +def batch_get_into_multi_buffer_ranges( + self, + keys: List[str], + all_buffer_ptrs: List[List[int]], + all_sizes: List[List[int]], + all_src_offsets: List[List[int]], +) -> List[int] +``` + +**Parameters:** +- `keys` (List[str]): Object identifiers (must have an active get session) +- `all_buffer_ptrs` (List[List[int]]): Per-key list of destination buffer addresses +- `all_sizes` (List[List[int]]): Per-key list of transfer sizes in bytes +- `all_src_offsets` (List[List[int]]): Per-key list of object-byte source offsets + +**Returns:** +- `List[int]`: Bytes transferred per key (positive = success, negative = error) + +#### batch_get_session_end() + +Drop cached get-session metadata for the given keys. + +```python +def batch_get_session_end(self, keys: List[str]) -> int +``` + +**Parameters:** +- `keys` (List[str]): Object identifiers + +**Returns:** +- `int`: 0 on success, negative on error + +#### batch_put_session_start() + +Reserve objects and open a put session without transferring data. + +```python +def batch_put_session_start( + self, + keys: List[str], + sizes: List[int], + config: ReplicateConfig = None, +) -> List[int] +``` + +**Parameters:** +- `keys` (List[str]): Object identifiers +- `sizes` (List[int]): Full object sizes in bytes +- `config` (ReplicateConfig, optional): Replication configuration (applies at start only). + If `group_ids` is set, its length must equal `len(keys)`. When some keys already + have a put session, they are skipped and `group_ids` is filtered to match the + remaining keys. + +**Returns:** +- `List[int]`: Per-key status (0 = success, negative = error) + +#### batch_put_from_multi_buffer_ranges() + +Ranged put from multiple buffers using an active put session (no Master RPC). + +```python +def batch_put_from_multi_buffer_ranges( + self, + keys: List[str], + all_buffer_ptrs: List[List[int]], + all_sizes: List[List[int]], + all_dst_offsets: List[List[int]], +) -> List[int] +``` + +**Parameters:** +- `keys` (List[str]): Object identifiers (must have an active put session) +- `all_buffer_ptrs` (List[List[int]]): Per-key list of source buffer addresses +- `all_sizes` (List[List[int]]): Per-key list of transfer sizes in bytes +- `all_dst_offsets` (List[List[int]]): Per-key list of object-byte destination offsets + +**Returns:** +- `List[int]`: Bytes transferred per key (positive = success, negative = error) + +#### batch_put_session_end() + +Finalize a put session and make objects readable. + +```python +def batch_put_session_end(self, keys: List[str]) -> List[int] +``` + +**Parameters:** +- `keys` (List[str]): Object identifiers + +**Returns:** +- `List[int]`: Per-key status (0 = success, negative = error) + +#### batch_put_session_revoke() + +Abort an incomplete put session and release reserved space. + +```python +def batch_put_session_revoke(self, keys: List[str]) -> List[int] +``` + +**Parameters:** +- `keys` (List[str]): Object identifiers + +**Returns:** +- `List[int]`: Per-key status (0 = success, negative = error) + +**Example:** + +
+Click to expand: Session ranged put/get example + +```python +import numpy as np + +page = 1024 +layers = 4 +keys = ["block0", "block1"] +object_sizes = [page * layers] * len(keys) + +# Prepare one registered buffer per layer for each key +src = [np.full(page, i, dtype=np.uint8) for i in range(layers)] +dst = [np.zeros(page, dtype=np.uint8) for _ in range(layers)] +for buf in src + dst: + store.register_buffer(buf.ctypes.data, buf.nbytes) + +assert all(rc == 0 for rc in store.batch_put_session_start(keys, object_sizes)) +for layer in range(layers): + ptrs = [[src[layer].ctypes.data] for _ in keys] + sizes = [[page] for _ in keys] + offsets = [[layer * page] for _ in keys] + rcs = store.batch_put_from_multi_buffer_ranges(keys, ptrs, sizes, offsets) + assert all(rc == page for rc in rcs) +assert all(rc == 0 for rc in store.batch_put_session_end(keys)) + +assert all(rc == 0 for rc in store.batch_get_session_start(keys)) +for layer in range(layers): + ptrs = [[dst[layer].ctypes.data] for _ in keys] + sizes = [[page] for _ in keys] + offsets = [[layer * page] for _ in keys] + rcs = store.batch_get_into_multi_buffer_ranges(keys, ptrs, sizes, offsets) + assert all(rc == page for rc in rcs) +assert store.batch_get_session_end(keys) == 0 +``` +
+ ## MooncakeHostMemAllocator Class The `MooncakeHostMemAllocator` class provides host memory allocation capabilities for Mooncake Store operations. diff --git a/docs/source/api-reference/python/transfer-engine.md b/docs/source/api-reference/python/transfer-engine.md index 9958d9edf7..955b7ac257 100644 --- a/docs/source/api-reference/python/transfer-engine.md +++ b/docs/source/api-reference/python/transfer-engine.md @@ -636,7 +636,7 @@ The Transfer Engine respects the following environment variables: - `MC_LEGACY_RPC_PORT_BINDING`: Enables legacy RPC port binding behavior - `MC_TCP_BIND_ADDRESS`: Specifies the TCP bind address - `MC_RDMA_BIND_ADDRESS`: Specifies the RDMA bind address for NIC path construction in dual-NIC environments. When set, RDMA NIC paths use this address while TCP handshake uses the address from `local_hostname`. This is useful when TCP and RDMA traffic use separate network interfaces (e.g., `eth0` for TCP and `rdma-net1` for RDMA). -- `MC_CUSTOM_TOPO_JSON`: Path to custom topology JSON file +- `MC_CUSTOM_TOPO_JSON`: Path to custom topology JSON file (classic NIC priority matrix, or under TENT also native `nics`/`mems` JSON). Honored by classic Transfer Engine and by TENT when `MC_USE_TENT=1` (maps to `topology/custom_json_path`). For TENT, prefer inlining `topology/priority_matrix` in `MC_TENT_CONF` when possible; see the {ref}`TENT C++ API `. - `MC_TE_FILTERS`: Optional comma-separated whitelist of IB device names (e.g. `mlx5_0,mlx5_2`) for legacy Transfer Engine topology discovery. When unset, all available devices are discovered. - `MC_TE_METRIC`: Enables metrics reporting (set to "1", "true", "yes", or "on"). **Note:** Not supported when using Transfer Engine TENT. - `MC_TE_METRIC_INTERVAL_SECONDS`: Sets metrics reporting interval in seconds @@ -849,6 +849,7 @@ The `mooncake.engine` module provides boolean attributes that indicate whether s - `engine.SUPPORT_EFA`: Whether EFA (Elastic Fabric Adapter) support is enabled - `engine.SUPPORT_HIP`: Whether HIP (Heterogeneous-compute Interface for Portability) support is enabled - `engine.SUPPORT_MNNVL`: Whether MNNVL transport protocol support is enabled +- `engine.SUPPORT_MUSA`: Whether the Moore Threads MUSA GPU IPC transport is enabled - `engine.SUPPORT_INTRA_NVLINK`: Whether intra-node NVLink support is enabled ### Usage Example @@ -865,5 +866,6 @@ print(f"CUDA: {engine.SUPPORT_CUDA}") print(f"EFA: {engine.SUPPORT_EFA}") print(f"HIP: {engine.SUPPORT_HIP}") print(f"MNNVL: {engine.SUPPORT_MNNVL}") +print(f"MUSA: {engine.SUPPORT_MUSA}") print(f"Intra-NVLink: {engine.SUPPORT_INTRA_NVLINK}") ``` diff --git a/docs/source/api-reference/rust/index.md b/docs/source/api-reference/rust/index.md index 30ab8e8325..59bb1b8f69 100644 --- a/docs/source/api-reference/rust/index.md +++ b/docs/source/api-reference/rust/index.md @@ -3,7 +3,7 @@ | Module | Description | |--------|-------------| | [Mooncake Store](./mooncake-store) | Safe Rust bindings for the distributed KV-cache store (`mooncake_store`) | -| [Transfer Engine](./transfer-engine) | Rust bindings for Transfer Engine (FFI wrapper used by the Rust example crate) | +| [Transfer Engine](./transfer-engine) | High-performance Rust library for Transfer Engine (`transfer_engine_rust`) | ```{toctree} :maxdepth: 1 diff --git a/docs/source/api-reference/rust/mooncake-store.md b/docs/source/api-reference/rust/mooncake-store.md index f589246d9c..dc276f9879 100644 --- a/docs/source/api-reference/rust/mooncake-store.md +++ b/docs/source/api-reference/rust/mooncake-store.md @@ -182,19 +182,21 @@ Replication settings for write operations (`put`, `put_from`, `batch_put_from`). Fields: - `replica_num`: number of replicas (0 means “use server default”). -- `with_soft_pin`: prefer retaining the object in memory (soft pin). +- `soft_pin_action`: soft-pin intent for the write, one of `SoftPinAction::Preserve` (default; keeps an existing unexpired deadline), `SoftPinAction::Enable` (commits a new soft pin when the write becomes readable), or `SoftPinAction::Disable` (removes an existing soft pin). A soft pin guards an object from eviction until a deadline fixed at write time; reads never extend it. +- `soft_pin_ttl_ms`: optional soft-pin TTL override in milliseconds. Only valid with `SoftPinAction::Enable`; `None` uses the master’s default TTL. - `with_hard_pin`: never evict (hard pin). - `preferred_segments`: whitelist of segment names that should host a replica. Example: ```rust -use mooncake_store::{MooncakeStore, ReplicateConfig}; +use mooncake_store::{MooncakeStore, ReplicateConfig, SoftPinAction}; fn write_with_replication(store: &MooncakeStore) -> Result<(), mooncake_store::StoreError> { let cfg = ReplicateConfig { replica_num: 2, - with_soft_pin: true, + soft_pin_action: SoftPinAction::Enable, + soft_pin_ttl_ms: None, with_hard_pin: false, preferred_segments: vec!["seg-a".to_string(), "seg-b".to_string()], }; diff --git a/docs/source/api-reference/rust/transfer-engine.md b/docs/source/api-reference/rust/transfer-engine.md index ed3d13e432..62ada671ec 100644 --- a/docs/source/api-reference/rust/transfer-engine.md +++ b/docs/source/api-reference/rust/transfer-engine.md @@ -1,206 +1,172 @@ # Transfer Engine Rust API -This page documents the Rust bindings living under `mooncake-transfer-engine/rust`. +This page documents the Rust crate `transfer_engine_rust` (located at +`mooncake-transfer-engine/rust`). It is a **library** wrapper around the +Transfer Engine C API (`transfer_engine_c.h`). -At the time of writing, the Rust package (`transfer_engine_rust`) is primarily used as a **Rust-side binding + example binary**. The public Rust types are implemented in `src/transfer_engine.rs` and wrap the Transfer Engine C API (`transfer_engine_c.h`). +Hot-path types (`TransferRequest`, `BufferEntry`, `TransferStatus`) are +`#[repr(C)]` and compile-time layout-checked against the bindgen C types, so +`submit_transfer` passes a Rust slice to C with **no heap allocation and no +per-request copy** on the Rust side. For Transfer Engine design docs and non-Rust APIs, see: - Transfer Engine design docs: `design/transfer-engine/index` - Transfer Engine C++ API: `api-reference/cpp/transfer-engine` +- Transfer Engine Python API: `api-reference/python/transfer-engine` ## Build & runtime prerequisites -The Rust package uses bindgen + CMake to link against the Transfer Engine C/C++ build outputs. - - **Build**: - - Requires Rust toolchain and libclang (bindgen). - - The package has `build.rs` that expects to find / build the native library via CMake. + - Requires a Rust toolchain and libclang (bindgen). + - CMake: `-DWITH_RUST_EXAMPLE=ON`, then + `cmake --build build --target build_transfer_engine_rust`. + - Or Cargo after exporting `MOONCAKE_BUILD_DIR` / `MOONCAKE_TE_LIB_DIR` / + `MOONCAKE_TE_INCLUDE_DIR` (see `mooncake-transfer-engine/rust/README.md`). - **Runtime**: - - Dynamic linker must find Transfer Engine shared libraries. - - You need a metadata server backend (commonly etcd) and a reachable peer segment registry. - -## Mental model + - Dynamic linker must find Transfer Engine shared libraries (`libasio.so`, …). + - A metadata server (HTTP metadata, etcd, or `P2PHANDSHAKE`) must be reachable. + - GitHub Actions runs `scripts/ci/run_transfer_engine_rust_smoke.sh` after the + C++ build (`cargo test --lib` plus the TCP loopback `minimal_smoke` test). -The Transfer Engine operates on **segments** and **transfer batches**: +## Quick start -- You create a `TransferEngine` bound to: - - `metadata_uri` (for example, etcd endpoint) - - `local_server_name` (this node's address/name) - - `rpc_port` (RPC listener port) -- You register local memory regions as RDMA-capable buffers. -- You open a remote segment to obtain a `segment_id` (an integer handle). -- You allocate a batch id for a fixed number of transfer requests. -- You submit a batch of `TransferRequest`. -- You poll status per task id inside the batch, then free the batch id. +```rust +use transfer_engine_rust::{MemoryPool, TransferEngine, TransferRequest, WILDCARD_LOCATION}; + +fn main() -> Result<(), transfer_engine_rust::EngineError> { + let engine = TransferEngine::initialize( + "127.0.0.1:12345", + "http://127.0.0.1:8080/metadata", + "tcp", + "", + )?; + + let pool = MemoryPool::new(1 << 20); + unsafe { + engine.register_local_memory(pool.as_void_ptr(), pool.len(), WILDCARD_LOCATION)?; + + let seg = engine.open_segment("peer:12345")?; + let req = TransferRequest::write(pool.as_void_ptr(), seg, /*offset*/ 0, 4096); + engine.submit_and_wait(&[req], None)?; + + engine.unregister_local_memory(pool.as_void_ptr())?; + } + Ok(()) +} +``` -## API reference +`initialize(local_hostname, metadata_server, protocol, device_name)` matches +the Python constructor. `device_name` is accepted for API compatibility; NIC +filtering is done with `MC_TE_FILTERS` because the C ABI has no device-name +argument. -### Enums +The lower-level constructors map onto `createTransferEngine`: -#### `OpcodeEnum` +- `TransferEngine::new(metadata_uri, local_server_name, rpc_port)` +- `TransferEngine::create(TransferEngineOptions { … })` -- `OpcodeEnum::Read` -- `OpcodeEnum::Write` +## Mental model -Used by `TransferRequest.opcode`. +- Register local memory regions as RDMA/TCP-capable buffers. +- Open a remote segment to obtain a `SegmentId`. +- Allocate a `BatchId` for a fixed number of requests. +- Submit a `&[TransferRequest]` (zero-copy FFI). +- Poll `get_transfer_status` / `wait_all`, then `free_batch_id`. -#### `TransferStatusEnum` +Python-shaped helpers (`transfer_sync_write`, `batch_transfer_sync_*`, +`transfer_submit_write`) cache segment ids by hostname. They allocate a +batch internally. Use `submit_transfer` + `wait_all` when you need to keep +the batch/request arrays on the stack. -Status values returned by the C layer. Common values you will check for: +## API reference -- `Completed` -- `Failed` +### Types + +- `Opcode::{Read, Write}` — `OPCODE_READ` / `OPCODE_WRITE` +- `TransferStatusCode::{Waiting, Pending, Invalid, Canceled, Completed, Timeout, Failed}` +- `TransferRequest { opcode, source, target_id, target_offset, length }` — + layout matches `transfer_request_t`. Helpers: `TransferRequest::read`, + `TransferRequest::write`. +- `BufferEntry { addr, length }` — layout matches `buffer_entry_t` +- `TransferStatus { status, transferred_bytes }` — layout matches `transfer_status_t` +- `BatchId(u64)` — `INVALID_BATCH` on allocate failure +- `NotifyMsg { name, msg }` +- `NicLoadStat { device_name, inflight_bytes, ewma_bandwidth_bps }` +- `MemoryPool` — page-aligned, zeroed host buffer for registration +- `WILDCARD_LOCATION` (`"*"`), `LOCAL_SEGMENT` (`0`) + +### Engine lifecycle + +- `initialize(local_hostname, metadata_server, protocol, device_name)` +- `new` / `create` +- `discover_topology` +- `install_transport` / `uninstall_transport` +- `local_ip_and_port` +- Drop destroys the native handle (no double-free) + +### Memory + +All pointer APIs are `unsafe`. Registered memory must stay valid until +unregistered. + +- `register_local_memory` / `register_local_memory_ex` (remote-accessible flag) +- `unregister_local_memory` +- `register_memory` / `unregister_memory` — Python aliases using `WILDCARD_LOCATION` +- `register_local_memory_batch(&[BufferEntry])` — zero-copy +- `unregister_local_memory_batch(&[*mut c_void])` + +### Segments + +- `open_segment` / `open_segment_no_cache` / `open_segment_cached` +- `close_segment` +- `warmup_efa_segment` +- `remove_local_segment` +- `sync_segment_cache` + +### Transfers (zero-copy hot path) + +- `allocate_batch_id(batch_size)` +- `submit_transfer(batch_id, &[TransferRequest])` +- `submit_transfer_with_notify(batch_id, requests, &NotifyMsg)` +- `get_transfer_status(batch_id, task_id) -> TransferStatus` +- `wait_all(batch_id, count, timeout)` +- `submit_and_wait(&[TransferRequest], timeout)` — allocate + submit + wait + free +- `free_batch_id` + +### Python-shaped transfers + +These open (and cache) a segment by hostname: + +- `transfer_sync` / `transfer_sync_write` / `transfer_sync_read` +- `batch_transfer_sync` / `batch_transfer_sync_write` / `batch_transfer_sync_read` +- `transfer_submit_write` — returns `BatchId`; caller must `free_batch_id` +- `transfer_check_status` — polls task 0; does **not** free the batch + +### Notifications and diagnostics + +- `take_notifies() -> Vec` +- `send_notify(target_id, &NotifyMsg)` +- `nic_load_stats() -> Vec` +- `enable_graceful_shutdown` +- `show_links(json: bool) -> String` + +### Errors + +`EngineError` (`thiserror`, `#[non_exhaustive]`): + +- `NullHandle` +- `InvalidString` (interior NUL) +- `OperationFailed(i32)` — raw C status +- `InvalidArgument` +- `TransferFailed` - `Timeout` -### Structs - -#### `TransferRequest` - -One transfer operation inside a batch. - -Fields: - -- `opcode: OpcodeEnum` -- `source: *mut c_void`: local source/destination pointer (depends on opcode). -- `target_id: i32`: segment id returned by `open_segment()`. -- `target_offset: u64`: byte offset inside the target segment. -- `length: u64`: transfer length in bytes. - -#### `BufferEntry` - -Used for batch memory registration: - -- `addr: *mut c_void` -- `length: u64` - -### `TransferEngine` - -#### `new(metadata_uri, local_server_name, rpc_port) -> anyhow::Result` - -Create a new engine handle. - -Notes: - -- `metadata_uri` and `local_server_name` are passed through `CString`; interior `\0` bytes will error. -- The wrapper currently disables `auto_discover` in the underlying C call. - -#### `discover_topology() -> anyhow::Result<()>` - -Trigger topology discovery. - -#### `install_transport(proto) -> anyhow::Result<()>` - -Install a transport by name (e.g. `"tcp"`, `"rdma"`, `"efa"` depending on build/runtime support). - -#### `register_local_memory(addr, length, location) -> anyhow::Result<()>` - -Register a local memory region for zero-copy transfers. - -- `addr`: pointer to the memory region. -- `length`: size in bytes. -- `location`: location string such as `"cpu:0"`. - -#### `unregister_local_memory(addr) -> anyhow::Result<()>` - -Unregister a previously registered memory region. - -#### `register_local_memory_batch(buffer_list, location) -> anyhow::Result<()>` - -Batch register multiple local buffers. - -- No-op when `buffer_list` is empty. - -#### `unregister_local_memory_batch(buffer_list) -> anyhow::Result<()>` - -Batch unregister multiple local buffers. - -#### `open_segment(name: String) -> anyhow::Result` - -Open a remote segment by name and get a segment id. - -#### `close_segment(segment_id: i32) -> anyhow::Result<()>` - -Close a previously opened segment. - -#### `warmup_efa_segment(name: &str) -> anyhow::Result<()>` - -Eagerly establish EFA endpoints so the first `submit_transfer()` does not pay the serial connection setup cost. - -- No-op on non-EFA transports. -- Call after `open_segment()` and after the metadata server has published the peer's NIC list. - -#### `sync_segment_cache() -> anyhow::Result<()>` - -Synchronize segment cache from metadata. - -#### `allocate_batch_id(batch_size) -> anyhow::Result` - -Allocate a batch id for `batch_size` transfer requests. - -You must call `free_batch_id(batch_id)` after all tasks are done. - -#### `submit_transfer(batch_id, requests) -> anyhow::Result<()>` - -Submit a batch transfer request list. - -- No-op when `requests` is empty. -- The wrapper converts each `TransferRequest` into the C representation (`transfer_request_t`). - -#### `get_transfer_status(batch_id, task_id) -> anyhow::Result<(i32, u64)>` - -Get status for one task in a batch. - -- `task_id` is an index inside the batch, typically `0..batch_size`. -- Returns `(status_code, transferred_bytes)`. - -The `status_code` maps to values in `TransferStatusEnum` (represented as `i32`). - -#### `free_batch_id(batch_id) -> anyhow::Result<()>` - -Free a previously allocated batch id. - -## Minimal usage example (pseudo-code) - -The crate's `src/main.rs` contains a full benchmark-style example. The following sketch shows the typical control flow: - -```rust -use std::ffi::c_void; -use transfer_engine_rust::transfer_engine::{OpcodeEnum, TransferEngine, TransferRequest}; - -fn main() -> anyhow::Result<()> { - let engine = TransferEngine::new("127.0.0.1:2379", "127.0.0.1", 12345)?; - engine.discover_topology()?; - engine.install_transport("tcp")?; - - // Register local memory (example only; you must allocate and pin memory appropriately). - let mut buffer = vec![0u8; 4096]; - engine.register_local_memory(buffer.as_mut_ptr() as *mut c_void, buffer.len(), "cpu:0")?; - - let seg_id = engine.open_segment("target-seg".to_string())?; - let batch_id = engine.allocate_batch_id(1)?; - - let mut reqs = [TransferRequest { - opcode: OpcodeEnum::Write, - source: buffer.as_mut_ptr() as *mut c_void, - target_id: seg_id, - target_offset: 0, - length: buffer.len() as u64, - }]; - - engine.submit_transfer(batch_id, &mut reqs)?; - let (status, bytes) = engine.get_transfer_status(batch_id, 0)?; - println!("status={status}, bytes={bytes}"); - - engine.free_batch_id(batch_id)?; - engine.close_segment(seg_id)?; - engine.unregister_local_memory(buffer.as_mut_ptr() as *mut c_void)?; - Ok(()) -} -``` - ## Safety & thread-safety -- The wrapper marks `TransferEngine` as `Send + Sync`, but it owns an FFI handle (`transfer_engine_t`). -- All pointer-based arguments must satisfy Rust’s aliasing and lifetime rules. -- You must ensure registered memory remains valid until it is unregistered. +- `TransferEngine` is `Send + Sync`; the C++ engine serializes internally. +- Pointer arguments must satisfy Rust aliasing and lifetime rules. +- Registered memory must remain valid until unregistered. +- `submit_transfer` does not copy request bytes; do not mutate a submitted + `TransferRequest` until the C call returns (the C layer copies into its own + vector before returning). diff --git a/docs/source/conf.py b/docs/source/conf.py index c10fad2189..51145545e0 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -27,9 +27,9 @@ # -- Project information ----------------------------------------------------- -project = 'Mooncake' -copyright = f'{datetime.datetime.now().year}, Mooncake Team' -author = 'the Mooncake Team' +project = "Mooncake" +copyright = f"{datetime.datetime.now().year}, Mooncake Team" +author = "the Mooncake Team" # -- General configuration --------------------------------------------------- @@ -54,7 +54,7 @@ ] myst_fence_as_directive = ["mermaid"] # Add any paths that contain templates here, relative to this directory. -templates_path = ['_templates'] +templates_path = ["_templates"] # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. @@ -75,20 +75,20 @@ # a list of builtin themes. # html_title = project -html_theme = 'sphinx_book_theme' -html_logo = 'image/mooncake-icon.png' -html_favicon = 'image/moonshot.ico' +html_theme = "sphinx_book_theme" +html_logo = "image/mooncake-icon.png" +html_favicon = "image/moonshot.ico" html_theme_options = { - 'path_to_docs': 'docs/source', - 'repository_url': 'https://github.com/kvcache-ai/Mooncake', - 'use_repository_button': True, - 'use_edit_page_button': True, + "path_to_docs": "docs/source", + "repository_url": "https://github.com/kvcache-ai/Mooncake", + "use_repository_button": True, + "use_edit_page_button": True, # Prevents the full API being added to the left sidebar of every page. # Reduces build time by 2.5x and reduces build size from ~225MB to ~95MB. - 'collapse_navbar': True, - 'show_navbar_depth': 2, + "collapse_navbar": True, + "show_navbar_depth": 2, # Makes API visible in the right sidebar on API reference pages. - 'show_toc_level': 3, + "show_toc_level": 3, } # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, @@ -99,19 +99,17 @@ myst_heading_anchors = 2 myst_url_schemes = { - 'http': None, - 'https': None, - 'mailto': None, - 'ftp': None, + "http": None, + "https": None, + "mailto": None, + "ftp": None, "gh-issue": { - "url": - "https://github.com/kvcache-ai/Mooncake/issues/{{path}}#{{fragment}}", + "url": "https://github.com/kvcache-ai/Mooncake/issues/{{path}}#{{fragment}}", "title": "Issue #{{path}}", "classes": ["github"], }, "gh-pr": { - "url": - "https://github.com/kvcache-ai/Mooncake/pull/{{path}}#{{fragment}}", + "url": "https://github.com/kvcache-ai/Mooncake/pull/{{path}}#{{fragment}}", "title": "Pull Request #{{path}}", "classes": ["github"], }, @@ -133,8 +131,7 @@ } # Always remove the warning banner -header_file = os.path.join(os.path.dirname(__file__), - "_templates/sections/header.html") +header_file = os.path.join(os.path.dirname(__file__), "_templates/sections/header.html") if os.path.exists(header_file): os.remove(header_file) @@ -158,8 +155,8 @@ def get_repo_base_and_branch(pr_number): response = requests.get(url) if response.status_code == 200: data = response.json() - _cached_base = data['head']['repo']['full_name'] - _cached_branch = data['head']['ref'] + _cached_base = data["head"]["repo"]["full_name"] + _cached_branch = data["head"]["ref"] return _cached_base, _cached_branch else: logger.error("Failed to fetch PR details: %s", response) @@ -167,9 +164,9 @@ def get_repo_base_and_branch(pr_number): def linkcode_resolve(domain, info): - if domain != 'py': + if domain != "py": return None - if not info['module']: + if not info["module"]: return None # Get path from module name @@ -183,8 +180,8 @@ def linkcode_resolve(domain, info): # Get the line number of the object with open(path) as f: lines = f.readlines() - name = info['fullname'].split(".")[-1] - pattern = fr"^( {{4}})*((def|class) )?{name}\b.*" + name = info["fullname"].split(".")[-1] + pattern = rf"^( {{4}})*((def|class) )?{name}\b.*" for lineno, line in enumerate(lines, 1): if not line or line.startswith("#"): continue @@ -237,12 +234,12 @@ def linkcode_resolve(domain, info): "Potentially problematic mock target (%s) found; " "autodoc_mock_imports cannot mock modules that have already " "been loaded into sys.modules when the sphinx build starts.", - mock_target) + mock_target, + ) intersphinx_mapping = { "python": ("https://docs.python.org/3", None), - "typing_extensions": - ("https://typing-extensions.readthedocs.io/en/latest", None), + "typing_extensions": ("https://typing-extensions.readthedocs.io/en/latest", None), "aiohttp": ("https://docs.aiohttp.org/en/stable", None), "pillow": ("https://pillow.readthedocs.io/en/stable", None), "numpy": ("https://numpy.org/doc/stable", None), @@ -255,83 +252,58 @@ def linkcode_resolve(domain, info): # Preserve published URLs when documentation is reorganized. Redirect targets # are relative to the generated location of each legacy page. redirects = { + "design/mooncake-store": "store/mooncake-store.html", + "design/ssd-offload": "store/ssd-offload.html", + "design/ssd-free-ratio-first-allocation": "store/ssd-free-ratio-first-allocation.html", + "design/engram": "store/engram.html", + "design/tent/tebench": "../../performance/mooncake/tebench.html", "deployment/ssd-offload": "ssd/ssd-offload.html", - "deployment/nvmf-ssd-deployment-guide": - "ssd/nvmf-ssd-deployment-guide.html", + "deployment/nvmf-ssd-deployment-guide": "ssd/nvmf-ssd-deployment-guide.html", "integrations/index": "../deployment/index.html", - "integrations/lmcache": - "../deployment/integrations/lmcache/index.html", - "integrations/lmdeploy": - "../deployment/integrations/lmdeploy.html", - "integrations/sglang/index": - "../../deployment/integrations/sglang/index.html", - "integrations/sglang/hicache-integration-v1": - "../../deployment/integrations/sglang/hicache-integration-v1.html", - "integrations/sglang/hicache-quick-start": - "../../deployment/integrations/sglang/hicache-quick-start.html", - "integrations/sglang/pd-disaggregation": - "../../deployment/integrations/sglang/pd-disaggregation.html", - "integrations/vllm/index": - "../../deployment/integrations/vllm/index.html", - "integrations/vllm/disagg-prefill-decode": - "../../deployment/integrations/vllm/disagg-prefill-decode.html", - "integrations/vllm/kv-cache-storage": - "../../deployment/integrations/vllm/kv-cache-storage.html", - "integrations/vllm/vllm-integration-v0.2": - "../../deployment/integrations/vllm/vllm-integration-v0.2.html", - "integrations/vllm/vllm-integration-v0.3": - "../../deployment/integrations/vllm/vllm-integration-v0.3.html", - "integrations/vllm/vllm-integration-v1.0": - "../../deployment/integrations/vllm/vllm-integration-v1.0.html", - "integrations/vllm/vllm-mooncakestoreconnector": - "../../deployment/integrations/vllm/vllm-mooncakestoreconnector.html", - "integrations/vllm/vllmv1-lmcache-integration": - "../../deployment/integrations/lmcache/vllmv1-lmcache-integration.html", - "integrations/vllm/vllmv1-lmcache-mp-integration": - "../../deployment/integrations/lmcache/vllmv1-lmcache-mp-integration.html", - "getting_started/examples/lmcache-integration": - "../../deployment/integrations/lmcache/index.html", - "getting_started/examples/lmdeploy-integration-v0.9": - "../../deployment/integrations/lmdeploy.html", - "getting_started/examples/sglang-integration-v1": - "../../deployment/integrations/sglang/pd-disaggregation.html", - "getting_started/examples/sglang-integration/index": - "../../../deployment/integrations/sglang/index.html", - "getting_started/examples/sglang-integration/hicache-integration-v1": - "../../../deployment/integrations/sglang/hicache-integration-v1.html", - "getting_started/examples/sglang-integration/hicache-quick-start": - "../../../deployment/integrations/sglang/hicache-quick-start.html", - "getting_started/examples/vllm-integration/index": - "../../../deployment/integrations/vllm/index.html", - "getting_started/examples/vllm-integration/disagg-prefill-decode": - "../../../deployment/integrations/vllm/disagg-prefill-decode.html", - "getting_started/examples/vllm-integration/kv-cache-storage": - "../../../deployment/integrations/vllm/kv-cache-storage.html", - "getting_started/examples/vllm-integration/vllm-integration-v0.2": - "../../../deployment/integrations/vllm/vllm-integration-v0.2.html", - "getting_started/examples/vllm-integration/vllm-integration-v0.3": - "../../../deployment/integrations/vllm/vllm-integration-v0.3.html", - "getting_started/examples/vllm-integration/vllm-integration-v1.0": - "../../../deployment/integrations/vllm/vllm-integration-v1.0.html", - "getting_started/examples/vllm-integration/vllm-mooncakestoreconnector": - "../../../deployment/integrations/vllm/vllm-mooncakestoreconnector.html", - "getting_started/examples/vllm-integration/vllmv1-lmcache-integration": - "../../../deployment/integrations/lmcache/vllmv1-lmcache-integration.html", - "getting_started/examples/vllm-integration/vllmv1-lmcache-mp-integration": - "../../../deployment/integrations/lmcache/vllmv1-lmcache-mp-integration.html", - "python-api-reference/dataproto-structured-object-transfer": - "../api-reference/python/dataproto-structured-object-transfer.html", - "python-api-reference/ep-backend": - "../api-reference/python/ep-backend.html", - "python-api-reference/mooncake-store": - "../api-reference/python/mooncake-store.html", - "python-api-reference/transfer-engine": - "../api-reference/python/transfer-engine.html", - "http-api-reference/http-service": - "../api-reference/http/http-service.html", - "design/transfer-engine/cpp-api": - "../../api-reference/cpp/transfer-engine.html", + "integrations/lmcache": "../deployment/integrations/lmcache/index.html", + "integrations/lmdeploy": "../deployment/integrations/lmdeploy.html", + "integrations/sglang/index": "../../deployment/integrations/sglang/index.html", + "integrations/sglang/hicache-integration-v1": "../../deployment/integrations/sglang/hicache-integration-v1.html", + "integrations/sglang/hicache-quick-start": "../../deployment/integrations/sglang/hicache-quick-start.html", + "integrations/sglang/pd-disaggregation": "../../deployment/integrations/sglang/pd-disaggregation.html", + "integrations/vllm/index": "../../deployment/integrations/vllm/index.html", + "integrations/vllm/disagg-prefill-decode": "../../deployment/integrations/vllm/disagg-prefill-decode.html", + "integrations/vllm/kv-cache-storage": "../../deployment/integrations/vllm/kv-cache-storage.html", + "integrations/vllm/vllm-integration-v0.2": "../../deployment/integrations/vllm/vllm-integration-v0.2.html", + "integrations/vllm/vllm-integration-v0.3": "../../deployment/integrations/vllm/vllm-integration-v0.3.html", + "integrations/vllm/vllm-integration-v1.0": "../../deployment/integrations/vllm/vllm-integration-v1.0.html", + "integrations/vllm/vllm-mooncakestoreconnector": "../../deployment/integrations/vllm/vllm-mooncakestoreconnector.html", + "integrations/vllm/vllmv1-lmcache-integration": "../../deployment/integrations/lmcache/vllmv1-lmcache-integration.html", + "integrations/vllm/vllmv1-lmcache-mp-integration": "../../deployment/integrations/lmcache/vllmv1-lmcache-mp-integration.html", + "getting_started/examples/lmcache-integration": "../../deployment/integrations/lmcache/index.html", + "getting_started/examples/lmdeploy-integration-v0.9": "../../deployment/integrations/lmdeploy.html", + "getting_started/examples/sglang-integration-v1": "../../deployment/integrations/sglang/pd-disaggregation.html", + "getting_started/examples/sglang-integration/index": "../../../deployment/integrations/sglang/index.html", + "getting_started/examples/sglang-integration/hicache-integration-v1": "../../../deployment/integrations/sglang/hicache-integration-v1.html", + "getting_started/examples/sglang-integration/hicache-quick-start": "../../../deployment/integrations/sglang/hicache-quick-start.html", + "getting_started/examples/vllm-integration/index": "../../../deployment/integrations/vllm/index.html", + "getting_started/examples/vllm-integration/disagg-prefill-decode": "../../../deployment/integrations/vllm/disagg-prefill-decode.html", + "getting_started/examples/vllm-integration/kv-cache-storage": "../../../deployment/integrations/vllm/kv-cache-storage.html", + "getting_started/examples/vllm-integration/vllm-integration-v0.2": "../../../deployment/integrations/vllm/vllm-integration-v0.2.html", + "getting_started/examples/vllm-integration/vllm-integration-v0.3": "../../../deployment/integrations/vllm/vllm-integration-v0.3.html", + "getting_started/examples/vllm-integration/vllm-integration-v1.0": "../../../deployment/integrations/vllm/vllm-integration-v1.0.html", + "getting_started/examples/vllm-integration/vllm-mooncakestoreconnector": "../../../deployment/integrations/vllm/vllm-mooncakestoreconnector.html", + "getting_started/examples/vllm-integration/vllmv1-lmcache-integration": "../../../deployment/integrations/lmcache/vllmv1-lmcache-integration.html", + "getting_started/examples/vllm-integration/vllmv1-lmcache-mp-integration": "../../../deployment/integrations/lmcache/vllmv1-lmcache-mp-integration.html", + "python-api-reference/dataproto-structured-object-transfer": "../api-reference/python/dataproto-structured-object-transfer.html", + "python-api-reference/ep-backend": "../api-reference/python/ep-backend.html", + "python-api-reference/mooncake-store": "../api-reference/python/mooncake-store.html", + "python-api-reference/transfer-engine": "../api-reference/python/transfer-engine.html", + "http-api-reference/http-service": "../api-reference/http/http-service.html", + "design/transfer-engine/cpp-api": "../../api-reference/cpp/transfer-engine.html", + "design/transfer-engine/efa_transport": "transport/efa_transport.html", + "design/transfer-engine/ascend_transport": "transport/ascend_transport.html", + "design/transfer-engine/ascend_direct_transport": "transport/ascend_direct_transport.html", + "design/transfer-engine/kunpeng_ub_transport": "transport/kunpeng_ub_transport.html", + "design/transfer-engine/sunrise_link_transport": "transport/sunrise_link_transport.html", + "design/transfer-engine/flagcx_transport": "transport/flagcx_transport.html", + "design/transfer-engine/mpcomm_transport": "transport/mpcomm_transport.html", + "design/transfer-engine/heterogeneous_ascend": "transport/heterogeneous_ascend.html", "design/tent/cpp-api": "../../api-reference/cpp/tent.html", - "design/conductor/indexer-api-design": - "../../api-reference/http/conductor-indexer.html", + "design/conductor/indexer-api-design": "../../api-reference/http/conductor-indexer.html", } diff --git a/docs/source/deployment/integrations/vllm/disagg-prefill-decode.md b/docs/source/deployment/integrations/vllm/disagg-prefill-decode.md index 93453080f1..6b02fbce2c 100644 --- a/docs/source/deployment/integrations/vllm/disagg-prefill-decode.md +++ b/docs/source/deployment/integrations/vllm/disagg-prefill-decode.md @@ -67,13 +67,21 @@ vllm serve Qwen/Qwen2.5-7B-Instruct \ **Proxy Server:** ```bash -# In vllm root directory. -python tests/v1/kv_connector/nixl_integration/toy_proxy_server.py \ - --prefiller-host 192.168.0.2 --prefiller-port 8010 \ - --decoder-host 192.168.0.3 --decoder-port 8020 +# From a vLLM repository checkout. The proxy lives under examples/ and is +# not included in the pip wheel. +python examples/disaggregated/mooncake_connector/mooncake_connector_proxy.py \ + --port 8000 \ + --prefill http://192.168.0.2:8010 8998 \ + --decode http://192.168.0.3:8020 ``` -> NOTE: The Mooncake Connector currently uses the proxy from nixl_integration. This will be replaced with a self-developed proxy in the future. +> NOTE: `MooncakeConnector` requires a router that generates a `transfer_id` +> and forwards the prefiller bootstrap address to the decoder +> ([vllm-project/vllm#31034](https://github.com/vllm-project/vllm/pull/31034)). +> The toy proxy under `tests/v1/kv_connector/nixl_integration/` does not +> implement this contract: outputs stay correct, but KV caches are silently +> recomputed on the decoder instead of transferred. The bootstrap port must +> match the prefiller's `VLLM_MOONCAKE_BOOTSTRAP_PORT` (default: 8998). Now you can send requests to the proxy server through port 8000. diff --git a/docs/source/deployment/integrations/vllm/kv-cache-storage.md b/docs/source/deployment/integrations/vllm/kv-cache-storage.md index 53b5f7a50c..9dd33f22b2 100644 --- a/docs/source/deployment/integrations/vllm/kv-cache-storage.md +++ b/docs/source/deployment/integrations/vllm/kv-cache-storage.md @@ -135,8 +135,8 @@ vllm serve meta-llama/Llama-3.1-8B-Instruct \ Proxy: ```shell -python examples/disaggregated/disaggregated_serving/mooncake_connector/mooncake_connector_proxy.py \ - --prefill http://192.168.0.2:8100 \ +python examples/disaggregated/mooncake_connector/mooncake_connector_proxy.py \ + --prefill http://192.168.0.2:8100 50052 \ --decode http://192.168.0.3:8200 ``` diff --git a/docs/source/deployment/integrations/vllm/vllm-mooncakestoreconnector.md b/docs/source/deployment/integrations/vllm/vllm-mooncakestoreconnector.md index 8517cb5de8..48af7deff7 100644 --- a/docs/source/deployment/integrations/vllm/vllm-mooncakestoreconnector.md +++ b/docs/source/deployment/integrations/vllm/vllm-mooncakestoreconnector.md @@ -123,8 +123,8 @@ vllm serve meta-llama/Llama-3.1-8B-Instruct \ Proxy: ```shell -python examples/disaggregated/disaggregated_serving/mooncake_connector/mooncake_connector_proxy.py \ - --prefill http://192.168.0.2:8100 \ +python examples/disaggregated/mooncake_connector/mooncake_connector_proxy.py \ + --prefill http://192.168.0.2:8100 50052 \ --decode http://192.168.0.3:8200 ``` diff --git a/docs/source/deployment/kv-cache-sharing-and-isolation.md b/docs/source/deployment/kv-cache-sharing-and-isolation.md index cb7706f166..79d172a6c3 100644 --- a/docs/source/deployment/kv-cache-sharing-and-isolation.md +++ b/docs/source/deployment/kv-cache-sharing-and-isolation.md @@ -123,7 +123,7 @@ Use a new namespace whenever cache compatibility may have changed, including cha Mooncake tenant configuration is independent of the framework-level model, release, and request namespaces. When the master is started with `--enable_multi_tenants=true`, the client `tenant_id` selects a tenant-scoped object namespace and the master applies that tenant's quota during admission. -Use the same `tenant_id` for framework instances that should share one quota and tenant namespace. See [Tenant Quota Management](mooncake-store-deployment-guide.md#tenant-quota-management) for configuration details. +Use the same `tenant_id` for framework instances that should share one quota and tenant namespace. See [Multi-Tenant Deployment](multi-tenancy) for configuration details. ## Operational Checklist diff --git a/docs/source/deployment/mooncake-store-deployment-guide.md b/docs/source/deployment/mooncake-store-deployment-guide.md index 640aec3c2e..b1434740ef 100644 --- a/docs/source/deployment/mooncake-store-deployment-guide.md +++ b/docs/source/deployment/mooncake-store-deployment-guide.md @@ -13,7 +13,7 @@ This guide covers minimal deployment, and operational tuning of Mooncake Store. **Metadata Service**: A separate service (etcd, Redis, or HTTP) used by the Transfer Engine for peer discovery and configuration. The master's embedded HTTP metadata server can replace an external etcd/Redis for simple deployments. We also provide a P2P handshake mechanism (`P2PHANDSHAKE`) that enables decentralized metadata management by storing metadata locally on each node, eliminating the need for a centralized service — this is the simplest metadata handshake method and the recommended starting point (see [Quick Start](#quick-start)). -For a detailed design discussion, see the [Mooncake Store Design](../design/mooncake-store.md). +For a detailed design discussion, see the [Mooncake Store Design](../design/store/mooncake-store.md). --- @@ -191,6 +191,116 @@ mooncake_master \ --- +### Batch OpLog Snapshot Metrics + +The master Prometheus endpoint exposes `ha_snapshot_*` metrics for +`enable_oplog_snapshot`. They are available without the optional OpLog performance +metrics build flag. Updates use existing scheduler, reader, and maintenance +observations; scraping does not read etcd or the object store. + +All names in the table have the `ha_snapshot_` prefix: + +| Metric suffix | Meaning | +| --- | --- | +| `enabled` | Whether the runtime is configured for batch snapshots; legacy mode is 0. | +| `active` | `enabled` and standby state is connecting, syncing, watching, recovering, or reconnecting. Filter snapshot freshness/capacity alerts on this gauge; it is 0 during promotion, after stop, and after fatal failure. Historical observations and in-flight operation counters remain available. | +| `latest_present`, `fallback_present`, `count` | Decodable pointers observed locally (0–2); not proof that all referenced artifacts remain intact. | +| `latest_age_seconds`, `fallback_age_seconds` | Time since the observed descriptor's creation, computed at scrape time. Missing pointers and future timestamps report 0; check `*_present` to distinguish absence. | +| `bytes`, `chunk_bytes`, `chunk_count` | Size/count of the last fully verified upload or successful snapshot restore. Total bytes include segments, chunks, manifest, and descriptor; they do not measure the whole bucket or imply publication success. | +| `capture_pause_us` | Last completed pause of standby apply for capture, including chunk encoding/upload while capture is held. | +| `suffix_batches` | Applied batches in the most recent bootstrap suffix replay attempt. | +| `catch_up_target_batch`, `catch_up_target_sequence` | Durable cursor observed when capture was released; zero after local apply reaches it. | +| `applied_batch`, `latest_batch`, `fallback_batch` | Local applied cursor and last observed pointer cursors; compare with the catch-up target and durable batch. | +| `durable_batch`, `compaction_floor`, `candidate_floor` | Last observed durable batch, reader-visible retention floor, and latest pruning candidate. The floor updates immediately after its CAS, before batch deletion. | +| `uncompacted_batches` | `max(durable_batch - compaction_floor, 0)`: the logical retained suffix, not a physical etcd key count or database size. Failed deletion can retain additional keys below the floor. | +| `floor_advances_total`, `lease_lost_total` | Successful floor advances and acquired maintenance leases observed lost before release. A failed publish CAS alone does not prove lease loss. | +| `gc_orphan_prefixes`, `gc_deleted_prefixes` | Unprotected attempt prefixes found by the last completed GC listing, and how many that sweep deleted. A failure before listing leaves the previous observation. | +| `operations_total{operation}`, `errors_total{operation}`, `duration_us_total{operation}` | Completed calls, failed calls (including exceptions), and total elapsed microseconds. Operations are `schedule`, `upload`, `bootstrap`, `replay`, `publish`, `gc`, `prune`, and `rebootstrap`. Scheduling decisions past the interval/lifecycle gates and no-op pruning calls are included in completed calls; inspect skip reasons and floor advances for its effect. | +| `skip_reason{reason}`, `skips_total{reason}` | One-hot most recent skip reason and cumulative skipped decisions. Fixed reasons are `none`, `disabled`, `interval`, `in_flight`, `stopped`, `promotion`, `no_new_batch`, `catch_up`, `lease_busy`, `capture_unavailable`, `no_fallback`, `invalid_pair`, and `floor_ahead`. | + +Upload time includes encoding, uploads, and verification. Bootstrap time includes +restore and suffix replay; rebootstrap also includes the floor recheck and state +replacement. These durations overlap and should not be added together. For +example, mean upload duration over five minutes in seconds is: + +```promql +rate(ha_snapshot_duration_us_total{operation="upload"}[5m]) +/ rate(ha_snapshot_operations_total{operation="upload"}[5m]) / 1e6 +``` + +Current gauges reset for a new standby runtime. Event counters remain cumulative +for the process; in legacy mode they stop increasing and gauges report +`enabled=0`, `skip_reason{reason="disabled"}=1`, and zero capacity values. Historical +counters do not indicate activity in the current mode. Publication, GC, and pruning +have independent error counters: a GC failure does not turn a committed publication +into a failure. Pointer observations may lag changes made by another standby until +the next scheduler/bootstrap read. + +--- + +### Batch OpLog Capacity Operations + +Batch pruning removes keys, but etcd MVCC history and backend files require +separate compaction and defragmentation. Mooncake does not run these operations. +The snapshot `compaction_floor` is a **batch ID**, not an etcd revision. + +Before enabling pruning through `enable_oplog_snapshot`, deploy compaction-floor +rebootstrap support to every electable standby; older binaries cannot follow a +pruned log. Verify shared durable snapshot storage and two distinct, readable +latest/fallback snapshots, with matching descriptor/manifest cursors and +`latest.last_included_batch_id > fallback.last_included_batch_id`. Pointer presence +alone is insufficient. The runtime revalidates both snapshots before pruning; +rehearse cold restore and promotion before rollout. + +Configure `--quota-backend-bytes` for measured history growth and disk headroom. +For auto-compaction, `--auto-compaction-mode=periodic --auto-compaction-retention=1h` +is an example; choose retention for all watch/revision consumers and keep member +configuration consistent. Monitor each member's `dbSize`, `dbSizeInUse`, quota +utilization and free disk space. Increasing quota does not replace cleanup. + +For Mooncake alerts, use `ha_snapshot_active=1` and the metrics described above. +Snapshot storage outages stall new baselines and pruning; GC failures retain +orphan artifacts. These failures retain recovery data safely but grow storage +until availability is at risk. The logical retained suffix does not include +undeleted keys below the floor. Never lower the floor or manually delete batch +keys/pointers to clear a capacity alarm. + +Use your deployment's TLS/authentication options. Before and after maintenance, +save these checks; `$ENDPOINTS` lists all members: + +```bash +etcdctl --endpoints="$ENDPOINTS" member list -w json +etcdctl --endpoints="$ENDPOINTS" endpoint health +etcdctl --endpoints="$ENDPOINTS" endpoint status -w json +etcdctl --endpoints="$ENDPOINTS" alarm list +``` + +Confirm healthy quorum and identify the leader. Select `$REVISION` from a successful +linearizable read, respecting required history; with mutations quiesced, the current +revision can be used for emergency reclamation. Compact once, then defragment one +member at a time, healthy followers first and leader last. `$MEMBER` must name one +endpoint. Defrag blocks that member; recheck health/quorum between members and stop +if degraded. Compare sizes before/after rather than relying only on exit status. + +```bash +etcdctl --endpoints="$MEMBER" compact "$REVISION" --physical +etcdctl --endpoints="$MEMBER" defrag # Repeat separately for each member. +``` + +For `NOSPACE`: stop/limit mutations → compact → defrag each member → confirm space +below quota on every member → `etcdctl --endpoints="$ENDPOINTS" alarm disarm` → +repeat checks and verify a controlled write/read → gradually restore traffic. +Health probes requiring a commit may fail under the alarm; inspect member status +as well. After writer fail-stop, Mooncake may need master restart/re-election; +verify acknowledged data and promotion readiness before resuming load. + +See the etcd [maintenance](https://etcd.io/docs/v3.5/op-guide/maintenance/) and +[configuration](https://etcd.io/docs/v3.5/op-guide/configuration/) guides for details. +Capacity test commands and evidence are documented in +`mooncake-store/tests/e2e/readme.md` under “Batch OpLog capacity tests”. + +--- + ### Tiered Storage with SSD Offload — Cost-Effective Capacity Extends the cache pool from DRAM to SSD while keeping normal reads and writes on the distributed memory path. With `--enable_offload=true`, completed memory writes are queued for asynchronous SSD persistence through the master control plane. Set `--offload_on_evict=true` to defer that SSD write until the memory eviction path selects an object for reclamation. When `--promotion_on_hit=true`, SSD-only objects can be promoted back to DRAM after repeated reads; admission is gated by `--promotion_admission_threshold`. @@ -262,25 +372,47 @@ HA leadership and metadata replication are configured separately: - The HA coordinator elects the active master. Configure it with `--enable_ha`, `--ha_backend_type`, `--ha_backend_connstring`, and `--cluster_id`. For `ha_backend_type=etcd`, legacy `--etcd_endpoints` is used only when `--ha_backend_connstring` is empty. - The optional batch-record OpLog persists metadata mutations so standby masters can catch up and later be promoted. Enable it explicitly with `--enable_oplog=true`; it is disabled by default and requires `ha_backend_type=etcd` and a build with `STORE_USE_ETCD`. +- The optional standby-generated batch OpLog snapshot path is enabled with `--enable_oplog_snapshot=true` together with `--enable_oplog=true`. It uses the batch snapshot provider/coordinator and does not use the legacy catalog snapshot manager. Startup fails when the required etcd, cluster ID, object-store, or chunk configuration is invalid; a temporary upload failure leaves OpLog apply running for a later attempt. - `--enable_oplog`: Enable the primary OpLog writer and standby reader. Defaults to `false`. +- `--enable_oplog_snapshot`: Enable standby-generated snapshots for batch OpLog recovery. Defaults to `false`; requires `enable_oplog=true`, HA with etcd, a valid snapshot object store, and a persistent `MOONCAKE_SNAPSHOT_LOCAL_PATH` when using `local`. +- With `enable_oplog_snapshot=true`, each successful publication also attempts batch OpLog pruning under the same maintenance lease. Pruning requires two independently validated snapshots: it publishes a monotonic `compaction_floor` at the fallback snapshot's batch ID before deleting covered batches. The first snapshot does not prune. GC or pruning failures keep the published snapshot successful; failed deletions can be retried after a later successful publication. +- Before enabling this mode, every standby that may be promoted must support compaction-floor rebootstrap. Once pruning has started, rollback requires a binary that understands the batch snapshot and floor protocol. This maintenance does not perform etcd MVCC compaction/defragmentation or delete legacy snapshot/OpLog data. +- `--snapshot_chunk_object_count`: Maximum objects written to one batch OpLog snapshot chunk. Defaults to `1000000`; must be greater than zero when `enable_oplog_snapshot=true`. - `--oplog_poll_interval_ms`: Base polling and retry delay for the batch standby, in milliseconds. - `--oplog_batch_max_entries`: Maximum number of entries admitted to an ordered batch. Defaults to `1024`. - `--batch_oplog_retry_timeout_sec`: Maximum consecutive retryable batch-standby failure window in seconds (default `180`). -For snapshot-based standby bootstrap, also configure: +For legacy catalog snapshot-based standby bootstrap, configure: - `--enable_snapshot_restore` (bool, default `false`): Enable standby to bootstrap from the latest snapshot at startup. - `--snapshot_object_store_type` (str): Snapshot object store type: `local` or `s3`. - `--snapshot_catalog_store_type` (str): Snapshot catalog store type: `embedded` (default) or `redis`. +For the new batch OpLog snapshot path, configure: + +```yaml +enable_ha: true +ha_backend_type: "etcd" +enable_oplog: true +enable_oplog_snapshot: true +snapshot_chunk_object_count: 1000000 +snapshot_interval_seconds: 600 +snapshot_object_store_type: "local" +``` + +The new path stores immutable artifacts below a cluster-specific batch OpLog +snapshot root. It restores `latest`, then `fallback`, then a proven complete +OpLog and replays only the suffix after the snapshot cursor. It remains +non-serving if recovery cannot prove a complete state. + ### Standby Bootstrap When a Standby starts, it follows this sequence: -1. **Snapshot Bootstrap** (if `enable_snapshot_restore=true`): - - Load the latest snapshot from the configured catalog and object store. +1. **Snapshot Bootstrap** (if `enable_snapshot_restore=true` for legacy catalog snapshots, or `enable_oplog_snapshot=true` for batch OpLog snapshots): + - Legacy mode loads the latest snapshot from the configured catalog and object store. Batch OpLog mode loads the latest/fallback descriptor and manifest directly from the batch snapshot control keys. - Rebuild object metadata and segment state from the snapshot baseline. 2. **OpLog Catch-up**: - Start from the snapshot's `last_included_seq` (or from 1 if no snapshot). @@ -304,8 +436,11 @@ When the Primary fails, the Standby is promoted through the following steps: - `applied_seq_id`: The latest applied OpLog sequence ID. - `objects`: All object metadata from the in-memory store. - `segments`: All segment registry entries. -4. **State Restoration**: The new Primary restores its state from the `PromotionContext`, populating metadata shards and the segment manager. -5. **Invalid Endpoint Filtering**: During restoration, any replica endpoints that correspond to segments no longer in the registry are automatically filtered out from `GetReplicaList` results. +4. **State Restoration**: The new Primary restores and validates the complete `PromotionContext`, populating metadata shards and the segment manager. A context with zero objects and segments still passes through restoration so that unsupported recovery modes cannot bypass validation. +5. **Serving Gate**: The supervisor revalidates leadership and exposes the RPC service only after restoration succeeds. Promotion, restoration, or leadership validation failure leaves `service_ready=false`, keeps data endpoints unavailable, and releases leadership. Failure to release leadership does not make the candidate serviceable. +6. **Invalid Endpoint Filtering**: During restoration, any replica endpoints that correspond to segments no longer in the registry are automatically filtered out from `GetReplicaList` results. + +This fail-closed behavior is intentional. Older versions could log a restoration error and continue serving from empty or partially restored metadata. That behavior was a correctness bug, not a supported availability fallback: the serving state could disagree with the durable OpLog and poison later recovery attempts. Mooncake does not automatically discard snapshots, OpLog records, or metadata after a recovery error. ### Example: HA Deployment with etcd @@ -319,9 +454,10 @@ cluster_id: "mooncake_cluster" enable_oplog: true oplog_poll_interval_ms: 1000 oplog_batch_max_entries: 1024 -enable_snapshot: true +enable_oplog_snapshot: true +snapshot_chunk_object_count: 1000000 +snapshot_interval_seconds: 600 snapshot_object_store_type: "local" -snapshot_catalog_store_type: "embedded" rpc_port: 50051 ``` @@ -335,9 +471,10 @@ cluster_id: "mooncake_cluster" enable_oplog: true oplog_poll_interval_ms: 1000 oplog_batch_max_entries: 1024 -enable_snapshot_restore: true +enable_oplog_snapshot: true +snapshot_chunk_object_count: 1000000 +snapshot_interval_seconds: 600 snapshot_object_store_type: "local" -snapshot_catalog_store_type: "embedded" rpc_port: 50052 ``` @@ -357,6 +494,18 @@ mooncake_master --config_path=primary.yaml mooncake_master --config_path=standby.yaml ``` +### Recovery from Unusable HA State + +First repair temporary backend, configuration, or snapshot-access failures and restart the affected Standby. If the recovery history is confirmed unusable and losing all cached metadata is acceptable, start a new empty cluster explicitly: + +1. Stop every Primary and Standby process that uses the old `cluster_id`. +2. Confirm that losing the old cache metadata and snapshots is acceptable. +3. Change every node to a new, previously unused `cluster_id`. +4. Start the new cluster and allow applications to repopulate the cache. +5. Keep the old namespace for diagnosis, then remove it separately after confirming that no old process can reconnect. + +Using a new `cluster_id` isolates the new cluster from the old OpLog, durable prefix, producer view, and snapshot namespace. Do not delete individual recovery keys or reuse the old `cluster_id` while any old process may still run. There is no automatic reset-on-restore-failure option. + ### Resetting a Legacy OpLog Namespace The batch-only implementation does not migrate or read older per-entry OpLog data. Reusing a namespace that contains legacy `latest`, numeric entry, or snapshot sidecar keys is rejected. @@ -386,10 +535,8 @@ When tenant quota is enabled, `/metrics` also includes per-tenant quota gauges a - `mooncake_tenant_quota_requested_bytes{tenant_id}` - `mooncake_tenant_quota_effective_bytes{tenant_id}` -- `mooncake_tenant_quota_used_bytes{tenant_id}` -- `mooncake_tenant_quota_reserved_bytes{tenant_id}` -- `mooncake_tenant_quota_committed_count{tenant_id}` -- `mooncake_tenant_quota_metadata_object_count{tenant_id}` +- `mooncake_tenant_quota_charged_bytes{tenant_id}` +- `mooncake_tenant_quota_admission_closed{tenant_id}` - `mooncake_tenant_quota_over_quota{tenant_id}` - `mooncake_tenant_quota_explicit_policy{tenant_id}` - `mooncake_tenant_quota_reject_total{tenant_id,reason}` @@ -402,78 +549,11 @@ When tenant quota is enabled, `/metrics` also includes per-tenant quota gauges a ## Tenant Quota Management -Tenant quota admission is disabled by default. Enable strict multi-tenant mode on the master when you want memory writes admitted against connector-managed per-tenant quota: - -```bash -mooncake_master \ - --enable_multi_tenants=true \ - --tenant_quota_connector_type=file \ - --tenant_quota_connector_uri=/etc/mooncake/tenant_quotas.yaml -``` - -You can also store the same YAML policy in etcd when Mooncake Store is built with `STORE_USE_ETCD=ON`: - -```bash -mooncake_master \ - --enable_multi_tenants=true \ - --cluster_id=mooncake_cluster \ - --tenant_quota_connector_type=etcd \ - --tenant_quota_connector_uri=127.0.0.1:2379 -``` - -The etcd connector stores the policy at `mooncake-store//tenant_quota_policy`. If the key does not exist, the master starts with an empty policy so the first tenant policy can be created through the admin API. It shares the process-wide store etcd client used by HA/oplog, so if HA or oplog also uses etcd, `tenant_quota_connector_uri` must match those etcd endpoints. The policy must use schema version `1`; tenant names must be non-empty, unique, must not start with `_`, and must not contain NUL or control characters; quotas must be positive integers with optional `B`, `KB`, `MB`, `GB`, or `TB` units: - -```yaml -version: 1 - -tenants: - - name: tenant-a - quota: 200GB - - - name: tenant-b - quota: 500GB -``` - -When strict multi-tenant mode is enabled, write requests must include a registered tenant. The `default` tenant is not special unless it is explicitly registered in the connector policy. - -The same HTTP port used for metrics exposes the tenant quota admin API: - -```bash -# List tenant quota snapshots -curl -s http://:9003/api/v1/tenant_quotas - -# Query one tenant -curl -s "http://:9003/api/v1/tenant_quotas?tenant_id=tenant-a" - -# Upsert an explicit policy. Explicit tenant policies must be positive. -curl -s -X PUT "http://:9003/api/v1/tenant_quotas?tenant_id=tenant-a" \ - -H 'Content-Type: application/json' \ - -d '{"requested_quota_bytes":2147483648}' - -# Delete an explicit policy. The tenant must not own objects or quota usage. -curl -s -X DELETE "http://:9003/api/v1/tenant_quotas?tenant_id=tenant-a" -``` - -Each tenant quota snapshot returns: - -```json -{ - "success": true, - "data": { - "tenant_id": "tenant-a", - "requested_quota_bytes": 2147483648, - "effective_quota_bytes": 2147483648, - "used_bytes": 0, - "reserved_bytes": 0, - "committed_count": 0, - "metadata_object_count": 0, - "over_quota": false, - "has_explicit_policy": true - } -} -``` +:::{toctree} +:maxdepth: 1 -In HA mode, quota admin requests are served only by the active master service. Standby, candidate, or inactive services return HTTP 503. If strict multi-tenant mode is disabled, the quota admin API returns HTTP 409 with `UNAVAILABLE_IN_CURRENT_MODE`. Deleting a non-empty tenant returns HTTP 409 with `TENANT_NOT_EMPTY`. +Multi-Tenant Deployment +::: --- @@ -483,8 +563,9 @@ In HA mode, quota admin requests are served only by the active master service. S - Start with default eviction settings; adjust `--eviction_high_watermark_ratio` and `--eviction_ratio` based on memory pressure and object churn. - Use `/metrics/summary` during bring-up; integrate `/metrics` with Prometheus/Grafana for production. - For detailed SSD offload configuration (storage backends, eviction policies, io_uring), see the [SSD Offload guide](ssd/ssd-offload). +- For OSS offload configuration, see the [OSS Offload guide](oss-offload). - For NVMe-oF SSD pool configuration see the [NVMe-oF SSD Pool Deployment Guide](ssd/nvmf-ssd-deployment-guide) -- For experimental 3FS (USRBIO) integration as a persistent storage backend, see the [3FS USRBIO Plugin guide](../getting_started/plugin-usage/3FS-USRBIO-Plugin). +- For the experimental HF3FS USRBIO adapter used by descriptor-based DFS replicas, see the [HF3FS USRBIO adapter guide](../getting_started/plugin-usage/3FS-USRBIO-Plugin). - For detailed monitoring and observation see [Observability](../getting_started/observability) :::{toctree} @@ -493,7 +574,8 @@ In HA mode, quota admin requests are served only by the active master service. S KV Cache Sharing and Isolation SSD Storage -HF3FS Plugin (Experimental)<../getting_started/plugin-usage/3FS-USRBIO-Plugin> +OSS Offload +HF3FS USRBIO Adapter (Experimental)<../getting_started/plugin-usage/3FS-USRBIO-Plugin> ../getting_started/observability ::: @@ -554,15 +636,21 @@ master publisher ` reference. | `--kv_events_bind_endpoint` | empty | ZMQ PUB bind endpoint, for example `tcp://0.0.0.0:5557`; required when enabled | | `--kv_events_backend_id` | empty | Cache-owner identity emitted as `backend_id`; required when enabled | | `--kv_events_emit_legacy_compat` | `true` | Include vLLM/SGLang-compatible aliases such as `type` and `block_hashes` | -| `--kv_events_emit_object_key` | `true` | Include the Mooncake `object_key`; unparsable sequence hashes are still published when this is enabled | -| `--kv_events_queue_capacity` | `65536` | Maximum pending events; the publisher drops the oldest event when the queue is full. Set to `0` for an unbounded queue | +| `--kv_events_emit_object_key` | `true` | Emit the raw Mooncake `object_key`. Setting this to `false` suppresses `stored` and `removed` entirely, since those events then carry no object identity; `cleared` is unaffected | +| `--kv_events_queue_capacity` | `65536` | Maximum pending events; the publisher drops the oldest event when the queue is full and reserves its sequence number so the loss stays visible. Set to `0` for an unbounded queue | + +One master publisher serves one fixed model and parallel context, so the +remaining flags below are emitted verbatim in every event envelope. Empty +strings and `--kv_events_block_size=0` are encoded as nil. -The legacy flags `--kv_events_model_name`, `--kv_events_tenant_id`, -`--kv_events_additional_salt`, `--kv_events_lora_name`, -`--kv_events_block_size`, and `--kv_events_dp_rank` are retained for config -compatibility but are not emitted in event payloads. Supply model, block-size, -hash-namespace, LoRA, and data-parallel metadata when registering the publisher -with the indexer; each event carries its object's tenant ID. +| Flag | Default | Description | +|------|---------|-------------| +| `--kv_events_model_name` | empty | Emitted as `model_name` | +| `--kv_events_additional_salt` | empty | Emitted as `additional_salt`; the hash namespace this publisher's keys belong to | +| `--kv_events_lora_name` | empty | Emitted as `lora_name` | +| `--kv_events_block_size` | `0` | Emitted as `block_size` | +| `--kv_events_dp_rank` | `0` | Emitted as `dp_rank`, both per event and in the batch trailer | +| `--kv_events_tenant_id` | `default` | Accepted for configuration compatibility but not emitted. Every event carries the tenant of the Store operation that produced it | ### HTTP Metadata Server (Embedded) @@ -647,6 +735,7 @@ mooncake_master \ |------|---------|-------------| | `--default_kv_lease_ttl` | `10000` ms | Lease TTL for KV objects. Supports `5000ms`, `5s`, `30m`, `1h` | | `--default_kv_soft_pin_ttl` | `1800000` ms | Soft pin TTL (30 min) | +| `--max_kv_soft_pin_ttl` | `86400000` ms | Maximum request-level soft pin TTL (24 h) | | `--allow_evict_soft_pinned_objects` | `true` | Allow evicting soft-pinned objects | | `--eviction_ratio` | `0.05` | Fraction evicted at high watermark | | `--eviction_high_watermark_ratio` | `0.90` | Usage ratio triggering eviction | @@ -659,6 +748,7 @@ mooncake_master \ | `--enable_multi_tenants` | `false` | Enable strict tenant registration and per-tenant memory quota admission | | `--tenant_quota_connector_type` | `file` | Tenant quota policy connector type: `file` or `etcd` when built with `STORE_USE_ETCD=ON` | | `--tenant_quota_connector_uri` | empty | Connector URI; for `file`, the writable YAML policy path; for `etcd`, the endpoints string | +| `--tenant_eviction_high_watermark_ratio` | `0.90` | Usage ratio of a tenant's own effective quota that triggers background eviction for that tenant; `0` disables it | ### High Availability @@ -671,6 +761,8 @@ mooncake_master \ | `--etcd_endpoints` | empty | Backward-compatible etcd HA endpoints, used only for `ha_backend_type=etcd` when `--ha_backend_connstring` is empty | | `--cluster_id` | `mooncake_cluster` | Cluster ID for HA persistence | | `--enable_oplog` | `false` | Enable the primary OpLog writer and standby reader; currently requires `enable_ha=true` and `ha_backend_type=etcd` | +| `--enable_oplog_snapshot` | `false` | Enable standby-generated batch OpLog snapshots; requires batch OpLog, HA/etcd, valid object-store configuration, and persistent local snapshot storage when applicable | +| `--snapshot_chunk_object_count` | `1000000` | Maximum objects per batch OpLog snapshot chunk; must be positive when the new snapshot path is enabled | | `--oplog_poll_interval_ms` | `1000` | Base polling and retry delay for the batch standby, in milliseconds | | `--oplog_batch_max_entries` | `1024` | Maximum number of entries admitted to an ordered batch | | `--batch_oplog_retry_timeout_sec` | `180` | Maximum consecutive retryable batch-standby failure window in seconds | @@ -744,14 +836,247 @@ When `--offload_on_evict=true` is active, each `BatchEvict` cycle can queue at m When `--allocation_strategy=cxl` is set alongside `--enable_cxl=true`, the master preferentially allocates new objects on CXL memory. -### DFS Storage +### Legacy Shared-filesystem `DISK` Persistence + +The older shared-filesystem persistence path remains available independently +of descriptor-based DFS: | Flag | Default | Description | |------|---------|-------------| -| `--root_fs_dir` | empty | Legacy DFS persistence directory; do not use with SSD offload | -| `--global_file_segment_size` | `INT64_MAX` (unlimited) | Max available space for DFS segments; default does not cap DFS usage | +| `--root_fs_dir` | empty | Enable legacy `DISK` replicas under `/`. The path must resolve to the same shared filesystem location on every participating client. | +| `--global_file_segment_size` | `INT64_MAX` (unlimited) | Declared legacy file capacity used by master usage metrics. It does not configure descriptor-based DFS shard files. | + +With `--root_fs_dir` set, the master adds a legacy `DISK` replica to each new +object and clients write it asynchronously. This path is distinct from both +client-owned `LOCAL_DISK` SSD offload and descriptor-based `DFS` replicas. Do +not combine `--root_fs_dir` with `--enable_offload=true`; configure real-client +SSD offload with `MOONCAKE_OFFLOAD_FILE_STORAGE_PATH` instead. + +(dfs-storage)= +### Descriptor-based DFS Storage + +```{warning} +**Work in progress.** Descriptor-based DFS is intended for development and +evaluation only. It is not production-ready and is not covered by Mooncake +Store's general fault-tolerance, HA continuity, durability, or multi-tenant +guarantees. +``` + +Mooncake Store can place an additional replica in a shared distributed +filesystem. The master allocates aligned ranges in pre-created shard files and +publishes a descriptor containing the shard, offset, and object size. Clients +use that descriptor to access the same files through either regular POSIX I/O +or the HF3FS USRBIO adapter. + +DFS replicas are separate from `LOCAL_DISK` SSD-offload replicas. They do not +use the legacy `--root_fs_dir` persistence path or the master's asynchronous +offload task queue. + +```{note} +DFS allocator state is not yet restored after a master restart or HA leader +failover. Do not enable descriptor-based DFS in a deployment that requires +master recovery, HA continuity, or multiple tenants. See the complete list of +limitations below. +``` + +#### Master configuration + +Enable the DFS allocator in the master process and select a shared root and +shard layout. For example, to use HF3FS: + +```bash +export MOONCAKE_ENABLE_DFS=1 +export MOONCAKE_DFS_ROOT_DIR=/mnt/3fs/mooncake +export MOONCAKE_DFS_FS_ADAPTER=hf3fs +export MOONCAKE_DFS_SHARD_COUNT=64 +export MOONCAKE_DFS_SHARD_CAPACITY=4294967296 +export MOONCAKE_DFS_ALIGNMENT=4096 +export MOONCAKE_DFS_SINGLE_TENANT=true + +mooncake_master [other master arguments] +``` + +At startup, the master creates `MOONCAKE_DFS_SHARD_COUNT` shard files and +preallocates each file to `MOONCAKE_DFS_SHARD_CAPACITY`. The example therefore +configures 256 GiB of total logical shard capacity (`64 * 4 GiB`). Ensure the +shared filesystem has sufficient capacity; whether all backing space is +reserved immediately depends on the selected filesystem adapter. + +The `hf3fs` adapter requires Mooncake to be built with `USE_3FS=ON`. Use +`MOONCAKE_DFS_FS_ADAPTER=posix` for development and integration testing on a +regular shared filesystem. -`--root_fs_dir` is a legacy persistence parameter and is expected to be replaced as the distributed filesystem path is refactored. For SSD offload, configure `MOONCAKE_OFFLOAD_FILE_STORAGE_PATH` on each real client instead. +#### Client configuration + +Every client that may read or write a DFS replica must initialize +`FileStorage` and select the distributed backend. Use an absolute DFS root path; +the root string, shard count, shard capacity, and alignment must match the +master configuration. Select an adapter that can access the same underlying +shared files; the examples use the same adapter in every process. + +```bash +export MOONCAKE_OFFLOAD_ENABLED=true +export MOONCAKE_OFFLOAD_STORAGE_BACKEND_DESCRIPTOR=distributed_storage_backend +export MOONCAKE_OFFLOAD_FILE_STORAGE_PATH=/data/file_storage +export MOONCAKE_MASTER=127.0.0.1:50051 +export MOONCAKE_DFS_ROOT_DIR=/mnt/3fs/mooncake +export MOONCAKE_DFS_FS_ADAPTER=hf3fs +export MOONCAKE_DFS_SHARD_COUNT=64 +export MOONCAKE_DFS_SHARD_CAPACITY=4294967296 +export MOONCAKE_DFS_ALIGNMENT=4096 +export MOONCAKE_DFS_SINGLE_TENANT=true + +python -m mooncake.mooncake_store_service +``` + +For a programmatic Python client, pass `enable_ssd_offload=True` to `setup()` +instead of `MOONCAKE_OFFLOAD_ENABLED`. Programmatic setup still reads the +backend-specific `MOONCAKE_OFFLOAD_STORAGE_BACKEND_DESCRIPTOR` and +`MOONCAKE_DFS_*` variables shown above; only the launcher-level setup fields are +supplied as Python arguments. The +`MOONCAKE_OFFLOAD_FILE_STORAGE_PATH` directory must already exist and be an +absolute, writable, non-symlink directory. DFS shard data is stored under +`MOONCAKE_DFS_ROOT_DIR`; the FileStorage path is still required for client +initialization because the shared `FileStorageConfig` validates it even when +the selected backend stores data in the DFS root. + +Native C++ clients must initialize a `DistributedStorageBackend` with the same +DFS layout and attach it to the client with `SetDfsStorageBackend()` before +issuing DFS reads or writes. Reads and writes use the DFS descriptor carried by +the current query or start-operation response; no client-side descriptor cache +is required. + +#### DFS configuration reference + +| Variable | Scope | Default | Description | +|----------|-------|---------|-------------| +| `MOONCAKE_ENABLE_DFS` | Master | `false` | Enable master-side DFS allocation. `MOONCAKE_DFS_ENABLED` is accepted as a compatibility fallback. | +| `MOONCAKE_DFS_ROOT_DIR` | Master and clients | `/mnt/3fs/mooncake` | Absolute shared shard root; use the same path string in every process. Falls back to `MOONCAKE_DISTRIBUTED_ROOT_DIR`. | +| `MOONCAKE_DFS_FS_ADAPTER` | Master and clients | `hf3fs` | Filesystem adapter: `hf3fs` or `posix`. Falls back to `MOONCAKE_DISTRIBUTED_FS_TYPE`. | +| `MOONCAKE_DFS_SHARD_COUNT` | Master and clients | `64` | Initial shard count. The master also discovers existing contiguous shard files at startup; running clients open added shards on demand. | +| `MOONCAKE_DFS_SHARD_CAPACITY` | Master and clients | `4294967296` (4 GiB) | Logical file capacity of each shard in bytes. Each object is allocated wholly within one shard. | +| `MOONCAKE_DFS_ALIGNMENT` | Master and clients | `4096` | Allocation alignment in bytes; must be a power of two and divide the shard capacity. | +| `MOONCAKE_DFS_SINGLE_TENANT` | Master and clients | `true` | Currently must remain `true`. | +| `MOONCAKE_DFS_EVICTION_ENABLED` | Master | `true` | Enable DFS allocator eviction. | +| `MOONCAKE_DFS_EVICTION_HIGH_WATERMARK` | Master | `0.9` | Usage ratio that triggers eviction. | +| `MOONCAKE_DFS_EVICTION_LOW_WATERMARK` | Master | `0.7` | Usage ratio targeted by an eviction cycle. | +| `MOONCAKE_DFS_DEFERRED_FREE_SECONDS` | Master | `30` | Delay before a freed shard range may be reused. | +| `MOONCAKE_DFS_EVICTION_CHECK_INTERVAL` | Master | `5` | Eviction check interval in seconds. | + +#### Growing DFS capacity online + +The default shard allocator supports adding shard files while the master and +clients remain running. Use the master's existing HTTP admin listener: + +```bash +curl http://127.0.0.1:9003/api/v1/dfs/shard_count +curl -X PUT http://127.0.0.1:9003/api/v1/dfs/shard_count \ + -H 'Content-Type: application/json' -d '{"shard_count": 128}' +``` + +Both requests return the current count, for example +`{"success":true,"shard_count":128}`. A PUT sets the desired **total** count, +not the number to add. Repeating the current count succeeds without changing +anything; shrinking, non-integer values, and non-positive counts return HTTP +400. DFS-disabled masters and concurrent expansion requests return HTTP 409; +unavailable or standby services return HTTP 503. Filesystem preparation runs +off the HTTP I/O threads, so health checks and other administration remain +available while an expansion is pending. + +Upgrade the master and every DFS client to a version supporting online shard +expansion before increasing capacity. An already running upgraded client can +open a new shard from its descriptor even when its initial +`MOONCAKE_DFS_SHARD_COUNT` is smaller. Older binaries reject those descriptors. +Every process must still use the same shared root, adapter, shard capacity, and +alignment. Provision sufficient backing filesystem space before expanding; +changing the root or per-shard capacity online is unsupported. + +Only one active master may manage a DFS root. Do not create, rename, truncate, +or remove its shard files outside that master. Clients do not create shard +files during initialization; they open them only from published descriptors. + +The allocator prepares new files and allocation state before publishing the +expanded shard set. Existing paths and allocated ranges remain unchanged, +including when the shard index gains another decimal digit. Allocation, reads, +writes, deferred frees, and eviction continue to use ready shards. A failed +expansion leaves the published shard count unchanged. + +On startup, the master discovers the contiguous existing shard layout and uses +at least the configured count. Duplicate indices, missing intermediate shards, +and unexpected file sizes are rejected rather than silently changing the +layout. This preserves **capacity**, not cached key metadata or allocation +ownership: DFS allocator recovery, snapshots, and HA remain subject to the +limitations below. Do not treat online expansion as a data durability guarantee. + +#### Requesting and accessing DFS replicas + +Callers request DFS placement through `ReplicateConfig`: + +```python +from mooncake.store import ReplicateConfig + +config = ReplicateConfig() +config.replica_num = 1 +config.dfs_replica_num = 1 +store.put("key", b"value", config) +``` + +`dfs_replica_num` may currently be `0` or `1`. A DFS replica must be requested +with at least one memory replica (`replica_num >= 1`), so DFS-only placement is +not supported. + +Allocation first tries the key's hash-selected DFS shard, then tries other +ready shards if that shard has no suitable extent. This lets new shards accept +writes even when older shards are full. `NO_AVAILABLE_HANDLE` means no ready +shard could satisfy the allocation. A DFS object is never striped across shards. +The selected shard must have room for the object rounded +up to `MOONCAKE_DFS_ALIGNMENT`, plus up to one alignment unit of allocator +padding (`MOONCAKE_DFS_ALIGNMENT - 1` bytes); usable object capacity is +therefore lower than the shard file's +logical size. + +For `Put`, `BatchPut`, `Upsert`, and `BatchUpsert`, the client writes requested +memory and NoF replicas, stages device buffers to host memory when necessary, +and then performs positional DFS writes. A successful request means the +requested DFS `WriteAt` operations completed. It does **not** imply that an +additional `fsync` completed. Batch operations isolate failures by key; a +failed key is revoked without downgrading successful keys. + +For a same-size `Upsert`, if either the existing object or the new request has +a DFS replica, the requested memory, NoF, and DFS replica counts must match the +existing topology. A different-size update releases the old placement and +allocates a new topology. + +On reads, the master returns the readable replica list through the normal query +path, and the client selects the first complete replica. If it selects DFS, any +client configured with the same DFS root and shard layout can issue positional +reads for that descriptor. + +#### Current limitations + +- Only the `default` tenant is supported. +- `dfs_replica_num` must be `0` or `1`, and `replica_num >= 1` is required when + it is enabled. +- C and Rust clients cannot currently request or access descriptor-based DFS: + their replication configuration does not expose `dfs_replica_num`, and their + setup API cannot initialize the distributed `FileStorage` backend. Use the + native C++ or Python/RealClient API. +- A DFS object must fit in a single shard after alignment and allocator + padding; objects are not striped across shards. +- DFS allocator state is currently in memory. A master restart or HA leader + failover does not reconstruct existing DFS allocations, so DFS cannot provide + continuity across those events. +- DFS cannot be enabled with snapshot generation, snapshot restore, oplog + recovery, or standby restore until DFS allocator state restoration is + implemented. +- There is currently no background DFS retry queue or configurable + asynchronous acknowledgement policy. +- DFS writes currently have no DFS-specific timeout, request cancellation, or + `fsync` durability guarantee. + +The older `--root_fs_dir` and `--global_file_segment_size` flags configure the +legacy `DISK` path described above and are not used by descriptor-based DFS +replicas. ### NoF (NVMe-oF SSD Pool) @@ -794,7 +1119,23 @@ allocation_strategy: "local_first" When enabled, the master applies local-first allocation only for memory replicas with `replica_num == 1`. Explicit `preferred_segment` or `preferred_segments` are tried first; if they are unavailable or full, Mooncake falls back through active hosts in cyclic lexicographic host-id order, starting from the writer host when it has active segments, or otherwise from the next greater active host id. Within the same host, segment names are sorted and rotated by key hash so multiple segments on one host do not always receive the first allocation attempt. -The client derives the host id from `local_hostname` by removing the port. For example, `host-a:50051` and `host-a:50052` map to the same host id, `host-a`. For local-first allocation to work correctly, all writer and store processes on the same physical or logical host must use the same stable, globally unique host part in `local_hostname`. In deployments with multiple NIC IPs, hostname aliases, or container/pod networking, choose one canonical host name or IP and use it consistently across processes on that host. Empty, loopback, and wildcard values such as `localhost`, `127.0.0.1`, `0.0.0.0`, `::1`, and `::` are treated as unknown and do not trigger automatic local-first placement for that client. +By default, the client derives the host id from `local_hostname` by removing the port. For example, `host-a:50051` and `host-a:50052` map to the same host id, `host-a`. Set `MOONCAKE_HOST_ID` to override this derived value with a stable, globally unique node identifier. The override is read directly by the C++ client, so it applies to every client initialization method. It must be set before creating the client, and all writer and store processes on the same physical or logical host must use the same value. An empty or whitespace-only override falls back to `local_hostname`. Loopback and wildcard values such as `localhost`, `127.0.0.1`, `0.0.0.0`, `::1`, and `::` are treated as unknown and do not trigger automatic local-first placement. + +In Kubernetes, keep `MOONCAKE_LOCAL_HOSTNAME` as the routable pod IP for the transfer endpoint and use `spec.nodeName` as the shared placement identity: + +```yaml +env: + - name: MOONCAKE_LOCAL_HOSTNAME + valueFrom: + fieldRef: + fieldPath: status.podIP + - name: MOONCAKE_HOST_ID + valueFrom: + fieldRef: + fieldPath: spec.nodeName +``` + +Apply the same `MOONCAKE_HOST_ID` mapping to every writer and store pod. This separates the per-pod network address from the node-level placement identity, allowing colocated pods with different IPs to match for local-first allocation. --- @@ -803,7 +1144,7 @@ The client derives the host id from `local_hostname` by removing the port. For e A client is configured through one of the **methods** introduced in [Start a Store Client](#start-a-store-client), plus a shared family of engine-tuning variables: -- **Method A — Programmatic (`setup()` arguments)**: you pass configuration as explicit Python arguments. `MOONCAKE_*` variables are **not** read in this method. +- **Method A — Programmatic (`setup()` arguments)**: launcher-level fields are passed as explicit Python arguments instead of being loaded through `MooncakeConfig`. Backend-specific variables read by C++, including `MOONCAKE_OFFLOAD_STORAGE_BACKEND_DESCRIPTOR` and `MOONCAKE_DFS_*`, still apply. - **Method B — Service / Integration (`MOONCAKE_*` + CLI)**: `mooncake.mooncake_store_service` and the vLLM/SGLang connectors read `MOONCAKE_*` environment variables (via `MooncakeConfig`). - **Method C — Resource-owning real client (`mooncake_client`)**: configured through `mooncake_client` CLI flags (see the **Method C** subsection below). - **Engine runtime tuning (`MC_*`)**: low-level variables read by the C++ Transfer Engine / store client at runtime. They are orthogonal to the above and **apply to all methods**. @@ -824,14 +1165,14 @@ Arguments of `MooncakeDistributedStore.setup(...)`: | `rdma_devices` | str | required | RDMA NIC(s), comma-separated (pass `""` for non-RDMA). **Keyword is `rdma_devices`, not `device_name`** | | `master_server_addr` | str | required | Master `host:port`. **Keyword is `master_server_addr`, not `master_server_address`** | | `engine` | TransferEngine | `None` | *(advanced)* Reuse an existing Transfer Engine instance instead of creating one | -| `enable_ssd_offload` | bool | `false` | *(advanced)* Enable client-side SSD offload | -| `ssd_offload_path` | str | empty | *(advanced)* SSD offload directory | +| `enable_ssd_offload` | bool | `false` | *(advanced)* Initialize client-side `FileStorage`; required for SSD offload and descriptor-based DFS | +| `ssd_offload_path` | str | empty | *(advanced)* FileStorage path; with the distributed backend, DFS data uses `MOONCAKE_DFS_ROOT_DIR` | | `tenant_id` | str | `default` | *(advanced)* Tenant identifier | -| `enable_client_http_server` | bool | `false` | Enable the client-side HTTP `/health`, `/metrics`, and `/metrics/summary` endpoints | +| `enable_client_http_server` | bool | `false` | Enable the client-side HTTP `/health`, `/metrics`, `/metrics/summary`, and `/version` endpoints | | `client_http_port` | int | `9300` | Client-side HTTP endpoint port, used only when `enable_client_http_server=true` | ```{note} -The first seven arguments have **no Python default** — the C++ defaults are not exposed by the pybind binding, so they must all be supplied (a bare `setup(local_hostname, metadata_server)` raises `TypeError`). The later arguments (`engine`, SSD offload fields, `tenant_id`, and client HTTP endpoint fields) are optional. Also, in Method A the `MOONCAKE_*` variables used by `MooncakeConfig` are ignored; low-level runtime variables such as the `MC_*` engine variables below are still read by the C++ client. +The first seven arguments have **no Python default** — the C++ defaults are not exposed by the pybind binding, so they must all be supplied (a bare `setup(local_hostname, metadata_server)` raises `TypeError`). The later arguments (`engine`, SSD offload fields, `tenant_id`, and client HTTP endpoint fields) are optional. In Method A, launcher-level `MOONCAKE_*` variables used only by `MooncakeConfig` are ignored. Variables consumed directly by the C++ client, including the FileStorage/DFS backend variables and low-level `MC_*` engine variables below, are still read. ``` ### Method B — Service / Integration (`MOONCAKE_*` + CLI) @@ -855,10 +1196,10 @@ The store service CLI only accepts `--config`, `-D/--define`, `--port`, and `--m | `MOONCAKE_GLOBAL_SEGMENT_SIZE` | `global_segment_size` | `3355443200` (3.125 GiB) | DRAM contributed; accepts byte integer **or** suffixed form like `500gb` | | `MOONCAKE_LOCAL_BUFFER_SIZE` | `local_buffer_size` | `1073741824` (1 GiB) | Transfer Engine buffer; same parsing as above | | `MOONCAKE_LOCAL_HOSTNAME` | `local_hostname` | `localhost` | | -| `MOONCAKE_OFFLOAD_ENABLED` | `enable_ssd_offload` | `false` | Client-side SSD offload | -| `MOONCAKE_OFFLOAD_FILE_STORAGE_PATH` | `ssd_offload_path` | empty | Offload directory | +| `MOONCAKE_OFFLOAD_ENABLED` | `enable_ssd_offload` | `false` | Initialize client-side `FileStorage`; required for SSD offload and descriptor-based DFS | +| `MOONCAKE_OFFLOAD_FILE_STORAGE_PATH` | `ssd_offload_path` | empty | FileStorage path; DFS shard data uses `MOONCAKE_DFS_ROOT_DIR` with the distributed backend | | `MOONCAKE_TENANT_ID` | `tenant_id` | `default` | Tenant identifier | -| `MOONCAKE_ENABLE_CLIENT_HTTP_SERVER` | `enable_client_http_server` | `false` | Enable client-side `/health`, `/metrics`, and `/metrics/summary` endpoints | +| `MOONCAKE_ENABLE_CLIENT_HTTP_SERVER` | `enable_client_http_server` | `false` | Enable client-side `/health`, `/metrics`, `/metrics/summary`, and `/version` endpoints | | `MOONCAKE_CLIENT_HTTP_PORT` | `client_http_port` | `9300` | Client-side HTTP endpoint port | | `MOONCAKE_CONFIG_PATH` | — | unset | Path to a JSON config file (takes precedence over the variables above) | @@ -927,9 +1268,12 @@ mooncake_client \ | `--tenant_id` | `default` | Tenant identifier | | `--enable_offload` | `false` | Enable client-side SSD offload | | `--start_offload_rpc_server` | `true` | Start the offload RPC server for dummy clients | -| `--enable_http_server` | `false` | Enable client-side `/health`, `/metrics`, and `/metrics/summary` endpoints | +| `--enable_http_server` | `false` | Enable client-side `/health`, `/metrics`, `/metrics/summary`, and `/version` endpoints | | `--http_port` | `9300` | Client-side HTTP endpoint port | +`mooncake_client --version` prints the release version plus the short git hash, +and the same value is logged at startup. + ### Client HTTP Health and Metrics Endpoint Each real client can expose its own lightweight HTTP endpoint independently of the master admin HTTP server and the Python store REST API. This endpoint is disabled by default for programmatic clients and `mooncake_store_service`; enable it explicitly when you want to scrape client-local metrics: @@ -955,9 +1299,18 @@ For `mooncake_store_service`, use `MOONCAKE_ENABLE_CLIENT_HTTP_SERVER=true` and | `GET /health` | Client health check | | `GET /metrics` | Prometheus-format client metrics | | `GET /metrics/summary` | Human-readable client metrics summary | +| `GET /version` | Client version as JSON (`version` for RPC handshake compatibility, `display_version` for release plus short git hash) | + +```bash +curl http://:9300/version +``` + +```json +{"version":"2.0.0","display_version":"0.3.12.post1 (git: f9e8311f)"} +``` ```{note} -`MC_STORE_CLIENT_METRIC` controls whether client metrics are collected. If the client HTTP server is enabled but `MC_STORE_CLIENT_METRIC=0`, `/metrics` and `/metrics/summary` return HTTP 503 with `metrics not available`. +`MC_STORE_CLIENT_METRIC` controls whether client metrics are collected. If the client HTTP server is enabled but `MC_STORE_CLIENT_METRIC=0`, `/metrics` and `/metrics/summary` return HTTP 503 with `metrics not available`. `/health` and `/version` are unaffected. ``` ### Engine Runtime Tuning (`MC_*`) @@ -969,8 +1322,8 @@ The following `MC_*` variables are read directly by the engine/client at runtime | Variable | Default | Description | |----------|---------|-------------| | `MC_RPC_PROTOCOL` | `tcp` | RPC transport protocol between master and clients: `tcp` or `rdma` | -| `MC_RPC_TIMEOUT_MS` | `30000` | Per-request deadline (ms) for all client→master RPCs. Applies uniformly to every RPC method. A negative value disables the timeout. On expiry the call returns `RPC_TIMEOUT` | -| `MC_RPC_CONNECT_TIMEOUT_MS` | `30000` | Connection-establishment timeout (ms) for the master RPC client | +| `MC_RPC_TIMEOUT_MS` | `30000` | Per-request deadline (ms) for client→master RPCs and for store→store SSD offload reads. Applies uniformly to every RPC method. A negative value disables the timeout. On expiry the call returns `RPC_TIMEOUT` | +| `MC_RPC_CONNECT_TIMEOUT_MS` | `30000` initially; `1000` during HA runtime | Connection-establishment timeout (ms) for the master RPC client and for the store→store SSD offload client. HA clients retain the normal retry budget during initial discovery and configuration, then use one bounded attempt per runtime reconnect because their monitor and heartbeat loops own the retry schedule. An explicit value overrides both defaults. Worth lowering when SSD offload is enabled: an offload read that picks a store which has gone away without deregistering waits this long on each of 3 connect attempts (91 s at the default) before returning a clean miss | | `MC_RPC_CLIENT_IO_THREADS` | `min(16, online CPU count)`, minimum `1` | Fallback number of threads and `io_context` instances for each component's RPC client I/O pool. A positive integer overrides the default; invalid values and `0` use the default | | `MC_STORE_RPC_CLIENT_IO_THREADS` | `MC_RPC_CLIENT_IO_THREADS` | Store/Master client RPC I/O pool size. This pool is isolated from Transfer Engine traffic. Invalid values and `0` use the fallback | | `MC_TE_RPC_CLIENT_IO_THREADS` | `MC_RPC_CLIENT_IO_THREADS` | Transfer Engine and TENT client RPC I/O pool size. This pool is isolated from Store/Master traffic. Invalid values and `0` use the fallback | @@ -1039,9 +1392,10 @@ Do not run binaries from before and after checksum support was introduced in the | Variable | Default | Description | |----------|---------|-------------| | `MC_STORE_USE_HUGEPAGE` | unset | Set `1` to request HugeTLB-backed `mmap()` | -| `MC_STORE_HUGEPAGE_SIZE` | `2MB` | Supported: `2MB`, `1GB` | +| `MC_STORE_HUGEPAGE_SIZE` | `2MB` | Supported: `2MB`, `512MB`, `1GB` | | `MC_MMAP_ARENA_POOL_SIZE` | unset | Pre-allocated arena pool size (e.g., `8gb`). Explicitly set to enable the arena | | `MC_DISABLE_MMAP_ARENA` | unset | Disable arena, fall back to per-call `mmap()`. Accepts `1`/`true`/`yes`/`on` (or `0`/`false`/`no`/`off`) | +| `MC_STORE_REGISTER_SPDK` | unset | Set `1` to register `ShmHelper`-allocated shared memory (host pool, dummy local buffer) with SPDK for NoF zero-copy transfers. Forces HugeTLB backing for those allocations, defaulting to 2MB hugepages when `MC_STORE_USE_HUGEPAGE` is unset. Must be set on BOTH the dummy and the real process (SPDK registration is per-process) | RDMA Store segments backed by HugeTLB are populated in parallel immediately before transfer-engine registration. No additional population-mode setting is @@ -1058,6 +1412,25 @@ with its `mbind()` region before touching pages. The mmap arena retains its eager `MAP_POPULATE` behavior for DMA safety; set `MC_DISABLE_MMAP_ARENA=1` if the deferred direct-mmap path is desired while the arena is otherwise enabled. +For NoF (NVMe-oF) zero-copy, `MC_STORE_REGISTER_SPDK=1` registers the shared +memory allocated by `ShmHelper` (SGLang host pool, dummy local buffer) with +SPDK (`spdk_mem_register`) so the NoF RDMA transport can DMA to/from it +directly — without it those buffers fail with `No translation for ptr`. +`spdk_mem_register` is per-process, so set this switch on BOTH the dummy +(sender) and the real client (receiver): the dummy registers its own mapping +in `ShmHelper::allocate`, and the real client registers its separate mapping +of the same shared fd in `RealClient::map_shm_internal_with_device`. Setting +it on only one process leaves the other without an SPDK translation and NoF +transfers still fail with `No translation for ptr`. SPDK +registration in iova=pa mode requires PHYSICALLY 2MB-aligned memory, which only +HugeTLB pages satisfy, so this switch forces HugeTLB for the affected +allocations even when `MC_STORE_USE_HUGEPAGE` is unset; it then defaults to 2MB +hugepages (set `MC_STORE_USE_HUGEPAGE=1` and `MC_STORE_HUGEPAGE_SIZE=1GB` for +1GB). Reserve enough HugeTLB pages (`/proc/sys/vm/nr_hugepages`) for the host +pool plus any hugepage-backed segments; when the pool is exhausted the first +allocation aborts with a clear error naming the hugepage size and count needed +rather than silently degrading. + #### yalantinglibs Log Level ```bash diff --git a/docs/source/deployment/multi-tenancy.md b/docs/source/deployment/multi-tenancy.md new file mode 100644 index 0000000000..8c8614eed4 --- /dev/null +++ b/docs/source/deployment/multi-tenancy.md @@ -0,0 +1,130 @@ +# Multi-Tenant Deployment + +## Configure the Master + +### File Connector + +Tenant quota admission is disabled by default. Enable strict multi-tenant mode on the master when you want memory writes admitted against connector-managed per-tenant quota: + +```bash +mooncake_master \ + --enable_multi_tenants=true \ + --tenant_quota_connector_type=file \ + --tenant_quota_connector_uri=/etc/mooncake/tenant_quotas.yaml +``` + +### etcd Connector + +You can also store the same YAML policy in etcd when Mooncake Store is built with `STORE_USE_ETCD=ON`: + +```bash +mooncake_master \ + --enable_multi_tenants=true \ + --cluster_id=mooncake_cluster \ + --tenant_quota_connector_type=etcd \ + --tenant_quota_connector_uri=127.0.0.1:2379 +``` + +The etcd connector stores the policy at `mooncake-store//tenant_quota_policy`. If the key does not exist, the master starts with an empty policy so the first tenant policy can be created through the admin API. It shares the process-wide store etcd client used by HA/oplog, so if HA or oplog also uses etcd, `tenant_quota_connector_uri` must match those etcd endpoints. + +## Define the Tenant Policy + +The policy must use schema version `1`; tenant names must be non-empty, unique, must not start with `_`, and must not contain NUL or control characters; quotas must be positive integers with optional `B`, `KB`, `MB`, `GB`, or `TB` units: + +```yaml +version: 1 + +tenants: + - name: tenant-a + quota: 200GB + + - name: tenant-b + quota: 500GB +``` + +When strict multi-tenant mode is enabled, write requests must include a registered tenant. The `default` tenant is not special unless it is explicitly registered in the connector policy. + +## Manage Tenant Quotas + +The same HTTP port used for metrics exposes the tenant quota admin API: + +```bash +# List tenant quota snapshots +curl -s http://:9003/api/v1/tenant_quotas + +# Query one tenant +curl -s "http://:9003/api/v1/tenant_quotas?tenant_id=tenant-a" + +# Upsert an explicit policy. Explicit tenant policies must be between 1 byte +# and 2^63 - 1 bytes. +curl -s -X PUT "http://:9003/api/v1/tenant_quotas?tenant_id=tenant-a" \ + -H 'Content-Type: application/json' \ + -d '{"requested_quota_bytes":2147483648}' + +# Delete an explicit policy. The tenant must not own objects or quota usage. +curl -s -X DELETE "http://:9003/api/v1/tenant_quotas?tenant_id=tenant-a" +``` + +Each tenant quota snapshot returns: + +```json +{ + "success": true, + "data": { + "tenant_id": "tenant-a", + "requested_quota_bytes": 2147483648, + "effective_quota_bytes": 2147483648, + "charged_bytes": 0, + "admission_closed": false, + "over_quota": false, + "has_explicit_policy": true + } +} +``` + +`charged_bytes` includes completed MEMORY replicas and in-flight MEMORY allocations. Put, Copy, Move, and promotion charge quota when admission starts; failed, revoked, partially completed, or expired operations refund the unused charge. `admission_closed` is `true` when the account rejects new writes, including after its explicit policy is removed. + +In HA mode, quota admin requests are served only by the active master service. Standby, candidate, or inactive services return HTTP 503. If strict multi-tenant mode is disabled, the quota admin API returns HTTP 409 with `UNAVAILABLE_IN_CURRENT_MODE`. Deleting a non-empty tenant returns HTTP 409 with `TENANT_NOT_EMPTY`. + +## SGLang + +When Mooncake is used as the HiCache storage backend, set `tenant_id` in the +Mooncake backend configuration: + +```bash +--hicache-storage-backend mooncake \ +--hicache-storage-backend-extra-config \ + '{"master_server_address":"127.0.0.1:50051","tenant_id":"tenant-a"}' +``` + +Alternatively, add `tenant_id` to the JSON file selected by +`SGLANG_HICACHE_MOONCAKE_CONFIG_PATH`, or use `MOONCAKE_TENANT_ID` when loading +the Mooncake configuration from environment variables. SGLang forwards the +resolved value to the Mooncake client. + +All prefill, decode, and replica instances that should share KV cache entries +must use the same `tenant_id` and compatible model and release namespaces. + +## vLLM + +Add `tenant_id` to the Mooncake client JSON configuration: + +```json +"tenant_id": "tenant-a" +``` + +Point `MOONCAKE_CONFIG_PATH` at that file and enable +`MooncakeStoreConnector` through `--kv-transfer-config`: + +```bash +MOONCAKE_CONFIG_PATH=/path/to/mooncake_config.json \ +vllm serve \ + --kv-transfer-config \ + '{"kv_connector":"MooncakeStoreConnector","kv_role":"kv_both"}' +``` + +`MooncakeStoreConnector` reads the JSON during initialization and passes its +tenant ID to the Mooncake client. + +All prefill, decode, and replica instances that should share KV cache entries +must use the same `tenant_id` and compatible model and release namespaces. diff --git a/docs/source/deployment/oss-offload.md b/docs/source/deployment/oss-offload.md new file mode 100644 index 0000000000..fdd34226cd --- /dev/null +++ b/docs/source/deployment/oss-offload.md @@ -0,0 +1,184 @@ +# OSS Offload + +## Overview + +Object storage services, such as OSS and S3, provide key-based storage. +Mooncake Store integrates object storage through `ObjectStorageAdapter` in the +existing `FileStorage` offload path. As with local SSD and NVMe KV backends, +the master records `LOCAL_DISK` replicas owned by a real client; readers still +access the payload through that owner. + +The examples below use the OSS adapter. Other services require a compatible +adapter; changing the endpoint alone does not add S3 support. + +For implementation details, see [OSS Backend Design](../design/store/oss-backend.md). + +## Prerequisites + +- An existing OSS bucket and an endpoint reachable from each offload owner. + No OSS filesystem mount is required. +- Credentials with permission to PUT, GET, HEAD, LIST, and DELETE within the + chosen namespace. STS credentials are supported. +- A dedicated object-key prefix for each offload owner. +- An existing absolute, writable, non-symlink directory for + `MOONCAKE_OFFLOAD_FILE_STORAGE_PATH`, required by common `FileStorage` + initialization. This does not enable a local SSD cache for OSS. +- libcurl and OpenSSL development libraries and headers. + +## Build Support + +The build enables the OSS adapter when libcurl and OpenSSL are available. +No OSS SDK or additional OSS-specific build flag is required. Follow the +[build guide](../getting_started/build.md) to build and install Mooncake. + +Batch I/O uses `curl_multi_wait`; libcurl 7.66.0 is not required. Upload-buffer +tuning is optional: with headers older than 7.62.0, the library default is used. + +## Topology + +```mermaid +flowchart TD + App["Application or requesting Mooncake client"] + Master["Mooncake master"] + Owner["Offload-owning real client"] + FileStorage["FileStorage"] + Backend["DistributedStorageBackend"] + Adapter["OssObjectStorageAdapter"] + OSS["OSS bucket and owner prefix"] + + App <-->|"metadata query"| Master + App <-->|"offload RPC and Transfer Engine"| Owner + Owner <-->|"offload heartbeat and LOCAL_DISK updates"| Master + Owner --> FileStorage --> Backend --> Adapter + Adapter <-->|"HTTP requests"| OSS +``` + +Only the offload owner needs OSS credentials. The master and requesting clients +do not directly read or write OSS objects. + +## Configuration + +Set the backend and OSS variables in each offload owner's environment: + +```bash +export MOONCAKE_OFFLOAD_STORAGE_BACKEND_DESCRIPTOR=distributed_storage_backend +export MOONCAKE_OFFLOAD_FILE_STORAGE_PATH=/data/file_storage +export MOONCAKE_DISTRIBUTED_FS_TYPE=oss +export MOONCAKE_DISTRIBUTED_ROOT_DIR=/mooncake/my-cluster/owner-1 +export MOONCAKE_OSS_ENDPOINT=https://oss-cn-hangzhou.aliyuncs.com +export MOONCAKE_OSS_BUCKET=my-mooncake-bucket +export MOONCAKE_OSS_REGION=cn-hangzhou +# Supply MOONCAKE_OSS_ACCESS_KEY_ID and MOONCAKE_OSS_ACCESS_KEY_SECRET +# through your credential-management mechanism, not checked-in scripts. +``` + +Replace the example endpoint, bucket, region, and owner prefix for your +deployment. Each `FileStorage` instance selects one backend: OSS does not run +alongside the local-file or NVMe KV backend within that instance. + +### Backend and namespace + +| Environment variable | Setting for OSS | Description | +|----------------------|-----------------|-------------| +| `MOONCAKE_OFFLOAD_STORAGE_BACKEND_DESCRIPTOR` | `distributed_storage_backend` | Select the backend that hosts the OSS adapter. | +| `MOONCAKE_OFFLOAD_FILE_STORAGE_PATH` | `/data/file_storage` by default | Existing local directory required by common initialization; object payloads go to OSS. | +| `MOONCAKE_DISTRIBUTED_FS_TYPE` | `oss` | Select object-storage mode rather than a filesystem adapter. | +| `MOONCAKE_DISTRIBUTED_ROOT_DIR` | An owner-specific prefix | Use an absolute-style path; the adapter strips leading and trailing slashes. This is not a mount point. | + +OSS offload does not require Master DFS configuration and does not require +disabling a separately configured DFS tier. In the offload owner's environment, +`MOONCAKE_DFS_FS_ADAPTER` and `MOONCAKE_DFS_ROOT_DIR` override the corresponding +`MOONCAKE_DISTRIBUTED_*` values because they share a configuration parser. +Leave these overrides unset when using the example above. + +### Endpoint and credentials + +| Environment variable | Default | Description | +|----------------------|---------|-------------| +| `MOONCAKE_OSS_ENDPOINT` | Required | Endpoint including `http://` or `https://`. Alias: `OSS_ENDPOINT`. | +| `MOONCAKE_OSS_BUCKET` | Required | Existing bucket. Alias: `OSS_BUCKET`. | +| `MOONCAKE_OSS_REGION` | Required | OSS signing region. Alias: `OSS_REGION`. | +| `MOONCAKE_OSS_ACCESS_KEY_ID` | Required unless anonymous | Access key ID. Alias: `OSS_ACCESS_KEY_ID`. | +| `MOONCAKE_OSS_ACCESS_KEY_SECRET` | Required unless anonymous | Access key secret. Alias: `OSS_ACCESS_KEY_SECRET`. | +| `MOONCAKE_OSS_SECURITY_TOKEN` | Empty | Optional STS token. Alias: `OSS_SESSION_TOKEN`. | +| `MOONCAKE_OSS_PATH_STYLE` | `false` | Use `endpoint/bucket/key` instead of virtual-hosted bucket addressing. | +| `MOONCAKE_OSS_ANONYMOUS` | `false` | Disable signing; only for test endpoints or suitably configured public access. | + +Primary names take precedence over aliases, including explicitly empty values. +Configuration is read at initialization; changing environment variables does +not reconfigure an active adapter or refresh its credentials. + +### Backend concurrency and health check + +| Environment variable | Default | Description | +|----------------------|---------|-------------| +| `MOONCAKE_OSS_MAX_CONNECTIONS` | `64` | Maximum admitted requests and total/per-host connections per batch; minimum `1`. Not a process-wide limit. | +| `MOONCAKE_OSS_RECEIVE_BUFFER_SIZE` | `1048576` (1 MiB) | libcurl receive-buffer suggestion for batch requests, clamped to 16 KiB–10 MiB. Single-request GETs keep the library default. | +| `MOONCAKE_OSS_UPLOAD_BUFFER_SIZE` | `1048576` (1 MiB) | Upload-buffer suggestion for `PutV` / `PutBatch`, clamped to 16 KiB–2 MiB. Applied only with libcurl headers 7.62.0 or newer; otherwise the library default is used. | +| `MOONCAKE_DISTRIBUTED_HEALTH_CHECK` | `false` | Write and read back a probe object during initialization, then best-effort delete it. | + +Numeric tuning values are decimal integers. Invalid or out-of-range integers +use the default; the bounds above then apply. Buffer sizes are libcurl +suggestions, not TCP socket-buffer sizes or guaranteed throughput settings. + +The common offload heartbeat defaults to 10 seconds and is configured through +`MOONCAKE_OFFLOAD_HEARTBEAT_INTERVAL_SECONDS`. Other common client settings are +described in [SSD Offload](ssd/ssd-offload.md). + +## Start Mooncake + +Start the master with offload enabled: + +```bash +mooncake_master --rpc_port=50051 --enable_offload=true +``` + +After applying the backend settings above, create the required local directory +and start a real client. This example uses a local master and TCP transfers: + +```bash +mkdir -p /data/file_storage +export MOONCAKE_MASTER=127.0.0.1:50051 +export MOONCAKE_LOCAL_HOSTNAME=127.0.0.1 +export MOONCAKE_PROTOCOL=tcp +export MOONCAKE_TE_META_DATA_SERVER=P2PHANDSHAKE +export MOONCAKE_OFFLOAD_ENABLED=true + +python -m mooncake.mooncake_store_service +``` + +Use routable addresses for a multi-node deployment. This launcher example +assumes `MOONCAKE_CONFIG_PATH` is unset; a service configuration file otherwise +takes precedence. + +Embedded real-client mode uses the same backend and OSS variables. Pass +`enable_ssd_offload=True` and `ssd_offload_path` to +`MooncakeDistributedStore.setup()` alongside the normal connection and memory +arguments. The [SSD Offload guide](ssd/ssd-offload.md) describes embedded and +standalone real-client deployment modes. + +## Troubleshooting + +### The adapter cannot initialize + +Check build dependencies, required endpoint/region/credential settings, and the +local directory. Check for stale `MOONCAKE_DFS_*` overrides. The optional health +check exercises OSS access; it does not test the complete Store read path. + +### Requests fail with authentication or permission errors + +Verify the endpoint, signing region, bucket permissions, and STS token lifetime. +The adapter does not refresh credentials automatically. + +### An object is in OSS but cannot be read through Store + +A bucket object alone is not a readable Store replica. The master must have +the key's metadata and a reachable owner. Keep prefixes owner-specific; a shared +bucket does not make owners interchangeable. + +### SSD capacity metrics do not match OSS usage + +`MOONCAKE_OFFLOAD_TOTAL_SIZE_LIMIT_BYTES` supplies a configured capacity value +(default: 2 TiB), not a queried OSS bucket capacity. Master usage tracks registered +`LOCAL_DISK` replicas. OSS has no backend quota enforcement or automatic object +GC here, so these metrics are not physical bucket usage or a cloud-cost limit. diff --git a/docs/source/deployment/ssd/index.md b/docs/source/deployment/ssd/index.md index 110445391a..a71448040f 100644 --- a/docs/source/deployment/ssd/index.md +++ b/docs/source/deployment/ssd/index.md @@ -6,6 +6,7 @@ storage pools. Choose the guide that matches the storage tier in your deployment | Storage option | Description | |----------------|-------------| | [SSD Offload](ssd-offload) | Configure local SSD offload, eviction, and I/O behavior. | +| [NVMe KV Local-Disk Backend](nvme-kv) | Configure a local NVMe KV namespace for SSD offload. | | [NVMe-oF SSD Pool](nvmf-ssd-deployment-guide) | Configure a shared NVMe-over-Fabrics storage tier. | :::{toctree} @@ -13,5 +14,6 @@ storage pools. Choose the guide that matches the storage tier in your deployment :hidden: ssd-offload +nvme-kv nvmf-ssd-deployment-guide ::: diff --git a/docs/source/deployment/ssd/nvme-kv.md b/docs/source/deployment/ssd/nvme-kv.md new file mode 100644 index 0000000000..242dcc2dbb --- /dev/null +++ b/docs/source/deployment/ssd/nvme-kv.md @@ -0,0 +1,150 @@ +# NVMe KV Local-Disk Backend + +## Overview + +Mooncake Store can use a node-local NVMe Key-Value namespace as an SSD offload backend. The backend implements `StorageBackendInterface`, so the master tracks offloaded objects as `LOCAL_DISK` replicas and applications continue to use the normal Mooncake Store APIs. + +For implementation details, see [NVMe KV Backend Design](../../design/store/nvme-kv-backend.md). + +## Prerequisites + +- A Linux host with an NVMe KV namespace exposed as a device node. +- Device support for NVMe KV Store, Retrieve, Delete, and store-if-not-exists semantics. +- Read and write permission on the configured device for the Mooncake real client process. +- An existing writable directory for `MOONCAKE_OFFLOAD_FILE_STORAGE_PATH`, as required by the common `FileStorage` configuration. +- `liburing` headers and library when the io_uring executor is required. + +## Build Support + +The ioctl executor is built as part of Mooncake Store. NVMe uring command support is enabled automatically when CMake finds `liburing` and Linux headers that expose `nvme_uring_cmd`, `IORING_OP_URING_CMD`, SQE128, and CQE32 support. + +During configuration, check for: + +```text +io_uring: NVMe uring command support enabled +``` + +If this support is unavailable, the backend can still use the ioctl executor. + +## Topology + +```mermaid +flowchart TD + App["Application or requesting Mooncake client"] + Master["Mooncake master"] + Client["Mooncake real client"] + FileStorage["FileStorage"] + Backend["NvmeKvStorageBackend"] + Connector["NvmeKvConnector"] + Executor["io_uring or ioctl executor"] + Device["Local NVMe KV namespace"] + + App <-->|"metadata and offload coordination"| Master + App <-->|"object RPC and data transfer"| Client + Client <-->|"LOCAL_DISK replica updates"| Master + Client --> FileStorage --> Backend --> Connector --> Executor --> Device +``` + +The master selects the real client that owns a `LOCAL_DISK` replica. Only that real client opens the local NVMe KV device and issues device commands. + +## Configuration + +Set the NVMe KV variables in the real client environment. They are not required by the master. + +```bash +export MOONCAKE_OFFLOAD_STORAGE_BACKEND_DESCRIPTOR=nvme_kv_storage_backend +export MOONCAKE_OFFLOAD_FILE_STORAGE_PATH=/var/lib/mooncake/nvme-kv +export MOONCAKE_NVME_KV_DEVICE_PATH=/dev/nvme1n1 +export MOONCAKE_NVME_KV_TRANSPORT=auto +``` + +The directory configured by `MOONCAKE_OFFLOAD_FILE_STORAGE_PATH` must already exist and be writable. NVMe KV object values are stored on the device selected by `MOONCAKE_NVME_KV_DEVICE_PATH`. + +### Required settings + +| Environment variable | Default | Description | +|----------------------|---------|-------------| +| `MOONCAKE_OFFLOAD_STORAGE_BACKEND_DESCRIPTOR` | `bucket_storage_backend` | Set to `nvme_kv_storage_backend`. | +| `MOONCAKE_OFFLOAD_FILE_STORAGE_PATH` | `/data/file_storage` | Existing writable directory required by the common `FileStorage` configuration. | +| `MOONCAKE_NVME_KV_DEVICE_PATH` | None | Namespace block-device path or NVMe generic character-device path. io_uring resolves namespace block paths to the matching `/dev/ng*` device. | + +### Transport and command settings + +| Environment variable | Default | Description | +|----------------------|---------|-------------| +| `MOONCAKE_NVME_KV_TRANSPORT` | `auto` | `auto`, `io_uring`, or `ioctl`. `auto` tries io_uring first and falls back to ioctl only when io_uring initialization fails. | +| `MOONCAKE_NVME_KV_NSID` | `1` | Namespace ID encoded in NVMe KV commands. | +| `MOONCAKE_NVME_KV_QUEUE_DEPTH` | `256` | io_uring queue depth and executor capability exposed to the backend. | +| `MOONCAKE_NVME_KV_RUNTIME_TRANSFER_LIMIT` | `270336` | Runtime upper bound for one NVMe KV value transfer, in bytes. | +| `MOONCAKE_NVME_KV_PROTOCOL_MAX_VALUE_SIZE` | `524288` | Protocol or device value-size ceiling. | +| `MOONCAKE_NVME_KV_TRANSFER_ALIGNMENT_BYTES` | `4096` | DMA buffer and transfer-length alignment. | +| `MOONCAKE_NVME_KV_VALUE_BLOCK_UNIT_BYTES` | `512` | Unit used to encode the NVMe KV value block count. | + +The effective maximum value size is the smaller of the runtime transfer limit and protocol maximum, rounded down to the configured transfer alignment. + +### Backend concurrency + +| Environment variable | Default | Description | +|----------------------|---------|-------------| +| `MOONCAKE_NVME_KV_IO_CONCURRENCY` | `18` | Total backend I/O concurrency. When unset, Mooncake uses the smaller of 18, queue depth, and the configured maximum. | +| `MOONCAKE_NVME_KV_MAX_IO_CONCURRENCY` | `256` | Upper bound for automatic or explicit backend I/O concurrency. | +| `MOONCAKE_NVME_KV_PREPARE_CONCURRENCY` | `12` | Workers used for checksum and object-layout preparation. | +| `MOONCAKE_NVME_KV_BATCH_SUBMIT_CONCURRENCY` | `6` | Independent chunk submission lanes. | +| `MOONCAKE_NVME_KV_ROOT_SUBMIT_CONCURRENCY` | `1` | Root submission lanes. A chunked object's root is queued only after its chunks complete. | +| `MOONCAKE_NVME_KV_READ_PLAN_BATCH_SIZE` | `8` | Logical objects grouped into one chunk-read planning task. | + +Preparation, chunk submission, and root submission use bounded worker pools. Explicit lane counts are capped by the effective I/O concurrency. + +## Transport Selection + +### `auto` (recommended) + +Configure a namespace block-device path such as `/dev/nvme1n1`. When NVMe uring command support is compiled in, Mooncake resolves the matching NVMe generic character device, such as `/dev/ng1n1`, and initializes io_uring. If initialization fails, Mooncake opens the original block-device path with ioctl. The selected transport remains fixed for the connector lifetime. + +### `io_uring` + +Use this mode to require io_uring. Mooncake accepts either a namespace block-device path with a matching `/dev/ng*` device or the generic character-device path directly. Initialization fails instead of falling back. + +### `ioctl` + +Use this mode to require Linux NVMe passthrough ioctl. Configure the namespace block-device path. Each command is synchronous within one backend worker, and the backend worker pool supplies parallelism. + +## Start Mooncake + +Start the master with SSD offload enabled: + +```bash +mooncake_master \ + --rpc_port=50051 \ + --enable_offload=true +``` + +Start a real client on the host that owns the NVMe KV device: + +```bash +mooncake_client \ + --master_server_address=127.0.0.1:50051 \ + --host= \ + --protocol=rdma \ + --device_names= \ + --port=50052 \ + --global_segment_size="4 GB" \ + --enable_offload=true \ + --metadata_server=P2PHANDSHAKE +``` + +Embedded real-client mode uses the same environment variables. Set `enable_ssd_offload=True` when calling `MooncakeDistributedStore.setup()`. See [SSD Offload](ssd-offload.md) for the complete client flows. + +## Troubleshooting + +### io_uring falls back to ioctl + +Check that the build enabled NVMe uring command support and that the matching generic character device exists. Set `MOONCAKE_NVME_KV_TRANSPORT=io_uring` to turn fallback into an initialization error while diagnosing the setup. + +### Backend initialization reports an empty device path + +Set `MOONCAKE_NVME_KV_DEVICE_PATH` in the real client environment. + +### Store or Retrieve reports invalid parameters + +Verify namespace ID, effective maximum value size, transfer alignment, and value block unit against the device implementation. The runtime transfer limit must be at least one transfer-alignment unit. diff --git a/docs/source/deployment/ssd/nvmf-ssd-deployment-guide.md b/docs/source/deployment/ssd/nvmf-ssd-deployment-guide.md index 5b27e077b1..56745af781 100644 --- a/docs/source/deployment/ssd/nvmf-ssd-deployment-guide.md +++ b/docs/source/deployment/ssd/nvmf-ssd-deployment-guide.md @@ -126,6 +126,7 @@ python3 -m mooncake.spdk_tgt_create \ | `ip` | IP address of the target node. | | `path` | SPDK installation path on the target node. | | `pci` | PCI addresses of SSDs to register with the target. Use commas to separate multiple PCI addresses. If this field is omitted, SPDK-ready or unmounted NVMe devices on the target node are registered. | +| `--port` | SSH port used to connect to target nodes. The default value is `22`. | | `--core-mask` | CPU core mask used to start `nvmf_tgt` with `-m`. The default value is `0xff`. | **Tip**: Run `/path/scripts/setup.sh status` on a target node to list available PCI addresses. diff --git a/docs/source/deployment/ssd/ssd-offload.md b/docs/source/deployment/ssd/ssd-offload.md index 34d2dd28a9..7a51e61969 100644 --- a/docs/source/deployment/ssd/ssd-offload.md +++ b/docs/source/deployment/ssd/ssd-offload.md @@ -133,6 +133,7 @@ Start with `--enable_offload=true` for eager SSD persistence. Add `--offload_on_ | `MOONCAKE_OFFLOAD_FILE_STORAGE_PATH` | `/data/file_storage` | Absolute path to the SSD storage directory | | `MOONCAKE_OFFLOAD_STORAGE_BACKEND_DESCRIPTOR` | `bucket_storage_backend` | Storage backend type (see below) | | `MOONCAKE_OFFLOAD_LOCAL_BUFFER_SIZE_BYTES` | `1342177280` (1.25 GB) | Client-side staging buffer size | +| `MC_STORE_PINNED_RESTORE_ARENA_SIZE_BYTES` | `0` | Size of the additional preallocated pinned-host arena for same-process SSD-to-GPU restores and DFS ranged-session reads into device memory. See the constraints below | | `MOONCAKE_OFFLOAD_SCANMETA_ITERATOR_KEYS_LIMIT` | `20000` | Max keys processed per iteration when scanning existing SSD metadata on startup | | `MOONCAKE_OFFLOAD_TOTAL_SIZE_LIMIT_BYTES` | `2199023255552` (2 TB) | Maximum disk usage | | `MOONCAKE_OFFLOAD_TOTAL_KEYS_LIMIT` | `10000000` | Maximum number of objects on disk | @@ -146,6 +147,8 @@ Start with `--enable_offload=true` for eager SSD persistence. Add `--offload_on_ The `MOONCAKE_OFFLOAD_*` watermark names are preferred. Short aliases `MOONCAKE_DISK_EVICTION_HIGH_WATERMARK_RATIO` and `MOONCAKE_DISK_EVICTION_LOW_WATERMARK_RATIO` are also accepted. The high watermark must be greater than the low watermark. +The pinned restore quota is separate from `MOONCAKE_OFFLOAD_LOCAL_BUFFER_SIZE_BYTES`; it does not convert the normal FileStorage arena to pinned memory. It is allocated only when `MC_STORE_MEMCPY=1`. The arena is selected when the current process owns the SSD replica and the restore destination is GPU memory, and is also used as request-scoped staging for DFS ranged-session reads into device memory. Tensor payload ranges are copied from their source offset without an additional FileStorage staging allocation. Remote and CPU-destination reads continue to use the existing path. If the pinned quota is exhausted, the request uses the normal client buffer arena; if the quota cannot be pinned at startup, the optimization remains disabled. io_uring disables the pinned restore arena. The file-per-key backend may still use its own temporary pageable buffer internally; the bucket and offset-allocator backends can read into the supplied restore buffer directly. + ### Bucket backend settings Applies when `MOONCAKE_OFFLOAD_STORAGE_BACKEND_DESCRIPTOR=bucket_storage_backend`. @@ -156,6 +159,8 @@ Applies when `MOONCAKE_OFFLOAD_STORAGE_BACKEND_DESCRIPTOR=bucket_storage_backend | `MOONCAKE_OFFLOAD_BUCKET_KEYS_LIMIT` | `500` | Max keys per bucket | | `MOONCAKE_OFFLOAD_BUCKET_MAX_TOTAL_SIZE` | `0` | Eviction threshold in bytes. When set to `0`, the backend uses **90% of the physical disk capacity** as the quota — it does not mean unlimited. Set an explicit value to control disk usage precisely. | | `MOONCAKE_OFFLOAD_BUCKET_EVICTION_POLICY` | `fifo` | Eviction policy: `none` / `fifo` / `lru` | +| `MOONCAKE_OFFLOAD_BUCKET_MAX_PHYSICAL_BYTES` | `0` (disabled) | Hard cap on **real on-disk** bytes (`du`-equivalent) under this backend's `ssd_offload_path`. `0` disables it. Its scope depends on the deployment — see below. | +| `MOONCAKE_OFFLOAD_BUCKET_DISK_SCAN_CACHE_MS` | `500` | How long the directory-scan result is cached before re-scanning, to bound the cost of the physical-usage check. `<= 0` scans on every check. | ### File-per-key backend settings @@ -212,7 +217,7 @@ Best for: high-concurrency scenarios with many small objects where restart durab ### Write-time eviction -When `MOONCAKE_OFFLOAD_BUCKET_MAX_TOTAL_SIZE` is set, the backend automatically evicts buckets before writing new ones if total disk usage would exceed the limit. +When `MOONCAKE_OFFLOAD_BUCKET_MAX_TOTAL_SIZE` or `MOONCAKE_OFFLOAD_BUCKET_MAX_PHYSICAL_BYTES` is set, the backend automatically evicts buckets before writing new ones if total disk usage would exceed the limit. | Policy | Behavior | |--------|----------| @@ -222,6 +227,21 @@ When `MOONCAKE_OFFLOAD_BUCKET_MAX_TOTAL_SIZE` is set, the backend automatically Eviction is two-phase: the bucket is removed from metadata and master is notified first, then in-flight reads are drained before files are deleted. +**`MAX_TOTAL_SIZE` vs `MAX_PHYSICAL_BYTES`** + +`MAX_TOTAL_SIZE` bounds a *logical* in-memory counter (`data_size + meta_size` summed per object). It undercounts real disk usage: it ignores filesystem block rounding, and a bucket file stays on disk until *every* object in it is evicted while the counter drops per object. `MAX_PHYSICAL_BYTES` instead measures the *real* on-disk bytes (`du`-equivalent), so prefer it when you must not exceed a hard physical limit — most importantly a Kubernetes `emptyDir` with a `sizeLimit`, which the kubelet enforces by the volume's actual `du` usage and evicts the pod when exceeded. + +**Scope depends on the deployment.** `MAX_PHYSICAL_BYTES` scans this backend's own `ssd_offload_path`, so its meaning changes with the directory layout: + +- **Shared directory** — all TP ranks point at the same path (SGLang's current default). Each rank scans the whole directory, so the cap bounds the **combined** usage of all ranks. Set it to the total capacity you must stay under (e.g. the `emptyDir` `sizeLimit`). +- **Per-rank directory or separate disks** — each rank scans only its own files, so the cap bounds **each rank individually**. Set it to the per-rank budget (e.g. `sizeLimit / N` when N ranks share one volume via separate subdirectories, or the disk capacity when each rank has its own disk). + +> A cap on the *global* sum across ranks that holds under any layout (including ranks on separate disks) cannot be done by a per-directory scan and would require master-side aggregation; it is not part of this feature. + +The two limits are independent and may be combined — eviction fires when either would be exceeded. `MAX_PHYSICAL_BYTES` only takes effect when an eviction policy is set (`fifo`/`lru`); with `none`, eviction never runs. + +--- + ### Proactive watermark eviction When `MOONCAKE_OFFLOAD_ENABLE_DISK_WATERMARK_EVICTION=true`, the FileStorage heartbeat asks the backend to check local-disk usage every `MOONCAKE_OFFLOAD_HEARTBEAT_INTERVAL_SECONDS` seconds. If usage exceeds `MOONCAKE_OFFLOAD_DISK_EVICTION_HIGH_WATERMARK_RATIO`, the backend evicts toward `MOONCAKE_OFFLOAD_DISK_EVICTION_LOW_WATERMARK_RATIO`. @@ -266,6 +286,8 @@ mooncake_master \ export MOONCAKE_OFFLOAD_FILE_STORAGE_PATH=/nvme/mooncake_offload export MOONCAKE_OFFLOAD_STORAGE_BACKEND_DESCRIPTOR=bucket_storage_backend export MOONCAKE_OFFLOAD_BUCKET_MAX_TOTAL_SIZE=$((200 * 1024 * 1024 * 1024)) # 200 GB +export MOONCAKE_OFFLOAD_BUCKET_MAX_PHYSICAL_BYTES=$((200 * 1024 * 1024 * 1024)) # optional; shared dir: total cap. per-rank dir: per-rank cap +export MOONCAKE_OFFLOAD_BUCKET_DISK_SCAN_CACHE_MS=500 # optional export MOONCAKE_OFFLOAD_BUCKET_EVICTION_POLICY=lru mooncake_client \ diff --git a/docs/source/design/conductor/conductor-architecture-design.md b/docs/source/design/conductor/conductor-architecture-design.md index 115156b5c4..2b13da0772 100644 --- a/docs/source/design/conductor/conductor-architecture-design.md +++ b/docs/source/design/conductor/conductor-architecture-design.md @@ -1,4 +1,4 @@ -# Mooncake Conductor Architecture +# Conductor Architecture ## Overview @@ -68,8 +68,10 @@ Within each context, Conductor stores: per-instance access metadata; - a DP-rank set used to report rank-level hit information. -The current implementation computes complete-block prefix hashes from token IDs -and ignores trailing partial blocks during `/query`. +The vLLM strategy computes complete-block prefix hashes from token IDs and +ignores trailing partial blocks during `/query`. The SGLang strategies also hash +the trailing partial block, so a match that reaches it is reported as the number +of tokens the request actually carries, never as a whole block. ## Event flow diff --git a/docs/source/design/conductor/index.md b/docs/source/design/conductor/index.md new file mode 100644 index 0000000000..61226e0b84 --- /dev/null +++ b/docs/source/design/conductor/index.md @@ -0,0 +1,14 @@ +# Mooncake Conductor + +Mooncake Conductor is the KV-cache indexer used by cache-aware routers. It +subscribes to KV cache events from inference engines or storage backends, +normalizes those events, maintains a global prefix cache table, and exposes +HTTP APIs for dynamic service registration and cache-hit queries. + +:::{toctree} +:maxdepth: 1 + +conductor-architecture-design +publisher-design +subscriber-guide +::: diff --git a/docs/source/design/conductor/publisher-design.md b/docs/source/design/conductor/publisher-design.md new file mode 100644 index 0000000000..8a2c2255a4 --- /dev/null +++ b/docs/source/design/conductor/publisher-design.md @@ -0,0 +1,150 @@ +# KV Event Publisher Design + +## Goals + +The master publishes logical cache availability for external indexers while +keeping physical replica management internal. The implementation uses the +existing RFC #1527 map protocol and supports `stored`, `removed`, and +`cleared`. It does not include an indexer, replay service, or Conductor. + +## Transport + +The master binds a ZeroMQ PUB socket. Each multipart message is: + +1. an empty topic frame; +2. an unsigned 64-bit big-endian transport sequence; +3. a msgpack payload `[timestamp_ms, [event_maps], dp_rank]`. + +Publishing is asynchronous. A bounded in-process queue drops the oldest event +when full and reserves a sequence gap so subscribers can detect loss. Relevant +master flags are: + +- `enable_kv_events` +- `kv_events_bind_endpoint` +- `kv_events_backend_id` +- `kv_events_model_name` +- `kv_events_block_size` +- `kv_events_additional_salt` +- `kv_events_lora_name` +- `kv_events_dp_rank` +- `kv_events_emit_object_key` +- `kv_events_emit_legacy_compat` +- `kv_events_queue_capacity` + +The feature is compiled only when `ENABLE_KV_EVENTS=ON`; public client APIs +remain available and become no-ops for event metadata in builds without ZMQ. + +## Object and medium state + +Event identity is `backend_id`, `tenant_id`, and `object_key`. The `medium` +field is one string, either `cpu` or `disk`. If an object is present in both +tiers, the publisher emits one event per tier. + +Replica types are collapsed onto those two logical tiers: memory replicas map to +`cpu`, and every non-memory replica type — disk, local disk, NOF SSD, and DFS — +maps to `disk`. Subscribers see one entry per storage class and are not exposed +to Mooncake's internal replica taxonomy, which can grow without becoming a +protocol change. + +Replica topology is normalized to medium availability: + +- the first completed replica on a medium emits `stored`; +- removing the last completed replica on a medium emits `removed`; +- changing the number or location of replicas within an available medium emits + no event; +- a successful Put or Upsert commit emits `stored` for every current medium. + +The publisher holds no per-object state. Every delta is computed from the +arguments of a single call: the medium set after the mutation, plus the set the +caller captured before it. The master already snapshots that set before mutating +metadata, so keeping a second copy in the publisher would only duplicate it, and +a per-object map would shadow the master's whole key space. + +This makes the caller responsible for the "before" set. A path that mutates +metadata and then publishes without a snapshot cannot produce a correct delta, +which is why the snapshot and the publish call sit in the same function +throughout the master. + +Duplicate `removed` events are not suppressed by the publisher. Where the same +removal can be reached twice — an eviction that drops the last replica, followed +by the erase of the now-invalid object — the master picks one publisher, not +both: the eviction path returns early when the object is no longer valid and +leaves the announcement to the erase path. Subscribers additionally treat +`removed` as idempotent, so a duplicate is harmless rather than load-bearing. + +## Event payload fields + +The publisher is deliberately key-agnostic. Store never parses, splits, or +interprets an object key, so no key format is privileged and no connector needs +a Mooncake-specific key convention. The raw Store key is forwarded verbatim as +`object_key`, and every field that would require interpreting that key stays +empty: + +- `seq_hashes` and the legacy `block_hashes` are emitted as empty arrays; +- `token_ids` and `parent_hash` are nil on `stored`; `base_block_idx` is nil on + both `stored` and `removed`; +- `group_id` carries the Store group identity, not a connector group field. + +A `cleared` event is envelope-only. It omits `object_key`, `group_id`, +`seq_hashes`, `block_hashes`, and `base_block_idx` entirely rather than +emitting them as nil. + +The remaining envelope fields come from master configuration, because one +publisher serves one fixed model, block-size, LoRA, additional-salt, and +data-parallel context: `model_name`, `block_size`, `additional_salt`, +`lora_name`, and `dp_rank`. `block_size=0` is encoded as nil, as are empty salt +and LoRA names. The configured `dp_rank` appears both in each event envelope and +in the batch trailer. Per-object `tenant_id` comes from the Store operation +rather than the global tenant config. + +Setting `kv_events_emit_object_key=false` suppresses `stored` and `removed` +entirely, since without the key those events carry no identity a subscriber can +act on. Suppressed events are counted as `skipped_keyless_events`. `cleared` is +unaffected: it is tenant-scoped and needs no object identity. + +With `kv_events_emit_legacy_compat=true` (the default) each event also carries a +legacy `type` alias alongside `event_type`: `BlockStored`, `BlockRemoved`, and +`AllBlocksCleared`. Legacy mode also adds the `block_hashes` array and, on +`stored`, a nil `parent_block_hash`. This lets subscribers written against the +pre-RFC field names consume the same stream unchanged. + +A subscriber that needs block-level or shard-level semantics must derive them +from the key itself, using the same connector convention that produced it, plus +its own registered topology. The publisher cannot help here: +it does not know which connector wrote a key, how many layers or shards a block +spans, or how deep a block sits in a prefix chain. + +## Publication points + +| Operation | Event behavior | +|---|---| +| Put/BatchPut commit | `stored` for all completed media | +| Upsert of an existing object | `removed` when the old value becomes unreadable, then `stored` for all completed media on commit | +| Copy/Move completion | Medium availability delta | +| Offload/promotion completion | `stored` when the target medium first appears | +| Replica clear or eviction | `removed` when the last replica on a medium disappears | +| Stale handle/client cleanup | Medium availability delta | +| Remove/BatchRemove/regex remove | `removed` for every available medium | +| RemoveAll | Per-object `removed`, then tenant `cleared` when every object was removed | +| Failed uncommitted new Put | No event | + +`cleared` uses `medium=nil` and clears all media for the specified +`backend_id + tenant_id`. It is emitted only when the tenant actually held +objects and none were skipped, so a tenant that never existed produces no +`cleared`, and a `RemoveAll` that leaves a still-leased object behind produces +none either. Under HA with the oplog the metadata erase is deferred to the +durable callback, so the decision is based on what the removal loop accepted, not +on whether the metadata map looks empty. Every skip counts, including the less +obvious ones: an object whose replicas are not all completed, an object with a +pending replication task, a failed oplog slot reservation, and a failed oplog +append. + +## Limitations + +The publisher is PUB-only. It does not replay missed events, publish a startup +snapshot, or persist its compact context cache. Subscribers must detect +transport sequence gaps and recover through their own reconciliation path. +Because the compact context is per-process and not persisted, a master restart +resets it: the first event for a previously known object is a fresh `stored` +rather than a delta. Objects restored before the publisher starts are only +described by tenant, backend, fixed publisher context, and object key. diff --git a/docs/source/design/conductor/subscriber-guide.md b/docs/source/design/conductor/subscriber-guide.md new file mode 100644 index 0000000000..0ed39e2e7b --- /dev/null +++ b/docs/source/design/conductor/subscriber-guide.md @@ -0,0 +1,141 @@ +# KV Event Subscriber Guide + +How to consume the Mooncake Store KV event stream. For why the publisher is +built the way it is, see [publisher-design](publisher-design.md). + +## Transport + +Events arrive on a ZMQ `PUB` socket as a three-frame multipart message: + +| Frame | Contents | +|---|---| +| 0 | Topic. Always empty, but always present. | +| 1 | Unsigned 64-bit big-endian sequence number, 8 bytes. | +| 2 | MessagePack payload. | + +The payload is a 3-element array: `[timestamp_ms, [event_map, ...], dp_rank]`. +One message carries up to 64 events, so a subscriber must iterate the middle +element rather than assuming one event per message. + +## Event envelope + +Every event map contains these fields: + +| Field | Type | +|---|---| +| `event_id` | `u64`, monotonic per publisher process | +| `timestamp` | `i64` milliseconds | +| `event_type` | `stored`, `removed`, or `cleared` | +| `model_name` | string or nil | +| `block_size` | `u32`, nil when configured as 0 | +| `additional_salt` | string or nil | +| `lora_name` | string or nil | +| `tenant_id` | string | +| `backend_id` | string | +| `medium` | string or nil | +| `dp_rank` | `u32` | + +`stored` and `removed` add `group_id`, `object_key` (unless +`kv_events_emit_object_key=false`), `seq_hashes`, and `base_block_idx`. `stored` +additionally carries `parent_hash` and `token_ids`. + +## Keys are not interpreted + +Store never parses, splits, or interprets an object key. The raw Store key is +forwarded verbatim as `object_key`, and every field that would require +interpreting it is empty or nil: + +- `seq_hashes` and the legacy `block_hashes` are always empty arrays; +- `token_ids` and `parent_hash` are always nil on `stored`; +- `base_block_idx` is always nil. + +A subscriber that needs block-level identity must derive it from `object_key` +itself using whatever convention the producer applied. Do not expect Mooncake to +supply block hashes, token ids, or block depth. + +## Media + +`medium` is normalized to exactly two logical tiers: `cpu` for memory replicas, +`disk` for every non-memory class (local disk, NVMe-oF, DFS). One event names one +medium. + +## Stored + +`stored` announces that the object is readable on the event's medium. Treat +repeated `stored` for the same object/backend/medium as idempotent, and do not +infer physical replica count from event count. + +An Upsert of an existing object publishes `removed` for the old value and +`stored` for the replacement, in that order. There is no separate update event. + +## Removed + +`removed` retracts availability for the event's medium only. Other media for the +same object may remain valid, so drop the object entirely only once no medium +remains. + +Treat repeated `removed` for the same object/backend/medium as idempotent. The +publisher does not deduplicate retractions, so a subscriber that reference-counts +media instead of storing a set can decrement past zero. + +## Cleared + +`cleared` is envelope-only. It omits `object_key`, `group_id`, `seq_hashes`, +`block_hashes`, and `base_block_idx` entirely rather than emitting them as nil, +and carries `medium=nil`. It means every object under the event's +`backend_id + tenant_id` is gone. + +Mooncake emits `cleared` when a `RemoveAll` actually empties a tenant. It is not +emitted for a tenant that held no objects, and it is not emitted when any object +was skipped (for example a still-leased object without `force`). + +## Legacy compatibility + +With `kv_events_emit_legacy_compat=true` (the default) each event also carries a +`type` field alongside `event_type`: + +| `event_type` | legacy `type` | +|---|---| +| `stored` | `BlockStored` | +| `removed` | `BlockRemoved` | +| `cleared` | `AllBlocksCleared` | + +Per-object events additionally carry an empty `block_hashes` array, and `stored` +carries a nil `parent_block_hash`. Set the flag to `false` to emit only the +RFC #1527 field names. + +## Ordering, loss, and recovery + +Sequence numbers are strictly monotonic and gap-free while the publisher runs. +Use frame 1 for transport ordering and `event_id` for event ordering within the +stream. + +A sequence gap means events were dropped. When the publisher's async queue is +full it drops the oldest events and **reserves the sequence numbers they would +have used**, so a gap is always visible rather than silent. On seeing a gap, a +subscriber must either invalidate the affected `backend_id + tenant_id` state or +reconcile it against the master, which is the authoritative source for key +placement. + +There is no replay endpoint and no startup snapshot. A subscriber that joins +after objects were stored receives nothing about them. Publisher state is +per-process and not persisted, so a master restart resets the sequence counter to +1 without emitting a reset signal; a subscriber that filters on monotonic +sequence must be prepared for the counter to move backwards and should reconcile +against the master rather than discarding the new events. + +## Observability + +`GET /kv_events/status` on the master admin port reports: + +| Field | Meaning | +|---|---| +| `enabled` | Whether a publisher is live | +| `published_batches` | ZMQ messages sent | +| `published_events` | Events inside those messages | +| `dropped_events` | Events dropped by a full queue; each leaves a sequence gap | +| `skipped_keyless_events` | Per-object events suppressed because no `object_key` was available | + +A nonzero `skipped_keyless_events` with `kv_events_emit_object_key=false` is +expected: that flag suppresses all `stored` and `removed` events, leaving only +`cleared`. diff --git a/docs/source/design/engram.md b/docs/source/design/engram.md deleted file mode 100644 index 0372ee4482..0000000000 --- a/docs/source/design/engram.md +++ /dev/null @@ -1,144 +0,0 @@ -# EngramStore Backend - -Mooncake provides EngramStore as the storage backend for Engram embedding tables. - -The scope is intentionally narrow: - -- the caller defines the physical table layout -- the caller uploads one table per head -- the caller provides precomputed row ids with shape `[B, L, H]` -- Mooncake returns the selected rows as `[B, L, H, D]` - -Mooncake does not implement tokenizer compression, N-gram hashing, query logic, -or any other model-side Engram algorithm. - -## Current Backend Boundary - -The current implementation is intentionally conservative. It keeps EngramStore -on top of the existing Store interfaces and does not depend on: - -- transfer scatter read -- grouped transfer task -- `get_into_range` -- `batch_query` -- local direct mapping -- query cache -- remote gather control-plane changes - -Those optimizations are deferred to follow-up PRs so that the EngramStore backend can -land first as a small, reviewable unit. - -## Configuration - -`EngramStoreConfig` contains the physical layout for one EngramStore layer: - -- `table_vocab_sizes`: per-head table sizes `[N_0, N_1, ..., N_{H-1}]` -- `embedding_dim`: row width `D` - -For `layer_id`, Mooncake generates one store key per head: - -```text -engram:l{layer_id}:h{head_idx} -``` - -Each key stores a `float32` table with shape `[N_h, D]`. - -## Public Interface - -Python: - -- `EngramStore(layer_id, config, store=None)` -- `populate(embedding_buffers)` -- `lookup(row_ids)` -- `remove_from_store(force=False)` -- `get_table_vocab_sizes()` -- `get_store_keys()` -- `get_num_heads()` -- `get_embedding_dim()` - -The Python `store` argument accepts the existing `MooncakeDistributedStore` -wrapper, or `None` for metadata-only construction. - -C++: - -- constructor `EngramStore(int layer_id, const EngramStoreConfig&, std::shared_ptr)` -- `populate(...)` -- `lookup_rows(...)` -- `lookup_rows_contiguous(...)` -- `remove_from_store(...)` -- metadata getters matching the Python surface - -## Data Contract - -Populate expects one NumPy `float32` array per head: - -```text -embedding_buffers[h].shape == [N_h, D] -``` - -Lookup accepts either: - -- nested Python lists with logical shape `[B, L, H]`, or -- a contiguous NumPy `int64` array with shape `[B, L, H]` - -Lookup returns: - -```text -output.shape == [B, L, H, D] -``` - -## Populate Flow - -Populate follows the existing Store write path: - -1. validate that exactly one table is provided for each head -2. validate that every table matches `[N_h, D]` -3. verify that the target head-table keys do not already exist -4. register each embedding table buffer -5. upload all head tables with `batch_put_from(...)` -6. unregister the staging buffers - -`populate(...)` is defined as a create-only operation for one EngramStore layer. To -reuse a `layer_id`, first remove the old tables with `remove_from_store(...)`. - -If upload fails after some head tables have already been written, or if publish -finishes but post-write buffer cleanup fails, the backend best-effort removes -the keys written by the failed populate attempt before returning an error. - -## Lookup Flow - -Each lookup follows the same simplified backend flow: - -1. validate the `row_ids` shape and bounds -2. build per-head byte ranges for the requested rows -3. issue one `get_into_ranges(...)` call to materialize those rows into the output buffer - -For NumPy `row_ids`, the binding uses a contiguous fast path and builds ranges -directly from the input tensor without first converting the entire input into a -nested C++ container. - -## Validation - -The backend enforces these invariants: - -- `table_vocab_sizes` is non-empty and every entry is positive -- `embedding_dim` is positive -- `populate(...)` receives exactly one table per head -- every populated table matches `[N_h, D]` -- `lookup(...)` receives a non-empty `[B, L, H]` input -- every row id satisfies `0 <= row_ids[..., h] < N_h` - -## Validation Status - -This backend is covered by: - -- correctness tests in `scripts/test_engram_store.py` -- benchmark coverage in `scripts/bench_engram_store_27b.py` - -`scripts/test_engram_store.py` can run against an existing Mooncake deployment through -`MOONCAKE_CONFIG_PATH` / `MOONCAKE_MASTER`, or it can start a local -`mooncake_master` instance automatically for a self-contained TCP test run. - -By default, the benchmark exercises `engram_store.populate(...)` directly. Its -fallback populate paths are gated behind `ENGRAM_ALLOW_POPULATE_FALLBACK=1` so -they do not silently mask regressions in the current implementation. diff --git a/docs/source/design/index.md b/docs/source/design/index.md deleted file mode 100644 index 6b69421b4e..0000000000 --- a/docs/source/design/index.md +++ /dev/null @@ -1,37 +0,0 @@ ---- -orphan: true ---- - -# Design Documents - -Architecture and implementation details for Mooncake's storage, transfer, and -distributed execution components. - -## Core Architecture - -| Document | Description | -|----------|-------------| -| [Mooncake Architecture](architecture) | KVCache-centric disaggregated serving architecture. | -| [Mooncake Store](mooncake-store) | Distributed object and KV cache storage design. | -| [Transfer Engine](transfer-engine/index) | High-performance data movement architecture and transports. | -| [P2P Store](p2p-store) | Peer-to-peer checkpoint and object transfer design. | - -## Serving and Cache Systems - -| Document | Description | -|----------|-------------| -| [HiCache](hicache-design) | Hierarchical KV cache design. | -| [Engram](engram) | Distributed serving and cache architecture. | -| [Unified Parallel Tensor I/O](unified-parallel-tensor-io) | Parallel tensor storage and transfer model. | -| [SSD Offload](ssd-offload) | SSD-backed cache hierarchy design. | -| [SSD Free-Ratio-First Allocation](ssd-free-ratio-first-allocation) | Capacity-aware replica placement strategy. | - -## Distributed Execution and Routing - -| Document | Description | -|----------|-------------| -| [Mooncake Backend (PG)](mooncake-backend-pg) | Fault-tolerant PyTorch process-group backend. | -| [Mooncake EP](mooncake-ep) | Expert-parallel communication and recovery. | -| [TENT](tent/overview) | Next-generation transfer engine design. | -| [TENT Benchmark](tent/tebench) | TENT benchmark framework and methodology. | -| [Conductor](conductor/conductor-architecture-design) | Cache-aware request routing architecture. | diff --git a/docs/source/design/mooncake-backend-pg.md b/docs/source/design/mooncake-backend-pg.md index ed61f776a3..630b3ea5ba 100644 --- a/docs/source/design/mooncake-backend-pg.md +++ b/docs/source/design/mooncake-backend-pg.md @@ -1,210 +1,521 @@ -# Mooncake Backend (PG) Design +# Mooncake PG Design -Mooncake Backend is a `torch.distributed` ProcessGroup backend for Mooncake. It -provides collective and point-to-point communication primitives, rank-health -tracking, and elastic recovery hooks for inference systems that need to keep -serving after partial rank failures. +Mooncake PG is a communication library built on Mooncake Transfer Engine. It +provides collective and P2P operations together with dynamic membership, fault +tolerance, and recovery. -This document is intended for developers who maintain Mooncake PG itself or -integrate it into higher-level serving systems. +## Dynamic Membership at a Glance -## Goals +If you are used to a fixed-membership process group, a natural mental model is +that a group is simply _the set of ranks that were there when the group was +created_. If the set changes, you create another group. -Mooncake Backend is designed to: +Mooncake PG uses a slightly different model. Think of a group as a **dinner +table with numbered seats**. -- integrate with PyTorch through the standard ProcessGroup extension mechanism; -- expose `mooncake` for accelerator tensors and `mooncake-cpu` for CPU tensors; -- support common collective APIs used by inference engines; -- track active and inactive ranks so collectives can continue after failures; -- allow replacement ranks to publish metadata, join an existing group, and be - activated by healthy ranks; -- reuse Mooncake Transfer Engine and topology information for data movement. +The table has a fixed number of seats, decided before dinner starts. During the +meal, however, not every seat has to be occupied. -Non-goals: +A diner may leave, but the seat does not disappear. The other diners do not +shuffle around to fill the gap; nobody gets a new seat number just because +someone stepped away. Later, that diner may return to the same seat, or someone +else may occupy it. New diners may also occupy seats that were reserved but +never used. -- It is not a drop-in replacement for every NCCL/Gloo behavior. Validate each - collective, dtype, and topology required by the application. -- Elastic recovery is an explicit protocol. The backend does not silently add a - new process to all collectives without application coordination. +So there are really two separate questions: -## Relationship with `torch.distributed` +- **How many seats does the table have?** +- **Which seats are currently occupied and participating in the dinner?** -Mooncake registers two PyTorch backends when the PG extension module is imported: +The first stays fixed. The second may change over time. -- `mooncake-cpu`, registered for CPU devices; -- `mooncake`, registered for accelerator devices such as CUDA or MUSA depending - on the build. +That is the basic intuition behind Mooncake PG's dynamic membership. -The backend class itself derives from `c10d::ProcessGroup`. Applications use -regular PyTorch APIs such as `dist.init_process_group()`, `dist.all_reduce()`, -`dist.new_group()`, and `dist.batch_isend_irecv()`. +### From the dinner table to Mooncake PG -Point-to-point dispatch in PyTorch expects a `c10d::Backend` object. Mooncake PG -therefore includes a lightweight P2P shim that delegates `send` and `recv` calls -back to the owning `MooncakeBackend` instance. +In Mooncake PG, the numbered seats correspond to **rank slots**. A group +reserves a fixed number of rank slots when it is created, bounded by +`max_group_size`. Which of those ranks currently participate in group +operations is tracked separately by `active_ranks`. -## Main runtime objects +For example, a group can reserve eight rank slots while initially activating +only four -- four diners at an eight-seat table: -### `MooncakeBackendOptions` +``` +max_group_size = 8 +active_ranks = [1, 1, 1, 1, 0, 0, 0, 0] +``` + +If rank 2 is later deactivated -- one diner leaves the table: + +``` +active_ranks = [1, 1, 0, 1, 0, 0, 0, 0] +``` + +its rank slot remains reserved, and the other ranks are not renumbered -- the +empty seat simply remains empty. + +Rank 4 may also subsequently become active without filling that hole: + +``` +active_ranks = [1, 1, 0, 1, 1, 0, 0, 0] +``` + +The group keeps the same rank structure throughout these changes; only the set +of active participants changes. + +This design also extends naturally to fault tolerance and recovery. A failed +rank can be removed from the active membership without reshaping the group, +while a recovered rank can later rejoin through the same membership mechanism. + +## Core Concepts -`MooncakeBackendOptions` carries Mooncake-specific process-group configuration: +### Capacity and size -| Field | Meaning | -| --- | --- | -| `activeRanks_` | Rank-health tensor exposed to collectives and user code. | -| `isExtension_` | Whether this process is a joining/replacement rank. | -| `maxWorldSize_` | Optional reserved capacity for future ranks. | +Mooncake PG has capacity at two scopes: -The `activeRanks_` tensor must be `torch.int32`. It must be on CPU for -`mooncake-cpu` and on the accelerator device for `mooncake`. When -`maxWorldSize_` is set, `activeRanks_` must be sized to `maxWorldSize_` so the -backend can reserve inactive rank slots. +- Process-level `max_world_size` bounds the number of ranks in the whole world. +- Group-level `max_group_size` bounds the number of ranks in a group. -### Transfer group metadata +Despite the name, a group's `size` is not the number of active members. It is +one past the highest active in-group rank and therefore acts as a rank-index +upper bound. For example: -Each backend owns shared metadata for: +```text +active_ranks = [1, 0, 1, 0] +size = 3 +max_group_size = 4 +``` + +User code that allocates rank-indexed buffers must cover at least `size` +entries, including inactive holes below that bound. Per-group masks such as +`active_ranks` and `failed_ranks_hint` span `max_group_size`. + +An extension rank before `joinGroup` (`Isolated` or `Quiescing`) is the +exception: `getSize()` continues to report the group's declared size for +PyTorch rank validation, even though its effective membership is temporarily +`{self}`. + +Holes preserve rank numbers. So user code that needs the number of participants +must count `active_ranks` rather than use `getSize()` or +`dist.get_world_size()`. + +### Rank namespaces + +A process has one `GlobalRank` in the world and may have a different +`InGroupRank` in each group: + +- `GlobalRank` indexes the world-capacity namespace `[0, max_world_size)`. +- `InGroupRank` indexes the group-capacity namespace `[0, max_group_size)`. -- the current rank and backend index; -- current capacity (`size`) and visible active size (`activeSize`); -- host and device active-rank masks; -- peer connection state; -- rank-local and rank-global mapping; -- store handles and extension state used by recovery; -- P2P proxy and connection poller state. +The distinction matters whenever a group contains only part of the world or +orders its ranks differently. Group-level operations, including +`activate_ranks` and `deactivate_ranks`, use in-group ranks unless stated +otherwise. -`size` is the reserved capacity. `activeSize` is the visible group size returned -by `dist.get_world_size()`. With `max_world_size`, `size` may be larger than -`activeSize`; inactive slots are masked out by the active-rank state. +### Rank state -### Transfer Engine ownership +The Coordinator tracks a process-level `RankState`: -By default, Mooncake Backend initializes its own Transfer Engine. Advanced -integrations may call `pg.set_transfer_engine(engine)` before -`init_process_group()` to inject an external Transfer Engine. In that mode, the -caller owns the engine and must keep it alive until all Mooncake process groups -using it are destroyed. +| State | Meaning | +| --------- | ----------------------------------------------------------------------------------------------- | +| `Offline` | No usable Agent session exists for this global rank. | +| `Synced` | The Agent session is synchronized, but the rank is not in the current healthy set. | +| `Healthy` | The Agent session is synchronized and current link evidence places the rank in the healthy set. | -## Initialization lifecycle +A new Agent session moves a rank from `Offline` to `Synced`. Link evidence can +promote it to `Healthy` or demote it back to `Synced`; losing the Agent session +makes it `Offline`. -The initialization flow is: +The Coordinator combines link reports from all ranks to determine which ranks +are `Healthy`. The healthy ranks must be connected to one another in both +directions. A registered rank that does not meet these conditions remains +`Synced`. -1. Python imports `mooncake.pg`, which loads a PyTorch-version-specific native - extension. -2. The extension registers `mooncake` / `mooncake-cpu` with PyTorch. -3. `dist.init_process_group()` invokes the backend factory with PyTorch - distributed options and optional `MooncakeBackendOptions`. -4. The backend initializes active-rank masks and reserved rank slots. -5. Non-extension ranks publish local peer metadata and wait until current peers - are connected. -6. Extension ranks enter local-only mode and wait for the explicit join protocol. +`RankState` is independent of any group. It describes whether a process is +ready for data-plane communication; participation in a particular group is +described separately by group membership. -The important distinction is that reserving capacity does not automatically make -future ranks active. New ranks are masked until `recover_ranks()` activates them. +### Group membership -## Active ranks and dynamic world size +`rank_order` connects the two namespaces: it records the `GlobalRank` assigned +to each `InGroupRank`. This is a stable slot assignment, not a list of current +participants. Whether an assigned rank participates is recorded separately by +its `GroupMemberState` and reflected in `active_ranks`. -Mooncake PG tracks two related concepts: +For example, suppose global ranks 2, 5, and 7 form a group with +`max_group_size = 5`. Inside that group they are in-group ranks 0, 1, and 2, so +`rank_order = [2, 5, 7]`. Calling `deactivate_ranks([1])` targets global rank 5: +its member state becomes `Inactive`, `active_ranks` becomes +`[1, 0, 1, 0, 0]`, and the rank order does not change. -- **Reserved capacity** (`size`): how many rank slots the backend knows about. -- **Visible active size** (`activeSize`): the current group size visible through - PyTorch APIs. +If global rank 9 is later added, it is assigned in-group rank 3 and the rank +order becomes `[2, 5, 7, 9]`. Existing in-group ranks are never renumbered; +new ranks extend the order, while unused group capacity remains unassigned. -When `max_world_size` is larger than the initial `world_size`, Mooncake reserves -extra slots but marks them inactive. This lets healthy ranks poll for joiner -metadata and activate the joiners later without reconstructing the process group. +`GroupMemberState` has the following values: -`extend_group_size_to(size)` can also increase capacity. Newly extended ranks -start inactive; the application must call `get_peer_state()` and -`recover_ranks()` before they participate in collectives. +| State | Meaning | +| -------------------- | ------------------------------------------------------------------------ | +| `None` | The rank has not registered with this group. | +| `Inactive` | The rank is registered but has not declared itself ready for activation. | +| `AwaitingActivation` | The rank calls `join_group` and declares itself ready for activation. | +| `Active` | The rank participates in collective operations. | +| `Left` | The rank unregistered from this group. | -## Elastic recovery protocol +Founding members become `Active` directly during group bootstrap. A joining +member's activation follows `Inactive` → `AwaitingActivation` → `Active`. +Deactivation returns an active member to `Inactive`. -Mooncake PG uses a two-phase protocol for recovery and scale-up: +A `Healthy` rank is ready for data-plane communication, but it participates in +a group only when its group member state is `Active`. + +Membership changes are checked against both kinds of state. To activate ranks, +the group must be ready, every newly activated target must be +`AwaitingActivation`, `Healthy`, and have a published endpoint, and every rank +in the resulting active set must be mutually connected with every other rank in +that set. An early activation request remains pending until these conditions +hold or its admission timeout expires. + +## Architecture + +The data plane executes transfers directly through Mooncake Transfer Engine, +while the control plane tracks processes, connectivity, endpoints, and +committed membership. ```mermaid -sequenceDiagram - participant H as Healthy ranks - participant J as Joining rank - participant S as Store / metadata - - H->>S: init_process_group(world_size=M, max_world_size=N) - J->>S: init_process_group(world_size=N, is_extension=True) - J->>S: publish local peer metadata - H->>S: get_peer_state(join_ranks) - H->>S: recover_ranks(join_ranks) - S-->>J: extension state - J->>J: join_group() returns - H->>J: collectives include recovered ranks +flowchart LR + Framework[Framework] --> Torch[torch.distributed] + + subgraph PG[Mooncake PG] + Comm[Communicator] + Agent[Agent] + Coordinator[Coordinator on rank 0] + Workers[Collective worker and P2P proxy] + Comm <--> Agent + Agent <--> Coordinator + Comm --> Workers + end + + Torch --> Comm + Workers --> TE[Transfer Engine] +``` + +### Process context and communicators + +Each process has one Mooncake PG context. It owns or references the Transfer +Engine and hosts an Agent and the process-wide worker managers; global rank 0 +also hosts the Coordinator. + +### Coordinator and Agents + +The Coordinator owns process `RankState` and each group's member states. +It serializes membership changes and publishes them as `GroupView`s. Agents +mirror those views and apply them to local communicators. Collective and P2P +workers report link evidence; the Coordinator derives the mutually connected +healthy set and decides the resulting state changes. + +The Coordinator currently runs on global rank 0 and is not highly available. + +### Collectives + +Collectives use a direct-write design over Transfer Engine. Each communicator +registers their send, receive, and synchronization buffers. +The worker records transfer failures in `failed_ranks_hint` and reports to the +Agent; membership is not changed on the collective worker side. + +### P2P + +P2P send and receive use a receiver-driven, credit-based protocol. A receive +operation reserves chunks from a receive pool and writes `CreditSlot`s to the +sender. Each credit identifies the destination chunk and length. The sender +then stages the corresponding data in a send-pool chunk, performs a TE write to +that destination, and writes an `AckSlot` back. The receiver copies the +acknowledged chunk into the user buffer and returns the chunk to the pool. + +Each communicator has per-peer operation queues and separate credit and +acknowledgement rings. The control slots carry a group epoch and sequence +number, and a matching header/footer token prevents a partially written slot +from being consumed. Epoch checks discard control traffic left over from an +older group view. + +The send and receive polling threads and their fixed-size chunk pools are +shared by all communicators on the same device. Chunk allocation never blocks +a polling thread. When a pool has no free chunk, the operation remains pending +and the poller retries it later. +A transfer error or timeout resets the affected peer lane and reports failure +evidence through the same control-plane path as a collective failure. + +## Planned scaling + +This section describes planned scaling through Mooncake PG dynamic membership. + +### Scale-up + +The group must have enough unused `max_group_size` capacity. A joining process +declares an extended rank order (also `is_extension=True` in the PyTorch +integration). The existing group adopts the appended slots as inactive; this +step alone never activates them. + +The later join and activation steps are: + +1. The joining rank starts in `Isolated` with an effective `{self}` + membership. This gives the upper layer framework a local-only window for + initialization and warmup before the rank can affect existing members. + Collectives in this state must not be interpreted as results from the + eventual group. +2. When local preparation is complete, the joining rank calls `join_group`. + The call enters `Quiescing`, drains previously issued collective and P2P + work, marks the member `AwaitingActivation`, and waits. +3. Any online rank calls `activate_ranks`. The Coordinator admits the request + only after the activation conditions described above hold for the complete + future active set. It distributes the new membership and waits for the + required ranks to apply it; both `join_group` and `activate_ranks` then + return. + +An activation request may arrive before `join_group`; it waits for the joining +rank to become ready rather than bypassing the checks. + +### Scale-down + +After the upper layer framework stops issuing operations that use the old +membership, any online rank can call `deactivate_ranks` for one or more in-group +ranks. The Coordinator changes those members from `Active` to `Inactive`, +distributes the new membership, and waits for acknowledgements from online +ranks in the old or new active set before returning. + +Deactivation does not renumber slots or mark the target process +unhealthy. Its Agent session and data-plane links remain available; to +participate again, that process calls `join_group` and follows the normal +activation path. + +## Fault tolerance + +### Failure handling + +A failed operation reports the caller's data-plane observations to the +Coordinator; the worker itself does not make a membership decision. The two +caller-visible results are: + +- `local_success`: whether all transfers required by this operation completed + at the caller. A successful local result is valid for that caller, but says + nothing about whether the operation completed at every other rank. +- `failed_ranks_hint`: a per-operation bitmap of length `max_group_size`, + indexed by in-group rank. It records the ranks for which the caller observed + a transfer failure; a set bit is evidence, not a global conclusion that the + peer is faulty. + +Failure hints can differ across ranks. Workers submit such observations to the +Coordinator instead of changing membership locally. + +#### Reconciliation and `sync_after_failure` + +Negative evidence opens a reconciliation window in the Coordinator, allowing +reports from different ranks to arrive before a single decision is made. The +window is 30 seconds by default and must be configured to exceed the default +collective timeout. When the window closes, the Coordinator derives the +mutually connected healthy set, updates `RankState`, and distributes the +result. For groups with auto-deactivation enabled, it also changes unhealthy +active members to `Inactive`. + +`sync_after_failure` is both a reporting path and a synchronization point. Its +request piggybacks the Agent's current, unacknowledged link observations. If the +caller has just observed `local_success=false`, those observations can open or +join the Coordinator's reconciliation window. The call then waits for any +pending reconciliation and applies the group view returned by +the Coordinator. + +With `auto_deactivate_on_failure=true`, a successful return means that the +caller has applied the membership produced by reconciliation. Its local +`active_ranks` therefore reflects the Coordinator's deactivation decision, and +locally cached readiness queries such as `get_peer_state` are based on the +reconciled state. + +With auto-deactivation disabled, reconciliation does not remove members, so +the membership in the returned view may be unchanged. + +#### Failure-handling modes + +The two options control different parts of the failure path: + +- `auto_deactivate_on_failure` selects who owns failure-driven membership + changes: Mooncake PG or the upper layer framework. +- `auto_sync_on_failure` selects whether a failed collective or P2P operation + calls `sync_after_failure` automatically before it completes. It does not + control whether the Coordinator reconciles observations or automatically + changes membership. + +With auto-deactivation disabled, an unhealthy rank may remain active. An +`Offline` rank rejects new operations locally, whereas a `Synced` but unhealthy +rank may still issue them. Successful transfers provide positive link evidence +and can return a `Synced` rank to `Healthy`. + +There are three valid configurations. + +##### PG-managed, synchronized (default) + +```text +auto_deactivate_on_failure = true +auto_sync_on_failure = true +``` + +After a local transfer failure, the operation reports its observations and +automatically enters `sync_after_failure`. The Coordinator reconciles rank +state, deactivates unhealthy members, and returns the resulting view; the +caller applies that view before the failed operation completes. The framework +does not need a separate synchronization or deactivation step. + +This is the safest and simplest mode, but it deliberately puts control-plane +latency on the failure-completion path. A negative observation opens a +reconciliation window, so the failed operation may remain pending for tens of +seconds while the Coordinator reconciles reports from different ranks. + +##### PG-managed, deferred synchronization + +```text +auto_deactivate_on_failure = true +auto_sync_on_failure = false +``` + +Mooncake PG still reconciles observations and owns the failure-driven +deactivation decision. The difference is that the operation returns after its +data-plane work finishes and exposes `local_success` and `failed_ranks_hint` +without waiting for the reconciliation window. The framework may perform +other work first and call `sync_after_failure` later, before relying on the new +membership or resuming communication on the group. + +This mode is useful because `local_success` and `failed_ranks_hint` are +data-plane results, whereas reconciliation is a much slower control-plane +operation. It separates failure notification from membership synchronization +without transferring the membership decision back to the framework. + +##### Framework-managed + +```text +auto_deactivate_on_failure = false +auto_sync_on_failure = false ``` -Healthy rank responsibilities: +The failed operation returns its local evidence without changing membership. +The framework observes failures through `local_success` and +`failed_ranks_hint`, chooses the ranks to remove, and then calls +`deactivate_ranks`. -1. Reserve capacity with `max_world_size` or `extend_group_size_to()`. -2. Poll `get_peer_state(backend, ranks)` from all healthy ranks in a consistent - order. -3. Call `recover_ranks(backend, ranks)` once candidate ranks are connected. -4. Refresh higher-level components, such as Mooncake EP buffers, if they cache - transport metadata. +##### Invalid combination -Joining rank responsibilities: +```text +auto_deactivate_on_failure = false +auto_sync_on_failure = true +``` + +This combination is rejected at construction. Automatic synchronization is +meaningful only when Mooncake PG also owns failure-driven deactivation; +otherwise synchronization cannot produce an automatically updated membership +for the failed operation. + +This restriction applies only to automatic synchronization. +`sync_after_failure` may still be called manually in any mode, including when +`auto_deactivate_on_failure=false`. The call also acts as an explicit pull of +the Coordinator's latest group view, rather than relying only on pushed view +updates. The framework can therefore obtain a current view while retaining its +own deactivation policy. + +### Recovery + +A replacement process and a same-process in-place rejoin are separate ways to +bring an inactive slot back. Both reuse the scale-up flow: restored +connectivity can make a rank `Healthy`, but rejoining membership still requires +`join_group` and activation. + +#### Replacement process + +A replacement registers a new Agent session for the same global rank. The +Coordinator increments the rank epoch and invalidates the old process's link +evidence and endpoints. The replacement recreates its local communicators in +extension mode, starts at `Synced`, may perform local warmup while isolated, +and then follows the normal join and activation flow. -1. Initialize the process group with `is_extension=True`. -2. Publish local peer metadata through the backend initialization path. -3. Call `join_group(backend)` and block until healthy ranks publish extension - state. -4. Re-enter normal collectives after `join_group()` returns. +#### In-place rejoin -## Subgroup semantics +In-place rejoin applies when the process and its control-plane session remain +alive but the rank has become inactive, commonly after a transient data-plane +failure. Once connectivity recovers, the Coordinator can mark the rank +`Healthy` again. The same process calls `join_group`, drains old +work, republishes the endpoint under a fresh epoch, and +waits for activation. No process restart is required. -Mooncake PG follows PyTorch process-group ordering requirements. All processes -that participate in a parent group should call `dist.new_group()` in a consistent -order. This is especially important for elastic subgroups because healthy ranks -and joining ranks must agree on store prefixes and backend indices. +## Integration -For split-rank elastic patterns, create subgroups using the current membership -on healthy ranks and the eventual membership on joining ranks, while preserving -the same creation order. The PG elastic tests contain executable examples of this -pattern. +### Choosing an integration model -## Collective behavior +An integration combines two independent choices: how planned scaling changes +the communication group, and who owns failure-driven deactivation. -Collectives use the backend active-rank state to skip inactive ranks. The exact -implementation varies by operation and device type, but the high-level contract -is: +For planned scaling, framework-level group replacement creates another group +and switches to it, so it works with a fixed-membership CCL. Mooncake PG dynamic +membership instead keeps the same group and changes its active ranks. -- active ranks participate in the collective; -- inactive ranks are not waited on; -- if communication detects a rank failure, active-rank state can be updated; -- user code can read the current mask with `pg.get_active_ranks(backend)`. +For failure handling, `auto_deactivate_on_failure` determines ownership. When +it is `true`, Mooncake PG owns deactivation; when it is `false`, the framework +does. `auto_sync_on_failure` is separate from ownership: it only controls +whether a failed operation invokes `sync_after_failure` automatically and waits +for any pending reconciliation before completing. -The backend currently implements common collective APIs including all-reduce, -broadcast, all-gather, reduce-scatter, all-to-all, barrier, reduce, gather, -scatter, and single-tensor P2P send/recv. +The table below summarizes the four supported combinations: -## Failure and recovery boundaries +| Scaling × Failure mode | **PG-managed membership**
PG reconciles and deactivates | **Framework-managed membership**
Framework synchronizes and decides | +| ---------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Mooncake PG dynamic membership**
Keep the current group and change its active ranks | Mooncake PG commits requested scaling changes and handles failure-driven deactivation in the existing group. This fits a lean integration with minimal membership orchestration and no group rebuilds. | The group stays in place, while the framework synchronizes after failures and submits its own deactivation decisions. This fits applications that need direct control over group membership; it uses dynamic membership without group rebuilds, but requires an explicit failure-control path. | +| **Framework-level group replacement**
Create a standby/new group and switch to it | The framework switches groups for planned scaling, while Mooncake PG handles failure-driven deactivation in the current group. This fits an existing standby-group design with minimal failure orchestration; planned scaling still incurs the cost of group creation and switching. | The framework's control plane manages both replacement groups and failure policy. This fits frameworks that already manage group lifecycle and failure policy centrally; it offers the most flexibility but requires the most orchestration. | -Mooncake PG exposes low-level recovery primitives; higher-level systems are -responsible for policy decisions such as: +### Interfaces -- which ranks are safe to replace; -- when to stop routing traffic to a failed rank; -- how to recreate model state on a replacement process; -- when to refresh EP, scheduler, or application-level metadata; -- how to coordinate subgroup recovery. +Mooncake PG exposes a PyTorch integration and an experimental C API. -Avoid assuming that `recover_ranks()` alone reconstructs all higher-level state. -It activates the process-group communication path; the application still owns -model weights, KV-cache state, routing policy, and request scheduling. +Importing `mooncake.pg` registers two `torch.distributed` backends: -## Testing checklist for PG changes +- `mooncake-cpu` for CPU devices; +- `mooncake` for the accelerator supported by the build. -When modifying PG internals, run at least: +`MooncakeBackend` derives from `c10d::ProcessGroup`, so applications use the +usual PyTorch entry points, including `dist.init_process_group()`, +`dist.new_group()`, `dist.all_reduce()`, and `dist.batch_isend_irecv()`. +Mooncake-specific capacity, extension, and failure-handling options are passed +through `MooncakeBackendOptions`. + +PyTorch dispatches P2P operations and some collective entry points through a +`c10d::Backend` object. Each `MooncakeBackend` therefore registers a lightweight +`MooncakeBackendShim` that forwards supported operations back to its owning +`MooncakeBackend`. +See the [Python API](../api-reference/python/ep-backend.md) for +initialization examples and API details. + +Non-PyTorch integrations can use the experimental C API declared in +[`mooncake_pg.h`](https://github.com/kvcache-ai/Mooncake/blob/main/mooncake-pg/include/mooncake_pg.h). + +## Contributing + +The PG tests are the executable contracts for current behavior: + +| Test file | Contract covered | +| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | +| `test_pg_init_functional.py` | Initialization, single rank, subgroup creation, destruction, and reinitialization | +| `test_pg_collectives.py` | Collective coverage | +| `test_pg_p2p.py` | Direct and batched P2P, ordering, multiple senders, and failure detection | +| `test_pg_elastic.py` | Automatic and manual failure handling, scale-up, process replacement, graceful leave, subgroup extension, holes, and in-place rejoin | +| `test_pg_inference_topologies.py` | TP, PP, DP, EP, and prefill/decode group layouts | +| `test_pg_inference_collectives.py` | Traffic across inference-style groups | + +Run tests from the repository root: ```bash -# CPU functional tests +# Run all PG tests +python -m unittest discover -s mooncake-pg/tests -v + +# Run CPU-only PG tests python -m unittest discover -s mooncake-pg/tests -k CPU -v -# CUDA functional tests, when GPUs are available +# Run CUDA PG tests python -m unittest discover -s mooncake-pg/tests -k CUDA -v # Collective benchmark smoke test @@ -213,9 +524,8 @@ python mooncake-pg/benchmark/pgbench.py \ --collective all_reduce --backend mooncake --device cuda -g 2 -b 8 -e 1M -f 2 ``` -Also run elastic tests for changes that touch active ranks, metadata polling, -subgroups, `extend_group_size_to()`, `get_peer_state()`, `recover_ranks()`, or -`join_group()`. +Set `MOONCAKE_PGTEST_DEVICE_FILTERS` to a comma-separated NIC/HCA list when the +test environment needs explicit device selection. ## Related documentation diff --git a/docs/source/design/mooncake-ep.md b/docs/source/design/mooncake-ep.md index cbd977e0af..f8ec989073 100644 --- a/docs/source/design/mooncake-ep.md +++ b/docs/source/design/mooncake-ep.md @@ -16,7 +16,7 @@ Mooncake EP is designed to: - keep the Python programming model close to DeepEP low-latency mode; - use Mooncake device transports for fast intra-node and inter-node movement; - detect failed source ranks through timeout-aware kernels; -- interoperate with Mooncake Backend (PG) for bootstrap metadata exchange and +- interoperate with Mooncake PG for bootstrap metadata exchange and rank-health state. ## High-level data flow @@ -232,16 +232,16 @@ Useful entry points: ```bash # EP grid correctness test -python mooncake-ep/tests/test_ep_grid.py +python python/tests/ep/test_ep_grid.py -# Wheel-level EP smoke test -python mooncake-wheel/tests/test_mooncake_ep.py +# EP wrapper smoke test +python python/tests/ep/test_mooncake_ep.py ``` Adapt launch commands to the target environment and number of GPUs. ## Related documentation -- [Mooncake Backend (PG) design](mooncake-backend-pg.md) +- [Mooncake PG design](mooncake-backend-pg.md) - [Python API reference](../api-reference/python/ep-backend.md) - [PG/EP troubleshooting](../troubleshooting/pg-ep-troubleshooting.md) diff --git a/docs/source/design/mooncake-reshard/index.md b/docs/source/design/mooncake-reshard/index.md new file mode 100644 index 0000000000..bf529efdb1 --- /dev/null +++ b/docs/source/design/mooncake-reshard/index.md @@ -0,0 +1,14 @@ +# Mooncake Reshard + +`mooncake-reshard` plans address-free conversions between complete resource +placements, then binds the selected logical regions to immutable runtime +snapshots. + +:::{toctree} +:maxdepth: 1 + +reshard-manifest +model-weight-reshard-planner +model-weight-store-upload-planning +weight-management +::: diff --git a/docs/source/design/mooncake-reshard/model-weight-reshard-planner.md b/docs/source/design/mooncake-reshard/model-weight-reshard-planner.md new file mode 100644 index 0000000000..eb8c689b7a --- /dev/null +++ b/docs/source/design/mooncake-reshard/model-weight-reshard-planner.md @@ -0,0 +1,166 @@ +# Model Weight Reshard Planner And Runtime Binding + +`mooncake-reshard` plans an address-free conversion between complete model +weight placements, then binds the selected logical regions to immutable +runtime snapshots. It does not inspect framework runtime objects or submit a +transfer. + +## Inputs and Output + +The source is either a complete `WeightPlacementManifest` or a committed +`StoredWeightManifest` snapshot. The target is a complete +`WeightPlacementManifest`. Both sides must identify the same resource, +revision, and weight generation. + +The public APIs are: + +- `plan_placement_transfer(source_placement, target_placement)`; +- `plan_placement_transfer_to_local_target(source_placement, + target_placement, target_participant_id)`; +- `plan_stored_transfer_to_target_placement(source_manifest, + target_placement)`. + +Each API returns a `LogicalTransferPlan`. It contains only canonical tensor +descriptors, selected placement participants, and logical regions. It contains +no GPU address, endpoint, allocation range, lease, or backend handle. + +`bind_logical_transfer_plan(logical_plan, target_bindings, ...)` is the second +public step. It accepts typed `WeightRuntimeBindingManifest` values and returns +a `TransferPlan` with selected runtime fragments, binding attestations, and +executor projections. A Store source is represented by a persistent +`StoredFragmentSnapshot`; a live runtime executor carries an ephemeral +`RuntimeFragmentSnapshot` for each selected fragment. +For a Store source, `TransferPlan` retains the authoritative canonical +`StoredWeightManifest` and its `StoredManifestIdentity.content_sha256`. Each selected +operation source is revalidated against that committed manifest during plan +construction and restore. The selected-fragment cache is derived state, so a +coordinated operation/cache mutation cannot redirect a plan to another Store +object. + +## N-D Regions + +Each `TransferRegion` represents one source/target N-D box overlap. It records +the overlap offset and shape, source and target base byte offsets, contiguous +`inner_bytes`, outer loop counts, and source/target byte strides. + +The planner preserves a compact strided representation. It does not expand a +cross-dimension overlap into one operation per row or element. `PlanningLimits` +bounds the total number of regions and any later segment expansion fails closed +when it exceeds the configured limit. + +## Parallel Semantics + +- **TP** changes logical boxes. The same overlap algorithm handles split, + merge, and source/target sharding on different dimensions. +- **PP** is explicit framework-provided tensor or layer ownership. Regions are + grouped by source and target PP owner and optional pipeline stage; the + planner does not infer ownership from a tensor name or layer-count formula. +- **EP** is represented by a logical expert coordinate. Independent expert + allocations remain independent logical fragments and are never packed or + all-gathered by the planner. +- **DP** does not change tensor geometry. A `ReplicatedAxis(kind="dp")` uses a + complete source replica. An `OwnershipAxis(kind="dp")` routes each tensor + through its declared owner and does not require every tensor on every DP + rank. + +All four axes are resolved by one logical-box plan, rather than by model-wide +per-axis conversion passes. + +### Supported axes and Store API selection + +The current `ParallelTopology` and `ParallelRank` types contain TP, PP, EP, and +DP only. CP (context parallelism) is not a supported axis, and `SplitAxis` +accepts only TP and EP. N-D logical planning therefore does not imply support +for arbitrary named parallel strategies or CP-aware KV-cache layouts. + +Store callers use the manifest-backed weight snapshot lifecycle for these +multi-axis weight placements. Legacy `*_with_tp` methods remain available for +ordinary TP tensor objects, but there are no corresponding CP/DP/EP/PP method +factories or `*_with_config` tensor methods. The former public +`*_with_parallelism` family was removed by +PR [#3772](https://github.com/kvcache-ai/Mooncake/pull/3772). +See {ref}`Choosing a Parallel Tensor IO API ` +for entry points and the distinction between parallel topology and Store +replication configuration. + +## Validation + +Placement construction validates the complete participant set, tensor +descriptors, topology, and logical coverage before planning. Planning then +fails closed when source and target tensor identity, dtype, shape, layout +fingerprint, ownership, or coverage differ. + +A `StoredWeightManifest` source is retained as an immutable logical snapshot. Its +canonical identity and selected stored fragments are revalidated whenever a +logical plan is constructed or reconstructed. This proves that the plan still +refers to the same Store snapshot; it does not make Store persistence or +runtime loading part of this layer. + +Coverage validation uses an ordered interval scan for 1-D inputs and a +coordinate-compressed sweep for 2-D inputs, both with `O(N log N)` behavior. +For 3-D and higher logical boxes, exact intersection remains supported under an +explicit pairwise-comparison budget; inputs that exceed it fail closed rather +than making validation work unbounded. + +## Runtime Binding + +Binding rechecks the exact source and target placement identities, placement +digests, participant selection, runtime fragment geometry, device/allocation +bounds, lease generation, and declared alias scope. It rejects a reconstructed +logical plan with incomplete target coverage, forged Store fragments, or +conflicting physical target ranges. + +The returned `TransferPlan` is a bound, attested snapshot. It has no `execute` +or `submit` operation. `RuntimeBindingAttestation` stores owner-free +`RuntimeBindingEvidence`: view geometry, worker/endpoint/device, backing +allocation range, lease, and generation. Framework allocation owners remain at +the runtime submission boundary, where a later Transfer Engine executor +acquires its allocation guard and revalidates bindings atomically with +submission. + +Transfer Engine lowering, DMA submission, Store persistence/lifecycle, and +framework activation remain outside this phase. Framework adapters own model +semantics and conversion into canonical manifests; Mooncake core does not infer +those semantics from framework objects or parameter names. + +## Reproducible Contract Benchmark + +The following opt-in benchmark measures only Python-side planning and binding +contracts. It creates synthetic manifests and runtime bindings but does not +allocate GPU memory, contact Store, or submit work to Transfer Engine: + +```bash +PYTHONPATH=mooncake-reshard/python \ + python mooncake-reshard/benchmarks/runtime_binding.py +``` + +The fixed topology is source `TP4/PP1/EP1/DP2` to target +`TP8/PP2/EP2/DP1`: 8 source fragments, 8 target fragments, and 8 logical +operations. Only the selected source DP replica appears in the bound executor +projection. It reports medians for logical planning, runtime binding, +`TransferPlan` revalidation, pickle serialization and restore, binding peak +memory, and a 128-region/16,384-segment physical-validation workload. It also +compares selected and complete source projections, and records accepted and +rejected logical and physical segment budgets. Results are metadata costs only +and must not be interpreted as TE, Store, G2G, or serving end-to-end +throughput. + +## Store Adapter Boundary + +This phase does not accept Store `with_parallelism` metadata or Store keys as a +planner input. A future Store adapter must translate one committed Store +snapshot into a complete canonical `StoredWeightManifest` or +`WeightPlacementManifest`, including tensor identity and descriptor, every +logical fragment's offset, shape, object range, and all TP, PP, EP, and DP +semantics. If Store metadata cannot represent any required fact, the adapter +must reject that snapshot; it must not infer a tensor layout from a key, +parameter name, rank, or `mode="full"` reconstruction. + +In particular, the current Store `validate_parallelism_spec()` representation +accepts a TP `split_dim`, but an EP axis requires an `expert_id` and rejects a +`split_dim`. It cannot losslessly encode a planner +`SplitAxis(kind="ep", dim=0)`. A Store-backed source with that layout is +therefore unsupported until the Store encoding is extended or the adapter has +an additional authoritative canonical manifest. This is an explicit +fail-closed limitation, not a request for the adapter to materialize or +all-gather a full tensor. diff --git a/docs/source/design/mooncake-reshard/model-weight-store-upload-planning.md b/docs/source/design/mooncake-reshard/model-weight-store-upload-planning.md new file mode 100644 index 0000000000..9d5fdcb777 --- /dev/null +++ b/docs/source/design/mooncake-reshard/model-weight-store-upload-planning.md @@ -0,0 +1,85 @@ +# Model Weight Store Upload Planning + +`plan_weight_upload` turns one complete runtime weight placement into an +immutable `WeightUploadPlan`. The plan supplies the Store writer with a +canonical `StoredWeightManifest`, payload object locations, and source +evidence for each upload operation. + +## Inputs + +- `WeightPlacementManifest` describes global tensor geometry and TP, PP, EP, + and DP ownership. +- `WeightRuntimeBindingManifest` values provide the live source bindings for + populated placement participants. + +The planner validates every supplied binding against the placement. Model +semantics remain in framework adapters, which export canonical tensor and +parallel-axis metadata before calling this API. + +## Replica Selection + +The planner stores one complete source replica: + +1. It requires replicated DP semantics and a complete logical coverage for a + DP replica. +2. It requires every selected source binding in that replica to have the same + generation. +3. It selects the lowest eligible DP rank deterministically. +4. It retains logical TP, PP, and EP ownership in every selected fragment. + +The resulting manifest has one stored fragment per selected logical source +fragment. DP replicas do not duplicate payload objects. + +## Plan Contents + +`WeightUploadPlan` contains: + +- a `StoredWeightManifest` with immutable Store group, manifest, and payload keys; +- an `UploadOperation` for each stored fragment; +- the source placement identity and digest; +- an upload transaction group and its control key. + +An operation stores an owner-free `RuntimeFragmentSnapshot`, source +participant and instance IDs, lease ID, and generation. The payload writer +rebinds this evidence to a fresh runtime manifest and acquires the framework +allocation guard before Store I/O. + +## Execution Boundary + +The Store writer performs payload writes, registration, transaction commit/abort, +and Store-to-runtime reads. These layers consume `WeightUploadPlan`; they do +not infer model layouts or parallelism from Store keys. + +## Snapshot API + +`MooncakeDistributedStore.begin_weight_snapshot(descriptor, adapter)` returns +one `WeightStoreWriter`. The framework adapter exports the complete source +placement and live bindings once. The caller then writes each framework tensor +through `writer.write_tensor(tensor_id, tensor)`. + +The adapter maps that tensor to canonical placement fragment IDs. The writer +uploads only the runtime fragments attested by those bindings and +`commit()` publishes one `StoredWeightManifest` after every required fragment +is durable. + +The writer owns the storage policy for its immutable payload and metadata +objects. Per-tensor replication, partition, and upsert parameters are outside +this API, so every committed snapshot has one explicit manifest contract. + +Restore begins from `StoredWeightManifest`. The loader reconstructs sources +from each stored fragment's tensor ID, global offset, local shape, object key, +object offset, and byte length, then uses `get_into_ranges` with the target +placement and runtime binding. This path uses no legacy `TensorMetadata` head +or legacy parallel-tensor reconstruction metadata. + +The persisted manifest contains logical tensor descriptors, fragment geometry, +payload keys, and snapshot identity. Runtime addresses, allocation owners, +leases, and worker instances remain in the live binding and allocation-guard +path used during Store I/O. + +## Native Store Requirement + +The native Store writer uses group semantics to keep payload, manifest, and +transaction-control objects in their declared groups. It requires a Mooncake wheel +whose `ReplicateConfig` exposes `group_ids` (the API introduced by PR #3000). +The adapter rejects an older binding before Store I/O starts. diff --git a/docs/source/design/mooncake-reshard/reshard-manifest.md b/docs/source/design/mooncake-reshard/reshard-manifest.md new file mode 100644 index 0000000000..1b310234c9 --- /dev/null +++ b/docs/source/design/mooncake-reshard/reshard-manifest.md @@ -0,0 +1,181 @@ +# Resource Reshard Manifest Contract + +This document defines the framework-neutral resource contract used by +Mooncake resharding and its model-weight specialization. The contract separates +complete logical placement from live physical addresses so planning can finish +before a runtime binding is available. + +The implementation is owned by the top-level `mooncake-reshard` module. Common +contracts are exposed through `mooncake.reshard.contracts`; the public weight API +is `mooncake.reshard.weight`. + +Framework-owned adapters outside Mooncake inspect framework runtime objects, +normalize framework-specific values, and construct the typed canonical +manifests. Mooncake core accepts only those typed values; it does not import or +inspect framework objects or accept alternate field names or duck-typed +records. + +## Contract Split + +| Contract | Contents | Lifetime | +|----------|----------|----------| +| `ResourceManifest` | structural protocol for resource identity and kind | shared public contract | +| `PlacementManifest` | structural protocol for address-free placement identity and digest | serializable and reusable | +| `RuntimeBindingManifest` | structural protocol for placement attestation, runtime instance, generation, and lease | one live runtime snapshot | +| `ParallelTopology` | TP/PP/EP/DP sizes and the explicit participant-to-rank mapping | one logical placement | +| `SplitAxis` | a parallel kind that shards one explicit logical dimension | one tensor descriptor | +| `ReplicatedAxis` | a parallel kind whose ranks each hold a complete replica | one tensor descriptor | +| `OwnershipAxis` | a parallel kind that assigns tensor or object ownership without splitting a dimension | one tensor descriptor | +| `WeightPlacementPart` | one participant's address-free tensors and logical fragments | framework-local contribution | +| `WeightPlacementManifest` | one complete global logical placement of a weight generation | serializable and reusable | +| `WeightRuntimeBindingManifest` | one participant's physical fragments for that global placement | one live runtime snapshot | + +The three common manifest contracts are public structural `Protocol` types. +Consumers depend on their fields and behavior, not inheritance from a Mooncake +base class. + +Weight revision, tensor geometry, model semantics, parallel ownership, and +weight generation belong to the weight specialization. GPU addresses, +endpoints, owners, generations, and leases never appear in +`WeightPlacementManifest`. + +`model_weight` is the serialized resource discriminator. Typed manifests carry +their `ResourceKind` explicitly; Mooncake does not infer a resource or model +type from parameter names. + +## Global Placement Assembly + +`ParallelTopology` declares the runtime's TP, PP, EP, and DP sizes and the exact +participants selected for this placement. Its `world_size` is the number of +declared participants, not `tp_size * pp_size * ep_size * dp_size`. Frameworks +may map axes such as TP and EP onto the same workers, and a placement may select +one DP replica while retaining the runtime's declared `dp_size`. + +For each participant, the framework adapter constructs one typed +`WeightPlacementPart`. A part carries the common resource ID, revision, weight +generation, placement-set ID, topology ID, participant ID, parallel rank, +tensor descriptors, and logical fragments. It contains no physical address. A +part declares exactly the tensor descriptors referenced by its fragments; an +empty part declares neither. + +A collection barrier assembles all declared parts into one +`WeightPlacementManifest`. Assembly fails when a participant is missing or +duplicated, when a part belongs to a different resource, generation, placement +set, or topology, or when its rank disagrees with the topology. Only after the +complete placement validates are its canonical `placement_id` and digest +available. + +For each live participant, the framework adapter then constructs a typed +`WeightRuntimeBindingManifest` that names its `participant_id` and attests the +same global `placement_id` and digest. Binding-set validation requires every +participant that owns fragments exactly once, and exact logical-fragment +membership for each such participant. Empty participants require no runtime +binding. + +## Logical Semantics + +Each tensor has a stable `tensor_id`, full `global_shape`, dtype, item size, +layout fingerprint, and optional layer or expert identity. Each fragment is an +N-D logical box described by `global_offset` and `local_shape`. + +`TensorDescriptor.shard_dims` is the only canonical shard representation. +`SplitAxis(kind, dim)` explicitly shards one logical dimension; +`ReplicatedAxis(kind)` requires each selected rank to provide a complete copy; +and `OwnershipAxis(kind)` assigns tensor or object ownership without splitting +a logical dimension. Axis size comes from `ParallelTopology`, and a fragment's +axis rank comes from `ParallelRank`. The dimensions named by all `SplitAxis` +values must match `shard_dims` exactly. + +The global manifest validates complete logical coverage. Every selected DP +replica must provide a gap-free cover of every tensor. `OwnershipAxis` and +`ReplicatedAxis` values form independent covers. Fragments across a `SplitAxis` +instead form one non-overlapping cover, and every split-axis rank declared by +the topology must participate. The explicit participant mapping defines the +selected workers and may be non-Cartesian overall. Within one tensor's owner and +replica cover, however, coordinates for multiple declared `SplitAxis` values +must form their Cartesian product so that each rank-to-dimension assignment is +provable. A physical coordinate coupled to another split rank but not +independently sharding the tensor is left out of that tensor's `parallel_axes`. +DP may therefore select one complete replica for transfer while the topology +retains the original `dp_size`. + +PP is layer or tensor ownership. A logical tensor may have complete replicas on +multiple PP owners, but every owner must independently provide a gap-free +cover; fragments from different PP owners cannot be combined to satisfy +coverage. For grouped expert tensors, EP uses `SplitAxis` on the leading logical +expert dimension rather than only an EP rank label. Independently allocated +experts use `OwnershipAxis` and remain independent tensors with an explicit +expert identity. + +Mooncake does not infer layer, expert, layout, or partition semantics from +model parameter names. Framework adapters must provide those facts. + +`placement_fragment_id` defaults to a canonical hash of tensor identity, +logical box, parallel rank, byte size, and alias group. Frameworks may supply +an explicit stable ID when they intentionally need a different identity. An +alias group is valid only when it contains the fragment's own `tensor_id`; two +fragments may share one runtime range only when both tensor IDs belong to the +same compatible alias group. + +Because an alias group can cross placement participants, a local +`WeightPlacementPart` validates only its own fragments. Complete +`WeightPlacementManifest` assembly is the authorization boundary: every alias +member must be in the global tensor catalog and every fragment of every member +must declare the same alias group before any runtime binding is accepted. + +## Identity And Fencing + +Canonical placement identity covers the resource, revision, weight generation, +placement-set ID, topology, global tensor descriptors, participant ownership, +and logical fragments. Runtime addresses, workers, endpoints, owners, and +leases do not affect placement identity. + +Every runtime binding carries the global placement ID and digest. Validation +rejects a binding when the logical placement changes, a participant is unknown, +a fragment is missing or unexpected, or its byte range differs. Generation and +lease fences remain live-runtime state and must be checked before transfer. + +Every runtime `address` points to the first transferable byte of a contiguous +tensor view. A runtime fragment preserves `itemsize`, `local_shape`, byte +strides normalized on singleton dimensions, storage base address, normalized +storage byte offset, and storage allocation size. Binding validation compares +item size, shape, and contiguous +row-major byte strides with the logical placement; singleton dimensions do not +constrain their corresponding stride. It also verifies +`address = storage_address + storage_offset_bytes`, and requires the complete +view range to remain inside the allocation. An optional +framework `is_contiguous` flag may reject a view early but is never accepted as +the sole proof of contiguity. Address zero is reserved as a null sentinel, and +all address ranges must have representable unsigned 64-bit exclusive ends. +Owner objects may keep framework allocations alive but are never serialized. + +## Integration Flow + +1. A framework-owned adapter reads framework state and constructs one typed + `ParallelTopology` plus the shared resource, revision, weight generation, + and placement-set ID. +2. The adapter constructs one typed `WeightPlacementPart` for every selected + participant. +3. A barrier collects the exact part set and constructs one complete + `WeightPlacementManifest`. +4. The adapter constructs a typed `WeightRuntimeBindingManifest` for each live + participant against the resulting placement ID and digest. +5. Planning consumes one source and one target `WeightPlacementManifest`. +6. Binding and execution use only the participant bindings referenced by the + logical plan, while preserving their generation and lease fences. + +`weight_placement_to_json` and `weight_placement_from_json` are the explicit +public JSON APIs for the canonical wire schema. Deserialization accepts exactly +the canonical fields and values; it does not accept aliases, attribute-based +records, or other framework-shaped inputs. Integer-valued contract fields +require Python `int` values and reject `bool`; framework adapters must normalize +framework-specific scalar types before constructing a manifest. + +## Boundaries + +The manifest contract does not inspect framework objects, infer model semantics, +synthesize framework placements, execute transfers, or define discovery, +activation, rollback, and other control-plane policies. Framework adapters own +object inspection and normalization. Planner, Store, and Transfer Engine +adapters consume the resulting canonical manifests without changing their +logical identity rules. diff --git a/docs/source/design/mooncake-reshard/weight-management.md b/docs/source/design/mooncake-reshard/weight-management.md new file mode 100644 index 0000000000..bf4fc67b65 --- /dev/null +++ b/docs/source/design/mooncake-reshard/weight-management.md @@ -0,0 +1,182 @@ +# Weight Management Architecture + +Mooncake Store manages one immutable model-weight revision as a first-class +resource. A caller discovers the revision by its exact identity, acquires a +revision lease, resolves its immutable manifest, and then transfers the +manifest's payload ranges. Callers do not need to know the manifest object key +in advance. + +This design adds revision-level discovery and lifecycle control without adding +independent tensor-level management metadata. + +## Delivery Status + +The first implementation stage introduces the weight-management contracts and +the standalone `WeightMetadataStore` state machine. Master integration, HA +replication, managed payload-group operations, and client RPCs are delivered +as follow-up changes. The state machine is intentionally independent of tensor +geometry and physical placement so those integrations do not redefine its +metadata contract. + +## Authority Model + +Three records have distinct authority: + +| Authority | Location | Owns | Does not own | +| --- | --- | --- | --- | +| Weight Metadata Store | Store Master memory, HA OpLog, and Master snapshot | exact revision discovery, availability, residency summary, operation progress, revision leases, manifest reference | tensor geometry, payload contents, physical replica addresses | +| `StoredWeightManifest` | immutable Store `METADATA` object | tensor descriptors and tensor-fragment-to-object-range mapping | lifecycle state, leases, live runtime addresses | +| Store object metadata | existing per-key Master metadata | replica placement and status in memory, local disk, DFS, or NoF | revision discovery, tensor meaning, serving activation | + +```mermaid +flowchart TD + C[Weight Metadata Store record] -->|manifest key and SHA-256| M[StoredWeightManifest] + C -->|payload group ID| G[Store group] + M -->|fragments: object key, offset, bytes| P[Weight payload objects] + G --> M + G --> P + P --> O[Per-object replica metadata] + M --> O +``` + +The metadata record stays outside the payload group. It therefore remains +discoverable while the group is cold, degraded, deleting, or physically +absent. The manifest and every payload object share one `payload_group_id`. +Generic eviction and removal paths recognize that group as managed and cannot +independently reclaim one member. + +The group is a logical lifecycle boundary, not a distributed transaction. +Physical work may be partial while an operation is running. The metadata store keeps +the operation non-terminal until reconciliation observes the required state +for every member. + +## Revision Identity and Manifest Location + +A revision is addressed by: + +```text +(tenant_id, namespace, resource_id, revision, weight_generation) +``` + +Its manifest object key is canonical: + +```text +weights/////manifest +``` + +`WeightMetadataStore` validates that the payload group ID and manifest key are +the canonical values derived from the revision identity. The Master integration +separately validates those references against physical Store object type, +membership, count, logical bytes, and payload-key digest. + +The three textual path components are UTF-8 URL-encoded as individual path +segments. The manifest is hard-pinned during import and stored as +`ObjectDataType::METADATA`. Payload fragments are stored as +`ObjectDataType::WEIGHT` in the same group. + +After publication, the manifest key, manifest SHA-256, payload group ID, +payload-key SHA-256, payload count, and logical byte count are immutable. +The Master validates object type, exact group membership, count, logical +bytes, and the digest of sorted payload keys. It deliberately does not parse +the tensor manifest body. A consumer must verify the stored manifest's identity +and SHA-256 before planning tensor ranges. + +## State Model + +Availability, physical residency, and an in-progress operation are separate: + +| Dimension | Values | Meaning | +| --- | --- | --- | +| Availability | `IMPORTING`, `READY`, `DEGRADED`, `DELETING`, `DELETED` | whether the complete revision is safe to discover and load | +| Residency | `UNKNOWN`, `HOT`, `COLD`, `MIXED`, `ABSENT` | observed placement across required group members | +| Operation | `NONE`, `EVICTING`, `REHYDRATING`, `REPAIRING` | durable non-terminal group work | + +`READY` means the manifest and every required payload object have a readable +replica. DRAM eviction changes residency but does not by itself make a revision +unavailable. Missing required members produce `DEGRADED`; complete physical +removal produces the retained `DELETED`/`ABSENT` tombstone. + +Every mutation is fenced by `expected_metadata_generation`. A stale writer +fails with `STALE_GENERATION`. A retry of an uncertain import commit with the +same generation and immutable manifest reference is idempotent. + +## Import and Publication + +The managed upload sequence is: + +1. `BeginWeightImport` creates or returns the `IMPORTING` metadata record and + Store-issued canonical payload group ID. +2. `WeightStore` writes every payload object into that group. +3. The immutable `StoredWeightManifest` is committed last into the same group. +4. `CommitWeightImport` validates exact group membership and the manifest + reference, then durably publishes `READY`. +5. `GetWeightRevision` or bounded `ListWeightRevisions` can discover it. + +`READY` is never inferred from key prefixes. An abandoned import is handled by +the explicit abort/reconciliation policy. + +## Load and Revision Leases + +A reader first resolves the exact metadata identity with `GetWeightRevision` +and acquires a revision lease against the returned metadata generation. It then +reads and validates the manifest, plans ranges, and executes Store-to-runtime +transfers. The caller renews short leases until all transfer work reaches a +terminal state and releases the lease on both success and failure. + +A live revision lease blocks deletion and residency operations that could +remove the last readable replica. Revision leases do not replace framework +allocation guards, runtime binding generations, or Store's per-object read +leases; those protect different ownership boundaries. + +## Residency, Rehydration, and Deletion + +`StartWeightResidencyOperation` durably records the operation ID, target, +fenced metadata generation, and progress. Reconciliation then uses existing +per-object primitives: + +- `EVICTING` removes memory replicas only after a readable cold replica exists + for each affected member; +- `REHYDRATING` queues promotion for every group member and waits until all + required members have readable memory replicas; +- busy or incomplete members keep the operation in progress; +- deletion blocks new leases, waits for live leases, removes payload objects, + removes the manifest last, verifies absence, and retains a tombstone. + +Generic `BatchEvict`, quota eviction, explicit remove, and cleanup paths skip +managed groups. Only the weight lifecycle path may change their aggregate +residency or availability. + +## Recovery and HA Rollout + +Weight metadata, leases, and operation records use durable-before-visible +OpLog publication. Standby replay stores them in a separate weight-metadata +namespace rather than encoding them as fake object metadata. Master snapshots +carry an optional `weight_metadata` section; an older snapshot without the +section restores empty Weight metadata while preserving ordinary KV metadata. +Derived group indexes are rebuilt from restored metadata records. + +Clusters using HA plus the etcd batch OpLog fail closed for weight-management +mutations unless the operator sets +`weight_management_oplog_capability_confirmed=true`. Set it only after every +configured standby runs a version that understands all weight metadata and +lease OpLog entries. This is an explicit rolling-upgrade capability assertion, +not automatic standby discovery. Reads and ordinary KV operations are not +gated by it. + +## Serving-System Boundary + +Store owns durable revision discovery, readable-residency state, leases, and +safe reclamation. SGLang or another runtime owns live GPU addresses, runtime +bindings, allocation guards, and worker-local snapshot generations. Slime, +Kubernetes, Ray, or another serving control plane owns multi-worker activation, +traffic switching, and rollback. Store does not choose a globally active +serving revision. + +## Integration Boundary + +Follow-up changes expose the lifecycle primitives through the Master RPC and +C++ client surfaces, then add a managed `WeightStore` Python facade for +manifest upload and load. Framework adapters remain responsible for building +and validating tensor manifests, holding runtime allocation guards, and +activating revisions. Manifest-only objects without a Weight metadata record +remain outside this lifecycle. diff --git a/docs/source/design/store/engram.md b/docs/source/design/store/engram.md new file mode 100644 index 0000000000..fb596843f3 --- /dev/null +++ b/docs/source/design/store/engram.md @@ -0,0 +1,225 @@ +# EngramStore Backend + +Mooncake provides EngramStore as the storage backend for Engram embedding tables. + +The scope is intentionally narrow: + +- the caller defines the physical table layout +- the caller uploads one table per head +- the caller provides precomputed row ids with shape `[B, L, H]` +- Mooncake writes selected rows into caller-owned `[B, L, H, row_bytes]` memory + +Mooncake does not implement tokenizer compression, N-gram hashing, query logic, +or any other model-side Engram algorithm. + +## Current Backend Boundary + +EngramStore uses the existing `batch_put_from`, `batch_query`, and +`get_into_ranges` Store interfaces. It stores one object per head and reads only +the requested byte ranges. Lookup is synchronous; the Python binding releases +the GIL during I/O. There is no model-specific hashing or GPU execution here. + +## Configuration + +`EngramStore` manages all Engram layers of a model. Its constructor accepts a +map from layer ID to `EngramStoreConfig`; each config describes one layer: + +- `table_vocab_sizes`: per-head table sizes `[N_0, N_1, ..., N_{H-1}]` +- `row_bytes`: required positive byte width of each row; the default `0` must be set before constructing the store + +```python +layer1 = EngramStoreConfig() +layer1.table_vocab_sizes = [17, 19] +layer1.row_bytes = 264 +layer14 = EngramStoreConfig() +layer14.table_vocab_sizes = [23, 29] +layer14.row_bytes = 264 +table = EngramStore({1: layer1, 14: layer14}, store) +``` + +The constructor copies layouts and creates no stored data. Each layer may have +different head counts, table sizes and row widths. For `layer_id`, Mooncake +generates one store key per head: + +```text +engram:l{layer_id}:h{head_idx} +``` + +Each key stores a contiguous `uint8` table with shape `[N_h, row_bytes]`. +The backend treats rows as opaque bytes. Callers pack and interpret embedding +values and any quantization scales. For example, a DeepSeek-V4.1 hash-head row +contains 256 FP8 bytes followed by 8 E8M0 scale bytes, so `row_bytes = 264`. +There is no dtype mode or configurable key prefix. Models with overlapping layer +IDs need separate Store deployments or explicit removal before replacement. + +## Public Interface + +Python: + +- `EngramStore(layers, store=None)` +- `populate(layer_id, embedding_buffers, config=ReplicateConfig())` +- `bind_local(layer_id, embedding_buffers)` +- `lookup_into(layer_id, row_ids, output)` +- `remove_from_store(layer_id, force=False)` +- `get_layer_ids()` +- `get_table_vocab_sizes(layer_id)` +- `get_store_keys(layer_id)` +- `get_num_heads(layer_id)` +- `get_row_bytes(layer_id)` + +The Python `store` argument accepts the existing `MooncakeDistributedStore` +wrapper, or `None` for metadata-only construction or local table binding. + +### Local table mode + +Construct without a Store client and bind immutable, contiguous `uint8` tables +before starting lookup workers: + +```python +table = EngramStore({1: layer1}) +heads = [np.memmap(path, mode="r", dtype=np.uint8, shape=(rows, layer1.row_bytes)) + for path, rows in zip(paths, layer1.table_vocab_sizes)] +table.bind_local(1, heads) +table.lookup_into(1, row_ids, output) +``` + +This mode copies selected rows directly from CPU-addressable memory into output; +it performs no Store metadata queries, registration, or network transfers. The +Python binding retains the supplied list and arrays. Do not change that list, +modify/resize the arrays, or truncate/unmap their backing files while bound. +Binding a layer twice, binding with a Store client, and lookup of an unbound +layer are rejected. C++ callers retain ownership of the bound memory. + +Same-host ranks can map the same immutable tmpfs files to share physical pages +without RDMA. Each rank still owns its staging output. Merely placing data in +another process on the same host does not make that process's pointers locally +addressable. Use separate directories for separate model instances. + +For large memory-mapped tables, consider prefaulting the mappings at startup +(for example, with Linux `MAP_POPULATE`). Resident file pages can still require +per-process page-table faults on first access, delaying random row lookups. + +C++: + +- constructor `EngramStore(const std::map& layers, std::shared_ptr)` +- `populate(...)` +- `lookup_into(int layer_id, const int64_t* row_ids, int B, int L, void* output, size_t output_size)` +- `remove_from_store(...)` +- metadata getters matching the Python surface + +## Data Contract + +Populate expects one C-contiguous NumPy `uint8` array per head; it does not +cast numeric arrays to bytes: + +```text +embedding_buffers[h].shape == [N_h, row_bytes] +``` + +`lookup_into` requires a C-contiguous NumPy `int64` row-ID array with shape +`[B, L, H]` and caller-owned, writable C-contiguous `uint8` output: + +```text +output.shape == [B, L, H, row_bytes] +``` + +It writes output in place and returns `None`. Neither argument is implicitly +converted. The explicit `layer_id` selects a configured layer; position +`h` in the last row-ID dimension selects `engram:l{layer_id}:h{h}`. + +For Store-backed reads, the caller must register the entire output buffer with the same Store client +before the first nonempty lookup, keep it registered throughout each call, and +unregister it when finished. `lookup_into` does not allocate, register, or +unregister output; there is no registration flag or automatic-registration mode. +An empty batch is a no-op and does not require registration. + +```python +output = np.empty((*row_ids.shape, table.get_row_bytes(layer_id)), dtype=np.uint8) +assert store.register_buffer(output.ctypes.data, output.nbytes) == 0 +try: + table.lookup_into(layer_id, row_ids, output) # Repeat using this buffer as needed. +finally: + assert store.unregister_buffer(output.ctypes.data) == 0 +``` + +Store registration does not allocate CUDA pinned memory. SGLang allocates +persistent pinned host buffers separately, fills them outside CUDA Graph, and +captures the per-layer H2D copy and dequantization inside the graph. The caller +must wait for both Store writes and any GPU reads before reusing or releasing +these buffers. + +To store existing floating-point arrays, explicitly expose their bytes, e.g. +`table.view(np.uint8).reshape(num_rows, row_bytes)`. The caller is responsible for +the dtype and layout when interpreting lookup results. + +## Populate Flow + +Populate follows the existing Store write path: + +1. validate that exactly one table is provided for each head +2. validate that every table matches `[N_h, row_bytes]` +3. verify that the target head-table keys do not already exist +4. register each embedding table buffer +5. upload all head tables with `batch_put_from(...)` +6. unregister the staging buffers + +`populate(...)` is defined as a create-only operation for the selected layer. To +reuse a `layer_id`, first remove that layer's old tables with `remove_from_store(layer_id, ...)`. Other layers are unaffected. + +If upload fails after some head tables have already been written, or if publish +finishes but post-write buffer cleanup fails, the backend best-effort removes +the keys written by the failed populate attempt before returning an error. + +## Lookup Flow + +Each Store-backed lookup follows this flow (local mode validates IDs and copies +rows directly): + +1. validate the `row_ids` shape and bounds +2. build per-head byte ranges for the requested rows +3. query head-table locations with `batch_query(...)` +4. issue one `get_into_ranges(...)` call to write the rows into the registered output buffer + +The binding builds ranges directly from contiguous NumPy row IDs. +It rejects Python lists and implicit dtype or layout conversion. + +## Validation + +The backend enforces these invariants: + +- the layer map is nonempty, IDs are nonnegative, and operations reject unknown layers + +- `table_vocab_sizes` is non-empty and every entry is positive +- `row_bytes` is positive and table/output byte sizes do not overflow +- `populate(...)` receives exactly one table per head +- every populated table matches `[N_h, row_bytes]` +- `lookup_into(...)` receives matching `[B, L, H]` IDs and registered output +- every row id satisfies `0 <= row_ids[..., h] < N_h` + +## Validation Status + +This backend is covered by: + +- correctness tests in `scripts/test_engram_store.py` +- benchmark coverage in `scripts/bench_engram_store_27b.py` + +`scripts/test_engram_store.py` can run against an existing Mooncake deployment through +`MOONCAKE_CONFIG_PATH` / `MOONCAKE_MASTER`, or it can start a local +`mooncake_master` instance automatically for a self-contained TCP test run. + +By default, the benchmark exercises `engram_store.populate(...)` directly. Its +fallback populate paths are gated behind `ENGRAM_ALLOW_POPULATE_FALLBACK=1` so +they do not silently mask regressions in the current implementation. + +## Client Instances and Storage + +SGLang shares one EngramStore and one Store client across all Engram layers in +each rank. Each layer still owns its fixed pinned output buffers for CUDA Graph. +Different ranks access the same backend keys; creating additional clients or +EngramStore handles does not replicate table data. Storage replicas are controlled +by the Store replication configuration used during upload. + +Per-rank reads currently duplicate network traffic and fetched rows in staging +buffers. Reducing that traffic requires a separate optimization such as one reader +per TP group followed by broadcast, or coordinated shared host buffers. Sharing a +multi-layer EngramStore instance alone does not deduplicate reads across processes. diff --git a/docs/source/design/store/immutable-dfs-bucket-allocator.md b/docs/source/design/store/immutable-dfs-bucket-allocator.md new file mode 100644 index 0000000000..69e386b1b3 --- /dev/null +++ b/docs/source/design/store/immutable-dfs-bucket-allocator.md @@ -0,0 +1,108 @@ +# Immutable DFS bucket allocator + +The immutable bucket allocator is an experimental alternative to the default +DFS shard allocator. It stores immutable objects by appending them to +fixed-capacity bucket files and reclaims space only by deleting a whole bucket. +The default remains `shard`; selecting `bucket` is an explicit deployment +choice. + +## On-disk layout + +Each bucket is named `bucket_<16-digit-id>.data` below +`MOONCAKE_DFS_ROOT_DIR`. Bucket IDs increase monotonically for the lifetime of +the master and are not reused. + +An entry contains only its value bytes followed by zero padding to +`MOONCAKE_DFS_ALIGNMENT`: + +```text +bucket_N.data ++----------------------+--------------------+----------------------+-----+ +| object A value bytes | zero alignment pad | object B value bytes | ... | ++----------------------+--------------------+----------------------+-----+ +^ descriptor.offset ^ next descriptor.offset +``` + +There is no per-entry header, footer, checksum, or persisted allocator +metadata in this first implementation. A DFS descriptor records the bucket +path and ID, value offset, object size, and aligned allocation size. The data +plane opens an existing bucket for each request and uses synchronous buffered +positional I/O. A successful request means that the requested `WriteAt` calls +completed; it is not an `fsync` durability guarantee. + +Allocations append in request order. A rollback or removal changes the +in-memory entry to a tombstone but never rewinds the append cursor and never +reuses that range. This rule also applies when the same key is allocated again. +Batch allocation is atomic at allocator level: if any request fails, all ranges +reserved by that batch become tombstones and no descriptor is returned. + +## Lifecycle and eviction + +New entries are `PENDING` until `PutEnd` commits them. A bucket containing a +pending entry cannot be selected for eviction. Full buckets are sealed and the +allocator creates a new bucket, up to the configured maximum. + +Eviction uses bucket-level LRU. Before deleting a candidate, the master locks +the affected metadata shards in a stable order and verifies that every live +entry still refers to the exact descriptor and is not leased or processing. +One failed check aborts the entire candidate; entries are never partially +evicted from a bucket. Metadata is removed only after every entry passes +validation. If physical deletion fails, the bucket remains charged against +capacity and is retained for a later deletion retry. + +## Configuration + +Set the same DFS root, adapter, allocator selection, alignment, and +`MOONCAKE_DFS_BUCKET_CAPACITY` on the master and every client that accesses +DFS replicas. `MOONCAKE_DFS_MAX_BUCKET_COUNT` and the bucket eviction settings +are consumed only by the master allocator. + +| Environment variable | Default | Description | +| --- | --- | --- | +| `MOONCAKE_DFS_ALLOCATOR` | `shard` | Select `bucket` to enable this allocator, or `shard` for the existing allocator. | +| `MOONCAKE_DFS_BUCKET_CAPACITY` | `1073741824` (1 GiB) | Logical and preallocated size of every bucket; must be nonzero, aligned, fit in a signed 64-bit file offset, and match on the master and every DFS client. | +| `MOONCAKE_DFS_MAX_BUCKET_COUNT` | `64` | Maximum number of live bucket files; must be positive. Buckets awaiting a successful deletion still count. | +| `MOONCAKE_DFS_ROOT_DIR` | `/mnt/3fs/mooncake` | Absolute shared directory containing the bucket files. | +| `MOONCAKE_DFS_FS_ADAPTER` | `hf3fs` | Filesystem adapter (`hf3fs` or `posix`). This version uses its buffered synchronous interface. | +| `MOONCAKE_DFS_ALIGNMENT` | `4096` | Power-of-two entry alignment; must divide the bucket capacity. | +| `MOONCAKE_DFS_EVICTION_ENABLED` | `true` | Enable bucket-level eviction. | +| `MOONCAKE_DFS_EVICTION_HIGH_WATERMARK` | `0.9` | Usage ratio that starts normal eviction. | +| `MOONCAKE_DFS_EVICTION_LOW_WATERMARK` | `0.7` | Usage ratio at which normal eviction stops. | +| `MOONCAKE_DFS_EVICTION_CHECK_INTERVAL` | `5` | Background eviction check interval in seconds. | + +For example: + +```bash +export MOONCAKE_ENABLE_DFS=1 +export MOONCAKE_DFS_ALLOCATOR=bucket +export MOONCAKE_DFS_ROOT_DIR=/mnt/3fs/mooncake +export MOONCAKE_DFS_FS_ADAPTER=posix +export MOONCAKE_DFS_BUCKET_CAPACITY=1073741824 +export MOONCAKE_DFS_MAX_BUCKET_COUNT=64 +export MOONCAKE_DFS_ALIGNMENT=4096 +``` + +`MOONCAKE_DFS_SHARD_COUNT`, `MOONCAKE_DFS_SHARD_CAPACITY`, and online shard +expansion apply only to the `shard` allocator. Conversely, the two bucket +capacity variables have no effect while `MOONCAKE_DFS_ALLOCATOR=shard`. + +## Current limitations + +This initial backend intentionally provides only the minimal runtime path: + +- Allocator and key metadata exist only in master memory. Startup fails closed + when the DFS root already contains a `bucket_*.data` or `bucket_*.meta` + artifact; restart recovery and persisted manifests are not implemented. +- Snapshot restore, OpLog recovery, standby promotion, and HA continuity do not + reconstruct bucket state. Do not select `bucket` when those guarantees are + required. +- I/O is synchronous and buffered. There is no asynchronous DFS pipeline, + Direct I/O (`O_DIRECT`), read coalescing, prefetch, or bucket read cache. +- There is no public administrator API for changing the maximum bucket count + while the service is running. +- The backend remains single-tenant and experimental. Only one active master + may own a DFS bucket root, and operators must not create, truncate, rename, + or delete bucket files behind it. + +See the {ref}`Mooncake Store deployment guide ` for the common DFS +replica setup and client requirements. diff --git a/docs/source/design/mooncake-store.md b/docs/source/design/store/mooncake-store.md similarity index 83% rename from docs/source/design/mooncake-store.md rename to docs/source/design/store/mooncake-store.md index aa68f03cf8..71ade57a4d 100644 --- a/docs/source/design/mooncake-store.md +++ b/docs/source/design/store/mooncake-store.md @@ -10,7 +10,7 @@ Mooncake Store provides low-level object storage and management capabilities, in Key features of Mooncake Store include: - **Object-level storage operations**: Mooncake Store provides simple and easy-to-use object-level APIs, including `Put`, `Get`, and `Remove` operations. -- **Optional object grouping**: Related objects can carry an optional group ID so that the Master can route their metadata to the same shard and apply best-effort shared lifecycle behavior. +- **Optional object grouping**: Related objects can carry an optional group ID so that the Master applies best-effort shared lifecycle behavior. Object routing stays decoupled from groups (always `hash(tenant, key)`); grouping only carries a shared group TTL. - **Multi-replica support**: Mooncake Store supports storing multiple data replicas for the same object, effectively alleviating hotspots in access pressure. Each slice within an object is guaranteed to be placed in different segments, while different objects' slices may share segments. Replication operates on a best-effort basis. - **Strong consistency**: Mooncake Store guarantees that `Get` operations always return correct and complete data. Once an object has been successfully `Put`, it remains immutable until removal, ensuring that all subsequent `Get` requests retrieve the most recent value. - **Zero-copy, bandwidth-saturating transfers**: Powered by the Transfer Engine, Mooncake Store eliminates redundant memory copies and exploits multi-NIC GPUDirect RDMA pooling to drive data across the network at full line rate while keeping CPU overhead negligible. @@ -21,7 +21,7 @@ Key features of Mooncake Store include: ## Architecture -![architecture](../image/mooncake-store-preview.png) +![architecture](../../image/mooncake-store-preview.png) As shown in the figure above, there are two key components in Mooncake Store: **Master Service** and **Client**. @@ -45,30 +45,10 @@ The `Client` can be used in three ways: Mooncake store supports two deployment methods to accommodate different availability requirements: 1. **Default mode**: In this mode, the master service consists of a single master node, which simplifies deployment but introduces a single point of failure. If the master crashes or becomes unreachable, the system cannot continue to serve requests until it is restored. 2. **High availability mode**: This mode enhances fault tolerance by running the master service as a cluster of multiple master nodes coordinated through an etcd cluster. The master nodes use etcd to elect a leader, which is responsible for handling client requests. -If the current leader fails or becomes partitioned from the network, the remaining master nodes automatically perform a new leader election, ensuring continuous availability. +If the current leader fails or becomes partitioned from the network, the remaining master nodes automatically perform a new leader election. Election alone does not make a node ready to serve: the elected node must complete standby catch-up, export and validate the complete promotion context, restore that context (including a valid empty context), and revalidate leadership. If any step fails, the node remains unavailable rather than serving from unverified or partial metadata. In both modes, the leader monitors the health of all client nodes through periodic heartbeats. If a client crashes or becomes unreachable, the leader quickly detects the failure and takes appropriate action. When a client node recovers or reconnects, it can automatically rejoin the cluster without manual intervention. -(client-c-api)= -## Client C++ API - -The `Client` class provides the primary interface for Mooncake Store operations: - -| API | Description | -|-----|-------------| -| `Init` | Initialize the client with metadata server, protocol, and master address | -| `Get` | Retrieve object data into pre-registered local memory slices | -| `Put` | Store object data with configurable replication and persistence | -| `Upsert` / `BatchUpsert` | Insert or update with existing placement reuse | -| `Remove` | Delete an object and all its replicas | -| `CreateCopyTask` / `CreateMoveTask` | Asynchronous cross-node data transfer | -| `QueryTask` | Monitor the status of async copy/move tasks | -| `BatchQueryIp` | Discover network locations of storage nodes | -| `BatchReplicaClear` | Batch clear replicas on specific segments | -| `QueryByRegex` / `RemoveByRegex` | Query or delete objects matching a regex | - -For full API signatures, parameter details, and usage examples, see the [Mooncake Store C++ API Reference](../api-reference/cpp/mooncake-store.md). - ## Master Service The cluster's available resources are viewed as a large resource pool, managed centrally by a Master process for space allocation and guiding data replication @@ -95,19 +75,7 @@ To reduce cache warm-up time after a master restart, the Master Service supports The Master Service can optionally enforce strict multi-tenant memory quota admission. This feature is disabled by default. When `enable_multi_tenants=false`, request tenant IDs are ignored for object placement, all objects use the `default` namespace, and tenant quota management requests return `UNAVAILABLE_IN_CURRENT_MODE`. -When strict multi-tenant mode is enabled, the tenant quota policy is loaded from the configured connector. Supported connector types are `file` and, when the store is built with `STORE_USE_ETCD=ON`, `etcd`. The `file` connector uses `tenant_quota_connector_uri=` as a writable YAML policy path. The `etcd` connector uses `tenant_quota_connector_uri=` as the etcd endpoints string and stores the same YAML policy in `mooncake-store//tenant_quota_policy`; if that key does not exist, the master starts with an empty policy so the first policy can be created through the admin API. The etcd connector shares the process-wide store etcd client used by HA/oplog, so deployments that enable both must configure matching etcd endpoints. Tenants must be explicitly present in that connector policy before they can write. Missing tenants, empty tenants, and an unregistered `default` tenant are rejected with `TENANT_NOT_REGISTERED`. - -The YAML policy uses schema version `1`: - -```yaml -version: 1 - -tenants: - - name: tenant-a - quota: 200GB -``` - -Tenant names must be non-empty, unique, must not start with `_`, and must not contain NUL or control characters. Quotas must be positive integers and may use `B`, `KB`, `MB`, `GB`, or `TB` units. +See [Multi-Tenant Deployment](../../deployment/multi-tenancy.md) for configuration details. Effective quota is recomputed from the current registered memory capacity: @@ -118,262 +86,10 @@ Effective quota is recomputed from the current registered memory capacity: `PutStart` and size-changing `UpsertStart` charge quota before memory is allocated. If the first reservation fails, the master performs tenant-scoped memory eviction for the target tenant and retries the reservation. The retry is bounded to two eviction attempts. Tenant quota eviction scans only the target tenant, skips hard-pinned objects, honors soft-pin eviction configuration, and preserves grouped-object lease safety checks. -Admin policy changes are persisted before the final in-memory policy is applied. `PUT` writes the connector first and then applies the policy in memory. `DELETE` first marks the tenant unregistered in memory to block concurrent writes, verifies the tenant is empty, writes the connector, and rolls back the in-memory mark if the connector write fails. The admin HTTP API exposes: - -| Method | Path | Description | -|--------|------|-------------| -| `GET` | `/api/v1/tenant_quotas` | List quota snapshots for active or explicit tenants | -| `GET` | `/api/v1/tenant_quotas?tenant_id=` | Query one tenant quota snapshot | -| `PUT` | `/api/v1/tenant_quotas?tenant_id=` | Create or update a tenant quota policy | -| `DELETE` | `/api/v1/tenant_quotas?tenant_id=` | Delete an empty tenant quota policy | - -Tenant quota snapshots include `tenant_id`, `requested_quota_bytes`, `effective_quota_bytes`, `used_bytes`, `reserved_bytes`, `committed_count`, `metadata_object_count`, `over_quota`, and `has_explicit_policy`. +Admin policy changes are persisted before the final in-memory policy is applied. `PUT` writes the connector first and then applies the policy in memory. `DELETE` first marks the tenant unregistered in memory to block concurrent writes, verifies the tenant is empty, writes the connector, and rolls back the in-memory mark if the connector write fails. Snapshots restore object runtime state only. Tenant quota policy is always loaded from the connector after metadata restore, then usage and effective quota are rebuilt from restored metadata and current registered capacity. If the connector cannot be loaded in strict multi-tenant mode, startup fails. -### Master Service APIs - -The protobuf definition between Master and Client is as follows: - -```protobuf -message BufHandle { - required uint64 segment_name = 1; // Storage segment name (can be simply understood as the name of the storage node) - required uint64 size = 2; // Size of the allocated space - required uint64 buffer = 3; // Pointer to the allocated space - - enum BufStatus { - INIT = 0; // Initial state, space reserved but not used - COMPLETE = 1; // Completed usage, space contains valid data - FAILED = 2; // Usage failed, upstream should update the handle state to this value - UNREGISTERED = 3; // Space has been unregistered, metadata deleted - } - required BufStatus status = 4 [default = INIT]; // Space status -}; - -message ReplicaInfo { - repeated BufHandle handles = 1; // Specific locations of the stored object data - - enum ReplicaStatus { - UNDEFINED = 0; // Uninitialized - INITIALIZED = 1; // Space allocated, waiting for write - PROCESSING = 2; // Writing data in progress - COMPLETE = 3; // Write completed, replica available - REMOVED = 4; // Replica has been removed - FAILED = 5; // Replica write failed, consider reallocation - } - required ReplicaStatus status = 2 [default = UNDEFINED]; // Replica status -}; - -service MasterService { - // Get the list of replicas for an object - rpc GetReplicaList(GetReplicaListRequest) returns (GetReplicaListResponse); - - // Get replica lists for objects matching a regex - rpc GetReplicaListByRegex(GetReplicaListByRegexRequest) returns (GetReplicaListByRegexResponse); - - // Batch query IP addresses for multiple client IDs - rpc BatchQueryIp(BatchQueryIpRequest) returns (BatchQueryIpResponse); - - // Batch clear replicas for multiple object keys - rpc BatchReplicaClear(BatchReplicaClearRequest) returns (BatchReplicaClearResponse); - - // Start Put operation, allocate storage space - rpc PutStart(PutStartRequest) returns (PutStartResponse); - - // End Put operation, mark object write completion - rpc PutEnd(PutEndRequest) returns (PutEndResponse); - - // Delete all replicas of an object - rpc Remove(RemoveRequest) returns (RemoveResponse); - - // Remove objects matching a regex - rpc RemoveByRegex(RemoveByRegexRequest) returns (RemoveByRegexResponse); - - // Storage node (Client) registers a storage segment - rpc MountSegment(MountSegmentRequest) returns (MountSegmentResponse); - - // Storage node (Client) unregisters a storage segment - rpc UnmountSegment(UnmountSegmentRequest) returns (UnmountSegmentResponse); -} -``` - -1. GetReplicaList - -```protobuf -message GetReplicaListRequest { - required string key = 1; -}; - -message GetReplicaListResponse { - required int32 status_code = 1; - repeated ReplicaInfo replica_list = 2; // List of replica information -}; -``` - -- **Request**: `GetReplicaListRequest` containing the key to query. -- **Response**: `GetReplicaListResponse` containing the status code status_code and the list of replica information `replica_list`. -- **Description**: Used to retrieve information about all available replicas for a specified key. The Client can select an appropriate replica for reading based on this information. - -2. GetReplicaListByRegex - -```protobuf -message GetReplicaListByRegexRequest { - required string key_regex = 1; -}; - -message ObjectReplicaList { - repeated ReplicaInfo replica_list = 1; -}; - -message GetReplicaListByRegexResponse { - required int32 status_code = 1; - map object_map = 2; // Matched objects and their replica information. -}; -``` - -- **Request**: GetReplicaListByRegexRequest, which contains the regular expression key_regex to be matched. -- **Response**: GetReplicaListByRegexResponse, which contains a status_code and an object_map. The keys of this map are the successfully matched object keys, and the values are the lists of replica information for each key. -- **Description**: Used to query for all keys and their replica information that match the specified regular expression. This interface facilitates bulk queries and management. - -3. BatchQueryIp - -```protobuf -message BatchQueryIpRequest { - repeated UUID client_ids = 1; // List of client IDs to query -}; - -message BatchQueryIpResponse { - required int32 status_code = 1; - map client_ip_map = 2; // Map from client ID to their IP address lists -}; - -message IPAddressList { - repeated string ip_addresses = 1; // List of unique IP addresses -}; -``` - -- **Request**: `BatchQueryIpRequest` containing a list of client IDs to query. -- **Response**: `BatchQueryIpResponse` containing the status code `status_code` and a `client_ip_map`. The keys of this map are the client IDs that have successfully mounted segments, and the values are lists of unique IP addresses extracted from all segments mounted by each client. Client IDs that have no mounted segments or are not found are silently skipped and not included in the result map. -- **Description**: Used to batch query the IP addresses for multiple client IDs. For each client ID in the input list, this interface retrieves the unique IP addresses from all segments mounted by that client. - -4. BatchReplicaClear - -```protobuf -message BatchReplicaClearRequest { - repeated string object_keys = 1; // List of object keys to clear - required UUID client_id = 2; // Client ID that owns the objects - optional string segment_name = 3; // Optional segment name. If empty, clears all segments -}; - -message BatchReplicaClearResponse { - required int32 status_code = 1; - repeated string cleared_keys = 2; // List of object keys that were successfully cleared -}; -``` - -- **Request**: `BatchReplicaClearRequest` containing a list of object keys to clear, the client ID that owns the objects, and an optional segment name. If `segment_name` is empty, all replicas of the specified objects are cleared (the objects are deleted entirely). If `segment_name` is provided, only replicas located on that specific segment are cleared. -- **Response**: `BatchReplicaClearResponse` containing the status code `status_code` and a list of `cleared_keys` representing the object keys that were successfully cleared. Only objects that belong to the specified `client_id`, have expired leases, and meet the clearing criteria are included in the result. Objects with active leases, incomplete replicas (when clearing all segments), or belonging to different clients are silently skipped. -- **Description**: Used to batch clear replicas for multiple object keys belonging to a specific client ID. This interface allows clearing replicas either on a specific segment or across all segments, providing flexible storage resource management capabilities. - -5. PutStart - -```protobuf -message PutStartRequest { - required string key = 1; // Object key - required int64 value_length = 2; // Total length of data to be written - required ReplicateConfig config = 3; // Replica configuration information - repeated uint64 slice_lengths = 4; // Lengths of each data slice -}; - -message PutStartResponse { - required int32 status_code = 1; - repeated ReplicaInfo replica_list = 2; // Replica information allocated by the Master Service -}; -``` - -- **Request**: `PutStartRequest` containing the key, data length, and replica configuration config. -- **Response**: `PutStartResponse` containing the status code status_code and the allocated replica information replica_list. -- **Description**: Before writing an object, the Client must call PutStart to request storage space from the Master Service. The Master Service allocates space based on the config and returns the allocation results (`replica_list`) to the Client. The allocation strategy ensures that each slice of the object is placed in different segments, while operating on a best-effort basis - if insufficient space is available for all requested replicas, as many replicas as possible will be allocated. The Client then writes data to the storage nodes where the allocated replicas are located. The need for both start and end steps ensures that other Clients do not read partially written values, preventing dirty reads. - -6. PutEnd - -```protobuf -message PutEndRequest { - required string key = 1; -}; - -message PutEndResponse { - required int32 status_code = 1; -}; -``` - -- **Request**: `PutEndRequest` containing the key. -- **Response**: `PutEndResponse` containing the status code status_code. -- **Description**: After the Client completes data writing, it calls `PutEnd` to notify the Master Service. The Master Service updates the object's metadata, marking the replica status as `COMPLETE`, indicating that the object is readable. - -7. Remove - -```protobuf -message RemoveRequest { - required string key = 1; -}; - -message RemoveResponse { - required int32 status_code = 1; -}; -``` - -- **Request**: `RemoveRequest` containing the key of the object to be deleted. -- **Response**: `RemoveResponse` containing the status code `status_code`. -- **Description**: Used to delete the object and all its replicas corresponding to the specified key. The Master Service marks all replicas of the corresponding object as deleted. - -8. RemoveByRegex - -```protobuf -message RemoveByRegexRequest { - required string key_regex = 1; -}; - -message RemoveByRegexResponse { - required int32 status_code = 1; - optional int64 removed_count = 2; // The number of objects removed. -}; -``` - -- **Request**: RemoveByRegexRequest, which contains the regular expression key_regex to be matched. -- **Response**: RemoveByRegexResponse, which contains a status_code and the number of objects that were removed, removed_count. -- **Description**: Used to delete all objects and their corresponding replicas for keys that match the specified regular expression. Similar to the Remove interface, this is a metadata operation where the Master Service marks the status of all matched object replicas as removed. - -9. MountSegment - -```protobuf -message MountSegmentRequest { - required uint64 buffer = 1; // Starting address of the space - required uint64 size = 2; // Size of the space - required string segment_name = 3; // Storage segment name -} - -message MountSegmentResponse { - required int32 status_code = 1; -}; -``` - -The storage node (Client) allocates a segment of memory and, after calling `TransferEngine::registerLocalMemory` to complete local mounting, calls this interface to mount the allocated continuous address space to the Master Service for allocation. - -10. UnmountSegment - -```protobuf -message UnmountSegmentRequest { - required string segment_name = 1; // Storage segment name used during mounting -} - -message UnMountSegmentResponse { - required int32 status_code = 1; -}; -``` - -When the space needs to be released, this interface is used to remove the previously mounted resources from the Master Service. - ### Object Information Maintenance The Master Service needs to maintain mappings related to buffer allocators and object metadata to efficiently manage memory resources and precisely control replica states in multi-replica scenarios. Additionally, the Master Service uses read-write locks to protect critical data structures, ensuring data consistency and security in multi-threaded environments. The following are the interfaces maintained by the Master Service for storage space information: @@ -474,13 +190,14 @@ Mooncake Store remains an object-oriented KV cache: objects are still put, queri For single-object writes, `group_ids` contains one entry. For batch writes, it must have the same length as the key list, and entry `i` is the group ID for key `i`. An empty string stores that key as ungrouped, and leaving the field unset preserves the legacy ungrouped behavior. For an existing object, group membership is immutable: `Upsert` may preserve the existing group, but it cannot move the object to another group or clear its group while the object exists. -On the Master side, group state is tenant-scoped. Objects with a non-empty group ID are routed to the metadata shard selected by `hash(group_id)`, and the Master keeps a tenant-scoped object-to-group routing index so existing key-based APIs can still locate grouped objects. The Master tracks only the current member set of each group; it does not require an expected member count, a member index, or a commit protocol for group completeness. +On the Master side, objects are always routed by `hash(tenant, key)` — group membership is an annotation, not a routing key. Group state is tenant-scoped and kept in a separate, single group domain (`group_domain_`) keyed by `scoped(tenant, group_id)`; it holds only the current member key list plus one shared group TTL. Existing key-based APIs locate grouped objects via `hash(tenant, key)` as usual; there is no object-to-group routing index. The Master tracks only the current member set of each group; it does not require an expected member count, a member index, or a commit protocol for group completeness. Group metadata affects lifecycle behavior on a best-effort basis: -- `ExistKey` and `GetReplicaList` refresh the lease, and the soft-pin timeout if present, for the current members of the group. -- Memory eviction expands a grouped candidate to the group's current members and then applies the existing per-object safety checks. Members with active leases, hard pins, soft pins when soft-pin eviction is disabled, incomplete writes, busy replicas, or unavailable replica states are skipped. -- Object removal APIs, copy/move tasks, and NoF eviction keep their existing object-level semantics. Group routing and membership metadata are cleaned up when objects are removed. +- `ExistKey` and `GetReplicaList` refresh the group's shared read TTL. A read of any member extends the same group TTL, so the whole group is protected together; a grouped object's own per-member lease is not authoritative. Object soft-pin deadlines are independent and are not extended. +- Memory eviction first consults the group's single shared TTL: if the group was read recently it is skipped as a whole; otherwise the group's current members are evicted all-or-none, applying the existing per-object safety checks (hard pins, soft pins when soft-pin eviction is disabled, incomplete writes, busy replicas, unavailable replica states) on a best-effort basis. The number of evicted objects is bounded by the requested eviction ratio. +- Object removal APIs, copy/move tasks, and NoF eviction keep their existing object-level semantics. Group membership metadata is cleaned up when objects are removed. +- On snapshot/standby restore, group state is rebuilt from object metadata, preserving the group's maximum restored lease deadline so grouped objects are not dropped as expired. Snapshots produced by a router that placed grouped objects on `hash(group_id)` shards are automatically re-routed to `hash(tenant, key)` on restore (`ReRouteRestoredObjectsByKey`), so they need not be regenerated; current-format snapshots restore correctly. This design is intentionally lightweight and backward compatible. Grouping should be treated as a lifecycle hint for related objects, not as a transactional guarantee that all members are created, made visible, or evicted atomically. @@ -494,7 +211,7 @@ Mooncake Store provides two concrete implementations of `BufferAllocatorBase`: **OffsetBufferAllocator (default and recommended)**: This allocator is derived from [OffsetAllocator](https://github.com/sebbbi/OffsetAllocator), which uses a custom bin-based allocation strategy that supports fast hard realtime `O(1)` offset allocation with minimal fragmentation. Mooncake Store optimizes this allocator based on the specific memory usage characteristics of LLM inference workloads, thereby enhancing memory utilization in LLM scenarios. -For measured utilization and allocation latency across LLM-style workloads, see [Allocator Performance](../performance/mooncake/allocator-benchmark-result.md). +For measured utilization and allocation latency across LLM-style workloads, see [Allocator Performance](../../performance/mooncake/allocator-benchmark-result.md). **CachelibBufferAllocator (deprecated)**: This allocator leverages Facebook's [CacheLib](https://github.com/facebook/CacheLib) to manage memory using a slab-based allocation strategy. It provides efficient memory allocation with good fragmentation resistance and is well-suited for high-performance scenarios. However, in our modified version, it does not handle workloads with highly variable object sizes effectively, so it is currently marked as deprecated. @@ -574,7 +291,7 @@ Valid values are: `random` (default), `free_ratio_first`, `ssd_free_ratio_first` | `free_ratio_first` | Balanced utilization, dynamic scaling | Slightly lower throughput due to sampling and sorting overhead | | `ssd_free_ratio_first` | SSD-aware memory allocation when SSD offloading is enabled | Depends on SSD usage metrics; falls back to random allocation when needed | | `cxl` | CXL memory hardware | CXL-specific; single-replica only | -| `local_first` | Colocated inference workers and memory store segments | Requires stable host identity in `local_hostname`; single memory replica only | +| `local_first` | Colocated inference workers and memory store segments | Requires stable host identity from `MOONCAKE_HOST_ID` or `local_hostname`; single memory replica only | **Use `random`** (default) when your cluster is relatively stable (segments rarely join or leave) and you want the highest possible allocation throughput. @@ -586,9 +303,9 @@ Valid values are: `random` (default), `free_ratio_first`, `ssd_free_ratio_first` **Use `cxl`** only when your hardware includes CXL (Compute Express Link) memory devices and you want to allocate data exclusively on CXL segments. -**Use `local_first`** when inference workers and Mooncake Store memory segments are colocated and you want writes to prefer the writer's host before falling back to other hosts. For this strategy to work correctly, all writer and store processes on the same physical or logical host must use the same stable, globally unique host part in `local_hostname`. +**Use `local_first`** when inference workers and Mooncake Store memory segments are colocated and you want writes to prefer the writer's host before falling back to other hosts. For this strategy to work correctly, all writer and store processes on the same physical or logical host must use the same stable, globally unique `MOONCAKE_HOST_ID`. When the variable is unset or empty, Mooncake derives the host identity from `local_hostname` by removing the port. -For benchmark data comparing `random` and `free_ratio_first` across segment counts, replica counts, and skewed capacities, see [AllocationStrategy Performance](../performance/mooncake/allocation-strategy-benchmark-result.md). +For benchmark data comparing `random` and `free_ratio_first` across segment counts, replica counts, and skewed capacities, see [AllocationStrategy Performance](../../performance/mooncake/allocation-strategy-benchmark-result.md). #### Strategy Details @@ -625,6 +342,8 @@ An SSD-aware variant of the free-ratio-first strategy. It first tries preferred Host-aware local-first allocation reuses the normal preferred-segment flow. The master derives the writer host id from the request's client host identity and builds an ordered preferred segment list: active hosts are visited in cyclic lexicographic host-id order, starting from the writer host when it has active segments, or otherwise from the next greater active host id. Within the same host, segment names are sorted and rotated by key hash so multiple local segments do not always receive the first allocation attempt. +The C++ client reads `MOONCAKE_HOST_ID` as an explicit deployment override for the client identity carried in allocation requests and the identity recorded for mounted segments. This lets containerized deployments keep `local_hostname` as a routable per-pod transfer endpoint while using a shared node-level placement identity. Loopback and wildcard overrides are rejected; an empty override preserves the derived-hostname behavior. + This strategy currently applies to memory allocation with `replica_num == 1`. Explicit `preferred_segment` or `preferred_segments` in `ReplicateConfig` are still tried first; if they are unavailable or full, allocation continues with the local-first ordered fallback list. **`cxl` — CxlAllocationStrategy** @@ -659,14 +378,21 @@ The default lease TTL is 10 seconds and is configurable via a startup parameter For important and frequently used objects, such as system prompts, Mooncake Store provides a soft pin mechanism. When putting an object, it can be configured to enable soft pin. During eviction, objects that are not soft pinned are prioritized for eviction. Soft pinned objects are only evicted when memory is insufficient and no other objects are eligible for eviction. -If a soft pinned object is not accessed for an extended period, its soft pin status will be removed. If it is accessed again later, it will automatically be soft pinned once more. +The soft-pin lifetime starts when the first replica becomes readable. When its deadline is reached, the object becomes ordinary cache; later reads grant only an ordinary read lease and do not reactivate soft pinning. A later write can explicitly enable it again. + +Soft pin is runtime-only eviction-priority state. It is not persisted in snapshots or the HA OpLog, so recovery and Standby promotion downgrade restored objects to ordinary cache. Existing snapshot fields are retained only for format compatibility and ignored during recovery. -There are two startup parameters in `master_service` related to the soft pin mechanism: +There are three startup parameters in `master_service` related to the soft pin mechanism: -- `default_kv_soft_pin_ttl`: The duration (in milliseconds) after which a soft pinned object will have its soft pin status removed if not accessed. The default value is `30 minutes`. +- `default_kv_soft_pin_ttl`: The fixed soft-pin lifetime (in milliseconds) used when an `ENABLE` request omits `soft_pin_ttl_ms`. The default value is `30 minutes`; reads do not extend it. + +- `max_kv_soft_pin_ttl`: The largest request-level soft-pin TTL accepted by the Master. The default value is `24 hours`. - `allow_evict_soft_pinned_objects`: Whether soft pinned objects are allowed to be evicted. The default value is `true`. +An explicit `ENABLE` TTL of zero commits the object as ordinary cache. TTL +overrides are rejected for `PRESERVE` and `DISABLE`. + Notably, soft pinned objects can still be removed using APIs such as `Remove` or `RemoveAll`. ## Hard Pin @@ -677,9 +403,9 @@ Hard pin is set at object creation time through the `with_hard_pin` field in `Re Key differences from soft pin: -- Hard pin never expires. Soft pin status is removed after a configurable TTL if the object is not accessed. +- Hard pin never expires. Soft pin expires at a fixed deadline that starts when the first replica becomes readable, regardless of later accesses. - Hard-pinned objects are completely skipped during eviction. Soft-pinned objects may still be evicted when no other candidates are available. -- Hard pin is immutable once set. Soft pin status is automatically refreshed on access. +- Hard pin is immutable once set. Soft pin can be explicitly preserved, enabled, or disabled by write requests; reads do not refresh or reactivate it. ## Zombie Object Cleanup @@ -697,16 +423,19 @@ Replica space allocated during a `PutStart` is considered releasable by the Mast Mooncake Store provides a **preferred segment allocation** feature that allows users to specify a preferred storage segment (node) for object allocation. This feature is particularly useful for optimizing data locality and reducing network overhead in distributed scenarios. -### How It Works - -The preferred segment allocation feature is implemented through the `AllocationStrategy` system and is controlled via the `preferred_segment` field in the `ReplicateConfig` structure: +The preferred segment allocation feature is implemented through the +`AllocationStrategy` system. The following excerpt shows the legacy +single-segment field and the memory-replica count used by this path; other +`ReplicateConfig` fields are omitted: ```cpp struct ReplicateConfig { - size_t replica_num{1}; // Total number of replicas for the object - bool with_soft_pin{false}; // Whether to enable soft pin mechanism for this object + size_t replica_num{1}; // Number of memory replicas + SoftPinAction soft_pin_action{SoftPinAction::PRESERVE}; + std::optional soft_pin_ttl_ms{}; // ENABLE override; omitted uses the Master default bool with_hard_pin{false}; // Whether to enable hard pin (never evicted) std::string preferred_segment{}; // Preferred segment for allocation + // Other fields, including nof_replica_num and dfs_replica_num, are omitted. }; ``` @@ -723,38 +452,92 @@ When a `Put` operation is initiated with a non-empty `preferred_segment` value, ## Multi-layer Storage Support -This system provides support for a hierarchical cache architecture, enabling efficient data access through a combination of in-memory caching and persistent storage. Data is initially stored in memory cache and asynchronously backed up to a Distributed File System (DFS), forming a two-tier "memory-SSD persistent storage" cache structure. - -### Enabling Persistence Functionality +Mooncake Store supports three file-backed storage models in addition to memory +and NoF replicas: -When the user specifies `--root_fs_dir=/path/to/dir` when starting the master, and this path is a valid DFS-mounted directory on all machines where the clients reside, Mooncake Store's tiered caching functionality will work properly. Additionally, during master initialization, a `cluster_id` is loaded. This ID can be specified during master initialization (`--cluster_id=xxxx`). If not specified, the default value `mooncake_cluster` will be used. Subsequently, the root directory for client persistence will be `/`. +- `DISK` is the legacy shared-filesystem persistence path enabled by the + master's `--root_fs_dir` flag. +- `LOCAL_DISK` replicas are owned by a real client and use the asynchronous SSD + offload and heartbeat protocol. +- `DFS` replicas occupy globally allocated ranges in shard files on a shared + distributed filesystem. Any correctly configured client can access them. -​Note​​: When enabling this feature, the user must ensure that the DFS-mounted directory (`root_fs_dir=/path/to/dir`) is valid and consistent across all client hosts. If some clients have invalid or incorrect mount paths, it may cause abnormal behavior in Mooncake Store. +These models have independent placement, metadata, and lifecycle rules. -This `root_fs_dir` path is a legacy persistence path. SSD offload uses `--enable_offload=true` on the master and real client, stores data under the real client's `MOONCAKE_OFFLOAD_FILE_STORAGE_PATH`, and records `LOCAL_DISK` replicas. Do not use `--root_fs_dir` with `--enable_offload=true`. +### Legacy shared-filesystem `DISK` replicas -### Persistent Storage Space Configuration​ -Mooncake provides configurable DFS available space. Users can specify `--global_file_segment_size=1048576` when starting the master, indicating a maximum usable space of 1MB on DFS. -The current default setting is the maximum value of int64 (as we generally do not restrict DFS storage usage), which is displayed as `infinite` in `mooncake_maseter`'s console logs. -**Notice** The DFS cache space configuration must be used together with the `--root_fs_dir` parameter. Otherwise, you will observe that the `SSD Storage` usage consistently shows: `0 B / 0 B` -**Notice** The capability for file eviction on DFS has not been provided yet +When the master starts with `--root_fs_dir=/shared/path`, it adds a legacy +`DISK` replica to each new object. Clients write that replica asynchronously to +the per-cluster directory `/` and can read it when the +normal replica-selection path chooses `DISK`. The path must identify the same +shared filesystem location on every participating client. -### Data Access Mechanism +`--global_file_segment_size` declares the legacy file capacity used by master +metrics; its default is unlimited. It does not configure or limit the +descriptor-based DFS shard allocator. Do not combine the legacy path with the +client-owned SSD-offload mode; see the deployment guide for the corresponding +flags and restrictions. -The persistence feature also follows Mooncake Store's design principle of separating control flow from data flow. The read/write operations of kvcache objects are completed on the client side, while the query and management functions of kvcache objects are handled on the master side. In the file system, the key -> kvcache object index information is maintained by a fixed indexing mechanism, with each file corresponding to one kvcache object (the filename serves as the associated key name). +### Descriptor-based DFS replicas -After enabling the persistence feature: - -- For each `Put` or `BatchPut` operation, both a synchronous memory pool write operation and an asynchronous DFS persistence operation will be initiated. -- For each `Get` or `BatchGet` operation, if the corresponding kvcache is not found in the memory pool, the system will attempt to read the file data from DFS and return it to the user. +```{warning} +**Work in progress.** Descriptor-based DFS is not production-ready and is not +covered by the general fault-tolerance, HA continuity, durability, or +multi-tenant guarantees described elsewhere in this design document. +``` -### 3FS USRBIO Plugin (Experimental) +The master owns DFS placement metadata and an allocator for the shared shard +files. During `PutStart` or `UpsertStart`, it allocates an aligned range and +returns a descriptor containing the shard path, shard index, offset, object +size, and aligned size. The replica remains `PROCESSING` until the request is +finalized. Removal, revocation, replacement, and allocator eviction release +the range, with a configurable deferred-free interval preventing immediate +offset reuse. + +The shard set can grow online through the master admin API. Expansion prepares +new shard files and allocator state, then atomically publishes the complete +ready set; existing shard paths and ranges remain unchanged. Allocation tries +the hash-selected shard first and falls back to other ready shards, so added +capacity can relieve full shards. Startup discovers the existing contiguous +layout to retain the expanded capacity; it does not recover allocation or key +metadata. + +The client owns the DFS data plane. `DistributedStorageBackend` validates the +descriptor and delegates positional I/O to either `PosixFsAdapter` or +`Hf3fsAdapter`. The master and clients must use the same DFS root and shard +layout so that a descriptor identifies the same physical file everywhere. +Clients do not open or create shard files during initialization. They open a +shard only when first using its published descriptor, validating the path, +shard index, and file capacity before caching the file handle. This prevents +clients from retaining files that an unsuccessful expansion rolls back. + +For a write, the client first completes the requested memory and NoF transfers, +then writes the DFS replica. `Put`, `BatchPut`, `Upsert`, and `BatchUpsert` +acknowledge success only after the requested DFS `WriteAt` operations return +successfully. This is request-synchronous acknowledgement, not an `fsync` +durability guarantee. If either an existing object or an incoming same-size +`Upsert` has a DFS replica, the requested memory, NoF, and DFS replica counts +must match the existing topology. + +For a read, the master returns the readable replica list through the normal +query path. The client selects the first complete replica; if that replica is a +DFS replica, it uses the descriptor to read the requested range directly from +the shared shard file. + +See the {ref}`Mooncake Store deployment guide ` for +configuration, usage, and current limitations. + +### HF3FS USRBIO Adapter (Experimental) ```{note} This integration is **experimental** and incomplete; see the plugin page for details before relying on it. ``` -If you need to use 3FS's native API (USRBIO) to achieve high-performance persistent file reads and writes, you can refer to the configuration instructions in this document [3FS USRBIO Plugin](../getting_started/plugin-usage/3FS-USRBIO-Plugin.md). +The descriptor-based DFS data plane can use the native HF3FS USRBIO API instead +of POSIX I/O. Select it with `MOONCAKE_DFS_FS_ADAPTER=hf3fs`; the legacy +`--root_fs_dir` option does not enable this path, and there is no automatic +fallback to POSIX. See the [HF3FS USRBIO adapter guide](../../getting_started/plugin-usage/3FS-USRBIO-Plugin.md) +for build prerequisites and configuration. ## Builtin Metadata Server Mooncake Store provides a built-in HTTP metadata server as an alternative to etcd for storing cluster metadata. This feature is particularly useful for development environments or scenarios where etcd is not available. @@ -785,11 +568,7 @@ To start the master service with the HTTP metadata server enabled: When enabled, the HTTP metadata server will start automatically and provide metadata services for the Mooncake Store cluster. This eliminates the need for an external etcd deployment, simplifying the setup process for development and testing environments. Note that the HTTP metadata server is designed for single-node deployments and does not provide the high availability features that etcd offers. For production environments requiring high availability, etcd is still the recommended choice. -For detailed guidance on monitoring master metrics, Prometheus endpoints, and health checks, see the [Observability guide](../getting_started/observability.md). - -## Mooncake Store Python API - -**Complete Python API Documentation**: [https://kvcache-ai.github.io/Mooncake/api-reference/python/mooncake-store.html](https://kvcache-ai.github.io/Mooncake/api-reference/python/mooncake-store.html) +For detailed guidance on monitoring master metrics, Prometheus endpoints, and health checks, see the [Observability guide](../../getting_started/observability.md). ## Version Management Policy @@ -801,6 +580,258 @@ When to bump the version: * **Minor version (0.X.0)**: For new features, API additions, or notable improvements that maintain backward compatibility * **Patch version (0.0.X)**: For bug fixes, performance optimizations, or minor improvements that don't affect the API +## Mooncake Store API + +Mooncake Store can be accessed through Python, C++, HTTP, and Master RPC APIs. + +For Python, C++, and HTTP API signatures, parameter details, and usage examples, see: + +- [Mooncake Store Python API](../../api-reference/python/mooncake-store.md) +- [Mooncake Store C++ API Reference](../../api-reference/cpp/mooncake-store.md) +- [Mooncake Store HTTP Service](../../api-reference/http/http-service.md) + +### Master Service APIs + +The protobuf definition between Master and Client is as follows: + +```protobuf +message BufHandle { + required uint64 segment_name = 1; // Storage segment name (can be simply understood as the name of the storage node) + required uint64 size = 2; // Size of the allocated space + required uint64 buffer = 3; // Pointer to the allocated space + + enum BufStatus { + INIT = 0; // Initial state, space reserved but not used + COMPLETE = 1; // Completed usage, space contains valid data + FAILED = 2; // Usage failed, upstream should update the handle state to this value + UNREGISTERED = 3; // Space has been unregistered, metadata deleted + } + required BufStatus status = 4 [default = INIT]; // Space status +}; + +message ReplicaInfo { + repeated BufHandle handles = 1; // Specific locations of the stored object data + + enum ReplicaStatus { + UNDEFINED = 0; // Uninitialized + INITIALIZED = 1; // Space allocated, waiting for write + PROCESSING = 2; // Writing data in progress + COMPLETE = 3; // Write completed, replica available + REMOVED = 4; // Replica has been removed + FAILED = 5; // Replica write failed, consider reallocation + } + required ReplicaStatus status = 2 [default = UNDEFINED]; // Replica status +}; + +service MasterService { + // Get the list of replicas for an object + rpc GetReplicaList(GetReplicaListRequest) returns (GetReplicaListResponse); + + // Get replica lists for objects matching a regex + rpc GetReplicaListByRegex(GetReplicaListByRegexRequest) returns (GetReplicaListByRegexResponse); + + // Batch query IP addresses for multiple client IDs + rpc BatchQueryIp(BatchQueryIpRequest) returns (BatchQueryIpResponse); + + // Batch clear replicas for multiple object keys + rpc BatchReplicaClear(BatchReplicaClearRequest) returns (BatchReplicaClearResponse); + + // Start Put operation, allocate storage space + rpc PutStart(PutStartRequest) returns (PutStartResponse); + + // End Put operation, mark object write completion + rpc PutEnd(PutEndRequest) returns (PutEndResponse); + + // Delete all replicas of an object + rpc Remove(RemoveRequest) returns (RemoveResponse); + + // Remove objects matching a regex + rpc RemoveByRegex(RemoveByRegexRequest) returns (RemoveByRegexResponse); + + // Storage node (Client) registers a storage segment + rpc MountSegment(MountSegmentRequest) returns (MountSegmentResponse); + + // Storage node (Client) unregisters a storage segment + rpc UnmountSegment(UnmountSegmentRequest) returns (UnmountSegmentResponse); +} +``` + +1. GetReplicaList + +```protobuf +message GetReplicaListRequest { + required string key = 1; +}; + +message GetReplicaListResponse { + required int32 status_code = 1; + repeated ReplicaInfo replica_list = 2; // List of replica information +}; +``` + +- **Request**: `GetReplicaListRequest` containing the key to query. +- **Response**: `GetReplicaListResponse` containing the status code status_code and the list of replica information `replica_list`. +- **Description**: Used to retrieve information about all available replicas for a specified key. The Client can select an appropriate replica for reading based on this information. + +2. GetReplicaListByRegex + +```protobuf +message GetReplicaListByRegexRequest { + required string key_regex = 1; +}; + +message ObjectReplicaList { + repeated ReplicaInfo replica_list = 1; +}; + +message GetReplicaListByRegexResponse { + required int32 status_code = 1; + map object_map = 2; // Matched objects and their replica information. +}; +``` + +- **Request**: GetReplicaListByRegexRequest, which contains the regular expression key_regex to be matched. +- **Response**: GetReplicaListByRegexResponse, which contains a status_code and an object_map. The keys of this map are the successfully matched object keys, and the values are the lists of replica information for each key. +- **Description**: Used to query for all keys and their replica information that match the specified regular expression. This interface facilitates bulk queries and management. + +3. BatchQueryIp + +```protobuf +message BatchQueryIpRequest { + repeated UUID client_ids = 1; // List of client IDs to query +}; + +message BatchQueryIpResponse { + required int32 status_code = 1; + map client_ip_map = 2; // Map from client ID to their IP address lists +}; + +message IPAddressList { + repeated string ip_addresses = 1; // List of unique IP addresses +}; +``` + +- **Request**: `BatchQueryIpRequest` containing a list of client IDs to query. +- **Response**: `BatchQueryIpResponse` containing the status code `status_code` and a `client_ip_map`. The keys of this map are the client IDs that have successfully mounted segments, and the values are lists of unique IP addresses extracted from all segments mounted by each client. Client IDs that have no mounted segments or are not found are silently skipped and not included in the result map. +- **Description**: Used to batch query the IP addresses for multiple client IDs. For each client ID in the input list, this interface retrieves the unique IP addresses from all segments mounted by that client. + +4. BatchReplicaClear + +```protobuf +message BatchReplicaClearRequest { + repeated string object_keys = 1; // List of object keys to clear + required UUID client_id = 2; // Client ID that owns the objects + optional string segment_name = 3; // Optional segment name. If empty, clears all segments +}; + +message BatchReplicaClearResponse { + required int32 status_code = 1; + repeated string cleared_keys = 2; // List of object keys that were successfully cleared +}; +``` + +- **Request**: `BatchReplicaClearRequest` containing a list of object keys to clear, the client ID that owns the objects, and an optional segment name. If `segment_name` is empty, all replicas of the specified objects are cleared (the objects are deleted entirely). If `segment_name` is provided, only replicas located on that specific segment are cleared. +- **Response**: `BatchReplicaClearResponse` containing the status code `status_code` and a list of `cleared_keys` representing the object keys that were successfully cleared. Only objects that belong to the specified `client_id`, have expired leases, and meet the clearing criteria are included in the result. Objects with active leases, incomplete replicas (when clearing all segments), or belonging to different clients are silently skipped. +- **Description**: Used to batch clear replicas for multiple object keys belonging to a specific client ID. This interface allows clearing replicas either on a specific segment or across all segments, providing flexible storage resource management capabilities. + +5. PutStart + +```protobuf +message PutStartRequest { + required string key = 1; // Object key + required int64 value_length = 2; // Total length of data to be written + required ReplicateConfig config = 3; // Replica configuration information + repeated uint64 slice_lengths = 4; // Lengths of each data slice +}; + +message PutStartResponse { + required int32 status_code = 1; + repeated ReplicaInfo replica_list = 2; // Replica information allocated by the Master Service +}; +``` + +- **Request**: `PutStartRequest` containing the key, data length, and replica configuration config. +- **Response**: `PutStartResponse` containing the status code status_code and the allocated replica information replica_list. +- **Description**: Before writing an object, the Client must call PutStart to request storage space from the Master Service. The Master Service allocates space based on the config and returns the allocation results (`replica_list`) to the Client. The allocation strategy ensures that each slice of the object is placed in different segments, while operating on a best-effort basis - if insufficient space is available for all requested replicas, as many replicas as possible will be allocated. The Client then writes data to the storage nodes where the allocated replicas are located. The need for both start and end steps ensures that other Clients do not read partially written values, preventing dirty reads. + +6. PutEnd + +```protobuf +message PutEndRequest { + required string key = 1; +}; + +message PutEndResponse { + required int32 status_code = 1; +}; +``` + +- **Request**: `PutEndRequest` containing the key. +- **Response**: `PutEndResponse` containing the status code status_code. +- **Description**: After the Client completes data writing, it calls `PutEnd` to notify the Master Service. The Master Service updates the object's metadata, marking the replica status as `COMPLETE`, indicating that the object is readable. + +7. Remove + +```protobuf +message RemoveRequest { + required string key = 1; +}; + +message RemoveResponse { + required int32 status_code = 1; +}; +``` + +- **Request**: `RemoveRequest` containing the key of the object to be deleted. +- **Response**: `RemoveResponse` containing the status code `status_code`. +- **Description**: Used to delete the object and all its replicas corresponding to the specified key. The Master Service marks all replicas of the corresponding object as deleted. + +8. RemoveByRegex + +```protobuf +message RemoveByRegexRequest { + required string key_regex = 1; +}; + +message RemoveByRegexResponse { + required int32 status_code = 1; + optional int64 removed_count = 2; // The number of objects removed. +}; +``` + +- **Request**: RemoveByRegexRequest, which contains the regular expression key_regex to be matched. +- **Response**: RemoveByRegexResponse, which contains a status_code and the number of objects that were removed, removed_count. +- **Description**: Used to delete all objects and their corresponding replicas for keys that match the specified regular expression. Similar to the Remove interface, this is a metadata operation where the Master Service marks the status of all matched object replicas as removed. + +9. MountSegment + +```protobuf +message MountSegmentRequest { + required uint64 buffer = 1; // Starting address of the space + required uint64 size = 2; // Size of the space + required string segment_name = 3; // Storage segment name +} + +message MountSegmentResponse { + required int32 status_code = 1; +}; +``` + +The storage node (Client) allocates a segment of memory and, after calling `TransferEngine::registerLocalMemory` to complete local mounting, calls this interface to mount the allocated continuous address space to the Master Service for allocation. + +10. UnmountSegment + +```protobuf +message UnmountSegmentRequest { + required string segment_name = 1; // Storage segment name used during mounting +} + +message UnMountSegmentResponse { + required int32 status_code = 1; +}; +``` + +When the space needs to be released, this interface is used to remove the previously mounted resources from the Master Service. --- @@ -809,4 +840,10 @@ When to bump the version: :maxdepth: 1 ssd-offload +immutable-dfs-bucket-allocator +ssd-free-ratio-first-allocation +nvme-kv-backend +oss-backend +engram + ::: diff --git a/docs/source/design/store/nvme-kv-backend.md b/docs/source/design/store/nvme-kv-backend.md new file mode 100644 index 0000000000..a54e991550 --- /dev/null +++ b/docs/source/design/store/nvme-kv-backend.md @@ -0,0 +1,178 @@ +# NVMe KV Backend Design + +## Overview + +The NVMe KV backend extends Mooncake Store's node-local SSD offload path with an NVMe Key-Value namespace. It preserves Mooncake's logical object API while translating variable-length logical keys and values into fixed-size NVMe KV keys and bounded device values. + +The implementation separates object semantics from command transport. `NvmeKvStorageBackend` owns logical object layout, integrity validation, key-conflict handling, and batched I/O orchestration. `NvmeKvConnector` binds one configured device to one executor. `NvmeKvCommandExecutor` submits Store, Retrieve, and Delete commands through io_uring or ioctl. + +## Design Goals + +- Integrate one node-local NVMe KV namespace with the existing SSD offload flow. +- Preserve object identity and integrity with placement validation and checksums. +- Support logical objects larger than one device value. +- Use store-if-not-exists for idempotent writes and explicit hash-collision handling. +- Overlap object preparation, chunk submission, and root submission with bounded concurrency. +- Use io_uring when available and ioctl as an initialization fallback. + +## Architecture + +```mermaid +flowchart TB + subgraph Control["Mooncake control plane"] + Master["Mooncake master\ntracks LOCAL_DISK replicas"] + end + + subgraph Requester["Requesting node"] + App["Application"] + RequestClient["Mooncake client"] + App --> RequestClient + end + + subgraph Owner["NVMe KV owner node"] + RealClient["Mooncake real client"] + FileStorage["FileStorage"] + Backend["NvmeKvStorageBackend"] + Connector["NvmeKvConnector"] + Executor["io_uring or ioctl executor"] + Device["Local NVMe KV namespace"] + + RealClient --> FileStorage --> Backend --> Connector --> Executor --> Device + end + + RequestClient <-->|"metadata query"| Master + RealClient <-->|"offload heartbeat and replica updates"| Master + RequestClient <-->|"object RPC and Transfer Engine data movement"| RealClient +``` + +The master records the object as a `LOCAL_DISK` replica owned by a real client. NVMe KV commands remain local to that client. + +## Layer Responsibilities + +| Layer | Responsibilities | +|------|------------------| +| `FileStorage` | Obtains memory slices, invokes the backend, reports successful replicas, and serves remote SSD reads. | +| `NvmeKvStorageBackend` | Builds object layouts, applies key-conflict policy, coordinates bounded batch I/O, and verifies data returned by the device. | +| `NvmeKvConnector` | Resolves the configured device, selects one executor during initialization, and forwards commands. | +| `NvmeKvCommandExecutor` | Encodes and submits NVMe KV Store, Retrieve, and Delete commands while owning transport-specific buffers and completion handling. | +| NVMe KV namespace | Executes commands submitted through the Linux device node. | + +## Physical Keys and Conflicts + +NVMe KV namespaces expose key/value limits through their KV format, including +the maximum key length and maximum value length supported by the namespace. +Command completion can also report invalid key or value sizes. Mooncake logical +keys are variable-length strings, so they cannot be passed to the device +unchanged. + +NVMe KV commands use a 16-byte physical key, while Mooncake logical keys can be longer. The key codec derives root and chunk keys from the complete logical key, object role, chunk index, and conflict slot. Two independently seeded XXH64 values form the physical key, and four independently seeded XXH64 values form the identity verification hash stored in the root header. No logical-key byte is reserved or discarded. + +The backend tries at most 64 conflict slots. Every Store uses store-if-not-exists. When a physical key already exists, the backend retrieves the value and compares it with the expected bytes. Identical bytes mean the same object and make the operation idempotently successful. Different bytes mean a physical-key collision, so the backend tries the next slot. Other device errors are returned to `FileStorage`. + +Reads derive the same root keys and probe conflict slots until the stored logical identity matches the requested key. Placement metadata in the root must agree with both the observed physical key and the selected slot. + +## Object Layout + +The value side has the same shape constraint. Mooncake objects can be larger +than one NVMe KV value, while each Store or Retrieve must stay within the +effective value limit derived from the protocol/device ceiling, runtime +transfer limit, and transfer alignment. Chunking exists to adapt Mooncake's +logical object size to that bounded value model. + +```mermaid +flowchart LR + Logical["Mooncake logical object"] + Decision{"Fits in one device value?"} + Inline["Root value\nheader + identity + payload"] + Manifest["Root manifest\nheader + identity + chunk records"] + Chunks["Raw chunk values\nchunk0 ... chunkN"] + + Logical --> Decision + Decision -->|"yes"| Inline + Decision -->|"no"| Manifest + Manifest --> Chunks +``` + +An inline root contains `NvmeKvObjectHeader`, stored identity metadata, and the logical payload. The header records object type, payload size, identity verification hash, payload checksum, header checksum, and identity metadata size. + +A larger object is split into raw chunk values followed by one root manifest. Each manifest record stores the physical chunk key, chunk size, and checksum. The root manifest is written only after all chunks for that object complete successfully, so the root acts as the visibility marker. The manifest itself must fit in one device value. + +## Write Path + +```mermaid +sequenceDiagram + participant F as FileStorage + participant B as NvmeKvStorageBackend + participant E as Executor + + F->>B: BatchOffload(key to slices) + B->>B: validate limits and prepare checksums/layout + alt inline object + B->>E: Store root if not exists + else chunked object + B->>E: StoreBatch chunks if not exists + E-->>B: all chunks for one object complete + B->>E: queue root on the root lane + end + alt physical key already exists + B->>E: Retrieve existing value + B->>B: accept identical object or try next slot + end + B-->>F: successful keys and object metadata +``` + +Preparation workers build payload views, checksums, chunks, and root manifests while independent submission lanes issue device commands. Chunk lanes feed completed objects to a dedicated root lane instead of waiting for every object in the batch. Worker counts and command batches are bounded by the configured concurrency budget and executor queue depth. + +On failure, the backend best-effort deletes only keys created by the current attempt. Pre-existing values accepted as the same object are never deleted. + +## Read Path + +For each requested logical key, the backend derives and validates its root object. Inline payloads are checksum-verified and copied directly to the destination. Manifest roots are validated and converted into chunk records and destination offsets for the current request. + +Chunk reads are grouped into bounded tasks. When the selected executor supports direct destination reads, aligned destinations use `RetrieveIntoBatch` so the device can read directly into the Mooncake buffer. Other destinations, and executors that use the default batch implementation, use executor-owned buffers through `RetrieveBufferBatch` followed by a validated copy. Header, identity, placement, manifest, payload, and every chunk checksum are verified before the operation returns. + +## Executor Design + +### Common command layer + +Common utilities own physical-key packing, NVMe KV opcodes, Store/Retrieve/Delete command construction, transfer rounding, aligned buffer allocation, status mapping, and capability calculation. The effective value limit is: + +```text +round_down(min(protocol_max_value_size, runtime_transfer_limit), + transfer_alignment) +``` + +### io_uring + +The io_uring executor uses a thread-local ring and NVMe uring commands with 128-byte SQEs. It requests CQE32 for command results and retries initialization without CQE32 when the kernel does not support it. + +Batch submission maintains a bounded number of commands in flight, drains available CQEs, and refills released SQEs immediately. A generation token plus command index in `user_data` rejects stale, duplicate, and out-of-batch completions. Request buffers remain alive until all accepted commands complete. On partial submission failure, the executor drains commands already accepted by the kernel before resetting the ring. + +### ioctl + +The ioctl executor builds the same command fields in `nvme_passthru_cmd` and submits them through `NVME_IOCTL_IO_CMD`. Calls are synchronous within one backend worker, while the backend worker pool provides parallelism across requests. + +### Selection + +`auto` tries io_uring first when compiled in and falls back to ioctl only if io_uring initialization fails. Runtime command failures are returned without changing transports. Explicit `io_uring` and `ioctl` modes fail initialization when the requested executor cannot be created. + +## Concurrency and Ownership + +- Backend worker pools bound object preparation, chunk submission, root submission, and fallback I/O. +- io_uring rings are thread-local, avoiding a shared hot-path ring lock. +- Batch APIs are synchronous at their boundary, so request arrays and DMA buffers outlive all command completions. +- Direct reads verify checksums in the destination buffer after completion. +- Root submission for a chunked object occurs only after its chunks finish successfully. + +## Failure Semantics + +| Condition | Behavior | +|----------|----------| +| Key not found | Return `OBJECT_NOT_FOUND`. | +| Store-if-not-exists finds identical bytes | Treat as idempotent success. | +| Store-if-not-exists finds different bytes | Treat as a physical-key collision and try the next slot. | +| Header, identity, placement, manifest, or checksum failure | Return `FILE_READ_FAIL`. | +| Partial chunk write | Best-effort cleanup of keys created by the failed attempt. | +| io_uring submission or completion anomaly | Drain accepted commands, reset the thread-local ring, and fail the operation. | +| io_uring initialization failure in `auto` mode | Try ioctl. | +| Runtime device I/O failure | Return the mapped error without transport fallback. | diff --git a/docs/source/design/store/oss-backend.md b/docs/source/design/store/oss-backend.md new file mode 100644 index 0000000000..57d6334c4e --- /dev/null +++ b/docs/source/design/store/oss-backend.md @@ -0,0 +1,183 @@ +# OSS Backend Design + +## Overview + +The object-storage backend extends Mooncake Store's client-owned offload path +to key-based storage services, such as OSS and S3, through service-specific +adapters. It uses the same `LOCAL_DISK` replica model as local-file and NVMe KV +backends: the master records an owner and the requesting client reads through +that owner. Object storage is not a separate first-class replica type here. + +This document describes the OSS adapter implementation. Its signing protocol +is OSS-specific; S3 requires a compatible adapter, not just a different endpoint. + +For prerequisites, configuration, and startup examples, see +[OSS Local-Disk Backend](../../deployment/oss-offload.md). + +## Design Goals + +- Reuse `FileStorage` offload coordination and existing Store APIs. +- Issue concurrent object GETs and PUTs within a synchronous batch. +- Give each batch its own libcurl multi handle, without a shared batch lock. +- Avoid a full-object intermediate string on the direct GET path and avoid + concatenating upload slices into a full-object buffer. + +## Architecture + +```mermaid +flowchart TB + Master["Mooncake master
LOCAL_DISK replicas and owners"] + Requester["Requesting Mooncake client"] + subgraph Owner["Offload owner process"] + RealClient["RealClient"] + FileStorage["FileStorage"] + Backend["DistributedStorageBackend"] + Adapter["OssObjectStorageAdapter
libcurl and OSS signing"] + RealClient --> FileStorage --> Backend --> Adapter + end + OSS["OSS bucket / owner prefix"] + + Requester <-->|"metadata query"| Master + RealClient <-->|"offload heartbeat and completion"| Master + Requester <-->|"offload RPC and Transfer Engine"| RealClient + Adapter <-->|"HTTP"| OSS +``` + +## Layer Responsibilities + +| Layer | Responsibilities | +|-------|------------------| +| Master | Schedule offload tasks and track completed `LOCAL_DISK` replicas and their owners. | +| `FileStorage` | Obtain source slices, call the selected backend, report successful writes, and manage read staging buffers. | +| `DistributedStorageBackend` | Convert `BatchOffload` / `BatchLoad` into object batches and check per-object results and read lengths. | +| `OssObjectStorageAdapter` | Map keys, sign HTTP requests, run libcurl transfers, and implement GET/PUT/HEAD/LIST/DELETE. | +| OSS | Store object payloads under the configured bucket and prefix. | + +One `FileStorage` instance selects one backend. Selecting OSS does not also +enable the local-file or NVMe KV backend, or create an SSD-to-OSS cache hierarchy. + +## Physical Keys and Object Layout + +The adapter maps each storage key supplied by `FileStorage` to one OSS object: + +```text +physical_key = owner_prefix + "/" + URIEncode(storage_key) +object_body = concatenated payload slices +``` + +The prefix separator is omitted when the prefix is empty. LIST decodes matching +object keys back into logical storage keys; `ScanMeta` uses these keys and object +sizes for metadata registration. + +There is no shard/offset allocation, per-object UUID descriptor, root manifest, +or adapter-level checksum envelope. A PUT targets the mapped key, not a +store-if-not-exists operation. Separate owner prefixes prevent owners from +overwriting each other's objects. + +## Write Path + +```mermaid +sequenceDiagram + participant M as Master + participant F as Owner FileStorage + participant B as DistributedStorageBackend + participant A as OSS adapter + participant O as OSS + + F->>M: OffloadObjectHeartbeat (default: every 10 s) + M-->>F: Offload tasks for this owner + F->>B: BatchOffload(keys and source slices) + B->>A: PutBatch(key, iov, iovcnt) + A->>O: Concurrent object PUTs + O-->>A: Per-request completion + A-->>B: Per-object results + B-->>F: Completion callback for successful objects + F->>M: NotifyOffloadSuccess + Note over M: Register completed LOCAL_DISK replicas +``` + +The application's memory `Put` is not a synchronous OSS write. Offload is +scheduled separately; only successful uploads are reported as completed +replicas. A batch is not a transaction: successful objects remain in OSS when +another upload fails. + +## Read Path + +```mermaid +sequenceDiagram + participant R as Requesting client + participant M as Master + participant F as Owner RealClient / FileStorage + participant B as Backend / OSS adapter + participant O as OSS + + R->>M: Query key + M-->>R: LOCAL_DISK owner and object size + R->>F: batch_get_offload_object + F->>B: BatchLoad into staging buffers + B->>O: Concurrent GETs (GetBatch) + O-->>B: Object bytes + B-->>F: Validate results and lengths + F-->>R: Staging buffer descriptors + R->>F: Fetch bytes through Transfer Engine + R->>F: Release staging buffers after transfer +``` + +The RPC coroutine posts blocking work to the existing blocking pool. It can +yield while a worker executes `BatchLoad`; the worker waits until the HTTP batch +finishes. No dedicated OSS worker pool is introduced. + +`Get`, `GetRange`, and `GetBatch` copy incoming libcurl chunks directly into the +destination buffer. This removes the full-object response string and its final +copy, not all copies. `GetV` still reads into a contiguous temporary buffer and +then scatters. `PutV` / `PutBatch` consume iovec arrays through the upload +callback; copies into libcurl's upload buffer remain. + +## Batch Execution + +Each `GetBatch` or `PutBatch` call creates a temporary `CURLM` and one easy handle +per prepared request. It admits up to `MOONCAKE_OSS_MAX_CONNECTIONS` requests +(default: 64), advances them together, and admits more as requests complete. +Requests waiting for admission stay outside `CURLM` so that their transfer +timeout is not consumed in libcurl's connection queue. + +Connections are reused within a batch, not across batches. Different caller +threads can execute separate batches concurrently; the limit is per batch, +not per adapter or process. Completion order may differ from input order, but +the returned result vector preserves input order and cardinality. + +## Concurrency and Ownership + +- Adapter APIs are synchronous: they return after batch processing and cleanup. +- Each batch owns its request contexts and libcurl handles; there is no shared + `CURLM` or adapter-wide lock serializing complete batches. +- **Download buffers, upload iovec arrays, and upload payloads must remain alive + until the call returns. Upload descriptors and payloads must not be modified + while in use.** +- **A failed read may have partially modified its destination; do not consume + that buffer as valid data.** A failed `BatchLoad` does not expose the batch as + a successful Store read, even if some underlying GETs completed. + +## Failure Semantics + +| Condition | Behavior | +|-----------|----------| +| Invalid buffer or iovec arguments detected before batch setup | Return `INVALID_PARAMS` for that entry; other valid entries can proceed. | +| GET reports a missing object | Return `FILE_NOT_FOUND`. | +| GET returns an unexpected status or length | Return a read failure; a nonempty range GET requires HTTP 206 and the requested length. | +| PUT returns a non-success HTTP status | Return a write failure; do not report that object as successfully offloaded. | +| Transport error or timeout | Return a request error; a timed-out PUT does not prove that OSS stored nothing. | +| Request setup or multi-handle failure | Return errors for requests that could not complete; do not roll back successful cloud writes. | +| One object fails in a batch | Preserve per-object results; successful uploads are not automatically deleted. | + +## Current Limitations + +- Readers depend on a reachable owner and valid Master metadata, not just the + existence of an OSS object. This is different from a first-class DFS replica. +- Master capacity accounting uses each owner's configured capacity and live + replica sizes. It neither queries OSS free space nor enforces a bucket quota. +- The backend does not implement capacity eviction or automatic cloud-object + garbage collection. Removing Master metadata does not issue an OSS DELETE; + metadata usage can therefore differ from actual bucket usage. +- Multipart uploads, automatic parallel range splitting, and automatic + credential refresh are not implemented. diff --git a/docs/source/design/ssd-free-ratio-first-allocation.md b/docs/source/design/store/ssd-free-ratio-first-allocation.md similarity index 100% rename from docs/source/design/ssd-free-ratio-first-allocation.md rename to docs/source/design/store/ssd-free-ratio-first-allocation.md diff --git a/docs/source/design/ssd-offload.md b/docs/source/design/store/ssd-offload.md similarity index 80% rename from docs/source/design/ssd-offload.md rename to docs/source/design/store/ssd-offload.md index 1ca2607499..f3be569cb7 100644 --- a/docs/source/design/ssd-offload.md +++ b/docs/source/design/store/ssd-offload.md @@ -6,7 +6,7 @@ Mooncake Store supports offloading KV cache objects from distributed memory to l SSD offload is implemented as a background subsystem within the **real client** process. It is transparent to the application: a `Put` that would otherwise be evicted from memory is persisted to disk, and a `Get` that finds no memory replica automatically falls back to reading from SSD. -For multi-turn conversation benchmark results, see [Mooncake SSD Offload Benchmark](../performance/mooncake/ssd-offload-benchmark-results.md). +For multi-turn conversation benchmark results, see [Mooncake SSD Offload Benchmark](../../performance/mooncake/ssd-offload-benchmark-results.md). --- @@ -93,7 +93,7 @@ Step by step: ### Load (SSD → memory) -The load path involves three parties: the **requesting client**, the **target client** that holds the SSD data, and the **Transfer Engine** for zero-copy data movement. +The default load path involves three parties: the **requesting client**, the **target client** that holds the SSD data, and the **Transfer Engine** for data movement. ``` Requesting Client Target Client Master @@ -119,13 +119,15 @@ Requesting Client Target Client Master │ │ (free ClientBuffer slot)│ ``` +For a same-process GPU destination, FileStorage reads into a quota-bounded pinned restore arena and submits H2D copies to the caller's GPU slices. Tensor ranges use a source offset, avoiding another CPU staging allocation. This path requires local memcpy and a pinned quota; CPU and remote restores are unchanged. + Step by step: 1. **Query master**: The requesting client calls `client_->BatchGet(keys, ...)` to query the master for replica locations. If the object has been offloaded, the master returns a `LOCAL_DISK` replica descriptor containing the target client's RPC address (`transport_endpoint`). -2. **RPC to target client**: The requesting client calls `batch_get_offload_object(keys, sizes)` on the target client identified by `transport_endpoint`. The target client calls `FileStorage::BatchGet`, which allocates slots in `ClientBuffer` and reads the requested objects from SSD via `StorageBackend::BatchLoad`. -3. **Response with buffer pointers**: The target client returns a `BatchGetOffloadObjectResponse` containing `batch_id`, a list of buffer `pointers` (addresses within `ClientBuffer`), the Transfer Engine address, and `gc_ttl_ms` (the buffer lease TTL). -4. **Zero-copy transfer**: The requesting client invokes `client_->BatchGetOffloadObject(transfer_engine_addr, keys, pointers, slices)`, which uses the Transfer Engine (RDMA or TCP) to pull the data directly from the target client's `ClientBuffer` into the application's target memory (DRAM or VRAM). No intermediate copy is made on the requesting client side. -5. **Release buffer**: After the transfer completes, the requesting client immediately calls `release_offload_buffer(batch_id)` on the target client to free the `ClientBuffer` slots. If the transfer takes longer than `gc_ttl_ms`, the buffer GC thread reclaims the slot automatically as a fallback. +2. **Select the owner path**: For a different process, the requesting client calls `batch_get_offload_object(keys, sizes)` on the target client identified by `transport_endpoint`. For a same-process GPU restore with pinned quota and local memcpy enabled, it calls the local `FileStorage::BatchGetLocal` path. +3. **Response with buffer pointers**: Remote requests receive pointers within `ClientBuffer`, plus a `batch_id`, Transfer Engine address, and buffer lease TTL. The local path receives pointers within the pinned restore arena and keeps their allocation owner in the requesting process. +4. **Transfer data**: Remote restores use the Transfer Engine (RDMA or TCP). The same-process pinned branch submits H2D copies from the local restore arena and supports per-object source offsets for tensor payloads. +5. **Release buffer**: Remote restores call `release_offload_buffer(batch_id)` and retain TTL GC as a failure fallback. A same-process allocation is held by an RAII owner through the synchronous H2D operation and released automatically on success or failure; it is never published in the remote batch map. --- @@ -160,6 +162,22 @@ A single pre-allocated file (`kv_cache.data`) is shared by all objects. Space wi When `MOONCAKE_OFFLOAD_BUCKET_MAX_TOTAL_SIZE` is set, the backend evicts existing buckets to make room before writing a new one. Eviction is disabled by default (`BucketEvictionPolicy::NONE`). +### Physical disk cap (BucketStorageBackend) + +`MOONCAKE_OFFLOAD_BUCKET_MAX_TOTAL_SIZE` bounds `total_size_`, a *logical* byte counter kept in memory: incremented on write, decremented per object as buckets are evicted. Two properties limit what it can guarantee: + +- It is **per backend instance**. At runtime each instance's `total_size_` counts only the bytes *it* wrote. So when several instances share one offload directory (e.g. one per tensor-parallel rank, all on the same `ssd_offload_path`), each caps only its own `~1/N` share and the directory as a whole is never bounded. (One exception: on restart, `Init()` rebuilds `total_size_` by scanning `storage_path_`, so in a shared directory a restarted instance folds *all* ranks' files into its own counter — a recovery-time double-count.) +- It is **logical, not physical**. It ignores filesystem block rounding and can lag the real on-disk footprint — a 256 MB bucket file stays on disk until every object packed into it has been evicted. + +`MOONCAKE_OFFLOAD_BUCKET_MAX_PHYSICAL_BYTES` (default `0` = disabled) adds a hard cap on the *real* on-disk usage of the offload directory, measured by `ActualDiskBytesUsedLocked()`: + +- It scans `storage_path_` with `std::filesystem::recursive_directory_iterator` and sums `stat.st_blocks * 512` over the entries. `st_blocks` is the number of 512-byte blocks actually allocated to a file — a fixed POSIX unit, independent of the filesystem block size — so the total equals what `du` reports and what a Kubernetes `emptyDir` `sizeLimit` is accounted against (it includes block rounding and excludes sparse holes). +- The scan is scoped to `storage_path_`, so the cap's meaning follows the layout: when instances **share** one directory, each scans the whole directory and the cap bounds their **combined** usage; with **per-rank** directories (or separate disks), each scans only its own files and the cap bounds **each rank individually**. (A cap on the global sum across ranks under any layout would require master-side aggregation and is out of scope here.) +- The scan is cached for `MOONCAKE_OFFLOAD_BUCKET_DISK_SCAN_CACHE_MS` (default 500 ms; `<= 0` re-scans on every check) to bound its cost, and is snapshotted once per `PrepareEviction` call under the metadata lock. `FinalizeEviction` invalidates the cache after deleting files so the next check re-measures. +- If the directory cannot be scanned — an open error, or an iteration error partway through — the function **fails closed**: it reports the cap as reached and does not cache the partial total, so a transient scan failure drives eviction/rejection instead of silently disabling the cap. + +The two caps are independent and may be combined; when both are set a write must satisfy both, and eviction continues until the projected post-eviction usage is under both limits. + ### Proactive watermark eviction `FileStorage::Heartbeat()` also calls the backend-level proactive disk watermark path when `MOONCAKE_OFFLOAD_ENABLE_DISK_WATERMARK_EVICTION=true`. The high watermark decides when eviction starts and the low watermark decides the target usage after eviction. This path is independent of write admission, so disk usage can move back toward the low watermark even when no new write arrives. @@ -189,7 +207,7 @@ Eviction is split into two phases to ensure that the master is notified before f **Phase 1 — `PrepareEviction(required_size)`** (called under exclusive lock): -1. Repeatedly call `SelectEvictionCandidate()` until `total_size_ + required_size <= max_total_size`. +1. Repeatedly call `SelectEvictionCandidate()` until the projected post-eviction usage is within every configured cap: `total_size_ + required_size <= max_total_size` (logical), and — when `max_physical_bytes > 0` — `physical_used_start + required_size - accumulated_freed_space <= max_physical_bytes` (physical), where `physical_used_start` is the `ActualDiskBytesUsedLocked()` snapshot taken once at the start of the call. If the candidate supply is exhausted before the caps are satisfied, the write is rejected with `FILE_WRITE_FAIL`. 2. For each selected bucket: remove it from `buckets_` and `object_bucket_map_`, subtract its size from `total_size_`. 3. Collect all evicted keys and bucket metadata into a `PendingEviction` struct and return it — no file I/O at this point. diff --git a/docs/source/design/tent/deadline-scheduling.md b/docs/source/design/tent/deadline-scheduling.md new file mode 100644 index 0000000000..5b763722a5 --- /dev/null +++ b/docs/source/design/tent/deadline-scheduling.md @@ -0,0 +1,610 @@ +# TENT Deadline Scheduling + +## Overview + +This document describes how TENT schedules transfers that carry a deadline: the +**admission queue** that orders, promotes and drops requests before they reach +a transport, and the **RDMA bandwidth arbitration** that orders slices on a NIC +before they are posted. Both are opt-in and both predict deadline feasibility +with one shared formula; they differ in what they can see ahead of a request, +and in what they do about it. + +It also sets the two next to [device selection](slice-spraying.md), which +runs on the same NICs and keeps its own bandwidth estimate, because the three +are easy to confuse and are deliberately not the same thing. + +The document is ordered from the shared parts to the specific ones: the path a +deadline takes, the prediction both layers make and the bandwidth series that +feeds it, then the admission queue and the arbitration each with a worked +example, then configuration, metrics and known limits. + +## Background + +A KV-cache transfer in disaggregated serving is only useful if it lands before +the consumer needs it. TENT's QoS priorities (see [Quality of +Service](qos.md)) separate business classes, but within one class every flow is +served alike: a NIC splits its bandwidth equally among contenders, and the +engine dispatches in arrival order. Neither knows that one flow has ten +milliseconds left and another has a second. + +RFC #2519 introduced an absolute deadline on `Request` and built on it in three +steps, each opt-in and each leaving the previous behavior byte-identical when +off: + +1. **Observe**: on completion, record how much of its window a transfer + actually took, as measured latency over window. This ratio is the MLU + (`tent_deadline_mlu_permille`). +2. **Order**: dispatch the request with the earliest deadline first. +3. **Degrade**: predict which requests cannot make their window and cancel + them up front, so the caller can fall back (recompute locally, for + example) instead of waiting for a late transfer. + +RFC #2792 added the same prediction at the NIC: among slices that are about to +be posted on one queue pair, post the most urgent first. + +## The Path of a Deadline + +``` +Request{deadline_ns, intent_type, priority} + | + v + submitTransfer + | + v + Admission queue (enable_runtime_queue) <- runtime_queue/* + tryAdmit : capacity limits, EDF insert + pickForDispatch: promotion, drop, dispatch window + | + v + Transport (RDMA) + device selection : which NIC <- transports/rdma/* (slice-spraying.md) + arbitration : post order on that NIC <- deadline_bw_arbitration + | + v + Completion -> tent_deadline_mlu_permille +``` + +A request with `deadline_ns == 0` takes the same path and is never reordered, +promoted or dropped: every predicate below treats "no deadline" as "nothing to +predict". + +## Predicting Feasibility: MLU + +The scheduling layers use the same ratio as the metric, with a **predicted** +transfer time in place of the measured latency: the metric reports what a +transfer took, the predictor estimates what it will take before it runs. Both +layers use one definition, `DeadlineMlu()` in `tent/runtime/deadline_mlu.h`: + +``` +predicted_mlu = ((bytes_ahead + length) / bandwidth) / (deadline_ns - now_ns) +``` + +- `length` is the request's own bytes. +- `bytes_ahead` is what must move before them. The deadline is absolute, so + this wait counts against the window as an **additive** delay over the wire + rate. It is not folded into a slower bandwidth, which would multiply the + wait by the request's size. +- `bandwidth` is the **transmit estimate**, one of the two per-NIC series + described in [Bandwidth Series](#bandwidth-series) below, never the + selection EWMA that device selection reads. + +MLU below 1 means the request is predicted to make its window; 1 is the +boundary; above 1 it is predicted to miss. Two edge rules: no deadline or no +usable bandwidth (`<= 0`) yields 0, so such a request is never urgent and never +dropped; a deadline already in the past is infinitely urgent, which in the +queue means it is dropped whenever the drop is on, and in the arbitration +means it posts first. + +The two layers deliberately read **different** `bytes_ahead`, each counting the +bytes it can actually see at its decision point: + +| Layer | Decides | `bytes_ahead` | +|---|---|---| +| Admission queue | before the request reaches a worker | every drop-eligible owner already dispatched and not yet completed, including slices still waiting in worker queues (`dispatching_bytes_`) | +| RDMA arbitration | at posting time, on one NIC | bytes already posted to that NIC's hardware (`getPostedBytes`), plus the slots the arbitration has already placed ahead in this pass | + +The admission side reads the wider set on purpose: its decision (a drop) is +irreversible, so it is the conservative one. The arbitration only reorders, and +the worker-queue slices it would otherwise count are its own contenders. + +### How Each Layer Evaluates It + +The formula is shared; every input to it is chosen per layer. + +| | Admission drop | RDMA arbitration | +|---|---|---| +| Evaluated | once per queued owner, in `pickForDispatch`, before the request reaches any worker | once per slice per slot, in `orderByDeadline`, just before `submitSlices` | +| `length` | the whole request | one slice | +| `bandwidth` | the transmit estimate of the transport that will carry the owner: RDMA's `getEstimatedBandwidth()` (sum over its RNICs) or UB's own estimate | the transmit estimate of the NIC these slices will post on | +| `bytes_ahead` on entry | `dispatching_bytes_`: every eligible owner dispatched and not yet completed, on any NIC, including slices still in worker queues | `getPostedBytes(dev)`: bytes on this NIC's hardware, not yet completed; nothing from worker queues | +| `bytes_ahead` during the pass | grows by each owner **dispatched** in this call; a dropped owner adds nothing | grows by each slice **placed** in an earlier slot; every slice is placed eventually | +| `now` | read once per `pickForDispatch` | read once per `orderByDeadline` | +| Who is scored | eligible owners only (RDMA or UB route, not staged); others are dispatched without a prediction | every slice in the group; a slice without a deadline scores 0 | +| Threshold | `mlu >= mlu_local_threshold` → cancel | none; scores are only compared with each other | +| Effect of a high value | the request never runs | the slice posts earlier | +| Past deadline | infinite → cancelled | infinite → first slot | +| No usable bandwidth | drop disabled for this pass, everything dispatched | order left as it arrived | + +Two consequences follow. The admission prediction treats all in-flight bytes +as if they were served serially ahead of the request, although they are spread +over several NICs and move in parallel, so it is systematically pessimistic; +that is why θ_local sits above 1.0. The arbitration prediction is per NIC and +per slice, so it is close to what that queue pair will actually do, but it +only ever sees the slices that reached this worker in this tick: a more urgent +slice arriving one tick later takes its turn in the next batch. + +## Bandwidth Series + +Each RDMA device keeps two bandwidth estimates, learned from the same +completion events but along two different loops. Both use the update +`new = α × old + (1 − α) × sample` and both are clamped to +`ewma_min_bandwidth_multiplier` .. `ewma_max_bandwidth_multiplier` of the +link speed; everything else differs. + +### One Slice, Two Meters + +``` + worker posts slice NIC completes it poller handles it + ---------+------------------------+-------------------------+---------------- + | submit_ts recorded | | poll_ts + | posted_bytes += len | | posted_bytes -= len + | (busy stretch opens | | completed_bytes += len + | if it was 0) | | + | | | + |<------- post -> completion latency ------------->| selection sample: + | | len / latency + | | + | busy time: only while posted_bytes > 0 | transmit sample, at most + | (this and every other slice on the NIC) | every 10 ms, last completion + | | of a poll pass: + | | Δcompleted_bytes / Δbusy_time +``` + +### Selection EWMA (device selection) + +1. **Sample source**: one slice, on its own successful completion. The + worker takes `poll_ts − submit_ts`, the time from this post to this + completion, and the sample is `slice length / that time`. +2. **What it contains**: the slice's wire time **plus** its wait behind every + work request the NIC already held when it was posted. A backed-up NIC + therefore produces low samples. +3. **When**: every successful completion, unconditionally. Failed, flushed, + timed-out, cancelled or re-routed slices contribute nothing. +4. **Update**: α = `bandwidth_learning_rate` = 0.01, so the estimate is + almost entirely the latest sample; it reacts within one or two + completions. +5. **Reader**: device selection, as `predicted_time = (inflight + slice) / + ewma` when scoring NICs. Queueing being inside the sample is what the + selector wants: it is choosing *among* NICs, and a NIC that is slow + because it is busy should lose the comparison. + +### Transmit Estimate (admission drop and arbitration) + +1. **Sample source**: the NIC as a whole, over a time interval. Two counters + run per device: `completed_bytes`, added to by every completion, and + `busy_ns`, which accumulates only while the NIC has at least one posted, + uncompleted work request (a stretch opens when `posted_bytes` goes from 0 + to non-zero and closes when it returns to 0). +2. **What it contains**: bytes moved per unit of time the NIC was actually + working. Idle gaps between bursts are not charged, and a slice's own wait + behind earlier work is not visible, because the ratio does not look at + individual slices at all. +3. **When**: at most once per `transmit_meter_interval_ns` (10 ms), taken at + the last completion of a poll pass (every completion in a pass carries the + same timestamp, so a sample mid-pass would split one burst across two + intervals). One sampler per interval is chosen by CAS when several lanes + poll at once. The first sample only sets the baseline. An interval that + spans more than `transmit_meter_max_interval_ns` (50 ms) of wall clock is + discarded: it describes a link too far in the past. A slice that ends + without moving its bytes (failed, flushed, timed out) invalidates the + current stretch and the meter starts fresh. +4. **Update**: α = `transmit_bandwidth_learning_rate` = 0.9, so a single + sample moves the estimate by a tenth of the difference; about ten + intervals, roughly 100 ms under load, to follow a real change. With no + usable interval the estimate keeps its last value, or the link-speed seed + from `openDevice()`: the optimistic direction, which cannot cause a false + drop. +5. **Readers**: the admission drop, as the estimate of the owner's own transport + (RDMA sums its RNICs, UB reports its own), and the arbitration, as this NIC's + value. + Both add the queueing term themselves through `bytes_ahead`, so the rate + must not contain it, or the wait would be counted twice. + +### Side by Side + +The two series, input by input: + +| | Selection EWMA | Transmit estimate | +|---|---|---| +| Unit of measurement | one slice | one NIC over one interval | +| Numerator | slice length | bytes completed in the interval | +| Denominator | post → completion of that slice | NIC busy time in the interval | +| Includes queueing behind earlier work | yes, on purpose | no, on purpose | +| Sampled | every successful completion | at most every 10 ms, last completion of a pass | +| α | 0.01 (follows the latest sample) | 0.9 (~100 ms to converge) | +| Fed by | `release()` | `maybeSampleTransmit()` | +| Read by | `DeviceSelector::allocate()` | `getEstimatedBandwidth()` (sum, RDMA) or the UB transport's estimate, `orderByDeadline()` (per NIC) | +| Question answered | which NIC is the better choice right now | how fast does this NIC move bytes once posted | + +Why per-completion timing cannot serve the predictors: up to `max_qp_wr` work +requests are posted in one call with effectively one timestamp, and a poll +pass timestamps every completion it collects alike, so a slice's own +post-to-completion time grows with the depth of the batch it travelled in. +Deep enough, the selection sample would sit on its lower clamp on a perfectly +healthy link. Bytes over busy time does not depend on how the work was +batched. + +## Admission Queue + +The queue lives in `LocalTransferAdmissionQueue` +(`tent/runtime/admission_queue.*`) and is owned by `TransferEngineImpl`. It is +off unless `enable_runtime_queue` is true; with it off, `submitTransfer` hands +requests straight to the transport, as before. + +### What Enters the Queue + +A submit is queued as a whole or not at all. A submit that contains any +**staged** owner (one that must be copied through a staging buffer, see the +proxy path) bypasses the queue entirely; staging-internal submits always +queue. Each owner records whether it is **degradation eligible**: routed to +RDMA or UB and not staged. Only eligible owners can be dropped and only their +bytes count toward `bytes_ahead`, because the bandwidth provider belongs to the +owner's own transport and says nothing about a TCP, NVLink or staging transfer. + +`tryAdmit` enforces the capacity limits: `max_outstanding_owners` and +`max_outstanding_bytes` bound everything admitted and not yet terminal, with +`staging_owner_reserve` and `staging_byte_reserve` held back so staging-internal +work can always make progress. A request longer than `max_dispatch_bytes` is +rejected at submit, since it could never fit a dispatch window. + +### Ordering + +With `deadline_aware` off the queue is FIFO. With it on, owners carrying a +deadline are inserted in **earliest-deadline-first** order at admission time, +and owners without a deadline keep FIFO order behind them. Dispatch then +consumes from the front, so the hot path stays O(picked) rather than re-sorting +on every call. + +### Promotion + +`promotion_slack_ns`, when positive, moves any queued owner whose remaining +slack (`deadline_ns - now`) has fallen below that value to the front of the +queue, ahead of owners with more slack or none. A stable partition keeps EDF +order inside each group. This is how a request that was comfortable when it +was admitted but has since become urgent overtakes the ones admitted before +it. It requires `deadline_aware`. + +### Deadline-Infeasible Drop + +`mlu_local_threshold` (θ_local), when positive and combined with +`deadline_aware` and an installed bandwidth provider, turns on the drop. +`pickForDispatch` walks the queue from the front; for each eligible owner it +computes the predicted MLU against the current `dispatching_bytes_` and, if the +value reaches θ_local, marks the owner **CANCELED** instead of dispatching it, +releases its capacity, and raises the `on_local_decode_suggested` hook. The +scan continues, since later owners have looser deadlines. A dropped owner does +not consume dispatch budget. + +The caller sees the drop as a `CANCELED` task status. The hook is the intended +signal for "recompute locally"; today the engine installs an empty hook set, +so nothing beyond the status is delivered (see [Known Limits](#known-limits)). + +### Bandwidth Provider + +`TransferEngineImpl` installs a transport-aware provider, so an owner is +predicted with the rate of the transport that will carry it: for RDMA that is +`RdmaTransport::getEstimatedBandwidth()`, the sum over the local RNICs of each +device's transmit estimate; for UB it is the UB transport's own estimate. It is +installed only when at least one of those transports is present; without either +the drop is inactive even if θ_local is set, and a warning is logged at startup. +The provider is resolved at most once per transport in a dispatch pass, and the +transport is snapshotted with the admission decision, so a later transport +failover does not re-score an owner that is already queued. + +### Choosing θ_local + +MLU 1.0 is the boundary between met and missed, so a +threshold **below 1.0 drops requests the predictor itself expects to +succeed**. Measured on hardware with θ = 0.5, dispatch fell to a fraction of +the offered load while every request that was dropped would have made its +window. Dropping does not depress the bandwidth estimate in return: the +transmit meter charges bytes to the NIC's busy time, so a NIC carrying half the +load reads the same rate over half the time. The loss is throughput, in +proportion to how much feasible work the threshold refuses. + +A value slightly **above** 1.0 is the useful setting, because the predictor is +conservative by construction. `bytes_ahead` is every eligible byte dispatched +and not yet completed, and the formula charges all of it ahead of the new +request as if it were served serially; in practice those bytes are spread over +several NICs and queue pairs and move in parallel, and the new request's own +slices are spread the same way. So a predicted MLU of 1.1 usually still lands +on time. θ in the range 1.2 to 1.5 absorbs that bias and drops only requests +the wait alone rules out. The code does not currently validate the lower +bound; treat 1.0 as the floor. + +### Dispatch Window and Progress + +`max_dispatch_owners` and `max_dispatch_bytes` bound how much the queue hands +to transports at once. The window refills on every submit and on every poll, +and the `ProgressWorker` refills it again whenever a transport reports task +completion. RDMA completions do not yet wake the worker, so with the queue +active the worker also refills on a timer, `progress_fallback_interval_us` +(50 ms by default). That timer is the upper bound on how long a dispatch slot +can sit free after an RDMA transfer finishes. + +### Worked Example: Three Requests + +Two RNICs whose transmit estimates sum to 800 Gb/s (100 GB/s), `deadline_aware` +on, θ_local = 1.2, `max_dispatch_bytes` raised to 1 GiB so the sizes below fit +one window, and an empty queue. Three submits arrive in this order: + +| Request | Length | Deadline window at submit | Eligible | +|---|---|---|---| +| R1 | 64 MiB | 20 ms | yes (RDMA) | +| R2 | 512 MiB | 5 ms | yes (RDMA) | +| R3 | 16 MiB | 3 ms | no (routed to TCP) | + +**Admission.** Each `tryAdmit` inserts by deadline, so the queue reads R3 +(3 ms), R2 (5 ms), R1 (20 ms) regardless of arrival order. Suppose the first +`pickForDispatch` runs after all three are queued. + +**Dispatch pass 1.** `bytes_ahead` starts at 0 (`dispatching_bytes_`). + +- R3 is first. It is not eligible, so the drop check is skipped; it is + dispatched to TCP. Its bytes do not enter `dispatching_bytes_`. +- R2: predicted time 512 MiB / 100 GB/s ≈ 5.4 ms against a 5 ms window, MLU + ≈ 1.07. Below 1.2, so it is dispatched; `dispatching_bytes_` becomes + 512 MiB. +- R1: predicted time (512 + 64) MiB / 100 GB/s ≈ 6.0 ms against 20 ms, MLU + ≈ 0.30. Dispatched; `dispatching_bytes_` is now 576 MiB. + +**A fourth request.** R4, 256 MiB with a 6 ms window, arrives while R2 and R1 +are still in flight. Its predicted time is (576 + 256) MiB / 100 GB/s ≈ 8.7 +ms, MLU ≈ 1.45. That reaches 1.2: R4 is marked `CANCELED` at pick time and +never reaches a transport. The caller sees `CANCELED` on its next poll and can +recompute locally instead of waiting almost 9 ms for a transfer that would +land close to 3 ms late. + +Had θ_local been 0.9, R2 would have been dropped in pass 1 with MLU 1.07: a +request the predictor expected to miss by only 0.4 ms, and one that on a +slightly better link would have made it. That is the sense in which values +below 1.0 are too aggressive and values just above 1.0 are the useful range. + +**Completion.** When R2 completes, `complete()` subtracts its 512 MiB from +`dispatching_bytes_`, and the transmit meter has meanwhile learned from the +bytes R2 moved: if the NICs sustained 380 Gb/s rather than 400, the provider's +sum drifts down over the next ~100 ms and later predictions become slightly +more conservative. + +## RDMA Bandwidth Arbitration + +Arbitration is in `Workers::orderByDeadline` with the pure ordering policy in +`tent/transport/rdma/bw_arbitration.h`. It is off unless +`transports/rdma/deadline_bw_arbitration` is true; off, the post order is +exactly the arrival order. + +It runs **within one priority tier**: QoS priorities still decide which tier +posts first, arbitration decides the order inside the tier. When a worker has +gathered the slices it is about to post on one NIC, it: + +1. Reads that NIC's transmit estimate. Zero or below means nothing to predict + from and the order is left alone. +2. Checks whether any slice carries a deadline; if none does, every MLU would + be 0 and the pass is skipped. +3. Takes `bytes_ahead` as the NIC's **posted bytes**: work that has reached + the hardware and has not completed. None of the candidates is in it, and + neither is work still sitting in a worker queue. +4. Builds the order one slot at a time: the slice with the highest predicted + MLU takes the next slot and its bytes join `bytes_ahead` for the ones still + waiting, since the queue pair will post them in that order. Ties keep + arrival order. The first 64 slots are resolved exactly; the remainder is + ranked once against the bytes those slots accumulated. `submitSlices` + posts a prefix of the order, as long as the queue pair's remaining budget + (`max_qp_wr`, 256 by default, minus what is already outstanding), and the + rest wait for the next tick. + +Arbitration never drops, admits or moves a slice to another NIC; it only +decides the order in which already-selected slices are posted. + +### Worked Example: Three Slices + +A NIC has 32 MiB posted and not yet completed, and its transmit estimate is +400 Gb/s (50 GB/s). One worker is about to post three 16 MiB slices on it: + +| Slice | Deadline window remaining | +|---|---| +| A | 10 ms | +| B | 2 ms | +| C | none | + +First slot, `bytes_ahead` = 32 MiB. A and B each predict (32 + 16) MiB / 50 +GB/s ≈ 1.0 ms of transfer: A's MLU is 1.0 / 10 = 0.10, B's is 1.0 / 2 = 0.50, +C's is 0. B takes the slot and its 16 MiB join `bytes_ahead`, now 48 MiB. + +Second slot: A now predicts (48 + 16) MiB / 50 GB/s ≈ 1.34 ms, MLU 0.134; C is +still 0. A takes it. C posts last. + +Post order: B, A, C. In arrival order (A, B, C) B would have waited behind A's +16 MiB, about 0.34 ms of its 2 ms window. The example also shows the two +rules a reader should expect: a slice with no deadline never overtakes one +with a deadline, and a slice's urgency rises as the slots ahead of it fill. + +## Comparison with Device Selection + +The earlier tables compared the two predictors' inputs and the two bandwidth +series. This one compares the three mechanisms themselves: what each is for, +where it runs, and what it can and cannot do. + +| | Device selection | Admission queue | RDMA arbitration | +|---|---|---|---| +| Question | which NIC | whether and when to dispatch | in what order to post on a NIC | +| Runs | per slice, in the worker | per submit and per poll, in the engine | per post batch, in the worker | +| Reads | selection EWMA, NUMA tier, inflight charge | transmit estimate (owner's transport), `dispatching_bytes_` | transmit estimate (this NIC), posted bytes | +| Can | choose, split across NICs | reorder, promote, cancel | reorder within a tier | +| Cannot | see deadlines | choose a NIC | drop or move a slice | +| Switch | `enable_smart_scheduling` | `enable_runtime_queue` + `deadline_aware` (+ `mlu_local_threshold`) | `deadline_bw_arbitration` | + +Device selection is documented in [Slice Spraying](slice-spraying.md). + +## Configuration + +### Enabling the Queue + +```json +{ + "enable_runtime_queue": true +} +``` + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `enable_runtime_queue` | bool | `false` | Route non-staged submits through the admission queue. Everything under `runtime_queue/` is read only when this is on | + +### Admission Queue + +```json +{ + "runtime_queue": { + "max_outstanding_owners": 1024, + "max_outstanding_bytes": 1073741824, + "staging_owner_reserve": 0, + "staging_byte_reserve": 0, + "max_dispatch_owners": 64, + "max_dispatch_bytes": 67108864, + "deadline_aware": true, + "promotion_slack_ns": 0, + "mlu_local_threshold": 1.2, + "progress_fallback_interval_us": 50000 + } +} +``` + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `max_outstanding_owners` | int | `1024` | Owners admitted and not yet terminal, queued or dispatched | +| `max_outstanding_bytes` | int | `1073741824` (1 GiB) | Same bound in bytes | +| `staging_owner_reserve` | int | `0` | Owners held back from user submits so staging-internal work can always be admitted | +| `staging_byte_reserve` | int | `0` | Same reserve in bytes | +| `max_dispatch_owners` | int | `64` | Owners in flight at the transports at once | +| `max_dispatch_bytes` | int | `67108864` (64 MiB) | Bytes in flight at the transports at once; a single request longer than this is rejected at submit | +| `deadline_aware` | bool | `false` | Earliest-deadline-first dispatch; owners without a deadline keep FIFO order behind those with one. Required by promotion and drop | +| `promotion_slack_ns` | int | `0` | Owners with less remaining slack than this move to the front of the queue. `0` disables promotion | +| `mlu_local_threshold` | float | `0.0` | θ_local. Eligible owners whose predicted MLU reaches this value are canceled instead of dispatched. `0` disables the drop. Values below `1.0` cancel requests predicted to succeed | +| `progress_fallback_interval_us` | int | `50000` | Timer on which the progress worker refills the dispatch window when no completion wake arrives. `0` disables the timer | + +### RDMA Arbitration and the Transmit Estimate + +```json +{ + "transports": { + "rdma": { + "deadline_bw_arbitration": true, + "transmit_bandwidth_learning_rate": 0.9, + "transmit_meter_interval_ns": 10000000, + "transmit_meter_max_interval_ns": 50000000 + } + } +} +``` + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `deadline_bw_arbitration` | bool | `false` | Order slices most-urgent-first before posting on a NIC | +| `transmit_bandwidth_learning_rate` | float | `0.9` | α for the transmit estimate (`1.0` = never learn, `0.0` = always take the latest sample) | +| `transmit_meter_interval_ns` | int | `10000000` (10 ms) | Minimum wall-clock span of one meter interval | +| `transmit_meter_max_interval_ns` | int | `50000000` (50 ms) | An interval longer than this is discarded instead of learned from | + +The transmit estimate also inherits the clamp and seed shared with device +selection: `ewma_min_bandwidth_multiplier`, `ewma_max_bandwidth_multiplier`, +`default_bandwidth_gbps`, `min_bandwidth_gbps`, `max_bandwidth_gbps`. See the +[Slice Spraying configuration](slice-spraying.md#configuration). + +### Request Fields + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `deadline_ns` | uint64 | `0` | Absolute steady-clock time in nanoseconds by which the transfer should complete. `0` means no deadline | +| `intent_type` | enum | `INTENT_UNSPEC` | What the transfer is for (foreground get, background prefetch, migration). Used by the transport selector's intent policies; not read by the scheduling described here | +| `priority` | int | `PRIO_HIGH` | QoS tier. Arbitration reorders only within a tier | + +C++: + +```cpp +Request req; +req.opcode = Request::WRITE; +req.source = local_buffer; +req.target_id = remote_segment; +req.target_offset = 0; +req.length = 1 << 20; +req.deadline_ns = now_ns() + 20'000'000; // 20 ms from now, steady clock +engine.submitTransfer(batch, {req}); +``` + +Python: + +```python +req = tent.Request(tent.OpCode.WRITE, src, target_id, 0, length, + deadline_ns=now_ns + 20_000_000) +``` + +`now_ns` must come from the same steady clock the engine uses +(`std::chrono::steady_clock` on the C++ side). A wall-clock timestamp is not +comparable and will be read as a deadline far in the past or future. + +## Metrics + +| Metric | Type | Description | +|--------|------|-------------| +| `tent_deadline_mlu_permille` | Histogram | Measured MLU at completion, times 1000: submit-to-completion latency over the window the transfer had at submit. `1000` is the met/missed boundary. Recorded only for transfers that reached `COMPLETED` with a deadline still ahead at submit; this is the measured ratio, not the predictor's estimate | +| `tent_deadline_infeasible_total` | Counter | Transfers whose deadline was already in the past at submit time. Kept separate from the histogram so they are not read as high-MLU samples | + +A dropped owner surfaces as a `CANCELED` task; it produces neither metric, +because it never ran. See [Metrics](metrics.md) for labels and export. + +## Where Each Effect Is Observable + +Ordering and promotion are engine-level and apply on any transport. The drop +acts on RDMA- or UB-routed, non-staged owners whenever a bandwidth provider is +installed for their transport; the arbitration runs only in the RDMA workers, so +it stays RDMA-only. On a host with TCP only, `deadline_aware` and +`promotion_slack_ns` change dispatch order, while `mlu_local_threshold` and +`deadline_bw_arbitration` do nothing. That is by design, not a +misconfiguration. + +## Known Limits + +- **Drop notification.** The engine installs an empty `DegradationHooks`, and + there is no public API to register one, so a caller learns of a drop only by + observing `CANCELED`. The hook exists for an upper layer to trigger local + recompute. +- **Threshold validation.** `mlu_local_threshold` below `1.0` is accepted and + cancels requests the predictor expects to succeed. Treat `1.0` as the floor. +- **Mixed submits.** A submit that mixes staged and non-staged owners bypasses + the queue as a whole, so its RDMA owners do not enter `dispatching_bytes_` + and the drop predictor underestimates the queue ahead of later requests. +- **Quarantined owners.** An owner that is dispatched and then never reaches + a terminal status (a batch abandoned by the lazy free path) keeps its bytes + in `dispatching_bytes_`, so the drop predictor grows more pessimistic over + time in that failure mode. +- **RDMA completion wake.** RDMA does not yet signal the progress worker on + completion, so dispatch-window refill after an RDMA completion waits for the + fallback timer. +- **Failover keeps the RDMA charge.** Eligibility is fixed at admission from + the planned route. A request that enters as RDMA, fails there and is + recovered over TCP keeps its bytes in `dispatching_bytes_` until it + completes, so the drop predictor is pessimistic by one transfer's length for + the duration of that recovery. +- **One provider, one transport.** The only bandwidth provider is the RDMA + transport's, and `bytes_ahead` is a single sum, so only RDMA-routed, + non-staged requests can be predicted and dropped. Extending the drop to + another transport would need a provider per transport and a queue-ahead + term per transport, with each owner recording which one predicts it. The + structure allows this; nothing implements it. + +## References + +- RFC #2519: deadline-aware transfers (observe, order, degrade) +- RFC #2792: deadline-aware NIC bandwidth arbitration +- [TENT Slice Spraying](slice-spraying.md): device selection and the two bandwidth series +- [TENT Quality of Service](qos.md): priority tiers the arbitration works within +- [TENT Metrics](metrics.md) diff --git a/docs/source/design/tent/failover.md b/docs/source/design/tent/failover.md index 676b986a4b..c713674f45 100644 --- a/docs/source/design/tent/failover.md +++ b/docs/source/design/tent/failover.md @@ -1,3 +1,4 @@ +(tent-failover)= # TENT Failover TENT hides transfer failures from the application by recovering inside the data path. @@ -89,6 +90,41 @@ Inside `RdmaTransport`, each completion drives the rail monitor: This produces two independent recovery signals — cooldown expiry and live success — so a flaky rail does not stall forever if no other rail is posted to, and a recovered rail returns to service at the first good completion instead of waiting for the full cooldown. +### UB rail recovery + +UB rails have their own monitor, `RailMonitor`, keyed by the physical rail +(`UbRailKey`: local topology id, remote segment id, remote device id) rather +than by the endpoint incarnation, so health and learned bandwidth survive an +endpoint rebuild. Posting paths read it the same way RDMA reads its rail state: + +* Bad completion → `recordError` / `recordTimeout`; good completion → + `recordSuccess`, which also feeds the EWMA used to rank paths. +* `error_threshold` (3) errors inside `error_window_ns` (10 s) pause the rail + for `cooldown_ns`, which is bound to `transports/ub/endpoint_cooldown_ms` + (default 1000 ms). +* Path selection skips paused rails and ranks the rest by quota availability, + topology locality and learned bandwidth *before* resolving endpoints, so a + paused or loaded rail is avoided without paying a bootstrap round trip. +* `recordEndpointRebuild` records at most one rebuild per endpoint generation + per rail and is safe to call from converging or retried rebuild paths. The + first generation to become ready establishes the rail's baseline, so the + initial bootstrap is not counted as a rebuild; only later generation + increases advance `endpoint_rebuilds`. + +Local device failure is handled separately from rail health. A failed local +device marks its context unavailable, and the transport then calls +`EndpointStore::retireLocalDevice`, which unpublishes every endpoint backed by +that device and quarantines any whose native cleanup has not completed. The +context only returns to service once every one of them reaches `Destroyed`, +because reactivating JFC health before that barrier would post onto Jetty sets +that still lack their flush fence. Quarantined endpoints are retried only when +the store actually needs a slot, and at most once per sweep interval, so a +still-failing device cannot turn every posting call into a serialized provider +call. + +When UB cannot resolve or post a slice, the generic submit-stage failover +described above moves the owner to the remaining candidate transports. + ## Configuration All knobs live in the top-level `transfer-engine.json`. Defaults are safe for production; tune only if you have evidence. @@ -135,63 +171,29 @@ The counter is only built when TENT is compiled with `-DTENT_METRICS_ENABLED=ON` | `Rail recovered: local_nic=... remote_nic=... (cooldown expired)` | Cooldown elapsed and the rail is back in service. | | `Rail recovered: ... (un-paused by successful transfer)` | Live success on a previously paused rail brought it back early. | +(tent-failover-testing)= ## Testing -Real hardware faults are hard to stage, so TENT tests the failover machinery with decorator-style fault injection. - -### FaultProxyTransport - -`FaultProxyTransport` wraps any `Transport` and injects four policy-driven faults: - -* `submit_fail_rate` — probability that `submitTransferTasks` returns an error. -* `status_corrupt_rate` — probability that `getTransferStatus` flips `COMPLETED → FAILED`. -* `fail_after_n_submits` — deterministic variant: succeed the first N submits, then always fail. -* `fail_install` — make `install()` fail, simulating a transport that cannot come up. - -Because it implements the `Transport` interface, the engine sees an ordinary transport. All failover paths (`submitTransfer`, `getTransferStatus`, `resubmitTransferTask`) run unmodified. - -### Test-only injection hook - -`TransferEngineImpl::swapTransportForTest` replaces the transport in one slot after `construct()`. This is the only way the end-to-end test can wrap the real transport with `FaultProxyTransport` without bypassing `resolveTransport` or `resubmitTransferTask`. Production code never calls it. - -### End-to-end suite +Real hardware faults are hard to stage, so failover is tested by driving the real `TransferEngineImpl` with FakeTransport backends and a fault-injecting decorator. The engine is unmodified: a completion-stage `FAILED` looks like a WC error or a dropped peer, and `resubmitTransferTask` runs as it would in production. -The end-to-end failover test suite drives the real `TransferEngineImpl` with fake transports (`FakeTransport`) wrapped in `FaultProxyTransport`. It uses a `p2p` metadata backend on `127.0.0.1` so no external services are required — the whole suite is self-contained. - -Current cases: - -| Test | What it exercises | -|------|-------------------| -| `StatusCorruptionTriggersFailoverToSecondary` | Primary reports `FAILED` in `getTransferStatus`; engine must resubmit on the secondary. | -| `BothTransportsFailExhaustsFailoverBudget` | Both transports fail at the completion stage; task must surface `FAILED` once the budget is drained. | -| `MixedFaultsAcrossManySubmissions` | 10 one-request batches with 30% completion corruption on RDMA; every task must end `COMPLETED`, and submit-counter math must hold. | -| `MaxFailoverAttemptsZeroDisablesFailover` | `max_failover_attempts = 0` → the first completion fault is permanent, TCP is never touched. | -| `MaxFailoverAttemptsOneAllowsSingleFailover` | `max_failover_attempts = 1` → one switch allowed; RDMA fault → TCP success. | -| `PerTaskFailoverCountsAreIndependent` | A failing task must not consume another task's budget; `failover_count` is strictly per-task. | - -A test-local `PerRequestFaultProxy` (in the same file) subclasses `FaultProxyTransport` to take a `std::function` predicate, remembers which sub-task ids it marked as "poisoned" at submit time, and flips only those completions from `COMPLETED` to `FAILED` at status-query time. - -### Running manually - -The TENT tests are **not** in CI today (the upstream workflow builds with `USE_TENT=OFF`). Run them locally: - -```bash -cmake -S . -B build-tent -DUSE_TENT=ON -DUSE_CUDA=OFF -cmake --build build-tent --target tent_failover_test tent_engine_failover_e2e_test -j -./build-tent/mooncake-transfer-engine/tent/tests/tent_failover_test -./build-tent/mooncake-transfer-engine/tent/tests/tent_engine_failover_e2e_test -``` +The harness — why a fake `Transport` is enough, how fakes are swapped in, and what this can and cannot prove — is in {ref}`TENT Testing `. That page is the mechanism; this section only notes what failover uses it for: -Setting `USE_CUDA=OFF` forces `CpuPlatform`, which always reports `MTYPE_CPU`. With `USE_CUDA=ON` on a host without a GPU, `cudaPointerGetAttributes` fails, `getMemoryType` returns `MTYPE_UNKNOWN`, every transport reports unavailable, and `resolveTransport` returns `UNSPEC` before the fault injection ever runs. +* Completion-stage `FAILED` on the primary must resubmit on the next available transport. +* Submit-stage failure: a synchronous non-OK from `submitTransferTasks` must fail the owner task over to the remaining candidates (bounded by `max_failover_attempts`) instead of terminal-failing it. +* Derived (merged) alias tasks must follow their owner's recovered route and must not cause a duplicate physical submission. +* Exhausting `max_failover_attempts` (including `0` and `1`) must surface `FAILED` and must not touch a transport beyond the budget. +* `failover_count` is per-task: one failing request must not spend another request's budget. +* With `enable_auto_failover_on_poll=false`, status polling is observational; `progressBatch` / `waitTransferCompletion` / `transferSync` still recover. -Companion unit tests cover the rail monitor and related building blocks: `tent_rail_monitor_test`, `tent_failover_test`, `tent_fault_proxy_test`. +Submit-stage recovery is exercised through the same FakeTransport harness with `force_submit_fail` backends (see `SubmitStageFailureFailsOverToSecondary` and friends in `engine_failover_e2e_test.cpp`). ## Known Gaps -* **Submit-stage failures do not trigger failover.** When `submitTransferTasks` returns non-OK, every task in that call is marked `UNSPEC` and surfaces as `FAILED`. A naive retry loop here is unsafe for two reasons: - 1. **Merged requests.** When `merge_requests` is enabled (default), `task_id_list[type]` contains both the real merged task and its derived aliases. Resubmitting per task-id re-posts one logical transfer multiple times on the fallback transport, breaking the deduplication the merge pass established. - 2. **Partial enqueue.** Some transports (for example `ShmTransport::submitTransferTasks`, `NVLinkTransport::submitTransferTasks`) enqueue or start work for earlier requests in `request_list` before returning an error on a later one. The return status alone does not tell us which tasks partially succeeded, so a blanket resubmit would duplicate already-started transfers. - A safe submit-stage recovery needs either (a) a transport-level "atomic submit" capability flag plus per-task skip of derived ids, or (b) per-request status returned from `submitTransferTasks`. Neither exists today. +* **Submit-stage failure recovery is implemented** (previously a gap): a synchronous non-OK from `submitTransferTasks` fails the owner tasks over to the remaining candidate transports (bounded by `max_failover_attempts`), reusing `resubmitTransferTask`. The two hazards that originally made a naive retry unsafe are handled as follows: + 1. **Merged requests.** Only the owner task of each merged request is resubmitted; derived aliases mirror the owner's recovered route (transport, sub-batch slot, status) instead of being resubmitted themselves, preserving the merge pass's deduplication. + 2. **Partial enqueue.** `NVLinkTransport::submitTransferTasks` rolls back its half-appended task entries on synchronous failure (no I/O has started at that point). Transports that start per-request work inside the submit loop (e.g. `ShmTransport`, whose copies are synchronous and idempotent) may still cause a duplicate same-content transfer on the fallback; re-posting an identical request is a data-level no-op for KV-cache-style workloads. + A failover target whose own synchronous submit also fails is retried within the same budget; a task that exhausts all candidates surfaces `FAILED` attributed to the last attempted transport (never `unspec`). * `markRecovered` (and cooldown expiry in `available`) clears the exponential-backoff memory entirely. A rail that flaps repeatedly therefore does not accumulate a growing cooldown across recovery cycles. If this becomes a problem the fix is to decay rather than reset. * Cross-transport failover is driven purely by return status; there is no latency-based "this transport is healthy but too slow, try another" signal. That belongs to the scheduler, not this document. -* TENT tests are not exercised by CI. A follow-up can add a CI job that builds with `-DUSE_TENT=ON -DUSE_CUDA=OFF` and runs the `tent_*` test targets; none of the code in this document changes in that case. +* Runtime-layer failover is covered by FakeTransport tests in the `tent-ci` `cuda-off` legs. DMA integrity, real WC errors, and staging under NVLink still need hardware runners; see {ref}`TENT Testing `. +* UB rail health, endpoint-rebuild telemetry and device-failure cleanup are covered by fake-URMA-adapter tests (`tent_ub_core_test`, `tent_ub_teardown_test`, `tent_ub_native_data_path_test`, `tent_rail_monitor_test`). Real provider errors, bonding-device failover and the bootstrap fan-out of a cold multi-rail peer still need Kunpeng/URMA hardware runners. diff --git a/docs/source/design/tent/hp-tcp.md b/docs/source/design/tent/hp-tcp.md new file mode 100644 index 0000000000..cd9558579a --- /dev/null +++ b/docs/source/design/tent/hp-tcp.md @@ -0,0 +1,287 @@ +# TENT High-Performance TCP + +`hp_tcp` is a standalone TENT transport for CPU DRAM transfers over +data-center TCP. Standard `tcp` remains the RPC-based compatibility path. +The first version intentionally excludes GPU memory, TLS, WRITE striping, rail +failover, transparent replay after an ambiguous WRITE and dynamic lane +scheduling. + +## Architecture + +Each worker owns one `asio::io_context` and one thread. Each peer has a +configured number of persistent lanes. A separate sequence for each peer +rotates operations across them, so interleaved traffic to other peers cannot +pin a peer to one lane. Each sequence starts at that peer's first request ID +to preserve its initial lane choice. Request IDs remain globally unique. +A stable hash of peer and lane selects the owner; socket state +never moves between workers, and operations on a lane are FIFO. ASIO provides +the event queue; process-wide task and byte admission limits bound all accepted +work, including callbacks waiting in that queue. + +Both ends disable Nagle's algorithm: headers and payloads use separate writes, +so a delayed ACK must not hold up a short payload on a persistent socket. + +The server uses the same worker pool. Accepted sockets are assigned to workers +and stored in worker-owned session sets. A global connection limit bounds live +sessions; closing a session removes it immediately rather than retaining one +thread per historical connection. + +```text +TENT request -> bounded admission -> owner worker -> persistent lane + -> versioned TCP protocol -> registered remote buffer +``` + +### Static multi-rail routing + +`rail_addresses` optionally assigns persistent lanes across an ordered set of +local and remote TCP addresses. Each entry must be a numeric address assigned +to the local host. The current implementation accepts IPv4 addresses only. +When the list is non-empty it supplies the published +endpoints instead of `advertise_address`. Lane `i` uses rail +`i % rail_count`, binds its socket to the matching local address, and keeps the +existing peer-and-lane worker ownership. Both peers must configure the same +non-zero rail count. A specific listener address must match the sole rail; +multiple local rails require a wildcard listener. + +Routing is deliberately static. Small transfers and all WRITEs stay on one +persistent lane. A READ large enough to contribute at least one internal I/O +step per configured rail is split into one contiguous slice per rail. The +slices reuse the persistent lanes and complete as one TENT task. `hp_tcp` does +not rebalance traffic or fail over between rails. + +The current internal step is 1 MiB: with two rails, READs of at least 2 MiB +are sliced; smaller READs and single-rail READs are not. Remainder bytes are +assigned to the first slices, so uneven lengths still cover the request +exactly. Multiple independent WRITEs can use different rails, but each WRITE +stays on one lane and waits for its own remote completion ACK. + +`connections_per_peer` is the **total** lane budget, not a per-rail multiplier. +For example, four lanes and two rails give two lanes per rail. Worker count is +independent of rail count: several lanes may share an owner worker. Increasing +lanes on a single rail does not split a single READ into multiple streams. + +## Protocol and memory safety + +Requests contain a version, opcode, request ID, registration ID, remote +address and length. Responses contain the request ID, status and committed +byte count. A WRITE completes only after the target has copied the full payload +and returned an acknowledgement. A READ completes after the full response +payload arrives. + +Every registered buffer has an ID formed from a per-registry random namespace +and a monotonic sequence, plus a remote permission. This prevents a stale ID +from a previous server incarnation from becoming valid after restart. The +target validates the ID, range and permission before access. An operation holds +a lease until its final I/O callback retires; unregister hides the range from +new work and waits for existing leases. Stale registration metadata causes one +bounded metadata refresh and retry on the same transport. Permission and range +failures are terminal. + +If a WRITE request may have reached the peer but no valid acknowledgement is +received, the remote outcome is unknown. That failure is terminal and is not +replayed through another transport; otherwise a committed WRITE whose ACK was +lost could execute twice. + +## Timeouts and shutdown + +Resolve/connect use `connect_timeout_ms`. Header, payload and response progress +use `progress_timeout_ms` on both client and server. A newly accepted connection +must send its first header byte before the deadline, and every partial header +or payload must continue to make progress. After a valid request completes, +pure idle time on its persistent connection is not treated as stalled I/O; the +deadline resumes as soon as the next header begins. A timeout cancels the +resolver or socket; terminal completion is published only after the +corresponding callback retires. + +A failed READ slice cancels its siblings; the logical task settles only after +all slice callbacks retire. A stale-registration result can trigger the +existing bounded metadata refresh/retry, not migration onto another rail. +Independent peers can continue while a peer is waiting for its progress +timeout. FIFO sharing within one peer's lanes can still delay small requests +behind large ones; slicing is not a priority or preemption mechanism. + +The client separately closes a pooled socket after +`idle_connection_timeout_ms` without active or queued work on that lane +(default 60 seconds). New work before expiry cancels this timer and reuses the +socket; work after expiry reconnects. This releases receiver connection slots +held by idle updated clients. The server does not evict established idle sockets: +it cannot know whether a client has just started another WRITE. Older clients +that keep sockets open indefinitely still require their own pool cleanup. +Tasks attempted while the receiver connection limit is full can still fail; +idle cleanup is not task backpressure or an automatic retry policy. + +Shutdown closes admission and the listener, drains queued dispatch callbacks, +cancels every client lane and server session on its owner, waits for operations +and leases, then stops and joins worker threads. This makes shutdown bounded +even when a peer sends only part of a request. + +This ordering is a lifecycle invariant, not an incidental destructor detail: +the client and server are destroyed before the worker contexts they use. In a +debug build, normal teardown asserts that admission, client operations and +server sessions have all drained before their owners are destroyed. + +An exception escaping an ASIO handler marks the runtime failed and blocks +further admission. The owner event loop continues only to retire previously +committed work and process teardown cancellation with the same affinity. Once +those resources drain, shutdown joins the workers and reports the failure. +Likewise, admission-release underflow is fail-closed: counters are preserved, +new work is rejected, and drain returns an error instead of treating live work +as complete. + +## Configuration + +The transport is configured under `transports.hp_tcp`: + +| Field | Meaning | +| --- | --- | +| `enable` | Enable `hp_tcp`; set `transports.tcp.enable` to `false`. The two transports cannot be enabled together because control-plane notification ownership is singular. | +| `bind_address`, `advertise_address`, `port` | Listener and published endpoint. | +| `rail_addresses` | Ordered numeric IPv4 source addresses for static lane-to-rail routing. The list must be unique, no longer than `connections_per_peer`, and have the same length on both peers. A non-wildcard `bind_address` must equal the sole rail address; multiple rails require it to be empty or `0.0.0.0`. | +| `worker_count` | ASIO event-loop threads. | +| `connections_per_peer` | Persistent lanes per peer. | +| `max_outstanding_tasks`, `max_outstanding_bytes` | Global admission bounds. | +| `max_transfer_bytes` | Maximum request size. When HP TCP is enabled, coalescing of HP TCP/UNSPEC requests respects both local and advertised remote limits; an individually oversized request is still rejected. | +| `connect_timeout_ms`, `progress_timeout_ms` | Connection and I/O deadlines. | +| `idle_connection_timeout_ms` | Positive client idle-pool retention time; default 60000 ms. Active or queued requests are never expired by this timer. Shorter retention frees receiver slots sooner but requires more reconnections for intermittent traffic. | + +### Single-rail and paired-rail examples + +For a single rail, put this in the server's `MC_TENT_CONF` JSON file (replace +the example address with an address assigned to the host): + +```json +{ + "transports": { + "tcp": {"enable": false}, + "rdma": {"enable": false}, + "shm": {"enable": false}, + "hp_tcp": { + "enable": true, + "bind_address": "", + "rail_addresses": ["10.0.0.2"], + "worker_count": 4, + "connections_per_peer": 4 + } + } +} +``` + +Use the same configuration on the client with its local address `10.0.0.1`. +For two rails, change only the lists: + +| Host | `rail_addresses` | +| --- | --- | +| Client | `["10.0.0.1", "10.1.0.1"]` | +| Server | `["10.0.0.2", "10.1.0.2"]` | + +Entries pair by index, and both hosts need working source-address routes for +those pairs. Reused sockets retain that mapping; failed sockets are closed +before later requests reconnect. + +`MC_TENT_CONF` loads a complete configuration, so include the transport enable +flags even when using tebench's `--xport_type=hp_tcp`. To check data with the +existing benchmark, start its target, then run an initiator with its advertised +segment name: + +```bash +MC_TENT_CONF=client.json tebench --backend=tent --xport_type=hp_tcp \ + --tent_transport_hint=hp_tcp --target_seg_name=SERVER_SEGMENT \ + --seg_type=DRAM --op_type=mix --check_consistency=true \ + --start_block_size=67108864 --max_block_size=67108864 --duration=3 +``` + +Repeat with both block-size flags set to `4096` for the unsliced path. +With `--xport_type=hp_tcp --check_consistency=true`, the CPU checker uses +seed-reproducible, non-constant data and a full byte comparison to detect +reordered slices. Run throughput separately without this checking overhead. +Ordinary `mix`, other backends and `write_seed`/`read_verify` retain their +existing data patterns. + +### Checking rail use + +Test-local socket relays record the peer/local addresses and completed slice +ranges. Full-engine tests check payloads, guards, the slicing threshold and +connection reuse; two-process E2Es also cover unequal transfer-size limits. + +On two machines, inspect connections with `ss -tnp`, source-address routes with +`ip route get REMOTE from LOCAL`, and per-interface byte counters before/after +a transfer. READ payload moves from server TX to client RX. Compare both +rails' deltas with successful application bytes; account for protocol overhead +and unrelated traffic. Two open connections alone do not prove payload use. + +Loopback proves routing, not physical NIC use. Check PCI devices and shared +host/fabric limits. Compare one rail/one lane, one rail/multiple lanes, and two +rails/the same total lanes: the last two differ in single-READ slicing as well +as rail placement. + +### Tuning concurrent READs + +For one peer with `C` outstanding large READs and `R` rails, at most +`min(connections_per_peer, C * R)` payload streams can be active. This assumes +the READs are large enough to slice across all rails. Four outstanding READs +with four total lanes therefore have the same four-stream upper bound with +one or two rails; adding a rail alone does not increase that bound. + +With four rails and four total lanes, each rail has one connection. Eight +lanes provide two per rail. Each lane processes one operation at a time; +once those four lanes already have distinct owners, adding workers alone +cannot increase the number of active lane owners. Compare lane and worker counts separately, recording the counts +at both endpoints, throughput, tail latency and CPU use. Additional lanes +sharing the same workers need not increase throughput. + +Keep CPU and memory placement fixed during these comparisons; record the +NICs' NUMA nodes. Use per-thread CPU measurements because Store and tebench +callers also consume CPU polling for completion. A stream-count upper bound +does not predict throughput or establish a universal rail/worker default. + +### Measured scope + +On two Xeon 8457C virtual machines, with four workers and four total lanes, +three interleaved runs (1-second warmup, 3-second measurement) gave the +following medians: + +| READ workload | Metric | One rail | Two rails | +| --- | --- | ---: | ---: | +| 64 MiB, one concurrent task | Throughput (GB/s) | 3.22 | 6.46 | +| 64 MiB, four concurrent tasks | Throughput (GB/s) | 11.13 | 10.96 | +| 4 KiB, one concurrent task | Mean latency (microseconds) | 72 | 78 | + +Both interfaces carried payload-direction traffic, but their underlying +resource independence is not guaranteed. In that four-lane/four-worker setup, +four concurrent READs showed no additional gain. A same-pool 4 KiB/64 MiB +closed-loop mix still delayed small +tasks behind large ones: static slicing offers neither latency isolation nor +universal bandwidth scaling. + +A Store `get_into` comparison on two H20 hosts used the unchanged `202ad9c89` +Release build, 8 MiB host-memory objects and four closed-loop callers (one +outstanding READ each). Both endpoints used the worker/lane counts below, +CPU 0-89 and new allocations bound to NUMA node 0. Two rails used eth1/2 +on node 0; four rails also used eth3/4 on node 1. There were three 30-second +runs per configuration after a 2-second warmup. Each pair of configurations +was interleaved; the three pairs ran sequentially. + +| Rails | Workers / total lanes | Median GB/s [min, max] | Client CPU (core equivalents) | +| ---: | ---: | ---: | ---: | +| 2 | 4 / 4 | 10.371 [9.664, 10.747] | 6.84 | +| 2 | 8 / 8 | 8.022 [7.904, 8.159] | 5.40 | +| 4 | 4 / 4 | 7.599 [7.440, 7.877] | 6.86 | +| 4 | 4 / 8 | 8.537 [8.139, 8.624] | 6.93 | +| 4 | 8 / 4 | 7.339 [7.260, 7.790] | 6.83 | +| 4 | 8 / 8 | 11.487 [11.343, 11.551] | 7.76 | + +For four rails, increasing both counts improved throughput by 51.2% over +four/four, with client CPU rising from 6.86 to 7.76 cores. Increasing workers +alone did not help; increasing lanes alone helped less. The four-rail 8/8 +configuration was 10.8% faster than the best measured two-rail configuration +(4/4), with 13.5% more client CPU. Server-process CPU samples also rose +from about 2.24 to 3.35 core equivalents. Two rails regressed with 8/8, so +these results do not justify raising defaults. + +Before each of the 18 timing runs, all 32 objects passed bytewise checks. +Every run used the configured 4 or 8 connections, and per-rail payload byte +totals matched the static split. Separate stack samples confirmed +four active receive workers in the four-lane case and were excluded from +timing results. The experiment changes both endpoints' worker counts; it +does not isolate client versus server costs or remove NUMA effects. It covers +Store host-memory READs, not GPU transfers or model-level performance. diff --git a/docs/source/design/tent/metrics.md b/docs/source/design/tent/metrics.md index 65055fd74a..1dbe92f28a 100644 --- a/docs/source/design/tent/metrics.md +++ b/docs/source/design/tent/metrics.md @@ -258,7 +258,7 @@ enum closed set — no arbitrary transport strings are accepted. | Label | Values | Description | |-------|--------|-------------| -| `transport` | `unspec`, `rdma`, `mnnvl`, `shm`, `nvlink`, `gds`, `io_uring`, `tcp`, `ascend`, `sunrise_link`, `tpu` | The transport that handled the transfer | +| `transport` | `unspec`, `rdma`, `mnnvl`, `shm`, `nvlink`, `gds`, `io_uring`, `tcp`, `ascend`, `sunrise_link`, `tpu`, `ub`, `mpcomm`, `hp_tcp`, `xpu` | The transport that handled the transfer | | `operation` | `read`, `write` | Attempt operation | | `from` | (same set) | Transport that failed before failover | | `to` | (same set) | Transport that the failover switched to | @@ -266,11 +266,25 @@ enum closed set — no arbitrary transport strings are accepted. Transport label values come from the shared `transportTypeName()` mapping. `unspec` covers transfers that failed before a transport was selected. -**Cardinality**: the `transport` label has 11 values; the failover -`from`/`to` pair has at most 11x11 = 121 combinations (in practice only a -few pairs ever occur), and each attempt metric has at most 11x2 = 22 +**Cardinality**: the `transport` label has 15 values; the failover +`from`/`to` pair has at most 15x15 = 225 combinations (in practice only a +few pairs ever occur), and each attempt metric has at most 15x2 = 30 transport/operation combinations. Total series across all metrics is bounded -at ~1500. +by this closed set. + +### Per-rail load (`getNicLoadStats`) + +`TransferEngine::getNicLoadStats()` returns one `NicLoadStats` entry per usable +rail — `device_name`, `inflight_bytes` and `ewma_bandwidth_bps`. RDMA and UB +both implement it: RDMA reports one entry per local NIC, UB reports one entry +per UB device under the same `ub::` topology name used in logs and +metrics. A rail whose context failed to construct, or whose port is down, is +omitted rather than listed with a meaningless bandwidth. + +These are pull-based snapshots, not Prometheus series: UB rail health +(pauses, errors in window, timeouts, endpoint rebuilds) lives in `RailMonitor` +and is read through the transport rather than scraped. See {ref}`TENT Failover +` for how those counters drive rail selection. ## Integration with TransferEngine diff --git a/docs/source/design/tent/overview.md b/docs/source/design/tent/overview.md index 9559622614..6b37df87e7 100644 --- a/docs/source/design/tent/overview.md +++ b/docs/source/design/tent/overview.md @@ -93,6 +93,14 @@ metrics transport-selector ::: +## TENT High-Performance TCP + +:::{toctree} +:maxdepth: 1 + +hp-tcp +::: + ## TENT Quality of Service :::{toctree} @@ -109,6 +117,14 @@ qos slice-spraying ::: +## TENT Deadline Scheduling + +:::{toctree} +:maxdepth: 1 + +deadline-scheduling +::: + ## TENT Failover :::{toctree} @@ -116,3 +132,11 @@ slice-spraying failover ::: + +## TENT Testing + +:::{toctree} +:maxdepth: 1 + +testing +::: diff --git a/docs/source/design/tent/slice-spraying.md b/docs/source/design/tent/slice-spraying.md index 09355e6f19..c139f686fe 100644 --- a/docs/source/design/tent/slice-spraying.md +++ b/docs/source/design/tent/slice-spraying.md @@ -81,7 +81,7 @@ The penalty is applied as a multiplier to predicted completion time, making remo ### EWMA Bandwidth Estimation -Each device maintains an EWMA (Exponentially Weighted Moving Average) of its effective bandwidth: +Each device maintains a **selection EWMA** (Exponentially Weighted Moving Average) of its effective bandwidth, the series that device selection scores with: ``` initial_value = theoretical_bandwidth @@ -110,6 +110,69 @@ The EWMA provides: - **Stability**: Smooths out transient fluctuations - **Adaptability**: Tracks gradual changes in link quality +#### Transmit Estimate + +Each device also keeps a second series, the **transmit estimate**, for the +deadline predictors (described end to end in +[Deadline Scheduling](deadline-scheduling.md)): the admission queue's +deadline-infeasible drop (`runtime_queue/mlu_local_threshold`, reads the +transmit estimate of the transport that will carry the owner) and the RDMA +workers' bandwidth arbitration (`transports/rdma/deadline_bw_arbitration`, +reads the local NIC's value). Both compute the same predicted MLU from it: + +``` +predicted_mlu = ((bytes_ahead + length) / transmit_bandwidth) / remaining_window +``` + +`bytes_ahead` is what the request must wait behind before its own bytes move: +for the admission queue, every drop-eligible owner (RDMA or UB, not staged) +already dispatched and not yet completed — owners on other transports share the +queue but not the NIC, and the rate used for the prediction is the one the +owner's own transport reports. For the arbitration it is the NIC's **posted +bytes**: what has reached the hardware and not yet completed. That is +deliberately not the selector's `inflight_bytes`, which is charged when a slice +is *allocated* and so would include the very slices being ordered as well as +work still sitting in a worker queue. The order is then built one slot at a +time — the slice that takes a slot joins `bytes_ahead` for the ones still +waiting, since the QP posts them in that order (exactly for the first 64 slots; +the rest are ranked once against the bytes those slots accumulated). + +The deadline is absolute, so that wait counts against the window — as an +additive delay over the wire rate, not as a slower bandwidth (which would +multiply the wait by the request's slice count). + +It uses the same update rule and clamp as the selection EWMA, but it is fed +from a different measurement because it answers a different question: + +| | Selection EWMA | Transmit estimate | +|---|---|---| +| Question | Which NIC should the next slice go to? | How fast does this NIC move bytes? | +| Sample | one successful completion: bytes / (post → completion), so the NIC's own queueing behind earlier work requests is included and a backed-up NIC scores worse | one meter interval: bytes completed / time the NIC spent with work posted | +| α | `bandwidth_learning_rate` = 0.01 (~99% latest sample) | `transmit_bandwidth_learning_rate` = 0.9 (~10 intervals, ≈100 ms, to follow a change) | + +Per-completion timing cannot answer the second question. Up to `max_qp_wr` +work requests are posted in one call with timestamps that are effectively +one, and a poll pass timestamps every completion it collects alike, so a +slice's own "post → completion" grows with the depth of the batch it +travelled in — deep enough and the estimate would sit on its lower clamp on +a healthy link. Bytes over the NIC's busy time does not care how the work was +batched. + +Busy time is the time the device has had at least one work request posted: +a stretch opens when its posted bytes go from zero to non-zero and closes +when they return to zero, so the gaps of a workload that bursts and waits are +not charged to the link. A sample is offered only at the last completion of a +poll pass (every completion in a pass carries the same timestamp), and one +that spans more than `transmit_meter_max_interval_ns` of wall clock is +dropped rather than learned from: it describes a link too far in the past. A +posted slice that ends without moving its bytes — failed, flushed, timed +out — makes its stretch unusable, so the meter starts its next interval +fresh. With no usable interval the estimate keeps its last value, or the +link-speed seed — the optimistic direction, which cannot cause a false drop. + +With queueing carried by `bytes_ahead`, the rate itself must exclude +queueing or the wait would be counted twice. + ### Multi-Path Allocation For large transfers, TENT distributes slices across multiple devices: @@ -188,7 +251,8 @@ All slice spraying parameters are configurable via the configuration file: { "transports": { "rdma": { - "numa_penalties": [1.0, 5.0, 10.0] + "numa_penalties": [1.0, 5.0, 10.0], + "strict_local_numa": false } } } @@ -197,12 +261,44 @@ All slice spraying parameters are configurable via the configuration file: | Parameter | Type | Default | Description | |-----------|------|---------|-------------| | `numa_penalties` | array[float] | `[1.0, 5.0, 10.0]` | Penalty multipliers for each NUMA tier | +| `strict_local_numa` | bool | `false` | Never select a cross-NUMA NIC instead of penalizing it | **Guidelines**: - Higher values = stronger preference for local devices - Set all to `1.0` to disable NUMA awareness - Increase remote penalties if cross-NUMA latency is high +### Strict Local NUMA + +`numa_penalties` makes a remote NIC expensive but still selectable, so a busy +local NIC eventually loses to a cross-NUMA one. Set `strict_local_numa` (or the +`MC_STRICT_LOCAL_NUMA` environment variable, which accepts `1`/`0` and +`true`/`false`) to remove those NICs from selection entirely. + +A NIC is only excluded when the memory location and the NIC **both** report a +NUMA node and the nodes differ. If either side is unknown the NIC keeps its +`numa_penalties` weight, because discovery reports `-1` in cases where excluding +everything would break otherwise working hosts: + +- virtual machines and some GPUs, where sysfs exposes no `numa_node` +- bonded NICs such as `mlx5_bond_0` +- classic priority-matrix topologies (`MC_CUSTOM_TOPO_JSON`, + `topology/priority_matrix`), which carry no NUMA information at all + +On those hosts the flag has no effect; a warning is logged at startup so this is +visible rather than silent. A second warning names any location left without a +same-NUMA NIC, since transfers from it will fail with `DeviceNotFound`. + +**Trade-off**: strict mode converts a performance problem into an availability +one. Without a local NIC an allocation fails instead of degrading, so enable it +only where every memory location provably has a same-NUMA rail. + +**Scope**: the exclusion is enforced on the local NIC for both the first +selection and the retry path. For the remote NIC it is only a preference — the +peer publishes its own topology and may not run this policy, so failing a slice +because another host has no local rail would turn a local setting into a +cross-node outage. + ### Bandwidth Estimation ```json @@ -210,6 +306,9 @@ All slice spraying parameters are configurable via the configuration file: "transports": { "rdma": { "bandwidth_learning_rate": 0.01, + "transmit_bandwidth_learning_rate": 0.9, + "transmit_meter_interval_ns": 10000000, + "transmit_meter_max_interval_ns": 50000000, "ewma_min_bandwidth_multiplier": 0.1, "ewma_max_bandwidth_multiplier": 10.0 } @@ -219,15 +318,20 @@ All slice spraying parameters are configurable via the configuration file: | Parameter | Type | Default | Description | |-----------|------|---------|-------------| -| `bandwidth_learning_rate` | float | `0.01` | EWMA learning rate (0.0 = full adaptation, 1.0 = no learning) | +| `bandwidth_learning_rate` | float | `0.01` | Selection EWMA learning rate (0.0 = full adaptation, 1.0 = no learning) | +| `transmit_bandwidth_learning_rate` | float | `0.9` | Transmit estimate learning rate, same convention; read by the deadline predictors | +| `transmit_meter_interval_ns` | uint | `10000000` | How often a device's throughput is sampled (10 ms) | +| `transmit_meter_max_interval_ns` | uint | `50000000` | An interval longer than this is re-baselined instead of learned from | | `ewma_min_bandwidth_multiplier` | float | `0.1` | Minimum bandwidth as fraction of theoretical | | `ewma_max_bandwidth_multiplier` | float | `10.0` | Maximum bandwidth as fraction of theoretical | **Guidelines**: - Lower α (e.g., 0.001) → faster adaptation, more volatile → responds quickly to changes - Higher α (e.g., 0.1) → slower adaptation, more stable → smooths out transient fluctuations -- Default α = 0.01 provides balanced adaptation -- Multipliers constrain EWMA to reasonable range [0.1×, 10.0×] of theoretical bandwidth +- Default α = 0.01 provides balanced adaptation for device selection +- Keep `transmit_bandwidth_learning_rate` high: it backs an irreversible + drop decision, so it should follow sustained change, not single samples +- Multipliers constrain both series to [0.1×, 10.0×] of theoretical bandwidth ### Device Selection Scoring @@ -263,14 +367,39 @@ All slice spraying parameters are configurable via the configuration file: | Parameter | Type | Default | Description | |-----------|------|---------|-------------| -| `default_bandwidth_gbps` | float | `400.0` | Default NIC bandwidth when topology info unavailable | +| `default_bandwidth_gbps` | float | `400.0` | NIC bandwidth assumed when the port speed is unknown or out of range | | `min_bandwidth_gbps` | float | `10.0` | Minimum valid NIC bandwidth (Gbps) | | `max_bandwidth_gbps` | float | `800.0` | Maximum valid NIC bandwidth (Gbps) | **Notes**: -- These constants define the valid range and default for device bandwidth -- Used in EWMA calculations and theoretical bandwidth estimation -- If a device's reported bandwidth is outside [min, max], default_bandwidth is used +- Each device's bandwidth is read from the speed and width its port + negotiated (`ibv_query_port`), so a 100G and a 400G NIC in the same host + start from different theoretical rates. Where libibverbs provides + `ibv_query_port_speed()` (rdma-core >= 62) the *effective* speed it + reports is preferred: for a VF over LAG that is the bandwidth left after + a PF drops out of the bond, which the encoded link rate cannot express. + The verb is resolved as an optional symbol, so older libraries keep + working on the encoded rate. A query *error* keeps the last known + effective speed (falling back would briefly restore the higher encoded + rate on a degraded LAG); failures are counted per device and logged once + per episode +- The theoretical rate seeds the EWMA and bounds it to + `[ewma_min_multiplier, ewma_max_multiplier]` times that rate +- If a device's port speed cannot be read or is outside [min, max], + `default_bandwidth_gbps` is used and a warning is logged +- A NIC that cannot carry traffic -- its context was never constructed, + `construct()` failed, or its port is down -- is marked unavailable: it is + excluded from device selection and from the aggregate bandwidth the + admission queue's deadline predictor reads. The default speed applies + only to a usable NIC whose speed could not be determined. `PORT_ERR` + marks a device unavailable and `PORT_ACTIVE` restores it; both are + matched against the port the context opened, since a device's async + events cover every port of that device +- The link speed is re-read on `IBV_EVENT_PORT_ACTIVE`, and on + `IBV_EVENT_DEVICE_SPEED_CHANGE` where rdma-core (>= 62) provides it. If + the speed changed -- a 400G link returning at 100G, or a VF over LAG + losing a PF -- the device's EWMA is re-seeded and its clamp re-derived; a + link that returns at the same speed keeps its learned estimate ## Usage Examples diff --git a/docs/source/design/tent/testing.md b/docs/source/design/tent/testing.md new file mode 100644 index 0000000000..595ac43aec --- /dev/null +++ b/docs/source/design/tent/testing.md @@ -0,0 +1,202 @@ +(tent-testing)= +# TENT Testing + +TENT's runtime does not inspect how a byte moved. It decides from a small +`Transport` contract: capabilities, a submit `Status`, and a +`TransferStatus` (`PENDING` / `COMPLETED` / `FAILED`). That is the +opening for FakeTransport: an in-memory backend that speaks the same +contract, moves no bytes, and lets tests drive the real +`TransferEngineImpl` with deterministic faults. + +This page is about that mechanism. Individual test names and binaries +change; the contract does not. Transport-layer tests (RDMA work-request +errors, NVLink, GPU registration) stay with each real backend and need +hardware. + +## Why a Fake Transport Works + +Correctness splits into two layers: + +1. **Transport-layer** — how a backend moves bytes and reports hardware + faults. Owned by each `Transport`. +2. **Runtime-layer** — failover, batch lifecycle, progress worker, + shutdown. Owned by `TransferEngineImpl` and `ProxyManager`. The + runtime never looks at DMA; it only consumes the contract above. + +A real transport produces those values as a side effect of moving data. +A fake that returns the same values at the same times drives +`submitTransfer`, `getTransferStatus`, `resubmitTransferTask`, +`progressBatch`, and shutdown identically. Failures that are hard to +stage on hardware — a completion that never arrives, the Nth submit +failing, a batch still `PENDING` when memory is freed — become a +controlled return value. + +The production runtime is not stubbed. Tests wrap or replace the +transport slots, then exercise the engine as an application would. + +``` +Test + | + v +TransferEngineImpl / ProxyManager (unmodified production code) + | + +-- swapTransportForTest(...) + | + v +[optional] fault-injecting decorator + | + v +FakeTransport (programmable Status, no DMA) +``` + +## The Transport Contract Tests Actually Use + +FakeTransport implements `Transport` with the minimum surface the +runtime needs: + +- **Capabilities.** Advertise `dram_to_dram` so the engine considers the + slot available. Leave GPU capability bits off; FakeTransport tests + register CPU buffers. The runtime will not route a CUDA request to a + CPU-only fake. +- **Buffer tagging.** `addMemoryBuffer` records the fake's slot on + `BufferDesc::transports`. Without that tag, `resolveTransport()` will + not pick the swapped-in backend. +- **Submit.** Record the request and stamp a `TransferStatus`. Submit + itself succeeds unless a decorator or subclass fails it. +- **Poll.** Return the stamped status, or override it from the poll + count (for example `PENDING` for N polls, then `COMPLETED`). +- **Memory.** `malloc` / `free`. No pinning, no GPU registration, no + DMA. `warmupMemory` declines so the engine uses its own warmup path. + +Two optional hooks cover most status control without subclassing: + +- stamp a status at submit time +- override the status on each `getTransferStatus`, given the poll count + +Call counts on install / submit / poll / register are how a test proves +the runtime took a path (for example primary submitted once, secondary +submitted after failover) without looking inside the engine. + +Copies of FakeTransport live in the test files that need them, not in a +shared header. Each test can extend its copy; divergence is expected. +The point of the fake is the contract, not a single implementation. + +## Fault Injection Is a Decorator + +FakeTransport, by itself, completes successfully. Faults are layered on +top so the engine still sees an ordinary `Transport`: + +| Layer | Role | +|-------|------| +| FakeTransport | In-memory success path; programmable completions. | +| `FaultProxyTransport` | Wraps any `Transport` and injects policy-driven faults: failed submit, `COMPLETED → FAILED` on poll, failed `install()`, optional delay. | +| Test-local subclass | When the fault is not a policy (poison only some requests; flip an atomic mid-loop). | + +The engine's failover and retry paths run unmodified. The decorator is +what makes a completion-stage `FAILED` look like a WC error or a dropped +peer, without a verbs layer. + +`FaultProxyTransport` lives under +`mooncake-transfer-engine/tent/transport/fault_proxy/`. Its policy +fields are the source of truth; this page does not duplicate them. + +## Installing Fakes Without Bypassing the Runtime + +`TransferEngineImpl::swapTransportForTest` replaces one slot in +`transport_list_` after `construct()`. That is the only test hook that +installs a fake (or a wrapped fake) while leaving `resolveTransport()` +and `resubmitTransferTask()` on the production path. Production code +never calls it. + +A typical setup: + +1. Build a config with `p2p` metadata on loopback and **real transports + disabled**, so `construct()` does not require Redis, etcd, or an RDMA + device. +2. Construct the engine. +3. `install()` the fake (or proxy), then `swapTransportForTest` into the + slots under test. Swap only replaces the slot; it does not re-run + engine availability. +4. Register memory *after* the swap, so `addMemoryBuffer` tags the + buffers. +5. Submit and poll through the public engine API. + +Install two slots when the scenario needs a fallback (typically RDMA + +TCP). Install one slot when the test wants no failover. + +`USE_CUDA=OFF` is required on CPU-only hosts. With CUDA enabled and no +GPU, pointer queries fail, memory type becomes unknown, every transport +looks unavailable, and `resolveTransport()` returns `UNSPEC` before any +injected fault runs. + +## What the Mechanism Can Prove + +FakeTransport proves how the runtime *reacts* to the transport contract. +It cannot prove that a backend moved the right bytes. + +**In scope.** Cross-transport failover and budget exhaustion; poll vs +`progressBatch` recovery; progress-worker and `freeBatch` races; queue / +hint / metrics behavior that only depends on submit and poll results; +concurrency of runtime maps that FakeTransport can reach. + +**Out of scope.** DMA integrity; verbs-level failures (WC error, QP +failure); GPU registration; NVLink link faults; true async RPC timing +(in-process callbacks fire synchronously). Those belong to each +transport's tests, `tebench --check_consistency=true` on real peers, or +multi-node hardware CI. + +(staging-trigger-constraints)= + +**Staging is decided before the request reaches a transport.** +`findStagingPolicy()` inspects which hardware backends are installed and +what memory types the request uses, then `ProxyManager` runs the staging +loop. Swapping FakeTransport into RDMA or TCP does not change that +decision, and every current trigger is gated on NVLink, MNNVL, or TPU — +none of which a CPU-only fake replaces. A CPU-only host therefore cannot +exercise `transferEventLoop` with either real transports or fakes. +Covering that path is a hardware-runner problem, or a product change to +the staging policy, not a missing FakeTransport feature. + +## Running + +```bash +cmake -S . -B build-tent \ + -DUSE_TENT=ON -DUSE_CUDA=OFF \ + -DWITH_STORE=OFF -DWITH_STORE_RUST=OFF \ + -DBUILD_UNIT_TESTS=ON -DCMAKE_BUILD_TYPE=Release + +cmake --build build-tent --target mooncake_common -j +ctest --test-dir build-tent/mooncake-transfer-engine/tent/tests \ + --output-on-failure +``` + +`tent_link_group` links `mooncake_common` by archive path, which does +not create a CMake build-order dependency — build `mooncake_common` +first. The `tent-ci` `cuda-off` legs in GitHub Actions run the same +`ctest` directory. The `cuda-on` leg compiles only: runners have no GPU, +and CUDA stubs would bypass the fakes. + +Concurrency tests that touch shared runtime maps should also be run +under ThreadSanitizer (`-fsanitize=thread`). Which binaries those are +belongs in the test sources, not here. + +## What belongs in this suite + +Tests should call production functions or the public engine API. Do not +reimplement a state machine in the test file and assert against that +copy. Enum assignment, `std::atomic` store/load, and `Config::get/set` +without going through `install()` / `TransferEngineImpl` do not lock +runtime behavior. + +Binaries that are not ctest: + +- `tent_metrics_example` — HTTP metrics demo +- `deadline_promotion_bench` — hot-path microbenchmark +- `tent/benchmark/hip_bandwidth_bench.cpp` — standalone `hipcc` HIP + bandwidth sweep; CMake does not build it + +Hardware data-path tests (`tent_rdma_transport_test` roundtrip, +`tent_nvlink_transport_test`, Sunrise, MPComm) skip or are omitted from +ctest when the device is missing. That skip is expected; a green +`cuda-off` run does not mean those paths were exercised. `tent_nvlink_transport_test` +is built when CUDA is on but is not registered with ctest. diff --git a/docs/source/design/tent/transport-selector.md b/docs/source/design/tent/transport-selector.md index e4ba13e2ec..6eef995148 100644 --- a/docs/source/design/tent/transport-selector.md +++ b/docs/source/design/tent/transport-selector.md @@ -191,6 +191,25 @@ If no `policy` is configured, TENT falls back to original behavior: | File | GDS → IOURING → RDMA | | Memory | Uses `buffer_transports` order from buffer registration | +### UB as a selectable transport + +UB (`TransportType::UB`) takes part in the same policy machinery as every other +transport: it can be listed in a policy's `transports` array, pinned with +`transport_hint`, or reached through `transport_index`. Two places name it +explicitly instead: + +* **Local memory allocation.** When the requested transport is `UNSPEC`, + allocation walks `MNNVL → RDMA → UB → TCP → HP_TCP` (with `SHM` taking the + host-memory slot ahead of RDMA for CPU and wildcard locations), so an + installed UB transport is preferred over the generic TCP paths. +* **TPU staging.** The TPU staging policy needs a host-DRAM network transport + for the host↔host hop and gates on `RDMA`, `UB`, `TCP` or `HP_TCP`. + +Once UB is the selected transport, the rail for each slice is chosen inside +`UbTransport` rather than by this selector; see {ref}`TENT Failover +` for UB rail health, path ranking and endpoint rebuild +behaviour. + ## Complete Example ```json diff --git a/docs/source/design/transfer-engine/index.md b/docs/source/design/transfer-engine/index.md index 93d6d40125..c9b76a9a95 100644 --- a/docs/source/design/transfer-engine/index.md +++ b/docs/source/design/transfer-engine/index.md @@ -11,7 +11,7 @@ Mooncake Transfer Engine is a high-performance, zero-copy data transfer library As shown in the diagram, each specific client corresponds to a `TransferEngine`, which not only includes a RAM Segment but also integrates management for high-speed transfers across multiple threads and network cards. The RAM Segment, in principle, corresponds to the entire virtual address space of this `TransferEngine`, but in reality, only parts of it (known as a `Buffer`) are registered for (GPUDirect) RDMA Read/Write. Each Buffer can have separate permissions (corresponding to RDMA `rkey`, etc.) and network card affinity (e.g., preferred NICs for different types of memory). -Mooncake Transfer Engine provides interfaces through the `TransferEngine` class (located in `mooncake-transfer-engine/include/transfer_engine.h`), where the specific data transfer functions for different backends are implemented by the `Transport` class, currently supporting `TcpTransport`, `RdmaTransport`, `EfaTransport`, `NVMeoFTransport`, `NvlinkTransport`, `IntraNodeNvlinkTransport`, and `HipTransport`. +Mooncake Transfer Engine provides interfaces through the `TransferEngine` class (located in `mooncake-transfer-engine/include/transfer_engine.h`), where the specific data transfer functions for different backends are implemented by the `Transport` class, currently supporting `TcpTransport`, `RdmaTransport`, `EfaTransport`, `NVMeoFTransport`, `NvlinkTransport`, `IntraNodeNvlinkTransport`, `HipTransport`, and `ShmTransport`. (segment)= ### Segment @@ -41,6 +41,7 @@ With the help of Transfer Engine, Mooncake Store can achieve local DRAM/VRAM rea | NVMe-of | ✓ | ✓ | - Local memcpy: If the target Segment is actually in the local DRAM/VRAM, direct data copy interfaces such as memcpy, cudaMemcpy are used. +- SHM: Same-host DRAM transfers of POSIX shm-backed buffers (`MC_FORCE_SHM=1`). With `-DENABLE_MULTI_PROTOCOL=ON` this installs SHM alongside RDMA/TCP; without it, SHM is the only transport. Objects are `0600` (same UID). Same hostname does not imply a shared `/dev/shm`. In-flight copies pin the cached mmap so prune/cap cannot unmap it. - TCP: Supports data transfer between local DRAM and remote DRAM. - RDMA: Supports data transfer between local DRAM/VRAM and remote DRAM. It supports multi-network card pooling and retry functions in implementation. - HIP: Supports intra-node data transfers between GPU VRAM and GPU VRAM, as well as between GPU VRAM and CPU DRAM, using IPC handles or Shareable handles for ROCm. @@ -159,7 +160,7 @@ The following video shows a normal run as described above, with the Target on th ![transfer-engine-running](../../image/transfer-engine-running.gif) ## Transfer Engine C/C++ API -Transfer Engine provides interfaces through the `TransferEngine` class (located in `mooncake-transfer-engine/include/transfer_engine.h`), where the specific data transfer functions for different backends are implemented by the `Transport` class, currently supporting `TcpTransport`, `RdmaTransport`, `EfaTransport` (for AWS EFA), `NVMeoFTransport`, `NvlinkTransport` (for NVIDIA GPUs), `IntraNodeNvlinkTransport` (for NVIDIA GPUs), and `HipTransport` (for AMD GPUs). +Transfer Engine provides interfaces through the `TransferEngine` class (located in `mooncake-transfer-engine/include/transfer_engine.h`), where the specific data transfer functions for different backends are implemented by the `Transport` class, currently supporting `TcpTransport`, `RdmaTransport`, `EfaTransport` (for AWS EFA), `NVMeoFTransport`, `NvlinkTransport` (for NVIDIA GPUs), `IntraNodeNvlinkTransport` (for NVIDIA GPUs), `HipTransport` (for AMD GPUs), and `ShmTransport` (POSIX shm for same-host DRAM). For a complete C++ API reference, see [Transfer Engine C++ API Reference](../../api-reference/cpp/transfer-engine.md). @@ -446,7 +447,17 @@ To support the operational needs of P2P Store, Transfer Engine provides a Golang When compiling the project, enable the `-DWITH_P2P_STORE=ON` option to compile the P2P Store example program at the same time. ### Using Rust Interface -Under `mooncake-transfer-engine/rust`, the Rust interface implementation of TransferEngine is provided, and a Rust version of the benchmark is implemented based on the interface, similar to [transfer_engine_bench.cpp](gh-file:mooncake-transfer-engine/example/transfer_engine_bench.cpp). To compile the rust example, you need to install the Rust SDK and add `-DWITH_RUST_EXAMPLE=ON` in the cmake command. +`mooncake-transfer-engine/rust` ships `transfer_engine_rust`, a library crate +that wraps the Transfer Engine C API (`transfer_engine_c.h`). Hot-path types +(`TransferRequest`, `BufferEntry`, `TransferStatus`) match the C ABI so +`submit_transfer` is a zero-copy FFI call. A throughput benchmark example +(`examples/transfer_engine_bench.rs`) mirrors +[transfer_engine_bench.cpp](gh-file:mooncake-transfer-engine/example/transfer_engine_bench.cpp). + +To build the library and example, install a Rust toolchain and libclang, then +configure CMake with `-DWITH_RUST_EXAMPLE=ON`. See the +[Transfer Engine Rust API](../../api-reference/rust/transfer-engine.md) for +signatures and the crate `README.md` for Cargo-only builds. ## Advanced Runtime Options For advanced users, TransferEngine provides the following advanced runtime options, all of which can be passed in through **environment variables**. @@ -487,18 +498,24 @@ For advanced users, TransferEngine provides the following advanced runtime optio - `MC_ENABLE_DEST_DEVICE_AFFINITY` Enable device affinity for RDMA performance optimization. When enabled, Transfer Engine will prioritize communication with remote NICs that have the same name as local NICs to reduce QP count and improve network performance in rail-optimized topologies. The default value is false - `MC_TRACK_RDMA_POSTED_SLICES` Enable RDMA posted-slice tracking for timeout diagnostics. When enabled, CQ timeout logs include stuck transfer groups by peer NIC path, slice count, bytes, oldest post age, and sample addresses. This adds synchronization on the RDMA post and poll hot paths, so it is disabled by default and should be enabled only while diagnosing stuck completions. - `MC_ENABLE_PARALLEL_REG_MR` Control parallel memory region registration across multiple RDMA NICs. Valid values: -1 (auto, default), 0 (disabled), 1 (enabled). When set to -1, parallel registration is automatically enabled when multiple RNICs exist and memory has been pre-touched. Note: If memory hasn't been touched before registration, parallel registration can be slower than sequential registration -- `MC_MAX_CONCURRENT_REG_MR` Cap on how many buffers `registerLocalMemoryBatch` registers concurrently (EFA transport). The default 0 means unbounded — one thread per buffer, the historical behavior. Note the cap is **per process**, so a framework running one `TransferEngine` per TP rank multiplies it by the rank count. Capping can cut registration time substantially when a batch holds many large GPU buffers. Registration is CPU-bound, so a reasonable value is `cores / processes-per-node` — on a 192-core node running 8 ranks, around 16. Oversubscribing costs more than undersubscribing, and the result also depends on the order the caller passes buffers in, so a poorly chosen cap can be slower than unbounded — hence opt-in. +- `MC_MAX_CONCURRENT_REG_MR` Cap how many buffers the EFA and RDMA transports process concurrently in `registerLocalMemoryBatch` and `unregisterLocalMemoryBatch`. The default 0 means unbounded — one thread per buffer, the historical behavior. Note the cap is **per process**, so a framework running one `TransferEngine` per TP rank multiplies it by the rank count. Capping can cut registration time substantially when a batch holds many large GPU buffers. Registration is CPU-bound, so a reasonable value is `cores / processes-per-node` — on a 192-core node running 8 ranks, around 16. Oversubscribing costs more than undersubscribing, and the result also depends on the order the caller passes buffers in, so a poorly chosen cap can be slower than unbounded — hence opt-in. - `MC_EFA_NIC_SELECTION` Which NICs the EFA transport registers a buffer on. `all` (the default) registers every buffer on every NIC. `local` restricts **device** memory to the NICs the topology reports as closest to that GPU, which on p5.48xlarge is the 4 EFA devices sharing the GPU's PCIe root complex. Because EFA charges device-memory registration in proportion to the device bytes already registered on the same libfabric domain, narrowing the NIC set cuts registration time by close to the fan-out ratio. Set it when registering many GPU buffers is a startup bottleneck; it is opt-in because fewer NICs can serve a transfer touching that buffer, so a job whose working set sits behind a single GPU is capped at that rail group's bandwidth rather than the node's. Host memory is unaffected. Combine with `MC_MAX_CONCURRENT_REG_MR`, which bounds a different variable: this reduces the cost per registration, that one reduces how many run at once. - `MC_EFA_CQ_THREADS` Cap on the number of CQ polling threads in the EFA transport, default value 1 (which already reaches ~99.9% of peak throughput). Pollers busy-wait, so each extra thread costs a full core. Set 0 to lift the cap (one poller per EFA device). Values above the device count are ignored - `MC_FORCE_HCA` Force to use RDMA as the active transport, return error if no HCA has been found. - `MC_FORCE_MNNVL` Force to use Multi-Node NVLink as the active transport regardless whether RDMA devices are installed. +- `MC_FORCE_MUSA` (MUSA only) Force the `musa` GPU IPC transport even when RDMA devices are installed. `MC_FORCE_MNNVL` remains accepted as a compatibility alias in MUSA builds. - `MC_INTRA_NVLINK` Enable intra-node NVLINK transport, and cannot be used together with MC_FORCE_MNNVL. -- `MC_FORCE_TCP` Force to use TCP as the active transport regardless whether RDMA devices are installed. +- `MC_MUSA_IPC_OPEN_DEVICE` (`musa` transport only) Select the device context used to open imported IPC memory. The default `current` preserves the caller's context; `metadata` is an opt-in that uses the runtime-visible logical ordinal advertised by the remote buffer. With `metadata`, every peer must map each logical ordinal to the same physical GPU. `MTHREADS_VISIBLE_DEVICES` is a container-toolkit setting and is not used by Mooncake to infer this mapping. +- `MC_MUSA_COPY_API` (`musa` transport only) Select `auto` (default), `transfer_batch`, or `default`. On MUSA SDK 5.2 or newer, `auto` uses `muMemoryTransferBatchAsync` when every copy in a batch meets `MC_MUSA_TRANSFER_BATCH_MIN_BYTES`; `default` uses per-slice CUDA-compatible copies. +- `MC_MUSA_TRANSFER_BATCH_MIN_BYTES` (`musa` transport only) Minimum copy size selected by `MC_MUSA_COPY_API=auto`. The default is 1048576 (1 MiB). +- `MC_FORCE_TCP` Force to use TCP as the active transport regardless whether RDMA devices are installed. Takes precedence over `MC_FORCE_SHM`. +- `MC_FORCE_SHM` Opt in to POSIX SHM (or hugetlbfs-backed SHM via `allocateSharedMemory(..., SharedMemoryOptions)`) for same-host DRAM copies of buffers allocated with `allocateSharedMemory`. Default off. With `-DENABLE_MULTI_PROTOCOL=ON`, SHM is installed alongside RDMA/TCP (`rdma,shm` / `tcp,shm`). Without multi-protocol, SHM is the only transport (skip RDMA/TCP auto-install), like `MC_FORCE_TCP`. `MC_FORCE_TCP` is handled first in `init` and returns before SHM install. Alternative: `installTransport("shm")`, which logs a WARNING if it overwrites a non-empty rdma/tcp protocol. Objects are created `0600` (same UID). `freeSharedMemory` unlinks the object; `SIGKILL` leftovers in `/dev/shm` or on the hugetlbfs mount are not reaped (hugetlbfs files continue to reserve hugepages — do not wipe all `mooncake_*` on start). A peer `free`+`allocate` that reuses the same virtual address is remapped after the name disappears; a new address still needs a fresh `BufferDesc` (see `MC_TE_METADATA_REFRESH_INTERVAL_SECONDS`). - `MC_MIN_RPC_PORT` Specifies the minimum port number for RPC service. The default value is 15000. - `MC_MAX_RPC_PORT` Specifies the maximum port number for RPC service. The default value is 17000. - `MC_PATH_ROUNDROBIN` Use round-robin mode in the RDMA path selection. This may be beneficial for transferring large bulks. - `MC_TE_FILTERS` Optional comma-separated whitelist of IB device names (e.g. `mlx5_0,mlx5_2`) for legacy Transfer Engine topology discovery. When unset, all available devices are discovered. -- `WITH_NVIDIA_PEERMEM` When set to `1`, `ON`, or `TRUE`, Mooncake uses `ibv_reg_mr()` directly for GPU memory registration (requires the `nvidia-peermem` kernel module). By default (unset or `0`), Mooncake uses the DMA-BUF path which does not require `nvidia-peermem`. +- `WITH_NVIDIA_PEERMEM` When unset or set to `1`, `ON`, or `TRUE`, Mooncake uses `ibv_reg_mr()` directly for GPU memory registration (requires the `nvidia-peermem` kernel module). Set to `0` to use the DMA-BUF path without `nvidia-peermem`. `MC_RDMA_DATA_DIRECT=1` takes precedence for the classic RDMA transport. +- `MC_RDMA_DATA_DIRECT` Set to `1` to enable NVIDIA Data Direct for CUDA device memory in the classic RDMA transport (default: disabled). Overrides `WITH_NVIDIA_PEERMEM`. Requires a [supported platform](https://docs.nvidia.com/multi-node-nvlink-systems/grace-blackwell-cx8-gpudirect-rdma-guide/platform_software_and_configuration.html), compatible runtime `libmlx5`, and compatible NICs selected with `MC_TE_FILTERS` or custom topology. Unsupported configurations fail without fallback. - `MC_ENDPOINT_STORE_TYPE` Choose FIFO Endpoint Store (`FIFO`) or Sieve Endpoint Store (`SIEVE`), default is `SIEVE`. - `MC_TCP_ENABLE_CONNECTION_POOL` Enable TCP Connection Pool to avoid excessive sockets. - `MC_TCP_SLICE_SIZE` The segmentation granularity (in bytes) of TCP transport for splitting large transfers into socket read/write operations. Corresponds to `MC_SLICE_SIZE` for RDMA. Default value 65536 (64KB). @@ -516,33 +533,6 @@ For the complete C++ API reference, see [Transfer Engine C++ API](../../api-refe ../../getting_started/supported-protocols ::: -## EFA Transport (AWS) - -:::{toctree} -:maxdepth: 1 - -efa_transport -::: - -## Ascend Transport Component - -:::{toctree} -:maxdepth: 1 - -ascend_direct_transport -ascend_transport -heterogeneous_ascend -::: - -## Sunrise Link Transport Component - -:::{toctree} -:maxdepth: 1 - -kunpeng_ub_transport -sunrise_link_transport -::: - ## Benchmark and Tuning Guide :::{toctree} diff --git a/docs/source/design/transfer-engine/ascend_direct_transport.md b/docs/source/design/transfer-engine/transport/ascend_direct_transport.md similarity index 100% rename from docs/source/design/transfer-engine/ascend_direct_transport.md rename to docs/source/design/transfer-engine/transport/ascend_direct_transport.md diff --git a/docs/source/design/transfer-engine/ascend_transport.md b/docs/source/design/transfer-engine/transport/ascend_transport.md similarity index 100% rename from docs/source/design/transfer-engine/ascend_transport.md rename to docs/source/design/transfer-engine/transport/ascend_transport.md diff --git a/docs/source/design/transfer-engine/efa_transport.md b/docs/source/design/transfer-engine/transport/efa_transport.md similarity index 99% rename from docs/source/design/transfer-engine/efa_transport.md rename to docs/source/design/transfer-engine/transport/efa_transport.md index b624485646..3fcd89ddf8 100644 --- a/docs/source/design/transfer-engine/efa_transport.md +++ b/docs/source/design/transfer-engine/transport/efa_transport.md @@ -31,7 +31,7 @@ cd Mooncake sudo ./dependencies.sh -y ``` -This installs all system packages, git submodules (including pybind11 and yalantinglibs), and Go. +This installs all system packages, git submodules (pybind11), and Go. > **Note:** The EFA driver and libfabric are **not** installed by `dependencies.sh`. They must be pre-installed on the instance (see section 1 above). diff --git a/docs/source/design/transfer-engine/transport/flagcx_transport.md b/docs/source/design/transfer-engine/transport/flagcx_transport.md new file mode 100644 index 0000000000..0d03b369b0 --- /dev/null +++ b/docs/source/design/transfer-engine/transport/flagcx_transport.md @@ -0,0 +1,332 @@ +# FlagOS FlagCX Transport + +## Overview + +[FlagCX](https://github.com/flagos-ai/FlagCX) is the unified communication library in the FlagOS +ecosystem for multi-vendor and cross-vendor deployments. Mooncake's FlagCX transport connects the +classic Transfer Engine to the FlagCX P2P Engine. The integration keeps Mooncake's existing +segment, memory-registration, batch, and completion APIs and maps them to FlagCX connections and +P2P read/write requests. + +The protocol name used by Mooncake configuration and APIs is `flagcx`. `flagos` is not a protocol +name. + +> **Classic Transfer Engine only.** The FlagCX transport is implemented under +> `mooncake-transfer-engine/src/transport/` and is not a TENT transport. Build with +> `USE_FLAGCX=ON`, select `--backend=classic` in `tebench`, and use `flagcx` as a standalone +> protocol. + +### Code Structure + +| Unit | Responsibility | +|------|----------------| +| `flagcx_transport.{h,cpp}` | Creates the P2P Engine, advertises its endpoint, registers memory, opens peer connections, submits transfers, and polls completion | +| `flagcx_transport_internal.h` | Overflow-safe registered-range checks and descriptor-length conversion | +| `mooncake-common/common.cmake` | Locates the external FlagCX headers and library and defines `FlagCX::flagcx` | +| `multi_transport.cpp` | Instantiates `FlagCxTransport` when the protocol name is `flagcx` | + +### Transfer Pipeline + +1. **Initialization:** Mooncake creates a FlagCX P2P Engine and starts its RPC server. +2. **Advertisement:** the transport obtains the FlagCX endpoint and publishes it in the local + Mooncake segment descriptor. +3. **Registration:** `registerLocalMemory()` registers each local range with FlagCX and publishes + the corresponding Mooncake buffer descriptor. +4. **Connection:** the first request to a remote segment reads its published FlagCX endpoint and + opens a connection. FlagCX caches the connection for later requests. +5. **Submission:** Mooncake groups requests with the same target and operation, creates remote + descriptors, and calls the FlagCX vector read or write API. +6. **Completion:** a Mooncake worker polls the returned FlagCX transfer ID and updates the original + Mooncake task when the transfer becomes terminal. + +--- + +## Dependencies + +Use compatible FlagCX revisions on all nodes. Each node may build the accelerator backend that +matches its local platform. The prefix passed as `FLAGCX_HOME` must contain: + +- `include/flagcx_p2p.h` +- `lib/libflagcx.so` or `lib64/libflagcx.so` +- The accelerator runtime, communication library, and network dependencies required by the chosen + FlagCX backend + +Choose the FlagCX backend that matches each node's local platform; for example, `USE_NVIDIA=1`, +`USE_METAX=1`, or `USE_MUSA=1`. See the +[FlagCX getting-started guide](https://github.com/flagos-ai/FlagCX/blob/main/docs/getting_started.md) +for the current backend list and platform prerequisites. + +## Build and Compile + +### 1. Build FlagCX + +```bash +git clone https://github.com/flagos-ai/FlagCX.git +cd FlagCX +git submodule update --init --recursive + +# Replace USE_NVIDIA with the backend for the local platform. +make USE_NVIDIA=1 -j$(nproc) +``` + +The default build output is suitable for an in-tree Mooncake build: + +```text +FlagCX/build/include/flagcx_p2p.h +FlagCX/build/lib/libflagcx.so +``` + +Alternatively, install FlagCX under a dedicated prefix. Installing under `/opt` normally requires +root privileges: + +```bash +sudo make PREFIX=/opt/flagcx install +``` + +### 2. Build Mooncake + +From the Mooncake repository root: + +```bash +cmake -S . -B build \ + -DUSE_FLAGCX=ON \ + -DFLAGCX_HOME=/path/to/FlagCX/build +cmake --build build -j$(nproc) +``` + +For the installed layout above, use `-DFLAGCX_HOME=/opt/flagcx`. If `FLAGCX_HOME` is not passed to +CMake, Mooncake first checks the environment variable of the same name, then defaults to +`$HOME/FlagCX/build`. + +Configuration fails early if either `flagcx_p2p.h` or the FlagCX shared library cannot be found. +A successful configuration includes a message similar to: + +```text +FlagCX transport enabled, include=/path/to/FlagCX/build/include, library=/path/to/FlagCX/build/lib/libflagcx.so +``` + +Make the shared library and its backend dependencies visible to the dynamic linker when they are +not installed in a system search path: + +```bash +export FLAGCX_HOME=/path/to/FlagCX/build +export LD_LIBRARY_PATH="$FLAGCX_HOME/lib:${LD_LIBRARY_PATH:-}" +``` + +If Mooncake also needs to allocate or identify device memory, enable its matching hardware option, +such as `USE_CUDA`, `USE_MACA`, `USE_MUSA`, `USE_HIP`, `USE_COREX`, `USE_HYGON`, or `USE_MLU`. +See the [build guide](../../../getting_started/build.md) for the options and SDK requirements. + +--- + +## Enabling the Transport + +### Python API + +A Mooncake Python extension built from source with `USE_FLAGCX=ON` accepts `flagcx` through the +existing initialization API: + +```python +engine.initialize( + hostname="node1", + metadata_server="P2PHANDSHAKE", + protocol="flagcx", + device_name="", +) +``` + +The Python binding disables automatic RDMA transport discovery for this protocol and installs the +FlagCX transport explicitly. No new Python API is required. + +### C++ API + +Applications using the classic C++ Transfer Engine can install the transport by name after engine +initialization. Keep automatic transport discovery disabled so that `flagcx` remains the standalone +transport: + +```cpp +mooncake::TransferEngine engine(false); +if (engine.init("P2PHANDSHAKE", "node1") != 0) { + return -1; +} + +auto* transport = engine.installTransport("flagcx", nullptr); +if (transport == nullptr) { + return -1; +} +``` + +Continue to use the standard Transfer Engine APIs for memory registration, segment discovery, +batch submission, and status queries. + +--- + +## Run and Test + +### Connectivity Test with `tebench` + +`tebench` can validate registration, endpoint exchange, connection setup, read/write transfers, +and completion. Both peers must use the classic backend and the `flagcx` transport. An empty +`--target_seg_name` starts the target; setting it starts the initiator. + +Choose a network interface reachable by the other peer on both hosts: + +```bash +export FLAGCX_SOCKET_IFNAME=eth0 +``` + +Start the target first: + +```bash +./build/mooncake-transfer-engine/benchmark/tebench \ + --backend=classic \ + --xport_type=flagcx \ + --metadata_type=p2p \ + --seg_type=DRAM \ + --total_buffer_size=1073741824 +``` + +The target prints a command containing its segment name. Use that value on the initiator: + +```bash +./build/mooncake-transfer-engine/benchmark/tebench \ + --backend=classic \ + --xport_type=flagcx \ + --metadata_type=p2p \ + --target_seg_name= \ + --seg_type=DRAM \ + --total_buffer_size=1073741824 \ + --op_type=read \ + --start_block_size=4096 \ + --max_block_size=67108864 \ + --start_batch_size=1 \ + --max_batch_size=1 \ + --start_num_threads=1 \ + --max_num_threads=1 +``` + +Use `--op_type=write` to test the opposite direction. The classic `tebench` VRAM allocator currently +supports CUDA builds. For that path, add `-DUSE_CUDA=ON` when building Mooncake and run with +`--seg_type=VRAM`. Other accelerator backends should validate device buffers through their +application integration until `tebench` provides a matching allocator. + +Useful initialization messages include: + +```text +FlagCxTransport: engine up, endpoint=... +FlagCxTransport: install OK (direct submit) +tebench: FlagCX transport installed +``` + +### Hardware-Free Checks + +The internal range and descriptor-boundary test does not require FlagCX hardware: + +```bash +ctest --test-dir build -R '^flagcx_transport_internal_test$' --output-on-failure +``` + +The Python source integration check verifies that the binding recognizes and installs `flagcx`: + +```bash +python3 -m unittest mooncake-wheel/tests/test_transfer_engine_flagcx_source.py +``` + +--- + +## Runtime Configuration + +FlagCX owns network-device selection and most transport tuning. The variables most relevant to the +Mooncake integration are: + +| Variable | Purpose | +|----------|---------| +| `FLAGCX_SOCKET_IFNAME` | Selects the interface used for socket bootstrap and endpoint advertisement; it does not select the RDMA HCA | +| `FLAGCX_IB_HCA` | Selects the InfiniBand/RoCE HCA or HCA set used by FlagCX | +| `FLAGCX_P2P_TRANSPORT=accl` | Selects the optional ACCL P2P implementation when the FlagCX build includes it; both peers must set it | +| `LD_LIBRARY_PATH` | Makes `libflagcx.so` and non-system backend libraries visible at run time | + +Set `FLAGCX_SOCKET_IFNAME` and `FLAGCX_IB_HCA` for each node's local interfaces and devices. For +interface filtering syntax, GID selection, retry settings, and backend-specific tuning, see the +[FlagCX environment-variable reference](https://github.com/flagos-ai/FlagCX/blob/main/docs/environment_variables.md). + +## Important Notes + +### Memory Registration Lifecycle + +The FlagCX connection handshake exchanges the peer's registered-memory table, and the P2P Engine +caches established connections. Use the following lifecycle for the current Mooncake integration: + +1. Register every buffer that a peer may access. +2. Establish or use the peer connection only after registration is complete. +3. Keep those buffers registered while the connection or its transfers are active. +4. Stop submitting work and allow outstanding work to finish before unregistering or freeing the + buffers. + +Registering a new remote buffer after a peer has already cached its connection does not refresh the +connection's memory table. Reconnect both processes before using a changed registration set. + +### Deployment Scope + +- Use the literal protocol name `flagcx`; do not use `flagos`. +- Use `flagcx` as a standalone protocol. Multi-protocol selection and routing are outside the + current integration scope. +- The peers may use different accelerator backends, but they must use compatible FlagCX revisions + and the same P2P network transport: default IBRC on both peers, or ACCL on both peers. +- The transport relies on FlagCX's endpoint exchange and completion behavior; it does not add a + separate Mooncake cancellation API. + +### Security and Trust + +Treat the FlagCX endpoint like other RDMA-capable transport endpoints. Only expose it to trusted +peers, restrict the selected interface with network policy or host firewall rules, and register only +the memory required by the application. Mooncake segment metadata exposes the endpoint and buffer +addresses, while the FlagCX handshake exchanges the registered-region table. Do not expose either +channel to untrusted peers. + +## Troubleshooting + +### CMake Cannot Find FlagCX + +```text +USE_FLAGCX=ON but flagcx_p2p.h was not found +``` + +Check that `FLAGCX_HOME/include/flagcx_p2p.h` exists. If the header remains only in the FlagCX +source tree, finish the FlagCX build so its public headers are copied to `build/include`, or run its +install target and point `FLAGCX_HOME` at that prefix. + +```text +USE_FLAGCX=ON but the FlagCX library was not found +``` + +Check for `FLAGCX_HOME/lib/libflagcx.so` or `FLAGCX_HOME/lib64/libflagcx.so` and confirm that the +same prefix is passed during CMake configuration. + +### The Executable Cannot Load `libflagcx.so` + +If startup reports that `libflagcx.so` or a platform backend library is missing, add their library +directories to `LD_LIBRARY_PATH` or install them in a configured system loader path. This is a +run-time linker issue, not a Mooncake protocol-selection issue. + +### The FlagCX Engine Advertises the Wrong Interface + +Set `FLAGCX_SOCKET_IFNAME` before starting each process. Use an interface whose advertised address +is reachable from the peer. If InfiniBand/RoCE device selection is also ambiguous, set +`FLAGCX_IB_HCA` to the appropriate local HCA set on each side. + +### `installTransport(flagcx)` Fails + +Confirm all of the following: + +- Mooncake was configured with `USE_FLAGCX=ON`. +- The FlagCX shared library and its platform dependencies load successfully. +- `FLAGCX_SOCKET_IFNAME` resolves to a usable local address. +- The FlagCX log does not report a P2P Engine or RPC-server initialization error. + +### Connection or Remote-Descriptor Creation Fails + +Verify that the target process is still running, the endpoint printed in the target log is reachable, +and both peers selected the same FlagCX P2P network transport. Check host firewall rules on the +connection path. If the target's registrations changed after the connection was first used, restart +both processes and register all buffers before reconnecting. diff --git a/docs/source/design/transfer-engine/heterogeneous_ascend.md b/docs/source/design/transfer-engine/transport/heterogeneous_ascend.md similarity index 100% rename from docs/source/design/transfer-engine/heterogeneous_ascend.md rename to docs/source/design/transfer-engine/transport/heterogeneous_ascend.md diff --git a/docs/source/design/transfer-engine/kunpeng_ub_transport.md b/docs/source/design/transfer-engine/transport/kunpeng_ub_transport.md similarity index 97% rename from docs/source/design/transfer-engine/kunpeng_ub_transport.md rename to docs/source/design/transfer-engine/transport/kunpeng_ub_transport.md index a751b9971b..cfd9fcf2c4 100644 --- a/docs/source/design/transfer-engine/kunpeng_ub_transport.md +++ b/docs/source/design/transfer-engine/transport/kunpeng_ub_transport.md @@ -58,15 +58,6 @@ sudo apt-get install -y \ libyaml-cpp-dev \ pybind11-dev \ python3-dev - -# Install yalantinglibs (required) -cd /tmp -git clone https://github.com/alibaba/yalantinglibs.git -cd yalantinglibs -mkdir build && cd build -cmake .. -DCMAKE_INSTALL_PREFIX=/usr/local -make -j$(nproc) -sudo make install ``` ## Building Mooncake with UB Support diff --git a/docs/source/design/transfer-engine/transport/mpcomm_transport.md b/docs/source/design/transfer-engine/transport/mpcomm_transport.md new file mode 100644 index 0000000000..ad4017c0ff --- /dev/null +++ b/docs/source/design/transfer-engine/transport/mpcomm_transport.md @@ -0,0 +1,573 @@ +# MPComm Transport + +## Overview + +UCL-MPComm (Unified Communication Library - Memory Pool Communication) is a high-performance RDMA +communication library for heterogeneous memory pooling, developed by the Tencent Astral Network +Team. In Mooncake it is integrated as a transport backend of the TENT transfer framework and +registered as `MPCOMM`; the library is shortened to MPComm throughout this document. + +- Upstream repository: + +MPComm drives multiple RDMA NICs concurrently and performs two-level load balancing (across NICs +and across QPs within a NIC), with NUMA-aware worker placement. It exposes one-sided +`put`/`get` primitives that the transport maps onto TENT's `WRITE`/`READ` requests. + +At runtime, `TransferEngineImpl` loads MPComm Transport when `USE_MPCOMM` is enabled at build time +and `transports/mpcomm/enable=true` is set in the configuration. + +> **TENT only.** This transport is implemented for the TENT runtime +> (`mooncake-transfer-engine/tent/`) and is not available through the legacy Transfer Engine +> transport path. Builds must therefore enable `USE_TENT` together with `USE_MPCOMM`, and +> benchmarks must run with `--backend=tent`. + +### Code Structure + +The transport is split so that everything reaching MPComm goes through one interface. That keeps the +provider out of most translation units, which is what makes the TENT-side logic buildable and +testable without libmpcomm - the same arrangement `TpuPjrtShim` and `UrmaAdapter` use for their +providers. + +| Unit | Responsibility | Needs `mpcomm.h` | +|------|----------------|:----------------:| +| `mpcomm_adapter.{h,cpp}` | The MPComm boundary. A thin pass-through: MPComm owns its slicing, NIC/QP selection and worker threads, so there is no scheduling to model here. Compiles to an unavailable stub when `USE_MPCOMM` is off | **yes** (only here) | +| `mpcomm_peer_registry.{h,cpp}` | Peer cache and endpoint attribute parsing: which peers are connected, whose keys are current, and who may talk to a peer at any moment | no | +| `mpcomm_task_mapping.{h,cpp}` | Request and completion mapping: WRITE/READ onto put/get, provider outcome onto `TransferStatus`, handle release | no | +| `mpcomm_transport.{h,cpp}` | What needs the TENT runtime: resolving a `SegmentID` to a peer, reading the endpoint it advertises, driving batches, publishing capabilities | no | + +`MpcommTransport` also accepts an adapter through a second constructor, which is how tests +substitute the provider. + +### Transfer Pipeline + +1. **Init**: `MPComm::init()` is called with the local segment name as the MPComm host id, the + RDMA device list derived from the TENT `Topology`, and a TCP port used for MPComm's own + metadata handshake. `startAcceptThread()` then serves incoming handshakes. +2. **Advertisement**: the transport publishes `v1::` into the local segment's + `transport_attrs` under the `MPCOMM` key, via `SegmentManager::updateLocal()` followed by + `synchronizeLocal()`. Peers read this to learn where to complete the MPComm handshake. The `v1:` + prefix makes a later format change detectable instead of silently misparsed; an attribute without + a prefix is read as `v1`. Only IPv4 endpoints are accepted. +3. **Registration**: `addMemoryBuffer()` calls `registerMemory()` and `publishBuffer()` so the + buffer's rkeys become visible to peers. NUMA placement is auto-detected by MPComm. +4. **Connection**: on the first request to a peer, `ensurePeerConnected()` resolves the peer's + MPComm endpoint from its `transport_attrs`, calls `connect()`, and then fetches the peer's + memory keys with `queryRemoteBuffer()`. Concurrent callers for the same peer wait for the first + one rather than starting a second handshake. + + The cache is keyed by **MPComm host id** (the segment name), not by `SegmentID`, because that is + what MPComm keys connections by. Closing and reopening a segment yields a fresh `SegmentID` for + the same peer, and a second `connect()` to an already connected peer replaces its connection + record wholesale - discarding the keys it carries and leaking its queue pairs - so keying on the + `SegmentID` would reconnect a peer that is already connected. + + For the same reason the connection and the keys are tracked separately. A connection cannot be + closed, so once established it is kept and reused; if only the key query failed, the peer is left + in `CONNECTED_NO_KEYS` and the next request retries **the query alone**. A connection that was + never established is not cached at all, so the next request retries the full handshake. + + Keys are also refetched when the peer registers memory after they were fetched, which TENT + permits at any time. The transport compares the peer's currently published buffer ranges against + those the cached keys cover; a range that is not covered triggers a refresh. Unregistered ranges + do not, since a key for memory the peer no longer publishes is never used. Because segment + descriptors are cached per thread with a TTL, a newly registered buffer becomes visible only + after that TTL expires. +5. **Execution**: `submitTransferTasks()` issues `putAsync()` for `WRITE` and `getAsync()` for + `READ`, one MPComm transfer per request. MPComm performs its own slicing and NIC/QP selection + internally. +6. **Completion**: `getTransferStatus()` polls lazily with `isTransferComplete()`, then reads + `getTransferResult()` to obtain the error code and a byte count, and releases the handle. The + count is of bytes *posted*, which equals the request length once MPComm reports success; a + transfer reported as successful but short is demoted to failed rather than trusted. + +--- + +## Additional Dependencies + +In addition to Mooncake's base dependencies, MPComm Transport requires the MPComm library: + +- **Header**: `${MPCOMM_ROOT}/include/mpcomm.h` +- **Library**: `${MPCOMM_ROOT}/lib/libmpcomm.so` +- **Version**: **1.4 or newer, and major version 1.** Configuration reads MPComm's CMake package + config and fails with an explicit message if the version is older or the major differs - MPComm + declares `SameMajorVersion` compatibility, so a different major is an ABI break by its own + definition. Both the standalone install and the wheel ship that config, and the resolved version + is printed as `MPComm version: `. A prefix that has only the headers and the library copied + into it reports `unknown` instead, and the version is then not checked. + +Both are provided by an MPComm installation, which may come either from a standalone CMake install +or from the MPComm Python wheel (in which case `MPCOMM_ROOT` is the `mpcomm` package directory +inside `site-packages`, since the wheel ships `include/` and `lib/` under the package root). + +The two routes are not necessarily equivalent: MPComm gates several features behind build options +that default to `OFF`, notably `USE_CUDA` (device memory support) and `USE_MLNX` (Mellanox-specific +QP tuning that spreads traffic across ECMP paths, which affects multi-QP throughput). Its own +`build.sh` turns them on, whereas a plain `pip install` does not. Check how the library you install +was configured if device memory or multi-QP performance matters. + +Make sure `libmpcomm.so` can be found by the dynamic linker at run time, for example via +`LD_LIBRARY_PATH`. + +### Wheel Packaging + +The `mooncake-transfer-engine` wheel **deliberately does not bundle MPComm**. `scripts/build_wheel.sh` +passes `--exclude libmpcomm.so*` to `auditwheel repair`, so `engine.so` keeps its `DT_NEEDED` entry +on `libmpcomm.so.` and the library stays an external dependency resolved at run time. This keeps +MPComm independently upgradable: replacing `libmpcomm.so` does not require rebuilding or repackaging +Mooncake, as long as the MPComm major version (its `SOVERSION`) is unchanged. A major bump does +require rebuilding Mooncake against the new headers. + +Consequently, importing the Python extension without MPComm available fails with: + +``` +ImportError: libmpcomm.so.1: cannot open shared object file: No such file or directory +``` + +Provide the library through either MPComm installation form, then point the linker at it: + +```bash +# From the MPComm wheel +export MPCOMM_ROOT=$(python3 -c "import mpcomm, os; print(os.path.dirname(mpcomm.__file__))") +# ...or from a standalone CMake install, e.g. MPCOMM_ROOT=/opt/mpcomm + +export LD_LIBRARY_PATH=$MPCOMM_ROOT/lib:$LD_LIBRARY_PATH +python3 -c "from mooncake import engine" # should now import cleanly +``` + +--- + +## Build and Compile + +**Prerequisites** + +- MPComm is installed, providing both `include/mpcomm.h` and `lib/libmpcomm.so` +- RDMA devices are available and `libibverbs` is installed +- Build environment can access Mooncake and its base dependencies + +**CMake Configuration** + +```bash +# Clone Mooncake +git clone https://github.com/kvcache-ai/Mooncake.git +cd Mooncake + +# Enable TENT + MPComm. Add -DUSE_CUDA=ON if you need VRAM segments. +mkdir build && cd build +cmake .. -DUSE_TENT=ON -DUSE_MPCOMM=ON -DMPCOMM_ROOT=/opt/mpcomm + +# Build +make -j$(nproc) +``` + +`MPCOMM_ROOT` is mandatory when `USE_MPCOMM=ON`. Configuration fails early if it is unset, or if +the expected header and library cannot be found underneath it. On success the configure log +reports the resolved paths: + +``` +-- MPComm transport is enabled +-- MPComm include: /opt/mpcomm/include +-- MPComm library: /opt/mpcomm/lib/libmpcomm.so +``` + +--- + +## Enabling the Transport + +Loading the transport and selecting it for a transfer are two separate steps. + +**1. Load it.** The transport is only instantiated when its config gate is on: + +```json +{ "transports": { "mpcomm": { "enable": true } } } +``` + +**2. Select it.** Any of the standard TENT mechanisms work: + +- A transport policy (see [Transport Selector](../../tent/transport-selector.md)): + + ```json + { + "transports": { "mpcomm": { "enable": true } }, + "policy": [ + { "name": "mpcomm_memory", "segment_type": "memory", "transports": ["mpcomm"] } + ] + } + ``` + +- A per-request override, which takes precedence over policies: + + ```cpp + Request r{}; + r.transport_hint = TransportType::MPCOMM; + ``` + +A policy or a hint is effectively **required**. In the default ordering returned by +`getSupportedTransports()`, `MPCOMM` comes second to last -- only `TPU` follows it -- so leaving +the choice to the default means MPComm is unlikely to be picked at all. + +--- + +## Run and Test + +`tebench` (the TENT benchmark, built at `build/mooncake-transfer-engine/benchmark/tebench`) +supports `mpcomm` for connectivity and performance validation. The role is determined by +`--target_seg_name`: empty means target, otherwise initiator. + +There is also a unit test, which is built when `USE_MPCOMM=ON` and unit tests are enabled. Its +functional case needs RDMA devices and MPComm at run time and skips itself otherwise: + +```bash +ctest -R tent_mpcomm_transport_test --output-on-failure +``` + +`--xport_type=mpcomm` selects the `MemoryOptions.type` used when registering buffers. It also +restricts the enabled transports, but setting `MC_TENT_CONF` replaces the configuration wholesale +and undoes that (see [Important Notes](#important-notes)), so the examples below list the +`transports` gates explicitly. Keeping them in a shell variable avoids repeating the block: + +```bash +XPORTS='"transports":{"mpcomm":{"enable":true},"rdma":{"enable":false}, + "tcp":{"enable":false},"shm":{"enable":false},"nvlink":{"enable":false}, + "mnnvl":{"enable":false},"gds":{"enable":false},"io_uring":{"enable":false}}' + +# Terminal 1: target +MC_TENT_CONF="{\"rpc_server_hostname\":\"10.0.0.1\",$XPORTS}" \ +MPCOMM_TCP_PORT=13579 \ +./tebench --backend=tent --xport_type=mpcomm \ + --metadata_type=p2p --rpc_server_port=12345 \ + --seg_type=DRAM --total_buffer_size=2147483648 + +# Terminal 2: initiator +MC_TENT_CONF="{\"rpc_server_hostname\":\"10.0.0.2\",$XPORTS}" \ +MPCOMM_TCP_PORT=13579 \ +./tebench --backend=tent --xport_type=mpcomm --tent_transport_hint=mpcomm \ + --metadata_type=p2p --rpc_server_port=12346 \ + --target_seg_name=10.0.0.1:12345 \ + --seg_type=DRAM --total_buffer_size=2147483648 \ + --op_type=read --start_block_size=262144 --max_block_size=262144 \ + --start_batch_size=32 --max_batch_size=32 \ + --start_num_threads=4 --max_num_threads=4 --duration=30 +``` + +Setting `rpc_server_hostname` is strongly recommended on multi-homed or containerized hosts; see +[Important Notes](#important-notes). `--seg_name` is deliberately absent: with `metadata_type=p2p` +TENT derives the local segment name from `rpc_server_hostname` and `rpc_server_port` and ignores +the flag. + +**On a single host** both processes share the port namespace, so give them different handshake +ports. Avoid the 15000-17000 range, which the TENT RPC server allocates from: + +```bash +MPCOMM_TCP_PORT=13579 ... # target +MPCOMM_TCP_PORT=13580 ... # initiator +``` + +To confirm that traffic really went over MPComm, look for these lines: + +``` +MpcommTransport: Installed successfully, host_id=10.0.0.2:12346, tcp_port=13579, devices=mlx5_0,mlx5_1 +MpcommTransport: Connected to segment 10.0.0.1:12345 +``` + +If MPComm fails to start, the engine only logs `Transport mpcomm skipped: ...` and continues with +the remaining transports. With every other transport disabled the run then fails later and less +obviously, when buffer registration finds no usable transport. + +Add `--check_consistency=true` to verify payload correctness. It writes and reads every block back, +so it changes the access pattern as well as lowering the reported bandwidth; leave it off when +measuring throughput. + +### Unit Tests + +There are two suites, because the data path needs hardware and the logic around it does not. + +`tent_mpcomm_boundary_test` drives the transport against an injected `MpcommAdapter`, so it needs +neither an RDMA device nor libmpcomm and is built in every configuration: + +```bash +cmake .. -DUSE_TENT=ON -DBUILD_UNIT_TESTS=ON # USE_MPCOMM not required +make -j tent_mpcomm_boundary_test +ctest -R tent_mpcomm_boundary_test --output-on-failure +``` + +It covers endpoint publication and parsing, single-flight connection under concurrency, key-query +retry over an existing connection, refreshing keys when a peer registers memory, the WRITE/READ +mapping, short-transfer and error handling, releasing each handle exactly once, and teardown. It does +not cover MPComm's own behaviour - slicing, NIC and QP selection, worker scheduling - which is the +provider's responsibility. + +`tent_mpcomm_transport_test` exercises the real data path: it forks a target, drives a WRITE followed +by a READ over MPComm and verifies the payload. It requires RDMA devices and a working MPComm +installation, and skips itself when the engine cannot be brought up: + +```bash +cmake .. -DUSE_TENT=ON -DUSE_MPCOMM=ON -DBUILD_UNIT_TESTS=ON -DMPCOMM_ROOT= +make -j tent_mpcomm_transport_test +ctest -R tent_mpcomm_transport_test --output-on-failure +``` + +Parent and child use distinct `MPCOMM_TCP_PORT` values derived from the pid, since MPComm's handshake +listener would otherwise collide; see the note on port uniqueness above. + +--- + +## Measured Performance + +Numbers from a two-node cross-machine run on **AMD Turin CPUs with 8x400 Gbps CX-7 NICs**, two NUMA +nodes per host, transferring host DRAM. Utilisation is against the line rate of the NICs actually in +use (400 Gbps = 50 GB/s each). + +| Layer | Workload | Bandwidth | NICs in use | Utilisation | +|-------|----------|-----------|-------------|-------------| +| Transfer Engine | `tebench`, READ | **392 GB/s** | 8 (2 NUMA) | 98% | +| Transfer Engine | `tebench`, WRITE | **392 GB/s** | 8 (2 NUMA) | 98% | +| Mooncake Store | `get_tensor_into` | **190 GB/s** | 4 (1 NUMA) | 95% | +| Mooncake Store | `put_tensor_from` | **191 GB/s** | 4 (1 NUMA) | 95.5% | + +--- + +## GPU (Device) Memory + +MPComm transfers device memory as well as host DRAM, so VRAM segments are supported in all four +combinations (DRAM to DRAM, DRAM to GPU, GPU to DRAM, GPU to GPU). + +Three preconditions must all hold, and only the first one is checked at run time: + +1. **`nvidia-peermem` is loaded.** Device memory is registered through the ordinary `ibv_reg_mr` + path: the kernel's `get_user_pages()` is intercepted by `nvidia-peermem` + (`ib_peer_memory_client`) to pin GPU pages. There is no dma-buf fallback. This is the same + dependency `RdmaTransport` has for GPU-Direct. +2. **MPComm itself was built with `-DUSE_CUDA=ON`.** That option defaults to `OFF`, and its device + detection is compiled out entirely when it is off, so device pointers are then registered as + host memory and NUMA/PCIe affinity selection silently degrades. The upstream `build.sh` enables + it; a plain `pip install` of the MPComm package does not. +3. **Mooncake was built with `-DUSE_CUDA=ON`**, otherwise `--seg_type=VRAM` is rejected outright. + +The transport probes `/proc/modules` during `install()` and advertises the GPU capabilities only +when `nvidia-peermem` is present. Otherwise it logs + +``` +MpcommTransport: nvidia_peermem not detected, GPU memory support is disabled +``` + +and reports `dram_to_dram` only, so transport selection will not route device memory to MPComm. +Setting `transports/mpcomm/disable_gpu_direct_rdma` to `true` forces the same behaviour on a host +that does have the module, which is reported separately: + +``` +MpcommTransport: GPU memory support disabled by transports/mpcomm/disable_gpu_direct_rdma +``` + +Note that conditions 2 and 3 are **not** detectable by that probe. If GPU transfers behave oddly +while the module is loaded, confirm how MPComm was built. + +Buffer registration is attempted for every loaded transport irrespective of capabilities, so on a +host without the module registering a VRAM buffer logs a warning from MPComm. That is harmless: +the buffer simply does not list `MPCOMM` among its transports, and selection skips it. + +To exercise device memory with `tebench`, pass `--seg_type=VRAM` on either or both sides, or +`--seg_type_mix=dram,vram` to drive both memory types from a single process: + +```bash +./tebench --backend=tent --xport_type=mpcomm --seg_type=VRAM ... +``` + +--- + +## Environment Variables + +MPComm reads its own tuning parameters directly from the environment. The transport adds the two +`*_TCP_PORT` variables. + +| Variable | Description | Default | +|----------|-------------|---------| +| `MPCOMM_TCP_PORT` | Local TCP port for MPComm's metadata handshake. Must be unique per process on a host: MPComm binds it and fails to initialise if it is taken, with no retry. `0` does not mean "pick one". The value is validated, and a non-numeric or out-of-range one fails `install()` rather than being silently treated as `0`. This port hands out remote memory keys - see [Security and Trust Boundary](#security-and-trust-boundary) | `13579` | +| `MPCOMM_REMOTE_TCP_PORT` | Peer handshake port, used only when the peer published no `transport_attrs`. Without it such a peer is rejected: the attribute is written by `install()`, so its absence means the peer runs no MPComm transport, and guessing a port would point `connect()` at an unrelated process - which, since the handshake has no timeout, can block the submitting thread indefinitely | (none; peer is rejected) | +| `MPCOMM_GID_INDEX` | RoCE/IB GID index used when creating QPs. Devices whose GID at this index is all zeroes are skipped, which can leave MPComm reporting no usable devices. Use `-1` to pick the first non-zero GID | `3` | +| `MPCOMM_NIC_FILTER` | Comma-separated list of allowed RDMA device names. When unset, the transport passes the device list derived from the TENT `Topology`; setting this variable overrides that list | (TENT topology) | +| `MPCOMM_MAX_RDMA_TRANSFER_SIZE` | Maximum bytes per RDMA operation; larger requests are sliced across NICs | `1 GB` | +| `MPCOMM_QPS_PER_CONNECTION` | Number of QPs per NIC per connection. Values above 16 make initialisation fail | `1` | +| `MPCOMM_POLL_BATCH_SIZE` | Maximum work completions per `ibv_poll_cq` call | `64` | +| `MPCOMM_MAX_IDLE_SPINS` | Number of idle polling iterations a worker spins (with a CPU pause hint) before backing off | `10000` | +| `MPCOMM_MAX_SEND_WR` | QP send queue depth | `512` | +| `MPCOMM_MAX_OUTSTANDING_PER_QP` | Maximum outstanding work requests per QP | `256` | +| `MPCOMM_LOG_LEVEL` | `error` / `warn` / `info` / `debug` | `info` | +| `MPCOMM_TRANSFER_STATS_INTERVAL` | Print statistics every N transfers (requires `debug`) | `0` (every transfer) | + +--- + +## Configuration Options (TENT) + +- `transports/mpcomm/enable`: enable or disable the transport (default: `true` once + `USE_MPCOMM` is compiled in) +- `transports/mpcomm/disable_gpu_direct_rdma`: force the GPU capabilities off even when + `nvidia-peermem` is loaded (default: `false`). See [GPU (Device) Memory](#gpu-device-memory). + +MPComm's own behaviour is tuned through the environment variables above rather than through TENT +configuration keys. + +--- + +## Important Notes + +1. **TENT only.** There is no MPComm backend on the legacy Transfer Engine transport path, so + `--backend=classic` and `transfer_engine_bench --protocol=mpcomm` are not supported. + +2. **Set `rpc_server_hostname` on multi-homed or containerized hosts.** With + `metadata_type=p2p`, TENT overwrites `local_segment_name` with `rpc_server_hostname:port`. When + `rpc_server_hostname` is unset it is auto-discovered, which may pick an address that peers + cannot route to (for example a container overlay address). Since the published + `transport_attrs` are derived from that name, MPComm would then advertise an unreachable + endpoint. `tebench` has no command-line flag for it, so pass it through `MC_TENT_CONF`. + +3. **`MC_TENT_CONF` replaces the configuration wholesale.** Keys other than the metadata identity + (`metadata_type`, `metadata_servers`, `local_segment_name`, `rpc_server_hostname`, + `rpc_server_port`) are not preserved across the load. If you set `MC_TENT_CONF` *and* rely on + `--xport_type` to disable the other transports, list the `transports` gates explicitly in + `MC_TENT_CONF` as well, otherwise they revert to their defaults. + +4. **Segment metadata mutation.** The transport publishes its endpoint through + `SegmentManager::updateLocal()`. Snapshots returned by `getLocal()` are copy-on-write and must + never be written through. + +5. **Buffer registration cost.** `registerMemory()` pins and maps memory on every NIC, so + registration of large buffers takes noticeable time at startup. Register once and reuse. + +6. **Optional transport interfaces are not implemented.** MPComm Transport does not provide + cancellation (`supportsCancellation()` returns `false`), notification + (`supportNotification()` returns `false`, so `tebench --notifi` is unavailable), bandwidth + estimation, or NIC load statistics, and it does not consume the `qp_pool` or + progress-notification facilities of `Transport::SubBatch`. Failover and QoS features that + depend on those hooks fall back to their defaults. + +7. **The first transfer to a peer performs the MPComm handshake inline.** Connection setup happens + on the submitting thread the first time a segment is used, so that submission takes noticeably + longer than subsequent ones. + +--- + +## Security and Trust Boundary + +MPComm's metadata handshake is a plain TCP exchange with **no authentication and no encryption**, +and what it exchanges is remote memory metadata: + +```c++ +struct RemoteBufferEntry { + uint64_t addr; + uint64_t length; + int numa_node; + std::vector rkeys; // <- remote keys +}; +``` + +An address together with its rkey is exactly what an RDMA READ or WRITE needs. Anything that can +reach `MPCOMM_TCP_PORT` can therefore obtain the means to read and write the process's registered +memory directly, without going through TENT at all. + +**Treat that port as being at the same trust level as the RDMA fabric itself.** In practice: + +- Keep it on the same trusted network as the fabric, and do not expose it to untrusted networks or + to the internet. Restrict it with host firewall rules if the host also carries untrusted traffic. +- Registered buffers are reachable by any peer that completes the handshake. There is no per-peer + authorisation, and no per-buffer permission model beyond what the fabric enforces. +- This is the same exposure model as raw RDMA between trusted nodes; MPComm adds no protection of + its own and does not weaken the fabric's either. + +### Addressing + +- **Only IPv4 endpoints are supported.** The handshake sockets are `AF_INET`, and the endpoint + attribute is parsed as `:`; an IPv6 literal is rejected at parse time rather than + being silently split at the wrong colon. +- **The listener binds all interfaces** (`INADDR_ANY`) and this is not configurable - it is + MPComm's own behaviour. Use firewall rules to restrict which interfaces are actually reachable. +- **The advertised address follows `rpc_server_hostname`**, since the endpoint attribute is derived + from the local segment name. On a multi-homed host this ties the handshake path to whichever + address TENT uses for RPC. Setting `rpc_server_hostname` explicitly is therefore the way to + control it today; a dedicated setting for advertising an address separate from the RPC one is not + implemented yet. + +--- + +## Troubleshooting + +### Configuration fails with a missing `MPCOMM_ROOT` + +``` +USE_MPCOMM=ON requires MPCOMM_ROOT to point at the MPComm install prefix +``` + +Pass `-DMPCOMM_ROOT=`. The prefix must contain `include/mpcomm.h` and +`lib/libmpcomm.so` (`lib64` is searched as well). + +### Configuration fails with MPComm not found + +``` +MPComm not found under MPCOMM_ROOT= +``` + +Verify the layout, and remember that CMake caches find results: + +```bash +ls $MPCOMM_ROOT/include/mpcomm.h $MPCOMM_ROOT/lib/libmpcomm.so +cmake .. -UMPCOMM_LIBRARY -UMPCOMM_INCLUDE_DIR +``` + +### `error while loading shared libraries: libmpcomm.so` + +The library is resolved by absolute path at link time, but the dynamic linker still needs to find +it at run time: + +```bash +export LD_LIBRARY_PATH=$MPCOMM_ROOT/lib:$LD_LIBRARY_PATH +ldd ./tebench | grep -E "mpcomm|not found" +``` + +### The peer connects to an unexpected address + +Symptom: the initiator logs `MPComm: Connecting to at :` with an address peers +cannot reach, and stalls, while the segment itself was opened successfully. + +The address MPComm uses comes from the peer's segment name and `transport_attrs`, which are +derived from `rpc_server_hostname`. Set it explicitly on **both** sides, then confirm the target +advertises the intended address: + +``` +MpcommTransport: Installed successfully, host_id=:, ... +``` + +### `MPComm: No RDMA devices found` + +MPComm skips any device whose GID at `MPCOMM_GID_INDEX` (default `3`) is all zeroes, which is +common on InfiniBand and on RoCE setups with a different GID layout. Set `MPCOMM_GID_INDEX=-1` to +pick the first non-zero GID, or point it at the correct index. + +### `Failed to query remote buffers` + +``` +MpcommTransport: Failed to query remote buffers from , error= +``` + +The connection succeeded but fetching the peer's memory keys did not. Those keys are the only way +to address the peer's memory, so this is a hard failure and the request fails. + +The connection itself is kept - MPComm cannot close one, and reconnecting would replace its +connection record and leak its queue pairs - so the peer is left in `CONNECTED_NO_KEYS` and the +next request retries **only the query**, not the handshake. + +The usual cause is that the peer had not finished registering its buffers yet, so start the target +and let it finish registration before starting the initiator. Since the retry is a query, the +recovery needs no restart on either side. + +### Lower than expected bandwidth + +- Remove `--check_consistency=true`; it writes and reads every block back. +- Increase `--duration`; the inline handshake on the first transfer to a peer is included in the + measurement. +- Increase `--start_batch_size` / `--max_batch_size`; with a batch size of 1 there are not enough + in-flight requests to fill `MPCOMM_MAX_OUTSTANDING_PER_QP`. +- Check `MPCOMM_MAX_RDMA_TRANSFER_SIZE` against the block size. Slicing is what spreads a transfer + over several NICs, so a request smaller than this limit becomes a single chunk on a single NIC. + Lower it to engage more NICs per request. +- For `--seg_type=DRAM`, `tebench` allocates one buffer of `--total_buffer_size` per NUMA node. + Restricting memory to a single node with `numactl --membind` while NICs on another node drive + traffic results in cross-socket access. diff --git a/docs/source/design/transfer-engine/sunrise_link_transport.md b/docs/source/design/transfer-engine/transport/sunrise_link_transport.md similarity index 100% rename from docs/source/design/transfer-engine/sunrise_link_transport.md rename to docs/source/design/transfer-engine/transport/sunrise_link_transport.md diff --git a/docs/source/design/unified-parallel-tensor-io.md b/docs/source/design/unified-parallel-tensor-io.md deleted file mode 100644 index ff9f1f3b40..0000000000 --- a/docs/source/design/unified-parallel-tensor-io.md +++ /dev/null @@ -1,405 +0,0 @@ -# Unified Parallel Tensor IO - -## Goal - -This document defines the single source of truth for Mooncake's next-generation tensor IO API. The purpose is to keep implementation aligned around one explicit design and avoid drifting into ad hoc TP/EP/DP/PP-specific interfaces. - -The target outcome is: - -- one unified write API family -- one unified read API family -- one unified upsert API family -- TP-specific APIs retained only as compatibility wrappers -- minimal caller-facing inputs, with layout and planning details derived internally -- one stable abstraction that can cover TP / DP / EP / PP combinations without exploding the public API surface - -## Design principles - -1. **One API family, not one family per parallelism type.** - Public APIs should not branch into separate long-term `*_with_tp`, `*_with_ep`, `*_with_pp`, and `*_with_dp` families. -2. **Parallelism is modeled as explicit axes.** - `mixed` must not be a vague enum value. A shard should be described by the exact axis coordinates that identify it. -3. **Reads must encode caller intent explicitly.** - A read request must say whether the caller wants the stored view, a target shard/view, or the reconstructed full tensor. -4. **Only require true caller intent.** - If Mooncake can derive a field from the input tensor, stored metadata, or planning context, it should not be required in the public API. -5. **Planner/runtime complexity stays internal.** - Byte ranges, payload offsets, reconstruction plans, and lowering to `get_into_ranges(...)` are runtime concerns, not public API concerns. -6. **Compatibility wrappers stay thin.** - Existing TP methods should lower to the same unified implementation path. - -## Core model - -### Parallelism is an axis list - -A tensor object may be identified by one axis or by multiple orthogonal axes. - -Examples: - -- pure TP shard: `[TP]` -- DP + TP shard: `[DP, TP]` -- PP + TP shard: `[PP, TP]` -- DP + PP + TP shard: `[DP, PP, TP]` -- DP + PP + EP + TP shard: `[DP, PP, EP, TP]` - -So the design should not use: - -```python -kind = "mixed" -``` - -Instead it should use: - -```python -class ParallelAxis: - kind: Literal["tp", "dp", "ep", "pp"] - rank: int - size: int - split_dim: Optional[int] = None - expert_id: Optional[int] = None - stage_id: Optional[int] = None -``` - -```python -class TensorParallelism: - axes: list[ParallelAxis] -``` - -Single-axis cases are just special cases of this model: - -- TP only: `axes=[TP(...)]` -- EP only: `axes=[EP(...)]` -- PP + TP: `axes=[PP(...), TP(...)]` - -## Public API shape - -### Write / upsert side - -```python -def put_tensor_with_parallelism( - self, - key: str, - tensor: torch.Tensor, - parallelism: TensorParallelism | None = None, - replica: ReplicateConfig | None = None, -) -> int -``` - -```python -def batch_put_tensor_with_parallelism( - self, - keys: list[str], - tensors: list[torch.Tensor], - parallelisms: list[TensorParallelism | None] | None = None, - replica: ReplicateConfig | None = None, -) -> list[int] -``` - -```python -def upsert_tensor_with_parallelism( - self, - key: str, - tensor: torch.Tensor, - parallelism: TensorParallelism | None = None, - replica: ReplicateConfig | None = None, -) -> int -``` - -```python -def batch_upsert_tensor_with_parallelism( - self, - keys: list[str], - tensors: list[torch.Tensor], - parallelisms: list[TensorParallelism | None] | None = None, - replica: ReplicateConfig | None = None, -) -> list[int] -``` - -### Read side - -Read-side requests need one extra structure because the request must encode not only the target parallel coordinates, but also the materialization mode. - -```python -class ReadTarget: - mode: Literal["as_stored", "shard", "full"] - parallelism: TensorParallelism | None = None -``` - -```python -def get_tensor_with_parallelism( - self, - key: str, - target: ReadTarget | None = None, -) -> torch.Tensor -``` - -```python -def batch_get_tensor_with_parallelism( - self, - keys: list[str], - targets: list[ReadTarget | None] | None = None, -) -> list[torch.Tensor] -``` - -Optional zero-copy forms follow the same model: - -```python -def get_tensor_with_parallelism_into( - self, - key: str, - buffer_ptr: int, - size: int, - target: ReadTarget | None = None, -) -> torch.Tensor -``` - -```python -def batch_get_tensor_with_parallelism_into( - self, - keys: list[str], - buffer_ptrs: list[int], - sizes: list[int], - targets: list[ReadTarget | None] | None = None, -) -> list[torch.Tensor] -``` - -## Caller inputs vs internal derived fields - -### Caller must provide - -These are true caller intent and belong in the public API: - -- the tensor itself -- whether the write target is full or shard-like -- the shard identity axes when writing a shard object -- the read target mode: `as_stored`, `shard`, or `full` -- the target axis coordinates when requesting a target shard/view -- `ReplicateConfig` when the caller wants replication / publish-like behavior - -### Mooncake should derive internally - -These should not be mandatory public inputs when they are derivable: - -- logical shape -- local shard shape -- metadata encoding/version details -- payload offsets -- source and destination byte ranges -- reconstruction plans -- lowering to `get_into_ranges(...)` - -## Why `ReadTarget` is required - -`parallelism` alone is not enough to describe a read. - -For example, if a tensor is stored as TP shards, a request that carries TP axis coordinates is ambiguous unless it also says whether the caller wants: - -- the stored local shard -- a target shard/view -- the reconstructed full tensor - -So reads must explicitly encode: - -```python -ReadTarget(mode="as_stored") -ReadTarget(mode="shard", parallelism=...) -ReadTarget(mode="full") -``` - -This distinction must not be guessed from the axis metadata. - -## Mixed-parallel scenarios in training and inference - -`mixed` is not a mode. It means the shard identity needs more than one axis coordinate to be uniquely described. - -### Scenario table - -| Scenario | Typical axis list | Meaning | -|---|---|---| -| TP training | `[TP]` | one TP slice of a logical tensor | -| DP + TP training | `[DP, TP]` | one TP slice within one DP replica/group | -| PP + TP training | `[PP, TP]` | one TP slice owned by one pipeline stage | -| DP + PP + TP training | `[DP, PP, TP]` | one TP slice in one stage in one DP replica | -| DP + PP + EP + TP training | `[DP, PP, EP, TP]` | one expert-local TP slice in one stage and one DP replica | -| TP inference | `[TP]` | one TP slice used by one inference rank | -| PP + TP inference | `[PP, TP]` | one TP slice owned by one inference pipeline stage | -| EP inference | `[EP]` or `[EP, TP]` | one expert shard, optionally further TP-sliced | -| multi-replica serving inference | `[DP, TP]` or `[DP, PP, TP]` | one shard scoped to a serving replica plus model-parallel axes | - -### Important distinctions - -- TP and EP often affect the tensor's physical layout directly. -- PP and serving-replica / DP often act more like ownership or scope tags, even when they do not themselves change the byte layout inside the local shard. - -## Write-side matrix - -The unified write family should be driven by the identity of the object being written, not by method-name proliferation. - -| Caller holds | Wants to store | API | Required `parallelism` | -|---|---|---|---| -| full tensor | full tensor | `put_tensor_with_parallelism(..., parallelism=None)` | none | -| full tensor | TP shard | `put_tensor_with_parallelism(...)` | `axes=[TP(rank,size,split_dim)]` | -| full tensor | DP-scoped shard/replica | `put_tensor_with_parallelism(...)` | `axes=[DP(rank,size)]` plus layout axis if actually sharded | -| full tensor | EP shard | `put_tensor_with_parallelism(...)` | `axes=[EP(rank,size,expert_id)]` plus `split_dim` if needed | -| full tensor | PP stage shard | `put_tensor_with_parallelism(...)` | `axes=[PP(rank,size,stage_id)]` | -| full tensor | combined shard | `put_tensor_with_parallelism(...)` | explicit axis list such as `[PP(...), TP(...)]` | -| shard tensor | shard object | `put_tensor_with_parallelism(...)` | explicit axis list describing that shard identity | - -For TP-containing **multi-axis** layouts, the write semantic is now: the caller may pass the **full source tensor**, and the provided TP rank/layout tells Mooncake which uniform shard to materialize and persist. Callers no longer need to pre-split the tensor themselves for `dp_tp` / `pp_tp` / `ep_tp` style writes. - -Single-axis TP compatibility wrappers and the preserved plain-TP `with_parallelism` behavior still accept shard input rather than auto-materializing from a full tensor. - -Pure DP still does not invent a split axis on its own. If the stored object is actually sharded, the request must still include the layout axis that defines the shard shape. - -The same matrix applies to `upsert_tensor_with_parallelism(...)`. - -## Read-side matrix - -The unified read family should be driven by `ReadTarget`. - -| Stored layout | Caller wants | API | `ReadTarget` | -|---|---|---|---| -| full tensor | stored full tensor | `get_tensor_with_parallelism(...)` | `None` or `mode="as_stored"` | -| TP shard object | stored shard | `get_tensor_with_parallelism(...)` | `mode="as_stored"` | -| TP shard set | target TP shard | `get_tensor_with_parallelism(...)` | `mode="shard", parallelism=TP(...)` | -| TP shard set | full tensor | `get_tensor_with_parallelism(...)` | `mode="full"` | -| EP shard set | target expert shard | `get_tensor_with_parallelism(...)` | `mode="shard", parallelism=EP(...)` | -| EP shard set | full tensor | `get_tensor_with_parallelism(...)` | `mode="full"` | -| PP + TP shard set | target PP+TP shard | `get_tensor_with_parallelism(...)` | `mode="shard", parallelism=[PP(...), TP(...)]` | -| mixed shard set | full tensor | `get_tensor_with_parallelism(...)` | `mode="full"` | - -## Source-layout to target-layout matrix - -The planner must eventually cover these conversions, but the public API should remain the same across all of them. - -| Stored source layout | Requested target layout | Support model | -|---|---|---| -| TP | TP | direct shard fetch or shard-local fast path | -| TP | full | reconstruct full tensor | -| TP | EP / PP / DP / mixed | planner-driven remap | -| EP | EP | direct shard fetch or shard-local fast path | -| EP | full | reconstruct full tensor | -| EP | TP / PP / DP / mixed | planner-driven remap | -| PP | PP | direct shard fetch | -| PP | full | reconstruct full tensor when meaningful | -| PP | TP / EP / DP / mixed | planner-driven remap | -| mixed | mixed | direct fetch if exact match, else planner-driven remap | -| mixed | full | reconstruct full tensor | -| mixed | TP / EP / PP / DP | planner-driven remap | - -The important point is that these combinations must not create public API explosion. - -## Compatibility wrappers - -Existing TP APIs remain compatibility wrappers. - -Conceptually: - -```python -put_tensor_with_tp(key, tensor, tp_rank, tp_size, split_dim) -``` - -lowers to: - -```python -put_tensor_with_parallelism( - key, - tensor, - TensorParallelism(axes=[TP(rank=tp_rank, size=tp_size, split_dim=split_dim)]), -) -``` - -and: - -```python -get_tensor_with_tp(key, tp_rank, tp_size, split_dim) -``` - -lowers to: - -```python -get_tensor_with_parallelism( - key, - ReadTarget( - mode="shard", - parallelism=TensorParallelism( - axes=[TP(rank=tp_rank, size=tp_size, split_dim=split_dim)] - ), - ), -) -``` - -Existing TP behavior stays stable, but implementation should flow through the unified path. - -## Runtime lowering direction - -Internally, reads should be planner-driven. - -When a request can be lowered to explicit: - -- key -- src offset -- dst offset -- size - -ranges, the runtime should reuse `get_into_ranges(...)` to assemble the result directly into the output buffer. - -Otherwise it can fall back to the simpler whole-object path. - -This keeps the public interface stable while allowing future planner work to add DP / TP / EP / PP remapping and optimized reconstruction without another API redesign. - -## Current implementation status - -The current `store_py.cpp` implementation now reflects the main shape of this design: - -- unified write APIs are exposed as `put_tensor_with_parallelism(...)` and `batch_put_tensor_with_parallelism(...)` -- unified read APIs are exposed as `get_tensor_with_parallelism(...)` and `batch_get_tensor_with_parallelism(...)` -- unified upsert APIs are exposed as `upsert_tensor_with_parallelism(...)` and `batch_upsert_tensor_with_parallelism(...)` -- zero-copy `_into` and `_from` variants exist for the unified API family -- TP-specific APIs remain available as compatibility wrappers and should not be treated as the long-term surface area - -### Implemented write-side convenience: `writer_partitions` - -Batch write and batch upsert paths also support `writer_partitions` as a convenience input for full tensors that should be written as stored shards. - -This is intentionally narrower than the full `TensorParallelism` model: - -- it is a write-side convenience, not a replacement for `TensorParallelism` -- it is primarily for batch full-tensor writes where the caller already knows rank / size / split_dim per item -- it should not change the unified read-side abstraction - -`writer_partitions` remains a separate explicit route. The newer TP-containing `parallelism` write semantic now overlaps with it for the common case of “full tensor in, store one requested shard”, but `writer_partitions` is still useful when the caller wants a lighter write-side request shape without constructing `TensorParallelism` objects. - -### Implemented read-side behavior - -Read-side support includes: - -- returning the stored local object (`mode="as_stored"`) -- returning a target shard (`mode="shard"`) -- reconstructing the full tensor (`mode="full"`) -- lowering reconstruction-oriented paths onto existing runtime helpers such as `get_into_ranges(...)` where appropriate - -### Compatibility boundary - -The project should continue to preserve this boundary: - -- keep old TP APIs functional -- keep them thin -- do not expand the old TP-specific family as the primary interface -- document and evolve the unified `*_with_parallelism` family instead - -## Scope still intentionally limited - -This document describes the stable public API direction, but not every theoretical source-layout to target-layout remap is fully implemented. - -In particular, the design should continue to avoid over-promising planner coverage for arbitrary remaps across all DP / TP / EP / PP combinations until those paths are explicitly implemented and tested. - -The safe documented contract today is: - -1. define the public API structures and signatures clearly -2. align `store_py.cpp` with `axes + ReadTarget` -3. keep TP wrappers working by lowering into the unified path -4. do not introduce a new long-term `kind="mixed"` model -5. do not push planner internals such as byte ranges and derived shapes into public arguments -6. do not expand into many parallelism-specific public methods -7. reuse existing runtime helpers like `get_into_ranges(...)` rather than inventing a parallel reconstruction path from scratch diff --git a/docs/source/getting_started/build.md b/docs/source/getting_started/build.md index bab4632376..de9f4a20f7 100644 --- a/docs/source/getting_started/build.md +++ b/docs/source/getting_started/build.md @@ -13,7 +13,7 @@ This document describes how to build Mooncake. Install common build dependencies first. A stable Internet connection is required because the script installs system packages, initializes submodules, -installs Go, and builds/installs yalantinglibs. +and installs Go. ```bash sudo bash dependencies.sh @@ -31,11 +31,11 @@ sudo make install ### Build with VRAM Segment -To enable VRAM Segment, install CUDA toolkit and build Mooncake with +To enable VRAM Segment, install CUDA toolkit and build Mooncake with `USE_VRAM_SEGMENT` enabled: ```bash -sudo bash dependencies.sh +sudo bash dependencies.sh mkdir build cd build @@ -44,7 +44,7 @@ make -j sudo make install ``` -If NVLink is available in your environment, you can also enable it +If NVLink is available in your environment, you can also enable it with `-DUSE_INTRA_NVLINK=ON`: ```bash @@ -75,6 +75,38 @@ sudo make install `-DUSE_NOF=ON` builds the NoF registration APIs and deployment tools. Use `-DUSE_NOF=OFF` or omit the option when the NVMe-oF SSD pool is not needed. +### RISC-V Build + +Mooncake supports native 64-bit RISC-V Linux builds with `USE_RISCV` enabled. +The option keeps regular Release optimizations but disables interprocedural +optimization for the Python extensions, avoiding the excessive memory use of +full GNU LTO on RISC-V build hosts. The build also detects whether 16-byte +atomic operations require `libatomic` and links it automatically. + +The following configuration builds the C++, Python, and Rust components while +disabling every component that requires Go: + +```bash +mkdir build-riscv +cd build-riscv +cmake -G Ninja .. \ + -DCMAKE_BUILD_TYPE=Release \ + -DUSE_RISCV=ON \ + -DWITH_STORE_GO=OFF \ + -DWITH_P2P_STORE=OFF \ + -DUSE_ETCD=OFF \ + -DSTORE_USE_ETCD=OFF \ + -DSTORE_USE_K8S_LEASE=OFF \ + -DBUILD_UNIT_TESTS=OFF \ + -DBUILD_EXAMPLES=OFF \ + -DBUILD_BENCHMARK=OFF +cmake --build . --parallel 4 +``` + +Adjust the parallel job count for the available memory. The example uses four +jobs because optimized C++ and Python binding translation units can each need +several gigabytes of memory on RISC-V. + ### Hardware Backend Setup Run `sudo bash dependencies.sh` before using any of these backend-specific build @@ -84,7 +116,7 @@ environment setup must be prepared separately. | Hardware / backend | Build option | External SDK / setup | Environment and notes | | --- | --- | --- | --- | | NVIDIA CUDA / GPUDirect | `-DUSE_CUDA=ON` | Install CUDA 12.1+ and enable `nvidia-fs` for cuFile builds. | Add CUDA libraries to `LIBRARY_PATH` and `LD_LIBRARY_PATH`, for example `/usr/local/cuda/lib64`. | -| NVIDIA NCCL DeviceTransport | `-DUSE_NCCL_DEVICE=ON` | Install NCCL 2.30.4+ with `nccl_device.h`. Requires CUDA. | Set `NCCL_ROOT` when NCCL is outside the standard search paths. NCCL Device API device code must be rebuilt or re-JITed with headers that exactly match the runtime `libnccl`. | +| NVIDIA NCCL DeviceTransport | `-DUSE_NCCL_DEVICE=ON` | Install NCCL 2.30.4+ with `nccl_device.h`. Requires CUDA. | Disabled by default. Enabling it directly links NCCL into the EP extensions, so importing `mooncake.ep` requires a matching runtime `libnccl` even when the NCCL transport is not selected. Set `NCCL_ROOT` when NCCL is outside the standard search paths. | | NVIDIA NCCL host RMA (WRITE only) | `-DUSE_NCCL_HOST=ON` | Install NCCL 2.30.4+. Requires CUDA. | Set `NCCL_ROOT` when NCCL is outside the standard search paths. Install NCCL as the only transport in a `TransferEngine(false)` instance before registering buffers. Peers must register matching buffer sizes in the same order. It has no multi-transport fallback and supports WRITE requests only because NCCL 2.30 has no public host Get operation. | | NVIDIA Multi-Node NVLink | `-DUSE_MNNVL=ON` | Requires CUDA. | Also set `-DUSE_CUDA=ON`. Not used with MUSA, HIP, or MACA builds. | | Moore Threads MUSA | `-DUSE_MUSA=ON` | Install MUSA SDK and `mthreads-peermem` for GPUDirect RDMA. | Add `/usr/local/musa/lib` to `LIBRARY_PATH` and `LD_LIBRARY_PATH`. | @@ -94,6 +126,7 @@ environment setup must be prepared separately. | Huawei Ascend UBSHMEM | `-DUSE_UBSHMEM=ON` | Install Ascend CANN Toolkit. Requires CANN >= 9.0.0, driver >= 26.0.0, Lingqu >= 1.5. | Source the CANN `set_env.sh` before configuring CMake. | | AMD HIP / ROCm | `-DUSE_HIP=ON` | Install ROCm/HIP SDK. | Ensure HIP compiler, headers, and runtime libraries are visible to CMake. | | Hygon DCU | `-DUSE_HYGON=ON` | Install DTK SDK. | Set `DTK_HOME`, or pass `-DDTK_ROOT=/path/to/dtk`. Use `-DDTK_INCLUDE_DIR` and `-DDTK_LIB_DIR` for custom layouts. | +| ScaleFabric SHCA | `-DUSE_SHCA=ON` | Install `shca-tools`. | Supports Transfer Engine/TENT RDMA paths only; Mooncake-EP IBGDA is not supported. `MC_RPC_PROTOCOL=rdma` is not supported on SHCA builds; Store/RPC should use TCP. | | Iluvatar CoreX | `-DUSE_COREX=ON` | Install CoreX SDK. | Set `COREX_HOME`, or pass `-DCOREX_ROOT=/path/to/corex`. Use `-DCOREX_INCLUDE_DIR` and `-DCOREX_LIB_DIR` for custom layouts. | ```{admonition} NCCL host RMA constraints @@ -197,7 +230,7 @@ The following options can be passed to `cmake ..`. | Option | Default | Description | | --- | --- | --- | | `-DUSE_CUDA=ON/OFF` | `OFF` | Enable GPU memory support, including GPUDirect RDMA, NVMe-oF, and GPU-aware TCP transport. Required when transferring GPU memory, even when using TCP. | -| `-DUSE_NCCL_DEVICE=ON/OFF` | `OFF` | Enable the experimental NCCL DeviceTransport backend. Requires CUDA and NCCL 2.30.4+; AOT- and JIT-compiled NCCL device code must use headers that exactly match the runtime `libnccl`. | +| `-DUSE_NCCL_DEVICE=ON/OFF` | `OFF` | Enable the NCCL DeviceTransport backend. Requires CUDA and NCCL 2.30.4+ with `nccl_device.h`. NCCL-enabled EP extensions require a matching runtime `libnccl` at import time, and AOT- and JIT-compiled NCCL device code must use headers that exactly match that runtime. | | `-DUSE_NCCL_HOST=ON/OFF` | `OFF` | Enable the experimental, WRITE-only NCCL host RMA transport. Requires CUDA and NCCL 2.30.4+, must be installed before its buffers are registered, and must be the engine's only installed transport. | | `-DUSE_MNNVL=ON/OFF` | `OFF` | Enable Multi-Node NVLink transport. Requires `-DUSE_CUDA=ON`; not used with MUSA, HIP, or MACA builds. | | `-DUSE_MUSA=ON/OFF` | `OFF` | Enable Moore Threads GPU support via MUSA. | @@ -206,11 +239,14 @@ The following options can be passed to `cmake ..`. | `-DUSE_HYGON=ON/OFF` | `OFF` | Enable Hygon DCU support via DTK SDK. Uses a CUDA-compatible runtime. | | `-DUSE_COREX=ON/OFF` | `OFF` | Enable Iluvatar CoreX GPU support. Uses a CUDA-compatible runtime. | | `-DUSE_MLU=ON/OFF` | `OFF` | Enable Cambricon MLU memory support via Neuware, including memory detection, topology discovery, and RDMA registration. | +| `-DUSE_RISCV=ON/OFF` | `OFF` | Enable RISC-V build compatibility settings, including disabling full IPO/LTO for Python extensions. | +| `-DUSE_SHCA=ON/OFF` | `OFF` | Enable ScaleFabric SHCA InfiniBand support for Transfer Engine/TENT RDMA paths only. Mooncake-EP IBGDA is not supported. `MC_RPC_PROTOCOL=rdma` is not supported on SHCA builds; Store/RPC should use TCP. | | `-DUSE_ASCEND_DIRECT=ON/OFF` | `OFF` | Enable Ascend Direct transport and HCCS support via the ADXL engine. Recommended for Ascend builds. | | `-DUSE_UBSHMEM=ON/OFF` | `OFF` | Enable Huawei Ascend NPU shared memory transport via CANN VMM APIs. | | `-DUSE_INTRA_NVLINK=ON/OFF` | `OFF` | Enable intranode NVLink transport. | | `-DUSE_VRAM_SEGMENT=ON/OFF` | `OFF` | Enable create VRAM Segment instead of (default) DRAM Segment. | | `-DUSE_CXL=ON/OFF` | `OFF` | Enable CXL support. | +| `-DUSE_MPCOMM=ON/OFF` | `OFF` | Enable the MPComm transport in TENT (multi-NIC memory pooling over RDMA). Requires `-DUSE_TENT=ON` and `-DMPCOMM_ROOT=`. See [MPComm Transport](../design/transfer-engine/transport/mpcomm_transport.md). | ### Vendor SDK Path Overrides @@ -229,12 +265,13 @@ The following options can be passed to `cmake ..`. | `-DNEUWARE_ROOT=/path/to/neuware` | `-DUSE_MLU=ON` | Override the Neuware SDK root. `NEUWARE_HOME` is also honored; default is `/usr/local/neuware`. | | `-DMLU_INCLUDE_DIR=/path/to/include` | `-DUSE_MLU=ON` | Override the Neuware include directory. | | `-DMLU_LIB_DIR=/path/to/lib64` | `-DUSE_MLU=ON` | Override the Neuware library directory. | +| `-DMPCOMM_ROOT=/path/to/mpcomm` | `-DUSE_MPCOMM=ON` | **Required.** MPComm install prefix; must contain `include/mpcomm.h` and `lib/libmpcomm.so`. Configuration fails if unset or if either file is missing. | ### Transport and Metadata Options | Option | Default | Description | | --- | --- | --- | -| `-DUSE_EFA=ON/OFF` | `OFF` | Enable AWS Elastic Fabric Adapter transport via libfabric. See [EFA Transport](../design/transfer-engine/efa_transport.md). | +| `-DUSE_EFA=ON/OFF` | `OFF` | Enable AWS Elastic Fabric Adapter transport via libfabric. See [EFA Transport](../design/transfer-engine/transport/efa_transport.md). | | `-DUSE_NOF=ON/OFF` | `OFF` | Build Mooncake Store with NVMe-oF SSD pool support. Use `sudo bash dependencies.sh --with-spdk` before enabling it. | | `-DUSE_REDIS=ON/OFF` | `OFF` | Enable Redis-based metadata service for Transfer Engine. Requires hiredis. | | `-DUSE_HTTP=ON/OFF` | `ON` | Enable HTTP-based metadata service. | @@ -250,8 +287,9 @@ The following options can be passed to `cmake ..`. | `-DWITH_TE=ON/OFF` | `ON` | Build the Mooncake Transfer Engine component and sample code. | | `-DWITH_STORE=ON/OFF` | `ON` | Build the Mooncake Store component. | | `-DWITH_STORE_GO=ON/OFF` | `OFF` | Build Go bindings for Mooncake Store when `-DWITH_STORE=ON`. | +| `-DWITH_CONDUCTOR=ON/OFF` | `OFF` | Build the Mooncake Conductor service. | | `-DWITH_P2P_STORE=ON/OFF` | `OFF` | Enable Golang support and build the P2P Store component. Requires Go 1.23+. | -| `-DWITH_RUST_EXAMPLE=ON/OFF` | `OFF` | Build the Transfer Engine Rust interface and sample code. | +| `-DWITH_RUST_EXAMPLE=ON/OFF` | `OFF` | Build the Transfer Engine Rust library (`transfer_engine_rust`), tests, and sample benchmark. | | `-DWITH_STORE_RUST=ON/OFF` | `ON` | Build Mooncake Store Rust bindings and CMake Rust targets. | | `-DWITH_EP=ON/OFF` | `OFF` | Build the EP and PG Python extensions for CUDA. Requires CUDA toolkit and PyTorch. Use `-DEP_TORCH_VERSIONS="2.13.0"` to build for specific PyTorch versions, or leave empty to use the currently installed torch. The CUDA version is detected automatically. | diff --git a/docs/source/getting_started/observability.md b/docs/source/getting_started/observability.md index ffc6b007bb..7b264ef433 100644 --- a/docs/source/getting_started/observability.md +++ b/docs/source/getting_started/observability.md @@ -122,6 +122,7 @@ The admin HTTP server runs on `metrics_port` (default: **9003**) and exposes the | `GET /metrics` | `text/plain; version=0.0.4` | All metrics in Prometheus exposition format | | `GET /metrics/summary` | `text/plain; version=0.0.4` | Human-readable summary (same content as the periodic log) | | `GET /health` | `application/json` | Health check with role, HA state, and service readiness | +| `GET /version` | `application/json` | Master version (`version` for RPC compatibility, `display_version` for release + git hash) | | `GET /role` | `text/plain` | Current HA role (`leader` / `standby`) | | `GET /ha_status` | `text/plain` | Current HA runtime state (`serving` / `starting` / etc.) | @@ -150,6 +151,9 @@ curl http://:9003/metrics/summary # Check health curl http://:9003/health + +# Check version +curl http://:9003/version ``` ### Configuration @@ -166,6 +170,64 @@ The admin HTTP server is configured in the master config file (`master.json` or Set `enable_metric_reporting` to `false` to disable the periodic metrics log. HTTP endpoints (`/metrics`, `/health`, etc.) remain available regardless of this setting. +### Process and Allocator Memory Metrics + +Both `/metrics` endpoints export the resident set size of the serving process, +so memory growth can be read from Mooncake itself rather than correlated +against a container-level metric. + +| Metric | Type | Source | +|--------|------|--------| +| `mooncake_process_rss_bytes` | gauge | `VmRSS` | +| `mooncake_process_rss_peak_bytes` | gauge | `VmHWM`, the high-water mark an OOM kill is decided on | +| `mooncake_process_rss_anon_bytes` / `..._rss_file_bytes` / `..._rss_shmem_bytes` | gauge | `RssAnon` / `RssFile` / `RssShmem` | +| `mooncake_process_vsize_bytes` | gauge | `VmSize` | +| `mooncake_process_swap_bytes` | gauge | `VmSwap` | + +These come from `/proc/self/status` and are exported whichever allocator the +binary is linked against, so they stay comparable across an allocator change. + +When the binary is built with `-DSTORE_USE_JEMALLOC=ON` (see below), +`mooncake_jemalloc_enabled` reports `1` and the allocator's own accounting is +exported alongside: `allocated` / `active` / `metadata` / `resident` / +`retained` / `mapped` / `dirty` / `muzzy` bytes, arena and background-thread +counts, the `opt.dirty_decay_ms` and `opt.muzzy_decay_ms` settings, and +cumulative purge counters (`mooncake_jemalloc_dirty_purge_runs_total`, +`..._dirty_madvises_total`, and the `muzzy` equivalents). Dirty and muzzy are +reported separately because jemalloc decays them on separate paths. + +`mooncake_jemalloc_resident_bytes / mooncake_jemalloc_allocated_bytes` is the +ratio to watch: it is how much memory the allocator holds beyond what the +application asked for. To attribute that ratio to a specific allocation size, +`mooncake_jemalloc_bin_regs`, `..._bin_slabs`, `..._bin_used_bytes` and +`..._bin_slab_bytes` break it down per small size class, labelled by +`size_class`. A slab is returned to the OS only once every region in it is +free, so a class with low `bin_used_bytes / bin_slab_bytes` occupancy is +holding pages it cannot release. + +Note that `mooncake_jemalloc_resident_bytes` is the allocator's own upper +estimate over the extents it maps and can exceed `mooncake_process_rss_bytes`; +compare the two for divergence rather than subtracting them. + +When the binary is not built with jemalloc, `mooncake_jemalloc_enabled` reports +`0`, no `mooncake_jemalloc_*` values or per-size-class series are emitted, and +the process series above still export. + +#### Building with jemalloc + +`STORE_USE_JEMALLOC` is **off by default**. It links jemalloc into the +`mooncake_master` and `mooncake_client` executables only, never into +`libmooncake_store`, so the Python extension keeps its host process's +allocator: + +```bash +cmake .. -DSTORE_USE_JEMALLOC=ON +``` + +Both executables are stripped at link time, so each logs the jemalloc version, +`background_thread` state and both decay settings at startup. That line is the +way to confirm on a release binary that the allocator was actually replaced. + ## Client Metrics Endpoint Mooncake clients can also expose a client-local HTTP endpoint for health checks @@ -198,13 +260,66 @@ For `mooncake.mooncake_store_service`, set | `GET /health` | `application/json` | Client health check | | `GET /metrics` | `text/plain; version=0.0.4` | Prometheus-format client metrics | | `GET /metrics/summary` | `text/plain` | Human-readable client metrics summary | +| `GET /version` | `application/json` | Client version (`version` for RPC compatibility, `display_version` for release + git hash) | ```bash curl http://:9300/health curl http://:9300/metrics curl http://:9300/metrics/summary +curl http://:9300/version +``` + +```json +{"version":"2.0.0","display_version":"0.3.12.post1 (git: f9e8311f)"} ``` +`/version` does not depend on client metric collection or on a fully +initialized client, so it stays available whenever the client HTTP server is +running. + Set `MC_STORE_CLIENT_METRIC=0` to disable client metric collection. If the client HTTP server remains enabled while metrics are disabled, `/metrics` and `/metrics/summary` return HTTP 503 with `metrics not available`. + + +### Master heartbeat observations + +Clients running the storage heartbeat expose two gauges through `/metrics`: + +| Metric | Meaning | +|--------|---------| +| `mooncake_client_master_heartbeat_status_ok` | Last observed Ping status: `1` for `OK`, `0` for `NEED_REMOUNT`. | +| `mooncake_client_master_heartbeat_observation_timestamp_seconds` | Client-side Unix receive time, in seconds, of the same observation. | + +Both samples carry the existing client labels. They are absent before the first +valid heartbeat, after a failed heartbeat or an unsupported status, and while +reconnecting. Late responses from an older connection cannot restore the +observation. A successful remount alone does not set the status to `1`; a later +Ping must return `OK`. + +A client without storage may never start this heartbeat. Memory or LocalDisk +mounts and DFS backend activation start the storage control plane; creating a +request-only client does not. Missing samples mean **unknown or inapplicable**, +not `NEED_REMOUNT`. Disabling client metrics also disables these observations. + +The timestamp is an observation value, not a Prometheus sample timestamp. An +in-flight Ping can leave the previous observation visible until it completes, +so check its age as well as its status. For example, for targets expected to +run a storage heartbeat, this query selects a recent `NEED_REMOUNT` observation +using an illustrative 10-second freshness limit: + +```promql +(mooncake_client_master_heartbeat_status_ok == 0) +and +((time() - mooncake_client_master_heartbeat_observation_timestamp_seconds) < 10) +``` + +Choose the freshness limit for the heartbeat/RPC timeouts and scrape interval +in your deployment, and account for clock skew. Do not replace missing status +samples with zero. Prometheus `up` describes the HTTP scrape, not Master Ping +success; missing or stale observations need separate handling. + +This is a sampled control-plane response. `OK` does not prove that every +segment is mounted, that transfer metadata or SSD recovery is complete, or +that data RPCs and RDMA transfers are healthy. The existing `/health` check +can remain healthy when a successful Ping returns `NEED_REMOUNT`. diff --git a/docs/source/getting_started/plugin-usage/3FS-USRBIO-Plugin.md b/docs/source/getting_started/plugin-usage/3FS-USRBIO-Plugin.md index 5566bc274b..d933af3782 100644 --- a/docs/source/getting_started/plugin-usage/3FS-USRBIO-Plugin.md +++ b/docs/source/getting_started/plugin-usage/3FS-USRBIO-Plugin.md @@ -1,47 +1,92 @@ -# Mooncake HF3FS Plugin (Experimental) +# Mooncake HF3FS USRBIO Adapter (Experimental) ```{warning} -**Experimental / incomplete.** The HF3FS (3FS USRBIO) integration is under development and is not yet considered production-ready. Behavior, build flags, and configuration may change without notice. Use only for evaluation and testing. +**Work in progress / experimental.** Descriptor-based DFS and its HF3FS (3FS +USRBIO) adapter are under development and are not production-ready. They are +not covered by Mooncake Store's general fault-tolerance, HA continuity, +durability, or multi-tenant guarantees. Behavior, build flags, and +configuration may change without notice. Use only for evaluation and testing. ``` -This plugin implements 3FS native API (USRBIO) as a high-performance storage backend for Mooncake. +This adapter implements the HF3FS native USRBIO data plane for Mooncake Store's +descriptor-based DFS replicas. The master allocates ranges in shared shard +files, and clients use USRBIO to access the ranges described by the replica +metadata. + +The adapter is not enabled by the legacy `--root_fs_dir` option. It also does +not automatically fall back to POSIX I/O; select +`MOONCAKE_DFS_FS_ADAPTER=posix` explicitly when POSIX behavior is required. ## Prerequisites -### 1. 3FS Installation +### 1. HF3FS installation + - Build and install [3FS](https://github.com/deepseek-ai/3FS/) - Required library: `libhf3fs_api_shared.so` (Default location: `3FS_PATH/build/src/lib/api`) → Install to: `/usr/lib/` - Required header: `hf3fs_usrbio.h` (Default location: `3FS_PATH/src/lib/api`) → Install to: `/usr/include/` -### 2. Mooncake Configuration -- Enable 3FS support during CMake configuration: -```bash +### 2. Mooncake build + +Enable HF3FS support during CMake configuration: +```bash cmake -DUSE_3FS=ON ... ``` -- Build and install Mooncake as usual. +Then build and install Mooncake as usual. ## Usage -### Basic Operation -Start master server and specify the 3FS mount point: +### Master + +Enable descriptor-based DFS and point it at a directory on the shared HF3FS +mount: + ```bash +export MOONCAKE_ENABLE_DFS=1 +export MOONCAKE_DFS_ROOT_DIR=/mnt/3fs/mooncake +export MOONCAKE_DFS_FS_ADAPTER=hf3fs +export MOONCAKE_DFS_SHARD_COUNT=64 +export MOONCAKE_DFS_SHARD_CAPACITY=4294967296 +export MOONCAKE_DFS_ALIGNMENT=4096 +export MOONCAKE_DFS_SINGLE_TENANT=true -./build/mooncake-store/src/mooncake_master \ - --root_fs_dir=/path/to/3fs_mount_point +./build/mooncake-store/src/mooncake_master [other master arguments] ``` -### Important Notes -1. The specified directory **must** be a 3FS mount point - - If not, the system will automatically fall back to POSIX API -2. For optimal performance: - - Ensure proper permissions on the 3FS mount point - - Verify 3FS service is running before execution - -### Example + +The master creates and preallocates the configured shard files during startup. +Ensure the mount is available, writable, and has enough capacity before +starting the process. + +### Clients + +Every client that may read or write DFS replicas must use the same root, +adapter, shard count, shard capacity, and alignment. The DFS root must be an +absolute path and use the same path string in every process. For the standalone +store service: + ```bash +export MOONCAKE_OFFLOAD_ENABLED=true +export MOONCAKE_OFFLOAD_STORAGE_BACKEND_DESCRIPTOR=distributed_storage_backend +export MOONCAKE_OFFLOAD_FILE_STORAGE_PATH=/data/file_storage +export MOONCAKE_MASTER=127.0.0.1:50051 +export MOONCAKE_DFS_ROOT_DIR=/mnt/3fs/mooncake +export MOONCAKE_DFS_FS_ADAPTER=hf3fs +export MOONCAKE_DFS_SHARD_COUNT=64 +export MOONCAKE_DFS_SHARD_CAPACITY=4294967296 +export MOONCAKE_DFS_ALIGNMENT=4096 +export MOONCAKE_DFS_SINGLE_TENANT=true -ROLE=prefill MOONCAKE_STORAGE_ROOT_DIR=/mnt/3fs python3 ./stress_cluster_benchmark.py +python -m mooncake.mooncake_store_service ``` + +`MOONCAKE_OFFLOAD_FILE_STORAGE_PATH` must already be an absolute, writable, +non-symlink directory. DFS shard data is stored under +`MOONCAKE_DFS_ROOT_DIR`; the separate FileStorage path is still validated +during client initialization. + +For the complete configuration reference, request example, synchronous write +semantics, and current recovery limitations, see the {ref}`DFS deployment +documentation `. diff --git a/docs/source/getting_started/quick-start.md b/docs/source/getting_started/quick-start.md index 90d17c6f30..c98d57b382 100644 --- a/docs/source/getting_started/quick-start.md +++ b/docs/source/getting_started/quick-start.md @@ -1,87 +1,301 @@ # Quick Start -This document describes how to quickly start using Mooncake Transfer Engine and Mooncake Store. +Get up and running with Mooncake in minutes. -## Before using Mooncake +This guide walks you through the entire flow of getting started with Mooncake: + +1. **Install** Mooncake +2. **Start** the Store master and **Send a request** with the Python Store API + +Serving-framework users can then connect SGLang, vLLM or other systems. + +## Prerequisites + +- **Python**: 3.10 or later; a virtual environment is recommended. +- **RDMA**: an RDMA driver and SDK (for example, Mellanox OFED), if you plan to use RDMA for data transfer. On ScaleFabric SHCA systems, install `shca-tools` and build with `-DUSE_SHCA=ON`. +- **CUDA**: 12.1 or later. For most CUDA-enabled use cases, such as RDMA-based KV cache transfer between GPUs or between GPU and DRAM, NVIDIA GPUDirect support is also required. You may install CUDA from [the NVIDIA downloads page](https://developer.nvidia.com/cuda-downloads). + +```{note} +The default pip, build and Docker paths target NVIDIA CUDA. For other +platforms, see [Other Platforms](#other-platforms) below. +``` -Install the following prerequisites before running any Mooncake component: -- Python 3.10 or later; a virtual environment is recommended. -- RDMA driver and SDK (for example, Mellanox OFED), if you plan to use RDMA for data transfer. -- CUDA 12.1 or later, if the package is built with `-DUSE_CUDA` (disabled by default). For most CUDA-enabled use cases, such as RDMA-based KV cache transfer between GPUs or between GPU and DRAM, NVIDIA GPUDirect support is also required. *You may install them from [here](https://developer.nvidia.com/cuda-downloads)*. -- Cambricon Neuware, if the package is built with `-DUSE_MLU`. By default Mooncake looks for Neuware under `NEUWARE_HOME` or `/usr/local/neuware`. -- Hygon DTK SDK, if the package is built with `-DUSE_HYGON`. By default Mooncake looks for DTK under `DTK_HOME` or `/opt/dtk`. -- Iluvatar CoreX SDK, if the package is built with `-DUSE_COREX`. By default Mooncake looks for CoreX under `COREX_HOME` or `/usr/local/corex`. ## Installation -Install the Mooncake package from PyPI. The same package provides: +The same package provides: - Mooncake Store Python bindings for vLLM and SGLang HiCache integrations. - Transfer Engine Python bindings and runtime components for direct `mooncake.engine.TransferEngine` usage. -**For CUDA-enabled systems:** +::::{tab-set} + +:::{tab-item} pip / uv +We recommend using **uv** for faster installation: + +```bash +pip install --upgrade pip +pip install uv +uv pip install mooncake-transfer-engine +``` + +Plain `pip` also works: -- CUDA < 13.0 ```bash pip install mooncake-transfer-engine ``` -- CUDA >= 13.0 +```{tip} +The default wheel targets CUDA 12.1–12.9 and includes Mooncake-EP and GPU +topology detection. For CUDA 13.0/13.1, install +`mooncake-transfer-engine-cuda13` instead. +``` +::: + +:::{tab-item} From Source +Clone the repository and build the default configuration: + +```bash +git clone https://github.com/kvcache-ai/Mooncake.git +cd Mooncake +sudo bash dependencies.sh + +mkdir build +cd build +cmake .. +make -j +sudo make install +``` + +For CUDA, VRAM segments, NVMe-oF, and other backend flags, see the +[Build Guide](build.md). +::: + +:::{tab-item} Docker +Published images are available on Docker Hub at +[kvcacheai/mooncake](https://hub.docker.com/r/kvcacheai/mooncake). + ```bash -pip install mooncake-transfer-engine-cuda13 +docker run --net=host \ + --ipc=host \ + --ulimit memlock=-1 \ + kvcacheai/mooncake:latest \ + mooncake_master +``` + +For details, see +[Use Mooncake in Docker Containers](build.md#use-mooncake-in-docker-containers). +::: + +:::: + +```{note} +If users encounter problems such as missing `lib*.so`, first install the +corresponding system runtime libraries. If the issue persists, uninstall the +package and [build the binaries manually](build.md). ``` -**For non-CUDA systems:** +## Other Platforms + +The default path above targets NVIDIA CUDA. Use the matching wheel or source +build for other platforms. Install only one variant in an environment. + +::::{tab-set} + +:::{tab-item} Non-CUDA +**Prerequisites** + +- Python 3.10 or later. +- Ubuntu runtime libraries: `libcurl4`, `libibverbs1`, `rdma-core`, + `librdmacm1`, `libnuma1`, and `liburing2`. + +**Installation** + ```bash +sudo apt-get update && sudo apt-get install -y \ + libcurl4 libibverbs1 rdma-core librdmacm1 libnuma1 liburing2 pip install mooncake-transfer-engine-non-cuda ``` +::: + +:::{tab-item} Ascend NPU +**Prerequisites** + +- Python 3.10 or later. +- Ascend CANN Toolkit. Source `/usr/local/Ascend/cann/set_env.sh` before + running Mooncake. Ascend Direct (ADXL/HIXL) is the recommended path. + +**Installation** -**For NPU systems:** ```bash pip install mooncake-transfer-engine-npu +source /usr/local/Ascend/cann/set_env.sh ``` -> **Important**: -> - The CUDA version (`mooncake-transfer-engine`) includes Mooncake-EP and GPU topology detection, requiring CUDA 12.1+. -> - The non-CUDA version (`mooncake-transfer-engine-non-cuda`) is for environments without CUDA dependencies, but it still needs system runtime libraries such as `libcurl4`, `libibverbs1`, `rdma-core`, `librdmacm1`, `libnuma1`, and `liburing2` on Ubuntu. In a fresh environment, run `sudo apt-get update` before installing them: -> ```bash -> sudo apt-get update && sudo apt-get install -y libcurl4 libibverbs1 rdma-core librdmacm1 libnuma1 liburing2 -> ``` -> - MLU support is currently available through source builds with `-DUSE_MLU=ON`; there is no dedicated prebuilt MLU wheel yet. -> - If users encounter problems such as missing `lib*.so`, first install the corresponding system runtime libraries. If the issue persists, uninstall the package and build the binaries manually. +See [Ascend Direct Transport](../design/transfer-engine/transport/ascend_direct_transport.md) +for the recommended path. The legacy backend is documented in +[Ascend Transport](../design/transfer-engine/transport/ascend_transport.md). +For mixed GPU/NPU transfers, see +[Heterogeneous Ascend Transport](../design/transfer-engine/transport/heterogeneous_ascend.md). +There are also two detailed Chinese guides: +[Mooncake KVPool guide](https://gitcode.com/cann/hixl/wiki/Mooncake%20KVPool%E6%8C%87%E5%8D%97.md) +and +[Mooncake NPU guide](https://gitcode.com/cann/hixl/wiki/Mooncake%EF%BC%88NPU%20%E7%89%88%EF%BC%89%E5%AE%8C%E6%95%B4%E6%8C%87%E5%8D%97.md). +::: -## Connect vLLM or SGLang +:::{tab-item} AMD ROCm +**Prerequisites** -Choose the integration path that matches your serving deployment. +- Python 3.10 or later. +- ROCm / HIP SDK, with `hipcc` and runtime libraries on `PATH` (for example + `/opt/rocm`). -### PD Disaggregation +**Installation** -PD disaggregation paths use Mooncake Transfer Engine for direct KV transfer -between prefill and decode workers. Configure these paths through the serving -framework guides, not by calling Transfer Engine APIs directly: +```bash +pip install mooncake-transfer-engine-rocm +``` +::: -- [SGLang Integration Overview](../deployment/integrations/sglang/index.md) -- [vLLM Integration Overview](../deployment/integrations/vllm/index.md) +:::{tab-item} Moore Threads MUSA +**Prerequisites** -### Mooncake Store +- Python 3.10 or later. +- MUSA SDK. Add `/usr/local/musa/lib` to `LIBRARY_PATH` and `LD_LIBRARY_PATH`. +- `mthreads-peermem` for GPUDirect RDMA. -Mooncake Store provides distributed KV cache storage for vLLM and SGLang -HiCache: +**Installation** -| Framework | Use case | Setup guide | -|-----------|----------|-------------| -| SGLang | HiCache L3 storage backend with Mooncake Store | [SGLang HiCache Quick Start](../deployment/integrations/sglang/hicache-quick-start.md) | -| vLLM | KV cache storage and sharing with `MooncakeStoreConnector` | [vLLM KV Cache Storage & Sharing](../deployment/integrations/vllm/kv-cache-storage.md) | +```bash +pip install mooncake-transfer-engine-musa +``` +::: + +:::{tab-item} AWS EFA +**Prerequisites** + +- An AWS instance with EFA (for example p5 or p6). +- AWS EFA driver and libfabric. Verify with `fi_info -p efa`, and keep + `/opt/amazon/efa/lib` on `LD_LIBRARY_PATH`. +- CUDA 12.1–12.9 or CUDA 13 if you use the GPU-aware EFA wheels. + +**Installation** + +```bash +# GPU memory transfers with CUDA 12 +pip install mooncake-transfer-engine-efa + +# GPU memory transfers with CUDA 13 +pip install mooncake-transfer-engine-efa-cuda13 + +# CPU/DRAM-only transfers +pip install mooncake-transfer-engine-efa-non-cuda +``` -The serving framework guides include the required Mooncake Store service -startup and connector configuration for each path. +See the [EFA transport guide](../design/transfer-engine/transport/efa_transport.md) +for prerequisites and configuration. +::: -## Optional Python Smoke Test +:::{tab-item} Cambricon MLU +**Prerequisites** -If you want to verify the Store Python API without a serving framework, run this -single-node `put`/`get` example after starting `mooncake_master`. It uses -`P2PHANDSHAKE`, so no separate Transfer Engine metadata service is required. +- Python 3.10 or later. +- Cambricon Neuware SDK. Set `NEUWARE_HOME`, or use the default + `/usr/local/neuware`. There is no dedicated prebuilt MLU wheel yet. + +**Installation** + +```bash +git clone https://github.com/kvcache-ai/Mooncake.git +cd Mooncake +sudo bash dependencies.sh +mkdir build && cd build +cmake .. -DUSE_MLU=ON +make -j +sudo make install +``` +::: + +:::{tab-item} MetaX MACA +**Prerequisites** + +- Python 3.10 or later. +- MACA SDK. Set `MACA_HOME`, or use the default `/opt/maca`. + +**Installation** + +```bash +git clone https://github.com/kvcache-ai/Mooncake.git +cd Mooncake +sudo bash dependencies.sh +mkdir build && cd build +cmake .. -DUSE_MACA=ON +make -j +sudo make install +``` +::: + +:::{tab-item} Hygon DCU +**Prerequisites** + +- Python 3.10 or later. +- Hygon DTK SDK. Set `DTK_HOME`, or use the default `/opt/dtk`. + +**Installation** + +```bash +git clone https://github.com/kvcache-ai/Mooncake.git +cd Mooncake +sudo bash dependencies.sh +mkdir build && cd build +cmake .. -DUSE_HYGON=ON +make -j +sudo make install +``` +::: + +:::{tab-item} Iluvatar CoreX +**Prerequisites** + +- Python 3.10 or later. +- Iluvatar CoreX SDK. Set `COREX_HOME`, or use the default `/usr/local/corex`. + +**Installation** + +```bash +git clone https://github.com/kvcache-ai/Mooncake.git +cd Mooncake +sudo bash dependencies.sh +mkdir build && cd build +cmake .. -DUSE_COREX=ON +make -j +sudo make install +``` +::: + +:::: + +## Start Mooncake Store + +If you installed with pip or from source, start the master service: + +```bash +mooncake_master +``` + +Wait until you see a line like this in the logs: + +``` +Master service started on port 50051, max_threads=4, ... +``` + +The default RPC port is `50051`. Skip this step if the Docker command above is +already running `mooncake_master`. + +## Send Your First Request + +Run this single-node `put`/`get` example after `mooncake_master` is running. This example uses `P2PHANDSHAKE`, so no separate Transfer Engine metadata service is required. ```python from mooncake.store import MooncakeDistributedStore @@ -105,6 +319,29 @@ print(data.decode()) # Output: Hello, Mooncake Store! store.close() ``` +## Connect vLLM or SGLang + +Choose the integration path that matches your serving deployment. + +### PD Disaggregation + +PD disaggregation paths use Mooncake Transfer Engine for direct KV transfer +between prefill and decode workers. Configure these paths through the serving +framework guides, not by calling Transfer Engine APIs directly: + +- [SGLang Disaggregated Serving with MooncakeTransferEngine](../deployment/integrations/sglang/pd-disaggregation.md) +- [Disaggregated Prefill-Decode with MooncakeConnector](../deployment/integrations/vllm/disagg-prefill-decode.md) + +### Distributed KV Cache Pooling + +Mooncake Store provides distributed KV cache storage for vLLM and SGLang +HiCache: + +| Framework | Use case | Setup guide | +|-----------|----------|-------------| +| SGLang | HiCache L3 storage backend with Mooncake Store | [SGLang HiCache Quick Start](../deployment/integrations/sglang/hicache-quick-start.md) | +| vLLM | KV cache storage and sharing with `MooncakeStoreConnector` | [vLLM KV Cache Storage & Sharing](../deployment/integrations/vllm/kv-cache-storage.md) | + ## AI Coding Assistant Skills If you use Claude Code or another coding assistant that supports reusable @@ -133,4 +370,4 @@ allocation strategies, SSD offload, and runtime tuning, continue to the [Mooncake Store Deployment & Tuning Guide](../deployment/mooncake-store-deployment-guide.md). For API details, see the [Mooncake Store Python API](../api-reference/python/mooncake-store.md) -and [Mooncake Store design](../design/mooncake-store.md). +and [Mooncake Store design](../design/store/mooncake-store.md). diff --git a/docs/source/getting_started/supported-protocols.md b/docs/source/getting_started/supported-protocols.md index baa8a6ebbd..a872910ebd 100644 --- a/docs/source/getting_started/supported-protocols.md +++ b/docs/source/getting_started/supported-protocols.md @@ -11,12 +11,16 @@ Mooncake Transfer Engine supports multiple communication protocols for data tran | **efa** | AWS EFA-capable instance | High-performance on AWS (libfabric SRD) | ✅ Primary | | **nvmeof** | NVMe-oF capable storage | Direct NVMe storage access | ⚠️ Advanced | | **nvlink** | NVIDIA MNNVL | Inter-node GPU communication | ⚠️ Advanced | +| **musa** | Moore Threads GPU + MTLink | Intra-node GPU IPC/P2P | ⚠️ Advanced | | **nvlink_intra** | NVIDIA NVLink | Intra-node GPU communication | ⚠️ Advanced | | **hip** | AMD ROCm/HIP | AMD GPU communication | ⚠️ Advanced | | **barex** | RDMA-capable NIC | Bare-metal RDMA extension | ⚠️ Advanced | | **cxl** | CXL-capable hardware | Memory pooling and sharing | ⚠️ Advanced | +| **shm** | None (POSIX shm, same host) | Same-host DRAM copies without NIC loopback | ⚠️ Advanced | | **ascend** | Huawei Ascend NPU | Ascend NPU communication | ⚠️ Advanced | | **tpu** | Google TPU (PJRT) | TPU KV-cache transfer via host-DRAM staging | 🧪 Experimental (TENT) | +| **mpcomm** | RDMA-capable NIC(s) | Multi-NIC memory pooling with NIC/QP load balancing | ⚠️ Advanced (TENT) | +| **flagcx** | RDMA-capable NIC(s) | Unified P2P transfer through FlagOS FlagCX | ⚠️ Advanced | ## Commonly Used Protocols (Python API) @@ -164,7 +168,7 @@ cmake .. -DUSE_EFA=ON -DUSE_CUDA=ON - Software-emulated RDMA writes (higher CPU overhead than true RDMA) - ~88% of RoCE RDMA throughput -**Documentation:** See [EFA Transport](../design/transfer-engine/efa_transport.md) for build instructions, benchmarks, and tuning. +**Documentation:** See [EFA Transport](../design/transfer-engine/transport/efa_transport.md) for build instructions, benchmarks, and tuning. ## Advanced Protocols (C++ Transfer Engine) @@ -204,6 +208,70 @@ export MC_FORCE_MNNVL=true **Note:** When `protocol="rdma"` is set and RDMA NICs exist, you must explicitly set `MC_FORCE_MNNVL=true` to use MNNVL instead of RDMA. If no RDMA HCA is detected, MNNVL will be used automatically. +**Host memory over NVLink (TENT, EGM):** on Grace-Blackwell systems the GPUs of +an NVLink domain can also address each other's host DRAM (Extended GPU Memory). +The TENT `mnnvl` transport exports host buffers this way when +`transports/mnnvl/egm` is enabled (`MC_MNNVL_EGM=1`, off by default), adding the +`dram_to_dram` and `gpu_to_dram` capabilities so CPU-resident data (weight or +KV caches) moves over NVLink instead of the NIC: + +```bash +export MC_ENABLE_MNNVL=1 # select the TENT mnnvl transport +export MC_MNNVL_EGM=1 # transports/mnnvl/egm +``` + +Only buffers allocated with `allocateLocalMemory("cpu:")` (or any +`cuMemCreate` allocation with a `HOST_NUMA` location and a fabric handle) are +exported; other host memory keeps the previous `cudaHostRegister` behaviour and +is reachable through RDMA/TCP as before. Requires an IMEX domain spanning the +peers and EGM enabled in the driver. + +### MUSA Transport (musa) + +**Description:** Moore Threads GPU IPC transport for P2P copies over the +intra-node MTLink path. It reuses the NVLink transport's transfer bookkeeping, +but opens imported IPC memory and submits copies using MUSA-specific device +context rules. + +**Requirements:** +- Moore Threads GPUs with peer access (validated on S5000) +- MUSA SDK/runtime; MUSA 5.2 or newer enables the low-CPU transfer-batch API +- Compiled with `USE_MUSA=ON` + +**Configuration:** +```bash +# Use the same runtime-visible logical-device mapping in every peer process. +export MUSA_VISIBLE_DEVICES=0,1 +export MC_FORCE_MUSA=1 + +# Safe defaults shown explicitly. Opt in to metadata after checking the +# visibility contract below; "default" rolls back to per-slice copies. +export MC_MUSA_IPC_OPEN_DEVICE=current +export MC_MUSA_COPY_API=auto + +# Performance path after both peers use the same logical device mapping. +export MC_MUSA_IPC_OPEN_DEVICE=metadata +``` + +`MTHREADS_VISIBLE_DEVICES` is consumed by mt-container-toolkit when the +container is created; Mooncake does not use it to infer the MUSA runtime's +logical device mapping. Buffer metadata uses runtime-visible logical ordinals +such as `musa:0`. With `metadata`, every peer must map each logical ordinal to +the same physical GPU. Mooncake validates that the advertised ordinal exists +locally, but it cannot prove cross-peer identity from environment variables. +Both peers must run a version that recognizes the `musa` protocol; rolling +interoperability with an older peer advertising only `nvlink` is not supported. +`MC_MUSA_IPC_OPEN_DEVICE=current` is the safe default; select `metadata` only +when peers satisfy the logical mapping contract above. Python bindings that +register the default wildcard location (`*`) resolve the owning MUSA device +during registration; an older peer that still advertises `*` falls back to the +current-device open path. + +In `auto` mode, batches whose copies are at least 1 MiB use +`muMemoryTransferBatchAsync`; set `MC_MUSA_TRANSFER_BATCH_MIN_BYTES` to tune the +threshold, `MC_MUSA_COPY_API=transfer_batch` to force the API, or +`MC_MUSA_COPY_API=default` to use the CUDA-compatible per-slice path. + ### Intra-Node NVLink (nvlink_intra) **Description:** NVIDIA NVLink for GPU-to-GPU communication within a single node. @@ -214,7 +282,15 @@ export MC_FORCE_MNNVL=true **Requirements:** - NVIDIA NVLink hardware -- Compiled with `USE_INTRA_NVLINK=ON` +- Compiled with `USE_INTRA_NVLINK=ON` (enabled in the prebuilt `x86_64` CUDA wheels; other variants must be built from source) + +**Configuration:** +```bash +# Select the intra-node NVLink transport. Cannot be combined with MC_FORCE_MNNVL. +export MC_INTRANODE_NVLINK=true +``` + +**Note:** On a build without `USE_MNNVL=ON`, leaving `MC_INTRANODE_NVLINK` unset keeps the usual RDMA (or TCP, when no HCA is detected) selection. ### HIP Transport (hip) @@ -251,6 +327,31 @@ export MC_FORCE_MNNVL=true **Requirements:** - CXL-capable hardware +### SHM Transport (shm) + +**Description:** Same-host DRAM copies over POSIX shared memory. Classic Transfer Engine maps the peer's named shm object, relocates the peer virtual address into the local mapping, and `memcpy`s. This is a first-class transport like HIP, not a replacement for `CxlTransport` (DAX offset addressing). + +**Use When:** +- Two processes on the same machine exchange DRAM buffers +- You want to avoid RDMA/TCP loopback for that path + +**Requirements:** +- Linux POSIX shm (`/dev/shm`), or a writable hugetlbfs mount when allocating with `SharedMemoryOptions.use_hugepage` (2MB / 512MB / 1GB) +- Buffers allocated with `TransferEngine::allocateSharedMemory` (ordinary `malloc` cannot be exported) +- Runtime opt-in: `MC_FORCE_SHM=1`, or `installTransport("shm")`. With `-DENABLE_MULTI_PROTOCOL=ON` this adds SHM next to RDMA/TCP (`rdma,shm` / `tcp,shm`); without it, SHM is the only transport. +- Same-host SHM **and** cross-host RDMA/TCP in one engine: build with `-DENABLE_MULTI_PROTOCOL=ON` (segment protocol becomes `rdma,shm` or `tcp,shm`) + +**Limitations:** +- Same host only. Without `ENABLE_MULTI_PROTOCOL`, `MC_FORCE_SHM=1` (or `installTransport("shm")` after another transport) sets `segment.protocol` to `shm` and replaces RDMA/TCP routing; `installTransport("shm")` logs a WARNING when it overwrites a non-empty protocol. Coexistence needs `-DENABLE_MULTI_PROTOCOL=ON`. +- `registerLocalMemory` must use the pointer from `allocateSharedMemory` (a shorter prefix is allowed). A sub-range or overflowing range returns an error instead of silently skipping. Ordinary `malloc` is still skipped so TCP/RDMA can register it. +- Same-UID only: objects are created `0600`. POSIX names are `/mooncake__xxxxxxxx`; hugepage files are `/mooncake__xxxxxxxx`. Creator and consumer must share a user; a hostname match does not imply a shared `/dev/shm` or hugetlbfs mount (for example Kubernetes `hostNetwork` pods). +- Crash or `SIGKILL` can leave POSIX objects in `/dev/shm` and hugetlbfs files on the mount. There is no automatic reaper (wiping `mooncake_*` on start would hit live peers on the same mount). POSIX leftovers waste tmpfs until reboot; **hugetlbfs leftovers keep hugepages reserved** until the file is unlinked or the node reboots. After a crash, delete only `mooncake__*` whose pid no longer exists, e.g. `rm /dev/hugepages/mooncake__*`. +- Hugepage allocations do not fall back to tmpfs. `length` must be a multiple of the hugepage size; TE does not round up. +- After `freeSharedMemory` + `allocateSharedMemory`, a peer that still has a cached mapping probes the object name before memcpy. An unlinked object is dropped and the segment descriptor is refetched once; a changed virtual address still requires the initiator to read the new `BufferDesc.addr` (relocate cannot guess a new offset). Background refresh remains optional via `MC_TE_METADATA_REFRESH_INTERVAL_SECONDS`. +- Relocate caches at most 32 mmap'd peer objects per target. An in-flight copy pins its mapping so prune/cap cannot `munmap` it until memcpy returns; the cache may briefly exceed 32 while pins are held. +- Default off because the path is not NUMA-aware +- Mooncake Store segments are not shm-backed until a follow-up allocator change + ### Ascend Transport (ascend) **Description:** Huawei Ascend NPU communication using HCCL (Huawei Collective Communication Library) or direct transport. @@ -264,8 +365,8 @@ export MC_FORCE_MNNVL=true - HCCL runtime **Documentation:** -- [Heterogeneous Ascend](../design/transfer-engine/heterogeneous_ascend.md) -- [Ascend Transport](../design/transfer-engine/ascend_transport.md) +- [Heterogeneous Ascend](../design/transfer-engine/transport/heterogeneous_ascend.md) +- [Ascend Transport](../design/transfer-engine/transport/ascend_transport.md) ### TPU Transport (tpu) — Experimental @@ -300,6 +401,84 @@ planned as a follow-up. - DMA-mapped (pinned) staging buffers for true async device DMA are a planned performance follow-up. +### MPComm Transport (mpcomm) + +**Description:** UCL-MPComm (Unified Communication Library - Memory Pool Communication) is an RDMA +library for heterogeneous memory pooling, integrated as a TENT transport. It drives multiple RDMA +NICs concurrently with two-level load balancing (across NICs, and across QPs within a NIC) and +NUMA-aware worker placement, exposing one-sided put/get primitives. It is shortened to MPComm +below. + +**Status:** TENT only. There is no MPComm backend on the legacy Transfer Engine transport path, +so it cannot be selected through `MOONCAKE_PROTOCOL` or `transfer_engine_bench --protocol=`. + +**Use When:** +- The host has several RDMA NICs and you want them saturated by a single transfer stream +- Multi-NUMA hosts where NIC-to-NUMA affinity matters + +**Requirements:** +- Built with `-DUSE_TENT=ON -DUSE_MPCOMM=ON -DMPCOMM_ROOT=` +- MPComm installed, providing `include/mpcomm.h` and `lib/libmpcomm.so` + () +- `libmpcomm.so` reachable by the dynamic linker at run time + +**Enable:** +```json +{ "transports": { "mpcomm": { "enable": true } } } +``` + +See [MPComm Transport](../design/transfer-engine/transport/mpcomm_transport.md) for the full guide, +including selection via transport policy, tuning environment variables, and troubleshooting. + +### FlagOS FlagCX Transport (flagcx) + +**Description:** [FlagCX](https://github.com/flagos-ai/FlagCX) is the unified communication library +in the FlagOS ecosystem for multi-vendor and cross-vendor deployments. Mooncake integrates the +FlagCX P2P Engine as a classic Transfer Engine transport, allowing the existing Mooncake transfer +workflow to use the accelerator and network backends provided by the local FlagCX build. + +**Use When:** +- Deploying Mooncake on a platform supported by FlagCX +- Using FlagCX's P2P Engine for accelerator memory transfers +- Building a cross-vendor deployment around the FlagOS communication stack + +**Build Requirements:** +```bash +cmake -S . -B build \ + -DUSE_FLAGCX=ON \ + -DFLAGCX_HOME=/path/to/FlagCX/build +cmake --build build -j +``` + +`FLAGCX_HOME` must contain `include/flagcx_p2p.h` and either `lib/libflagcx.so` or +`lib64/libflagcx.so`. If it is omitted, Mooncake checks `$FLAGCX_HOME` and then +`$HOME/FlagCX/build`. + +**Configuration:** +```python +engine.initialize( + hostname="node1", + metadata_server="P2PHANDSHAKE", + protocol="flagcx", + device_name="" +) +``` + +```bash +# Select the interface used for FlagCX bootstrap and endpoint advertisement. +export FLAGCX_SOCKET_IFNAME="eth0" +``` + +**Current Scope:** +- Available through the classic Transfer Engine; it is not a TENT transport +- Must be built from source with `USE_FLAGCX=ON` +- Should be selected as the standalone `flagcx` protocol, not as part of a multi-protocol string +- Buffers should be registered before the first transfer to a peer and remain registered while + that peer connection is active + +See [FlagOS FlagCX Transport](../design/transfer-engine/transport/flagcx_transport.md) for dependency, +build, benchmark, runtime configuration, and troubleshooting details. + ## Configuration Examples ### Configuration File (JSON) @@ -360,6 +539,7 @@ export MOONCAKE_LOCAL_HOSTNAME="node1" | AMD GPU Clusters | rdma + hip | Use HIP for local GPU communication | | Cambricon MLU Clusters | rdma | Build with `-DUSE_MLU=ON`; MLU uses the normal RDMA protocol | | Ascend NPU Clusters | rdma + ascend | Use Ascend for NPU-specific operations | +| Multi-vendor or cross-vendor clusters | flagcx | Build with `-DUSE_FLAGCX=ON`; transfers use the FlagCX P2P Engine over RDMA-capable NICs | ## Troubleshooting @@ -402,3 +582,17 @@ If a protocol fails to initialize: - [Transfer Engine Benchmark](../design/transfer-engine/transfer-engine-bench-tuning.md) - Performance tuning - [Python API Reference](../api-reference/python/transfer-engine.md) - API documentation - [Deployment Guide](../deployment/mooncake-store-deployment-guide.md) - Production deployment + +:::{toctree} +:maxdepth: 1 +:hidden: + +../design/transfer-engine/transport/efa_transport +../design/transfer-engine/transport/ascend_direct_transport +../design/transfer-engine/transport/ascend_transport +../design/transfer-engine/transport/heterogeneous_ascend +../design/transfer-engine/transport/kunpeng_ub_transport +../design/transfer-engine/transport/sunrise_link_transport +../design/transfer-engine/transport/flagcx_transport +../design/transfer-engine/transport/mpcomm_transport +::: diff --git a/docs/source/index.md b/docs/source/index.md index 4f980bcc30..942820bc25 100644 --- a/docs/source/index.md +++ b/docs/source/index.md @@ -39,15 +39,17 @@ At the center of Mooncake is a KVCache-centric scheduler that balances effective

🔄 Updates

+- **Aug 20, 2026**: Mooncake is integrated into [Miles](https://github.com/radixark/miles) as a rollout data-transfer backend for the fragmented, heterogeneous data moving between rollout and training in disaggregated RL. Blogs: [KVCache.AI](https://kvcache.ai/blog/mooncake-rl-rollout-data-transfer/), [lmsys](https://www.lmsys.org/blog/2026-08-20-miles-mooncake-rollout-data-transfer). +- **Aug 17, 2026**: Mooncake is integrated into [Speculators](https://github.com/vllm-project/speculators) as a distributed backend for multi-node online training, efficiently moves hidden-state between vLLM inference workers and Speculators trainers through RDMA, eliminating the need for massive hidden-state storage in offline training. [Benchmark on GB300 NVL72](https://x.com/mgoin_/status/2072785822231728363). - **May 7, 2026**: 🚀 [vLLM officially features Mooncake Store](https://vllm.ai/blog/mooncake-store) — a deep dive into how Mooncake's distributed KVCache engine supercharges vLLM inference with high-throughput, memory-efficient, cross-instance KV cache sharing! - **Apr 29, 2026**: SGLang introduces [RDMA-based P2P weight transfer for large-scale distributed RL](https://lmsys.org/blog/2026-04-29-p2p-update/) using Mooncake TransferEngine, achieving 7x faster weight updates for the 1T-parameter Kimi-K2 model (53s → 7.2s) with zero-copy RDMA transfer across thousands of GPUs. - **Mar 19, 2026**: [TorchSpec: Speculative Decoding Training at Scale](https://pytorch.org/blog/torchspec-speculative-decoding-training-at-scale) is [open sourced](https://github.com/torchspec-project/TorchSpec), using Mooncake to decouple inference and training via efficient hidden states management. -- **Mar 5, 2026**: [LightX2V](https://github.com/ModelTC/LightX2V/pull/893) now supports disaggregated deployment based on Mooncake, enabling encoder/transformer service decoupling with Mooncake Transfer Engine for high-performance cross-device and cross-machine data transfer. -- **Feb 25, 2026**: [SGLang](https://github.com/sgl-project/sglang) merged [Encoder Global Cache Manager](https://github.com/sgl-project/sglang/pull/16137), introducing a Mooncake-powered global multimodal embedding cache that enables cross-instance sharing of ViT embeddings to avoid redundant GPU computation. :::{dropdown} More :animate: fade-in +- **Mar 5, 2026**: [LightX2V](https://github.com/ModelTC/LightX2V/pull/893) now supports disaggregated deployment based on Mooncake, enabling encoder/transformer service decoupling with Mooncake Transfer Engine for high-performance cross-device and cross-machine data transfer. Details in [blog](https://light-ai.top/LightX2V-BLOG/posts/Disaggregation/). +- **Feb 25, 2026**: [SGLang](https://github.com/sgl-project/sglang) merged [Encoder Global Cache Manager](https://github.com/sgl-project/sglang/pull/16137), introducing a Mooncake-powered global multimodal embedding cache that enables cross-instance sharing of ViT embeddings to avoid redundant GPU computation. - **Feb 24, 2026**: [vLLM-Omni](https://docs.vllm.ai/projects/vllm-omni/en/latest/design/feature/disaggregated_inference/) introduces disaggregated inference connectors with support for both `MooncakeStoreConnector` and `MooncakeTransferEngineConnector` for multi-node omni-modality pipelines. - **Feb 12, 2026**: [Mooncake Joins PyTorch Ecosystem](https://pytorch.org/blog/mooncake-joins-pytorch-ecosystem/) We are thrilled to announce that Mooncake has officially joined the PyTorch Ecosystem! - **Jan 28, 2026**: [FlexKV](https://github.com/taco-project/FlexKV), a distributed KV store and cache system from Tencent and NVIDIA in collaboration with the community, now supports [distributed KVCache reuse](https://github.com/taco-project/FlexKV/blob/main/docs/dist_reuse/README_en.md) with the Mooncake Transfer Engine. @@ -87,8 +89,8 @@ At the center of Mooncake is a KVCache-centric scheduler that balances effective :caption: Getting Started :maxdepth: 1 -getting_started/build getting_started/quick-start +getting_started/build ::: @@ -124,19 +126,15 @@ performance/vllm/index :maxdepth: 1 design/architecture -design/mooncake-store -design/p2p-store +design/transfer-engine/index +design/tent/overview +design/store/mooncake-store design/mooncake-backend-pg design/mooncake-ep -design/transfer-engine/index +design/conductor/index +design/mooncake-reshard/index +design/p2p-store design/hicache-design -design/engram -design/unified-parallel-tensor-io -design/tent/overview -design/tent/tebench -design/conductor/conductor-architecture-design -design/ssd-offload -design/ssd-free-ratio-first-allocation ::: % API Documentation diff --git a/docs/source/performance/mooncake/index.md b/docs/source/performance/mooncake/index.md index d511245f60..6fd7559b26 100644 --- a/docs/source/performance/mooncake/index.md +++ b/docs/source/performance/mooncake/index.md @@ -1,6 +1,6 @@ # Mooncake Performance -Benchmarks evaluating Mooncake Store's core storage, allocation, and cache hierarchy behavior. +This section collects Mooncake performance evaluations and benchmark results for Mooncake core components. | Document | Area | Key Findings | |----------|------|---------------| @@ -8,6 +8,7 @@ Benchmarks evaluating Mooncake Store's core storage, allocation, and cache hiera | [Allocator Benchmark](allocator-benchmark-result) | Segment allocation | The optimized OffsetAllocator significantly improves utilization for uniform-size LLM KV cache allocation patterns. | | [Allocation Strategy Benchmark](allocation-strategy-benchmark-result) | Allocation routing | Compares random and free-ratio-first allocation across segments, replicas, skewed capacity, and DSA-style KV+indexer workloads. | | [SSD Offload Benchmark](ssd-offload-benchmark-results) | Cache hierarchy | SSD offload extends the KV cache hierarchy with NVMe, reducing the performance cliff after DRAM cache capacity is exhausted in long multi-turn conversations. | +| [Guide for tebench](tebench) | Transfer Engine | End-to-end bandwidth and latency benchmarking for classic TE and TENT backends across block size, batch size, and concurrency. | :::{toctree} :maxdepth: 1 @@ -17,4 +18,5 @@ storage-benchmark allocator-benchmark-result allocation-strategy-benchmark-result ssd-offload-benchmark-results +tebench ::: diff --git a/docs/source/design/tent/tebench.md b/docs/source/performance/mooncake/tebench.md similarity index 72% rename from docs/source/design/tent/tebench.md rename to docs/source/performance/mooncake/tebench.md index 4e9ce606f7..23c517c056 100644 --- a/docs/source/design/tent/tebench.md +++ b/docs/source/performance/mooncake/tebench.md @@ -75,6 +75,15 @@ On the initiator machine: --duration=5 ``` +### 3.3 Request Pacing + +Use `--request_interval_us=` to add a per-thread delay before each +transfer batch. The value is in microseconds; `0` disables pacing. + +When pacing is enabled, `Avg Lat (us)` includes the pacing gap because it is +computed from wall-clock runtime, while `Avg Tx (us)` and Tx percentiles only +measure transfer execution time. + ## 4. Output Metrics Each output row corresponds to one benchmark configuration. @@ -84,8 +93,9 @@ Each output row corresponds to one benchmark configuration. | `BlkSize (B)` | Block size per request (bytes) | | `Batch` | Number of requests per submission | | `BW (GB/S)` | Throughput (total bytes / total time) | -| `Avg Lat (us)` | Average end-to-end latency (scaled by thread count) | -| `Avg Tx (us)` | Average per-transfer execution time | +| `Avg Inst GB/s` | Average per-transfer instantaneous bandwidth | +| `Avg Lat (us)` | Average wall-clock per-operation latency, including pacing or scheduling gaps (scaled by thread count) | +| `Avg Tx (us)` | Average per-transfer execution time, excluding gaps | | `P99 Tx (us)` | P99 transfer latency | | `P999 Tx (us)` | P999 transfer latency | @@ -187,6 +197,51 @@ computed from each class's actual transfer size. `--qos_classes_json`, and the global `--tent_intent_type`. Non-default per-class intents and deadlines require the TENT backend. +### 4.3 Per-Target Metrics + +Multi-target runs print one `[target-summary]` line per target. Use +`--result_output_jsonl=` to also append a schema-versioned JSON record for +each benchmark configuration. The record keeps the aggregate operation, byte, +and throughput totals plus each target's segment name, assigned thread count, +completed operations, transferred bytes, throughput, and latency distribution. +The aggregate throughput uses the pooled average worker duration, matching the +existing `BW (GB/s)` table calculation. + +Targets with no assigned worker are retained with zero-valued metrics. This +makes an under-provisioned run (`threads < targets`) visible instead of silently +dropping targets from the result. + +### 4.4 Submit/Wait Split Log + +`Avg Tx` is submit plus completion wait in one timer. To separate those +phases, pass `--split_output_jsonl=` on the **initiator**. Default is +empty: the transfer loop is unchanged, with no extra clocks or I/O. + +When set, each completed transfer appends one JSON line: + +```json +{"batch_size":207360,"submit_us":66874,"wait_us":556363,"polls":201} +``` + +| Field | Meaning | +| ----- | ------- | +| `submit_us` | Time in `submitTransfer` | +| `wait_us` | Time from submit return until the batch reports COMPLETED | +| `polls` | Number of status polls in that wait | + +The file is opened once as `std::ofstream` and written with stream buffering. Skip the first +couple of lines when summarizing; they include warmup. This log is for +control-path analysis. Do not treat `wait_us` or table `BW` as a substitute +for an application poll interval. + +```bash +./tebench \ + --target_seg_name= \ + --backend=classic \ + --op_type=read \ + --split_output_jsonl=submit-wait.jsonl +``` + ## 5. Runtime Configuration This section summarizes the key runtime options that control workload behavior, @@ -250,7 +305,9 @@ Example: thus multiple transports — SHM for DRAM, NVLink for VRAM) concurrently. Empty falls back to `--seg_type` (single type, existing behavior). See Section 5.8 for usage and the multi-transport configuration it requires. -* `--target_seg_name` : target segment name (empty → Target mode) +* `--target_seg_name` : target segment name (empty → Target mode). + A comma-separated list enables multi-target initiator mode, and worker + threads are distributed across all listed target segments. **Scan ranges** @@ -266,6 +323,54 @@ A test case is skipped when: block_size × batch_size × num_threads > total_buffer_size ``` +**Multi-target initiator** + +Use a comma-separated `--target_seg_name` value when one initiator process +should send traffic to multiple target segments: + +```bash +./tebench \ + --backend=tent \ + --metadata_type=p2p \ + --target_seg_name=,, \ + --op_type=read \ + --start_num_threads=3 \ + --max_num_threads=3 +``` + +Thread `i` selects target `i % target_count`. Within the selected target, the +local target-thread index is `i / target_count`, so increasing the thread count +spreads traffic across targets before advancing to the next buffer slot inside +each target. `--target_gpu_id` shifts the per-target buffer slot and does not +change the target selection order. + +For multi-node benchmarks, tebench intentionally stays at the endpoint level: +each process publishes its own segment, and an external launcher decides which +target segment list each initiator receives. This keeps M-to-N, fan-out, +incast, and all-to-all topologies as different launch configurations over the +same comma-separated `--target_seg_name` primitive. + +For multiple initiator processes sharing the same target segment, use +`--target_offset` and `--target_range_size` to partition the remote address +space. The range size is relative to each initiator; tebench also validates that +`target_offset + relative_offset + transfer_size` stays inside the actual target +buffer. + +```bash +# Initiator 0 uses [0, 512MiB) +./tebench --target_seg_name= --target_offset=0 --target_range_size=536870912 + +# Initiator 1 uses [512MiB, 1GiB) +./tebench --target_seg_name= --target_offset=536870912 --target_range_size=536870912 +``` + +For read-only verification with multiple readers, first write deterministic data +using `--op_type=write_seed`, then run readers with `--op_type=read_verify` +against the same target range. `read_verify` performs pure READs and validates +the local buffer without modifying the remote data. Do not combine these modes +with `--check_consistency`; `--check_consistency` remains the existing +WRITE→READ self-check mode. + --- ### 5.5 GPU Affinity @@ -294,6 +399,9 @@ gpu_id + thread_id is explicitly enabled or disabled by tebench — the engine reads the transport enable list from the `MC_TENT_CONF` config file (see Section 5.8 for multi-transport scenarios). + Classic `--backend=classic --xport_type=shm` also uses this flag: DRAM + buffers come from POSIX shm and are `mbind`'d onto the same NUMA node as + `numa_alloc_onnode` before registering `cpu:`. * `--tent_intent_type` : attach a standard transfer intent to every request, such as `foreground_get`, `background_prefetch`, or `checkpoint`. This is useful for validating intent-specific transport and QoS policy selection. @@ -311,6 +419,10 @@ gpu_id + thread_id * `--qos_link_capacity_gbps` : measured usable link capacity in decimal GB/s * `--qos_output_jsonl` : append one schema-versioned JSON object per benchmark configuration +* `--result_output_jsonl` : append aggregate and per-target metrics for each + benchmark configuration +* `--split_output_jsonl` : initiator-only per-transfer submit/wait JSONL + described in Section 4.4; empty disables it QoS mode intentionally requires a fixed thread count. Sweep offered load by running explicit cases with different class thread allocations so every output diff --git a/docs/source/performance/vllm/vllm-v1-pd-performance.md b/docs/source/performance/vllm/vllm-v1-pd-performance.md index 8780b3a4e5..f643fbaa2c 100644 --- a/docs/source/performance/vllm/vllm-v1-pd-performance.md +++ b/docs/source/performance/vllm/vllm-v1-pd-performance.md @@ -84,6 +84,12 @@ python tests/v1/kv_connector/nixl_integration/toy_proxy_server.py \ --decoder-host 10.0.28.202 --decoder-port 8020 ``` +> NOTE: These commands reproduce the benchmark on vLLM 0.11.2.dev358, which +> predates the bootstrap contract +> ([vllm-project/vllm#31034](https://github.com/vllm-project/vllm/pull/31034)). +> For current vLLM, see the +> [PD disaggregation deployment guide](../../deployment/integrations/vllm/disagg-prefill-decode.md). + ### Benchmark Script We used `vllm bench serve` to generate traffic with varying prompt lengths. diff --git a/docs/source/troubleshooting/pg-ep-troubleshooting.md b/docs/source/troubleshooting/pg-ep-troubleshooting.md index 684e08f993..a4cb3ed708 100644 --- a/docs/source/troubleshooting/pg-ep-troubleshooting.md +++ b/docs/source/troubleshooting/pg-ep-troubleshooting.md @@ -1,7 +1,7 @@ # Mooncake PG/EP Troubleshooting This page covers common setup, import, runtime, and recovery issues for -Mooncake Backend (PG) and Mooncake EP. +Mooncake PG and Mooncake EP. ## Import fails with a PyTorch version error @@ -41,80 +41,25 @@ Fixes: 3. Make sure the Python environment used at runtime is the same one used for the build. -## `activeRanks must be int` or device mismatch +## `dist.get_world_size()` differs from the number of active ranks -Symptoms: - -```text -activeRanks must be int. -activeRanks must be on CPU. -activeRanks must be on GPU. -activeRanks must be sized to max_world_size when max_world_size is set -``` - -Causes and fixes: - -- Use `torch.int32`, not `bool`, `int64`, or floating-point types. -- For `backend="mooncake"`, put `active_ranks` on the accelerator device. -- For `backend="mooncake-cpu"`, put `active_ranks` on CPU. -- If `max_world_size` is set, allocate `active_ranks` with length - `max_world_size`, even if the initial visible `world_size` is smaller. - -Examples: - -```python -# CUDA / accelerator backend -active_ranks = torch.ones(max_world_size, dtype=torch.int32, device="cuda") - -# CPU backend -active_ranks = torch.ones(max_world_size, dtype=torch.int32) -``` - -## `dist.get_world_size()` is smaller than `max_world_size` - -This is expected. Mooncake PG distinguishes reserved capacity from visible active -membership: +Mooncake PG preserves stable in-group rank slots. Consequently: -- `max_world_size` reserves future rank slots. -- `dist.get_world_size()` returns the visible active size. -- Reserved ranks are inactive until `recover_ranks()` activates them. +- `max_group_size` is the fixed slot capacity; +- `dist.get_world_size()` is the highest active in-group rank plus one; +- holes below that extent remain visible in the rank space but are skipped by + the active mask. Use `pg.get_active_ranks(backend)` to inspect the current backend mask. -## `get_peer_state()` or `join_group()` hangs +## `join_group()` hangs or activation times out Common causes: -- Healthy ranks are not all calling `get_peer_state()` in the same order. -- The joining rank did not initialize with `is_extension=True`. -- `max_world_size` / rank numbering differs between healthy and joining ranks. -- Subgroups were created in different orders on healthy and joining processes. -- The joining process has not published peer metadata yet. -- Network device filters differ across ranks. - -Debug checklist: - -1. Confirm all ranks use the same rendezvous address and store. -2. Print rank, visible `world_size`, and `max_world_size` at initialization. -3. Confirm `active_ranks` length and values on every rank. -4. Verify healthy ranks poll the same `join_ranks` list. -5. For subgroup recovery, confirm every rank calls `dist.new_group()` in the same - order. -6. If using RDMA, set the same HCA whitelist on every rank. - -## Newly extended ranks participate too early - -After `pg.extend_group_size_to(backend, new_size)`, new ranks are reserved but -inactive. They should not participate in collectives until healthy ranks call -`pg.recover_ranks(backend, ranks)`. - -If a new rank appears to participate early, check whether: - -- `active_ranks` was initialized with `1` for future ranks without masking them - through the backend protocol; -- the application called collectives on the joining process before - `pg.join_group()` returned; -- different ranks used inconsistent `max_world_size` or rank IDs. +- No existing rank submitted `activate_ranks()` / `recover_ranks()` after the + joining process entered `join_group()`. +- The future active set is not mutually connected, so the activation proposal + remains pending until timeout. ## EP dispatch/combine timeout marks a rank inactive @@ -240,7 +185,7 @@ python mooncake-pg/benchmark/pgbench.py \ --collective all_reduce --backend mooncake --device cuda -g 2 -b 8 -e 1M -f 2 # EP grid test -python mooncake-ep/tests/test_ep_grid.py +python python/tests/ep/test_ep_grid.py ``` Adapt process counts, device filters, and launchers to the target cluster. diff --git a/docs/source/zh_archive/build.md b/docs/source/zh_archive/build.md index 62ff9d2ee4..1287749f71 100644 --- a/docs/source/zh_archive/build.md +++ b/docs/source/zh_archive/build.md @@ -71,7 +71,7 @@ libnuma-dev \ libcurl4-openssl-dev \ libhiredis-dev - + # For centos/alibaba linux os yum install cmake \ gflags-devel \ @@ -157,17 +157,7 @@ make -j ``` -7. 安装 yalantinglibs - ```bash - git clone https://github.com/alibaba/yalantinglibs.git - cd yalantinglibs - mkdir build && cd build - cmake .. -DBUILD_EXAMPLES=OFF -DBUILD_BENCHMARK=OFF -DBUILD_UNIT_TESTS=OFF - make -j$(nproc) - make install - ``` - -8. 进入项目根目录,运行下列命令进行编译 +7. 进入项目根目录,运行下列命令进行编译 ```bash mkdir build cd build @@ -175,7 +165,7 @@ make -j ``` -9. 安装 Mooncake python 包和 mooncake_master 可执行文件 +8. 安装 Mooncake python 包和 mooncake_master 可执行文件 ```bash make install ``` @@ -193,6 +183,7 @@ - `-DNEUWARE_ROOT=/path/to/neuware`: 在 `-DUSE_MLU=ON` 时覆盖默认 Neuware SDK 根路径;未设置时使用 `NEUWARE_HOME` 或 `/usr/local/neuware`。 - `-DMLU_INCLUDE_DIR=/path/to/include` / `-DMLU_LIB_DIR=/path/to/lib64`: 在 `-DUSE_MLU=ON` 时覆盖 Neuware 头文件与库目录。 - `-DUSE_HIP=[ON|OFF]`: 通过 HIP/ROCm 启用对 AMD GPU 的支持 +- `-DUSE_SHCA=[ON|OFF]`: 启用 ScaleFabric SHCA InfiniBand 支持。默认 OFF;仅支持 Transfer Engine/TENT RDMA 路径,不支持 Mooncake-EP IBGDA;SHCA 构建下不支持 `MC_RPC_PROTOCOL=rdma`。 - `-DUSE_HYGON=[ON|OFF]`: 通过 DTK SDK 启用对海光 DCU 的支持。默认 OFF;使用 CUDA 兼容运行时。 - `-DDTK_ROOT=/path/to/dtk`: 在 `-DUSE_HYGON=ON` 时覆盖默认 DTK SDK 根路径;未设置时使用 `DTK_HOME` 或 `/opt/dtk`。 - `-DDTK_INCLUDE_DIR=/path/to/include` / `-DDTK_LIB_DIR=/path/to/lib64`: 在 `-DUSE_HYGON=ON` 时覆盖 DTK 头文件与库目录。 @@ -253,4 +244,3 @@ cd /app/build/mooncake-transfer-engine/example --ulimit memlock=-1 解除内存锁定限制,RDMA 操作需要 --net=host 让容器使用宿主机的网络命名空间 - diff --git a/docs/source/zh_archive/mooncake-store.md b/docs/source/zh_archive/mooncake-store.md index aade0b725d..b5cffe90d4 100644 --- a/docs/source/zh_archive/mooncake-store.md +++ b/docs/source/zh_archive/mooncake-store.md @@ -103,7 +103,8 @@ tl::expected Put(const ObjectKey& key, ```C++ struct ReplicateConfig { size_t replica_num{1}; // 对象的总副本数 - bool with_soft_pin{false}; // 是否为该对象启用软固定机制 + SoftPinAction soft_pin_action{SoftPinAction::PRESERVE}; // 软固定状态转换 + std::optional soft_pin_ttl_ms{}; // ENABLE 时可覆盖默认 TTL std::string preferred_segment{}; // 首选的分配段 }; ``` @@ -628,11 +629,15 @@ virtual tl::expected, ErrorCode> Allocate( 对于重要且频繁使用的对象,例如 system prompt,Mooncake Store 提供了软固定(soft pin)机制。在执行 `Put` 操作时,可以选择为特定的对象开启软固定机制。在执行替换任务时,系统会优先替换未被软固定的对象。仅当内存不足且没有其他对象可以被替换时,才会替换被软固定的对象。 -如果某个软固定的对象长时间未被访问,其软固定状态将被解除。而后当该对象再次被访问时,它将自动重新进入软固定状态。 +soft pin 生命周期从首个副本变为可读时开始。deadline 到达后,对象降级为普通 Cache;后续访问只授予普通读租约,不会重新启用 soft pin。后续写入仍可显式重新启用。 -`master_service` 中有两个与软固定机制相关的启动参数: +soft pin 是仅在运行时生效的淘汰优先级状态,不会持久化到快照或 HA OpLog。恢复或 Standby 提升后,恢复出的对象将降级为普通 Cache;快照中的兼容字段仅用于保持格式,恢复时会被忽略。 -* `default_kv_soft_pin_ttl`:表示一个被软固定的对象在多长时间(毫秒)未被访问后会自动解除软固定状态。默认值为`30 分钟`。 +`master_service` 中有三个与软固定机制相关的启动参数: + +* `default_kv_soft_pin_ttl`:未显式传入 TTL 时使用的固定软固定生命周期。默认值为 `30 分钟`,访问不会续期。 + +* `max_kv_soft_pin_ttl`:Master 接受的请求级 soft pin TTL 上限。默认值为 `24 小时`。 * `allow_evict_soft_pinned_objects`:是否允许替换已被软固定的对象。默认值为 `true`。 @@ -661,7 +666,8 @@ Mooncake Store 提供了**首选段分配**功能,允许用户为对象分配 ```cpp struct ReplicateConfig { size_t replica_num{1}; // 对象的总副本数 - bool with_soft_pin{false}; // 是否为该对象启用软固定机制 + SoftPinAction soft_pin_action{SoftPinAction::PRESERVE}; // 软固定状态转换 + std::optional soft_pin_ttl_ms{}; // ENABLE 时可覆盖默认 TTL std::string preferred_segment{}; // 首选的分配段 }; ``` diff --git a/extern/yalantinglibs b/extern/yalantinglibs deleted file mode 160000 index 7801bc9ad9..0000000000 --- a/extern/yalantinglibs +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 7801bc9ad9021781f15217552214e325a1cf7373 diff --git a/monitoring/grafana/dashboards/mooncake.json b/monitoring/grafana/dashboards/mooncake.json index 77933094f5..31ffa446a8 100644 --- a/monitoring/grafana/dashboards/mooncake.json +++ b/monitoring/grafana/dashboards/mooncake.json @@ -196,8 +196,9 @@ "targets": [ { "expr": "rate(master_put_start_failures_total[5m])", "legendFormat": "PutStart Fail", "refId": "A" }, { "expr": "rate(master_put_start_alloc_failures_total[5m])", "legendFormat": "PutStart Alloc Fail", "refId": "B" }, - { "expr": "rate(master_put_end_failures_total[5m])", "legendFormat": "PutEnd Fail", "refId": "C" }, - { "expr": "rate(master_put_revoke_failures_total[5m])", "legendFormat": "PutRevoke Fail", "refId": "D" } + { "expr": "rate(master_put_start_object_already_exists_total[5m])", "legendFormat": "PutStart Already Exists", "refId": "C" }, + { "expr": "rate(master_put_end_failures_total[5m])", "legendFormat": "PutEnd Fail", "refId": "D" }, + { "expr": "rate(master_put_revoke_failures_total[5m])", "legendFormat": "PutRevoke Fail", "refId": "E" } ], "title": "Put Failure Rates", "type": "timeseries" @@ -864,11 +865,12 @@ "options": { "legend": { "calcs": ["mean", "lastNotNull"], "displayMode": "table", "placement": "bottom" }, "tooltip": { "mode": "multi", "sort": "desc" } }, "targets": [ { "expr": "rate(master_batch_put_start_items_total[5m])", "legendFormat": "BatchPutStart Items", "refId": "A" }, - { "expr": "rate(master_batch_put_end_items_total[5m])", "legendFormat": "BatchPutEnd Items", "refId": "B" }, - { "expr": "rate(master_batch_get_replica_list_items_total[5m])", "legendFormat": "BatchGetRL Items", "refId": "C" }, - { "expr": "rate(master_batch_exist_key_items_total[5m])", "legendFormat": "BatchExistKey Items", "refId": "D" }, - { "expr": "rate(master_batch_query_ip_items_total[5m])", "legendFormat": "BatchQueryIp Items", "refId": "E" }, - { "expr": "rate(master_batch_replica_clear_items_total[5m])", "legendFormat": "BatchReplicaClear Items", "refId": "F" } + { "expr": "rate(master_batch_put_start_object_already_exists_total[5m])", "legendFormat": "BatchPutStart Already Exists Items", "refId": "B" }, + { "expr": "rate(master_batch_put_end_items_total[5m])", "legendFormat": "BatchPutEnd Items", "refId": "C" }, + { "expr": "rate(master_batch_get_replica_list_items_total[5m])", "legendFormat": "BatchGetRL Items", "refId": "D" }, + { "expr": "rate(master_batch_exist_key_items_total[5m])", "legendFormat": "BatchExistKey Items", "refId": "E" }, + { "expr": "rate(master_batch_query_ip_items_total[5m])", "legendFormat": "BatchQueryIp Items", "refId": "F" }, + { "expr": "rate(master_batch_replica_clear_items_total[5m])", "legendFormat": "BatchReplicaClear Items", "refId": "G" } ], "title": "Batch Operation Item Throughput", "type": "timeseries" diff --git a/mooncake-common/FindGLOG.cmake b/mooncake-common/FindGLOG.cmake index f7c770c1e4..54fd77d7e9 100644 --- a/mooncake-common/FindGLOG.cmake +++ b/mooncake-common/FindGLOG.cmake @@ -1,30 +1,71 @@ find_package(glog QUIET CONFIG) +# Track the detected glog version so we can decide whether newer public APIs +# (e.g. google::IsGoogleLoggingInitialized(), added in glog 0.6.0) are +# available. Different distros ship different glog versions, so relying on that +# symbol unconditionally breaks builds against glog < 0.6.0. +set(GLOG_DETECTED_VERSION "") + if(TARGET glog::glog) + set(GLOG_FOUND TRUE) + set(GLOG_TARGET glog::glog) + if(DEFINED glog_VERSION) + set(GLOG_DETECTED_VERSION "${glog_VERSION}") + endif() +else() + find_package(PkgConfig QUIET) + if(PKG_CONFIG_FOUND) + pkg_check_modules(PC_GLOG QUIET libglog) + endif() + + find_path( + GLOG_INCLUDE_DIR glog/logging.h + HINTS ${PC_GLOG_INCLUDEDIR} ${PC_GLOG_INCLUDE_DIRS} + PATHS /usr/include /usr/local/include) + + find_library( + GLOG_LIBRARY glog + HINTS ${PC_GLOG_LIBDIR} ${PC_GLOG_LIBRARY_DIRS} + PATHS /usr/lib /usr/lib64 /usr/local/lib /usr/local/lib64) + + if(GLOG_INCLUDE_DIR AND GLOG_LIBRARY) set(GLOG_FOUND TRUE) + add_library(glog::glog INTERFACE IMPORTED) + target_include_directories(glog::glog INTERFACE ${GLOG_INCLUDE_DIR}) + target_link_libraries(glog::glog INTERFACE ${GLOG_LIBRARY}) set(GLOG_TARGET glog::glog) -else() - find_package(PkgConfig QUIET) - if(PKG_CONFIG_FOUND) - pkg_check_modules(PC_GLOG QUIET libglog) - endif() - - find_path(GLOG_INCLUDE_DIR glog/logging.h - HINTS ${PC_GLOG_INCLUDEDIR} ${PC_GLOG_INCLUDE_DIRS} - PATHS /usr/include /usr/local/include) - - find_library(GLOG_LIBRARY glog - HINTS ${PC_GLOG_LIBDIR} ${PC_GLOG_LIBRARY_DIRS} - PATHS /usr/lib /usr/lib64 /usr/local/lib /usr/local/lib64) - - if(GLOG_INCLUDE_DIR AND GLOG_LIBRARY) - set(GLOG_FOUND TRUE) - add_library(glog::glog INTERFACE IMPORTED) - target_include_directories(glog::glog INTERFACE ${GLOG_INCLUDE_DIR}) - target_link_libraries(glog::glog INTERFACE ${GLOG_LIBRARY}) - set(GLOG_TARGET glog::glog) - endif() + endif() + + if(PC_GLOG_VERSION) + set(GLOG_DETECTED_VERSION "${PC_GLOG_VERSION}") + endif() +endif() + +# The version comes from CONFIG (glog_VERSION) or pkg-config (PC_GLOG_VERSION) +# above. A missing version is treated as "old". + +# Only enable the newer API when we can positively confirm glog >= 0.6.0. When +# the version cannot be determined we conservatively assume it is too old and +# use the internal-symbol fallback declared in config.cpp. Note: on every glog +# version, an *unconditional* duplicate InitGoogleLogging() trips a CHECK and +# abort()s at runtime ("You called InitGoogleLogging() twice!"). Both code paths +# therefore guard with IsGoogleLoggingInitialized() before initializing; the +# only difference is where that symbol lives (top-level in >= 0.6.0, the +# internal namespace otherwise). +if(TARGET glog::glog) + if(GLOG_DETECTED_VERSION AND NOT GLOG_DETECTED_VERSION VERSION_LESS "0.6.0") + target_compile_definitions(glog::glog + INTERFACE MOONCAKE_GLOG_HAS_IS_INITIALIZED=1) + message(STATUS "glog ${GLOG_DETECTED_VERSION}: " + "IsGoogleLoggingInitialized() available") + else() + target_compile_definitions(glog::glog + INTERFACE MOONCAKE_GLOG_HAS_IS_INITIALIZED=0) + message(STATUS "glog version " + "'${GLOG_DETECTED_VERSION}' (< 0.6.0 or unknown): " + "IsGoogleLoggingInitialized() unavailable") + endif() endif() include(FindPackageHandleStandardArgs) -find_package_handle_standard_args(GLOG DEFAULT_MSG GLOG_TARGET) \ No newline at end of file +find_package_handle_standard_args(GLOG DEFAULT_MSG GLOG_TARGET) diff --git a/mooncake-common/FindYLT.cmake b/mooncake-common/FindYLT.cmake new file mode 100644 index 0000000000..e36d4d66da --- /dev/null +++ b/mooncake-common/FindYLT.cmake @@ -0,0 +1,30 @@ +include_guard(GLOBAL) + +include(FetchContent) + +if(POLICY CMP0135) + cmake_policy(SET CMP0135 NEW) +endif() + +set(YLT_ENABLE_IBV ON CACHE BOOL "Enable yalantinglibs ibverbs support") + +set(YLT_COMMIT 7801bc9ad9021781f15217552214e325a1cf7373) +FetchContent_Declare( + yalantinglibs + URL ${GH_MIRROR}https://github.com/alibaba/yalantinglibs/archive/${YLT_COMMIT}.tar.gz + URL_HASH SHA256=2a4b93c256c09fa84e1507bf4d5b33571ee47a4e2c65316ca255680ad3cdfc1e +) + +# Exclude from install for CMake before 3.28 +FetchContent_GetProperties(yalantinglibs) +FetchContent_Populate(yalantinglibs) +add_subdirectory( + ${yalantinglibs_SOURCE_DIR} + ${yalantinglibs_BINARY_DIR} + EXCLUDE_FROM_ALL +) + +# Suppress warnings for CMake before 3.25 +get_target_property(YLT_INCL_DIRS yalantinglibs INTERFACE_INCLUDE_DIRECTORIES) +set_target_properties(yalantinglibs PROPERTIES + INTERFACE_SYSTEM_INCLUDE_DIRECTORIES "${YLT_INCL_DIRS}") diff --git a/mooncake-common/common.cmake b/mooncake-common/common.cmake index 73c3110811..5e1e9cccaf 100644 --- a/mooncake-common/common.cmake +++ b/mooncake-common/common.cmake @@ -55,7 +55,10 @@ include(${CMAKE_CURRENT_LIST_DIR}/limit_jobs.cmake) option(ENABLE_SCCACHE "Whether to open sccache" OFF) if(ENABLE_SCCACHE) - find_program(SCCACHE sccache REQUIRED) + find_program(SCCACHE sccache) + if(NOT SCCACHE) + message(FATAL_ERROR "sccache executable not found") + endif() endif() if(SCCACHE AND ENABLE_SCCACHE) message(STATUS "Building with SCCACHE enabled") @@ -74,7 +77,8 @@ if(BUILD_UNIT_TESTS) endif() option(BUILD_BENCHMARK "Build benchmarks" ON) option(USE_CUDA "option for enabling gpu features for NVIDIA GPU" OFF) -option(USE_NCCL_DEVICE "option for enabling the NCCL DeviceTransport backend" OFF) +option(USE_NCCL_DEVICE "option for enabling the NCCL DeviceTransport backend" + OFF) option(USE_NCCL_HOST "option for enabling the NCCL host RMA transport" OFF) option(USE_MLU "option for enabling Cambricon MLU features" OFF) option(USE_MUSA "option for enabling gpu features for MTHREADS GPU" OFF) @@ -83,6 +87,20 @@ option(USE_HIP "option for enabling gpu features for AMD GPU" OFF) option(USE_HYGON "option for enabling gpu features for Hygon DCU with DTK" OFF) option(USE_COREX "option for enabling gpu features for Iluvatar CoreX" OFF) option(USE_SUPA "option for enabling gpu features for Biren GPU with SUPA" OFF) +option(USE_RISCV "Enable RISC-V build compatibility settings" OFF) +if(USE_RISCV) + if(NOT CMAKE_SYSTEM_PROCESSOR MATCHES "^riscv") + message( + WARNING + "USE_RISCV is enabled, but CMAKE_SYSTEM_PROCESSOR is '${CMAKE_SYSTEM_PROCESSOR}'" + ) + endif() + # Define this before any pybind11 module is created. Otherwise pybind11 adds + # its default full-LTO target, which is prohibitively resource-intensive on + # RISC-V build hosts. + set(CMAKE_INTERPROCEDURAL_OPTIMIZATION OFF) + message(STATUS "RISC-V: IPO disabled for Mooncake Python extensions") +endif() option(USE_NVMEOF "option for using NVMe over Fabric" OFF) option(USE_TCP "option for using TCP transport" ON) option(USE_BAREX "option for using accl-barex transport" OFF) @@ -97,10 +115,17 @@ option(USE_EFA "option for using AWS EFA transport" OFF) option(USE_UB "option for using UB protocol transport" OFF) option(USE_SUNRISE "option for enabling gpu features for Sunrise GPU with Tang runtime" OFF) -option(USE_TPU - "option for enabling TPU (PJRT) staging support in TENT; the PJRT adapter is loaded at runtime via dlopen, no build-time SDK required" - OFF) +option( + USE_TPU + "option for enabling TPU (PJRT) staging support in TENT; the PJRT adapter is loaded at runtime via dlopen, no build-time SDK required" + OFF) +option( + USE_XPU + "option for enabling Intel XPU (oneAPI SYCL) staging support in TENT; this is a direct-link (native) build that requires USE_TENT and the Intel DPC++ compiler (icpx / IntelLLVM) at build time -- there is no dlopen shim" + OFF) option(USE_VRAM_SEGMENT "option for vram segment" OFF) +option(USE_MPCOMM "option for using MPComm transport in TENT" OFF) +option(USE_SHCA "option for using ScaleFabric SHCA InfiniBand" OFF) if(USE_UB) add_compile_definitions(USE_UB) @@ -159,7 +184,7 @@ option(USE_ETCD_LEGACY "option for enable etcd based on etcd-cpp-api-v3" OFF) option(USE_REDIS "option for enable redis as metadata server" OFF) option(USE_HTTP "option for enable http as metadata server" ON) option(WITH_RUST_EXAMPLE - "build the Rust interface and sample code for the transfer engine" OFF) + "build the Transfer Engine Rust library and sample code" OFF) option(WITH_METRICS "enable metrics and metrics reporting thread" ON) option(USE_3FS "option for using 3FS storage backend" OFF) option(USE_EVENT_DRIVEN_COMPLETION @@ -207,7 +232,7 @@ if(USE_MNNVL) message(STATUS "Multi-Node NVLink support is enabled") endif() -if (USE_VRAM_SEGMENT) +if(USE_VRAM_SEGMENT) set(USE_CUDA ON) add_compile_definitions(USE_VRAM_SEGMENT) message(STATUS "VRAM SEGMENT is ON") @@ -227,8 +252,7 @@ endif() if(USE_NCCL_DEVICE OR USE_NCCL_HOST) if(NOT USE_CUDA) - message(FATAL_ERROR - "USE_NCCL_DEVICE and USE_NCCL_HOST require USE_CUDA=ON") + message(FATAL_ERROR "USE_NCCL_DEVICE and USE_NCCL_HOST require USE_CUDA=ON") endif() list(APPEND CMAKE_MODULE_PATH ${CMAKE_CURRENT_LIST_DIR}) find_package(NCCLDevice 2.30.4 REQUIRED MODULE) @@ -236,14 +260,15 @@ endif() if(USE_NCCL_DEVICE) add_compile_definitions(USE_NCCL_DEVICE) - message(STATUS - "NCCL DeviceTransport support is enabled (NCCL ${NCCLDevice_VERSION})") + message( + STATUS + "NCCL DeviceTransport support is enabled (NCCL ${NCCLDevice_VERSION})") endif() if(USE_NCCL_HOST) add_compile_definitions(USE_NCCL_HOST) - message(STATUS - "NCCL host RMA transport is enabled (NCCL ${NCCLDevice_VERSION})") + message( + STATUS "NCCL host RMA transport is enabled (NCCL ${NCCLDevice_VERSION})") endif() if(USE_SUPA) @@ -279,6 +304,34 @@ if(USE_TPU) message(STATUS "TPU (PJRT) staging support is enabled") endif() +if(USE_XPU) + # Every XPU source file lives under mooncake-transfer-engine/tent, which is + # only added when USE_TENT is ON. Without this guard -DUSE_XPU=ON configures + # and builds cleanly while compiling no XPU code at all. + if(NOT USE_TENT) + message( + FATAL_ERROR + "USE_XPU=ON requires USE_TENT=ON: all XPU support lives in TENT. Re-run cmake with -DUSE_TENT=ON." + ) + endif() + # The XPU platform links oneAPI SYCL directly (native / direct-link): its + # translation units include and are compiled with -fsycl, so + # the whole build must use the Intel DPC++ compiler. Configure with icpx, e.g. + # CXX=icpx cmake -DUSE_TENT=ON -DUSE_XPU=ON ... (from an intel/oneapi-basekit + # or intel/pytorch:xpu image, or after `source /opt/intel/oneapi/setvars.sh`). + if(NOT CMAKE_CXX_COMPILER_ID MATCHES "IntelLLVM" AND NOT CMAKE_CXX_COMPILER + MATCHES "icpx|icx|dpcpp") + message( + FATAL_ERROR + "USE_XPU=ON requires the Intel DPC++ compiler (icpx): the XPU platform " + "links SYCL directly. Re-run cmake with CXX=icpx (detected " + "'${CMAKE_CXX_COMPILER_ID}' at ${CMAKE_CXX_COMPILER}).") + endif() + add_compile_definitions(USE_XPU) + message( + STATUS "Intel XPU (oneAPI SYCL, direct-link) staging support is enabled") +endif() + if(NOT DEFINED NEUWARE_ROOT OR NEUWARE_ROOT STREQUAL "") if(DEFINED ENV{NEUWARE_HOME} AND NOT "$ENV{NEUWARE_HOME}" STREQUAL "") set(NEUWARE_ROOT @@ -472,6 +525,85 @@ if(USE_CXL) message(STATUS "CXL support is enabled") endif() +if(USE_MPCOMM) + if(NOT DEFINED MPCOMM_ROOT) + message( + FATAL_ERROR + "USE_MPCOMM=ON requires MPCOMM_ROOT to point at the MPComm install prefix, e.g. -DMPCOMM_ROOT=/opt/mpcomm" + ) + endif() + + # Oldest MPComm whose ABI this transport is written against, and the major it + # is written for - MPComm's own package config declares SameMajorVersion + # compatibility, so a different major is an ABI break by its own definition. A + # bare find_library() can express neither, since it accepts whatever + # libmpcomm.so happens to be on the prefix. + # + # Queried without a version so that an install that is present but too old is + # reported as such, rather than looking the same as no package config at all. + # + # find_package() also consults an upper-case _ROOT variable, and + # for this package that name is exactly our own MPCOMM_ROOT, which makes CMake + # 3.27+ emit a CMP0144 developer warning. Opting into the new behaviour is + # what we want anyway - the prefix really is where the package lives - and the + # setting is scoped so that no other find_package() is affected. + set(MPCOMM_MINIMUM_VERSION 1.4) + set(MPCOMM_SUPPORTED_MAJOR 1) + if(POLICY CMP0144) + cmake_policy(PUSH) + cmake_policy(SET CMP0144 NEW) + endif() + find_package(mpcomm CONFIG QUIET HINTS ${MPCOMM_ROOT}) + if(POLICY CMP0144) + cmake_policy(POP) + endif() + if(mpcomm_FOUND) + if(mpcomm_VERSION VERSION_LESS MPCOMM_MINIMUM_VERSION) + message( + FATAL_ERROR + "MPComm ${mpcomm_VERSION} found under MPCOMM_ROOT=${MPCOMM_ROOT} is too old; this transport requires >= ${MPCOMM_MINIMUM_VERSION}" + ) + endif() + if(NOT mpcomm_VERSION_MAJOR EQUAL MPCOMM_SUPPORTED_MAJOR) + message( + FATAL_ERROR + "MPComm ${mpcomm_VERSION} has major ${mpcomm_VERSION_MAJOR}, but this transport is written against major ${MPCOMM_SUPPORTED_MAJOR}; MPComm declares SameMajorVersion compatibility, so this is an ABI break" + ) + endif() + endif() + + find_path(MPCOMM_INCLUDE_DIR mpcomm.h HINTS ${MPCOMM_ROOT}/include) + # Resolve to an absolute library path instead of relying on -L/-l. Link + # directories are usage requirements and get stripped when a dependency is + # consumed through $ (mooncake_store links transfer_engine + # PRIVATE, so mooncake_master would otherwise see -lmpcomm without the + # matching -L). An absolute path survives that stripping. + find_library(MPCOMM_LIBRARY mpcomm HINTS ${MPCOMM_ROOT}/lib + ${MPCOMM_ROOT}/lib64) + if(NOT MPCOMM_INCLUDE_DIR OR NOT MPCOMM_LIBRARY) + message( + FATAL_ERROR + "MPComm not found under MPCOMM_ROOT=${MPCOMM_ROOT} (expected ${MPCOMM_ROOT}/include/mpcomm.h and ${MPCOMM_ROOT}/lib/libmpcomm.so)" + ) + endif() + add_compile_definitions(USE_MPCOMM) + message(STATUS "MPComm transport is enabled") + message(STATUS " MPComm include: ${MPCOMM_INCLUDE_DIR}") + message(STATUS " MPComm library: ${MPCOMM_LIBRARY}") + if(mpcomm_FOUND) + message(STATUS " MPComm version: ${mpcomm_VERSION}") + else() + # No package config under the prefix - for example a tree where only the + # headers and the library were copied into place. Say so rather than imply + # the version was verified: the transport still builds, but an ABI mismatch + # would then only surface at run time. + message( + STATUS + " MPComm version: unknown (no CMake package config under ${MPCOMM_ROOT}; requires >= ${MPCOMM_MINIMUM_VERSION})" + ) + endif() +endif() + if(USE_TCP) add_compile_definitions(USE_TCP) endif() @@ -581,5 +713,62 @@ if(NOT TARGET gflags::gflags) endif() endforeach() endif() -find_package(yalantinglibs CONFIG REQUIRED) -add_compile_definitions(YLT_ENABLE_IBV) + +set(GH_MIRROR "") +if(DEFINED ENV{ASCEND_GITHUB_MIRROR_URLS}) + set(GH_MIRROR $ENV{ASCEND_GITHUB_MIRROR_URLS}) +endif() +if(GH_MIRROR) + message(STATUS "Using Github mirror: ${GH_MIRROR}") +endif() + +include(${CMAKE_CURRENT_LIST_DIR}/FindYLT.cmake) + +option(USE_FLAGCX "option for using FlagCX-backed transport (cross-vendor CCL)" + OFF) +if(USE_FLAGCX) + if(NOT FLAGCX_HOME) + if(DEFINED ENV{FLAGCX_HOME}) + set(FLAGCX_HOME $ENV{FLAGCX_HOME}) + else() + set(FLAGCX_HOME "$ENV{HOME}/FlagCX/build") + endif() + endif() + find_path( + FLAGCX_INCLUDE_DIR + NAMES flagcx_p2p.h + HINTS "${FLAGCX_HOME}/include") + find_library( + FLAGCX_LIBRARY + NAMES flagcx + HINTS "${FLAGCX_HOME}/lib" "${FLAGCX_HOME}/lib64") + if(NOT FLAGCX_INCLUDE_DIR) + message( + FATAL_ERROR + "USE_FLAGCX=ON but flagcx_p2p.h was not found (set -DFLAGCX_HOME=...)") + endif() + if(NOT FLAGCX_LIBRARY) + message( + FATAL_ERROR + "USE_FLAGCX=ON but the FlagCX library was not found (set -DFLAGCX_HOME=...)" + ) + endif() + if(NOT TARGET FlagCX::flagcx) + add_library(FlagCX::flagcx UNKNOWN IMPORTED) + set_target_properties( + FlagCX::flagcx + PROPERTIES IMPORTED_LOCATION "${FLAGCX_LIBRARY}" + INTERFACE_INCLUDE_DIRECTORIES "${FLAGCX_INCLUDE_DIR}") + endif() + add_compile_definitions(USE_FLAGCX) + message( + STATUS + "FlagCX transport enabled, include=${FLAGCX_INCLUDE_DIR}, library=${FLAGCX_LIBRARY}" + ) +endif() + +if(USE_SHCA) + add_compile_definitions(USE_SHCA) +else() + add_compile_definitions(YLT_ENABLE_IBV) +endif() diff --git a/mooncake-common/etcd/CMakeLists.txt b/mooncake-common/etcd/CMakeLists.txt index 570e8bbf17..0e3d8ceb80 100644 --- a/mooncake-common/etcd/CMakeLists.txt +++ b/mooncake-common/etcd/CMakeLists.txt @@ -14,6 +14,13 @@ add_custom_target( DEPENDS ${ETCD_WRAPPER_LIB} ) +if(BUILD_UNIT_TESTS) + add_test( + NAME etcd_wrapper_go_test + COMMAND go test ./... + WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}) +endif() + install( FILES ${ETCD_WRAPPER_LIB} DESTINATION lib diff --git a/mooncake-common/etcd/etcd_wrapper.go b/mooncake-common/etcd/etcd_wrapper.go index 4ac718072c..29dc11bb02 100644 --- a/mooncake-common/etcd/etcd_wrapper.go +++ b/mooncake-common/etcd/etcd_wrapper.go @@ -35,6 +35,7 @@ import ( "context" "encoding/json" "errors" + "strconv" "strings" "sync" "time" @@ -42,6 +43,7 @@ import ( rpctypes "go.etcd.io/etcd/api/v3/v3rpc/rpctypes" clientv3 "go.etcd.io/etcd/client/v3" + "go.etcd.io/etcd/client/v3/concurrency" ) // prefixWatchInfo stores cancel function and callback context for a prefix watch @@ -56,6 +58,44 @@ type prefixWatchInfo struct { brokenNotified bool } +type maintenanceSession struct { + session *concurrency.Session + cancel context.CancelFunc +} + +func (s *maintenanceSession) close() error { + err := s.session.Close() + s.cancel() + return err +} + +var startMaintenanceSession = func(ctx context.Context, cli *clientv3.Client, + ttl int) (*concurrency.Session, error) { + return concurrency.NewSession(cli, concurrency.WithTTL(ttl), concurrency.WithContext(ctx)) +} + +func newMaintenanceSession(cli *clientv3.Client, ttl int, + startupTimeout time.Duration) (*maintenanceSession, error) { + ctx, cancel := context.WithCancel(context.Background()) + timer := time.AfterFunc(startupTimeout, cancel) + session, err := startMaintenanceSession(ctx, cli, ttl) + timedOut := !timer.Stop() + if err != nil { + cancel() + return nil, err + } + if session == nil { + cancel() + return nil, errors.New("maintenance session creation returned nil") + } + if timedOut { + _ = session.Close() + cancel() + return nil, context.DeadlineExceeded + } + return &maintenanceSession{session: session, cancel: cancel}, nil +} + // Use different etcd client so they are not affected by each other, // and can be configured separately. var ( @@ -69,6 +109,10 @@ var ( // keep alive contexts for store storeKeepAliveCtx = make(map[int64]context.CancelFunc) storeKeepAliveMutex sync.Mutex + // maintenance sessions own their keepalive and lease lifecycle in Go. + storeMaintenanceSessions = make(map[int64]*maintenanceSession) + storeMaintenanceNextHandle int64 + storeMaintenanceMutex sync.Mutex // watch contexts for store storeWatchCtx = make(map[string]context.CancelFunc) storeWatchMutex sync.Mutex @@ -89,6 +133,7 @@ const ( const ( storeDialKeepAliveTime = 10 * time.Second storeDialKeepAliveTimeout = 3 * time.Second + maintenanceStartupTimeout = 5 * time.Second ) func newStoreClientConfig(validEndpoints []string) clientv3.Config { @@ -279,7 +324,7 @@ func EtcdStoreResetClientWrapper(endpoints *C.char, errMsg **C.char) int { return -1 } - cancelAllStoreKeepAlives() + closeAllStoreMaintenanceSessions() cancelAllStoreWatches() cancelAllStorePrefixWatches() @@ -442,6 +487,92 @@ func EtcdStoreCreateWithLeaseWrapper(key *C.char, keySize C.int, value *C.char, } } +//export EtcdStoreAcquireMaintenanceSessionWrapper +func EtcdStoreAcquireMaintenanceSessionWrapper(key *C.char, keySize C.int, ttl int64, + sessionHandle *int64, leaseId *int64, createRevision *int64, errMsg **C.char) int { + cli := getStoreClient() + if cli == nil { + *errMsg = C.CString("etcd client not initialized") + return -1 + } + if ttl <= 0 { + *errMsg = C.CString("maintenance session TTL must be positive") + return -1 + } + + session, err := newMaintenanceSession(cli, int(ttl), maintenanceStartupTimeout) + if err != nil { + *errMsg = C.CString(err.Error()) + return -1 + } + + k := C.GoStringN(key, keySize) + id := int64(session.session.Lease()) + ownerToken := strconv.FormatInt(id, 10) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + resp, err := cli.Txn(ctx). + If(clientv3.Compare(clientv3.CreateRevision(k), "=", 0)). + Then(clientv3.OpPut(k, ownerToken, clientv3.WithLease(session.session.Lease()))). + Commit() + cancel() + if err != nil { + _ = session.close() + *errMsg = C.CString(err.Error()) + return -1 + } + if !resp.Succeeded { + _ = session.close() + *errMsg = C.CString("maintenance lock is already held") + return -2 + } + + storeMaintenanceMutex.Lock() + storeMaintenanceNextHandle++ + handle := storeMaintenanceNextHandle + storeMaintenanceSessions[handle] = session + storeMaintenanceMutex.Unlock() + + *sessionHandle = handle + *leaseId = id + *createRevision = resp.Header.Revision + return 0 +} + +//export EtcdStoreCloseMaintenanceSessionWrapper +func EtcdStoreCloseMaintenanceSessionWrapper(sessionHandle int64, errMsg **C.char) int { + storeMaintenanceMutex.Lock() + session, exists := storeMaintenanceSessions[sessionHandle] + if exists { + delete(storeMaintenanceSessions, sessionHandle) + } + storeMaintenanceMutex.Unlock() + if !exists { + return 0 + } + if err := session.close(); err != nil && !errors.Is(err, rpctypes.ErrLeaseNotFound) { + *errMsg = C.CString(err.Error()) + return -1 + } + return 0 +} + +//export EtcdStoreMaintenanceSessionAliveWrapper +func EtcdStoreMaintenanceSessionAliveWrapper(sessionHandle int64, errMsg **C.char) int { + storeMaintenanceMutex.Lock() + session, exists := storeMaintenanceSessions[sessionHandle] + storeMaintenanceMutex.Unlock() + if !exists { + *errMsg = C.CString("maintenance session handle not found") + return -1 + } + select { + case <-session.session.Done(): + return 0 + default: + return 1 + } +} + /* * @brief First cancel the watch context, then delete it from the map. * Cancel must be called before delete in case this is a new context @@ -461,17 +592,17 @@ func cancelAndDeleteWatch(k string) int { return -1 } -func cancelAllStoreKeepAlives() { - storeKeepAliveMutex.Lock() - cancels := make([]context.CancelFunc, 0, len(storeKeepAliveCtx)) - for leaseId, cancel := range storeKeepAliveCtx { - cancels = append(cancels, cancel) - delete(storeKeepAliveCtx, leaseId) +func closeAllStoreMaintenanceSessions() { + storeMaintenanceMutex.Lock() + sessions := make([]*maintenanceSession, 0, len(storeMaintenanceSessions)) + for handle, session := range storeMaintenanceSessions { + sessions = append(sessions, session) + delete(storeMaintenanceSessions, handle) } - storeKeepAliveMutex.Unlock() + storeMaintenanceMutex.Unlock() - for _, cancel := range cancels { - cancel() + for _, session := range sessions { + _ = session.close() } } @@ -622,11 +753,19 @@ func hasKeepAliveContext(leaseId int64) bool { //export EtcdStoreKeepAliveWrapper func EtcdStoreKeepAliveWrapper(leaseId int64, errMsg **C.char) int { - cli := getStoreClient() - if cli == nil { + storeCli := getStoreClient() + if storeCli == nil { *errMsg = C.CString("etcd client not initialized") return -1 } + // Keep lease traffic separate from Store traffic. A Store client reset or + // a busy Store connection must not stop leadership keep-alive. + cli, err := clientv3.New(newStoreClientConfig(storeCli.Endpoints())) + if err != nil { + *errMsg = C.CString(err.Error()) + return -1 + } + defer cli.Close() // Create a context with cancel function ctx, cancel := context.WithCancel(context.Background()) @@ -792,7 +931,7 @@ func EtcdStoreBatchCreateWrapper(keys **C.char, values **C.char, count C.int, er } //export EtcdStoreTxnCompareAndPutWrapper -func EtcdStoreTxnCompareAndPutWrapper(compareKeys **C.char, compareKeySizes *C.int, compareKinds *C.int, compareValues **C.char, compareValueSizes *C.int, compareCount C.int, putKeys **C.char, putKeySizes *C.int, putValues **C.char, putValueSizes *C.int, putCount C.int, errMsg **C.char) int { +func EtcdStoreTxnCompareAndPutWrapper(compareKeys **C.char, compareKeySizes *C.int, compareKinds *C.int, compareValues **C.char, compareValueSizes *C.int, compareRevisions *int64, compareCount C.int, putKeys **C.char, putKeySizes *C.int, putValues **C.char, putValueSizes *C.int, putPreserveLeases *C.int, putCount C.int, errMsg **C.char) int { cli := getStoreClient() if cli == nil { *errMsg = C.CString("etcd client not initialized") @@ -809,6 +948,7 @@ func EtcdStoreTxnCompareAndPutWrapper(compareKeys **C.char, compareKeySizes *C.i compareKindList := (*[1 << 28]C.int)(unsafe.Pointer(compareKinds))[:cmpN:cmpN] compareValuePtrs := (*[1 << 28]*C.char)(unsafe.Pointer(compareValues))[:cmpN:cmpN] compareValueSizeList := (*[1 << 28]C.int)(unsafe.Pointer(compareValueSizes))[:cmpN:cmpN] + compareRevisionList := (*[1 << 28]int64)(unsafe.Pointer(compareRevisions))[:cmpN:cmpN] for i := 0; i < cmpN; i++ { k := C.GoStringN(compareKeyPtrs[i], compareKeySizeList[i]) switch int(compareKindList[i]) { @@ -817,6 +957,8 @@ func EtcdStoreTxnCompareAndPutWrapper(compareKeys **C.char, compareKeySizes *C.i cmps = append(cmps, clientv3.Compare(clientv3.Value(k), "=", v)) case 1: cmps = append(cmps, clientv3.Compare(clientv3.CreateRevision(k), "=", 0)) + case 2: + cmps = append(cmps, clientv3.Compare(clientv3.CreateRevision(k), "=", compareRevisionList[i])) default: *errMsg = C.CString("unsupported compare kind") return -1 @@ -830,10 +972,15 @@ func EtcdStoreTxnCompareAndPutWrapper(compareKeys **C.char, compareKeySizes *C.i putKeySizeList := (*[1 << 28]C.int)(unsafe.Pointer(putKeySizes))[:putN:putN] putValuePtrs := (*[1 << 28]*C.char)(unsafe.Pointer(putValues))[:putN:putN] putValueSizeList := (*[1 << 28]C.int)(unsafe.Pointer(putValueSizes))[:putN:putN] + putPreserveLeaseList := (*[1 << 28]C.int)(unsafe.Pointer(putPreserveLeases))[:putN:putN] for i := 0; i < putN; i++ { k := C.GoStringN(putKeyPtrs[i], putKeySizeList[i]) v := C.GoStringN(putValuePtrs[i], putValueSizeList[i]) - ops = append(ops, clientv3.OpPut(k, v)) + if putPreserveLeaseList[i] != 0 { + ops = append(ops, clientv3.OpPut(k, v, clientv3.WithIgnoreLease())) + } else { + ops = append(ops, clientv3.OpPut(k, v)) + } } } diff --git a/mooncake-common/etcd/etcd_wrapper_test.go b/mooncake-common/etcd/etcd_wrapper_test.go new file mode 100644 index 0000000000..2e4fbc9797 --- /dev/null +++ b/mooncake-common/etcd/etcd_wrapper_test.go @@ -0,0 +1,42 @@ +package main + +import ( + "context" + "testing" + "time" + + clientv3 "go.etcd.io/etcd/client/v3" + "go.etcd.io/etcd/client/v3/concurrency" +) + +func TestNewMaintenanceSessionCancelsStartup(t *testing.T) { + original := startMaintenanceSession + defer func() { startMaintenanceSession = original }() + + started := make(chan struct{}) + startMaintenanceSession = func(ctx context.Context, _ *clientv3.Client, + _ int) (*concurrency.Session, error) { + close(started) + <-ctx.Done() + return nil, ctx.Err() + } + + result := make(chan error, 1) + go func() { + _, err := newMaintenanceSession(nil, 30, 10*time.Millisecond) + result <- err + }() + select { + case <-started: + case <-time.After(time.Second): + t.Fatal("maintenance session startup was not attempted") + } + select { + case err := <-result: + if err == nil { + t.Fatal("expected startup cancellation error") + } + case <-time.After(time.Second): + t.Fatal("maintenance session startup did not cancel") + } +} diff --git a/mooncake-common/include/default_config.h b/mooncake-common/include/default_config.h index ae5395ac27..925f5fb02b 100644 --- a/mooncake-common/include/default_config.h +++ b/mooncake-common/include/default_config.h @@ -124,6 +124,10 @@ class DefaultConfig { void GetString(const std::string& key, std::string* val, const std::string& default_value = "") const; + [[nodiscard]] bool Contains(const std::string& key) const { + return data_.find(key) != data_.end(); + } + void SetPath(const std::string& path) { path_ = path; } private: diff --git a/mooncake-common/include/environ.h b/mooncake-common/include/environ.h index f6224696dd..4b78a016a1 100644 --- a/mooncake-common/include/environ.h +++ b/mooncake-common/include/environ.h @@ -1,8 +1,13 @@ #pragma once -#include #include #include +#include +#include +#include + +#include "environment_variable.h" +#include "environment_value_parser.h" namespace mooncake { @@ -60,29 +65,10 @@ class Environ { bool GetIntraNvlink() const { return intra_nvlink_; } bool GetPathRoundrobin() const { return path_roundrobin_; } bool GetWithNvidiaPeermem() const { return with_nvidia_peermem_; } + bool GetRdmaDataDirect() const { return rdma_data_direct_; } int GetEfaCqThreads() const { return efa_cq_threads_; } bool GetStoreChecksumEnabled() const { return store_checksum_enabled_; } - // AWS / S3 client configuration - std::string GetAwsRegion() const { return aws_region_; } - std::string GetAwsS3Endpoint() const { return aws_s3_endpoint_; } - std::string GetAwsBucketName() const { return aws_bucket_name_; } - std::string GetAwsAccessKeyId() const { return aws_access_key_id_; } - std::string GetAwsSecretAccessKey() const { return aws_secret_access_key_; } - bool GetAwsUseVirtualAddressing() const { - return aws_use_virtual_addressing_; - } - bool GetAwsUseHttps() const { return aws_use_https_; } - // Empty string means "unset" — s3_helper keeps the AWS SDK default in - // that case. Parsing to AWS enums is done by the consumer. - std::string GetAwsRequestChecksumCalculation() const { - return aws_request_checksum_calculation_; - } - std::string GetAwsResponseChecksumValidation() const { - return aws_response_checksum_validation_; - } - int64_t GetAwsConnectTimeoutMs() const { return aws_connect_timeout_ms_; } - int64_t GetAwsRequestTimeoutMs() const { return aws_request_timeout_ms_; } uint32_t GetRpcClientIoThreads() const { return rpc_client_io_threads_; } uint32_t GetStoreRpcClientIoThreads() const { return store_rpc_client_io_threads_; @@ -91,11 +77,12 @@ class Environ { return transfer_engine_rpc_client_io_threads_; } - // Helper method to get int from env + // Helper methods to get numeric values from env static int GetInt(const char* name, int default_value); static int64_t GetInt64(const char* name, int64_t default_value); static uint32_t GetUInt32(const char* name, uint32_t default_value); static uint64_t GetUInt64(const char* name, uint64_t default_value); + static double GetDouble(const char* name, double default_value); // Helper method to get size_t from env static size_t GetSizeT(const char* name, size_t default_value); // Helper method to get a canonical boolean from env. Invalid values use the @@ -105,6 +92,35 @@ class Environ { static std::string GetString(const char* name, const std::string& default_value); + // Read a typed variable definition from the process environment. Missing + // or invalid typed values return nullopt; string variables preserve an + // explicitly empty value. + template + static std::optional Read(const EnvironmentVariable& variable) { + const char* value = std::getenv(variable.name); + if (value == nullptr) { + return std::nullopt; + } + return TryParseEnvironmentValue(value); + } + + template + static T ReadOr(const EnvironmentVariable& variable, T default_value) { + const char* value = std::getenv(variable.name); + if (value == nullptr) { + return default_value; + } + + const auto parsed = TryParseEnvironmentValue(value); + if (parsed.has_value()) { + return *parsed; + } + std::cerr << "[Mooncake] Warning: invalid value '" << value + << "' for env " << variable.name << ", using default " + << default_value << std::endl; + return default_value; + } + private: // Member variables int num_cq_per_ctx_; @@ -143,24 +159,12 @@ class Environ { bool intra_nvlink_; bool path_roundrobin_; bool with_nvidia_peermem_; + bool rdma_data_direct_; int efa_cq_threads_; bool store_checksum_enabled_; uint32_t rpc_client_io_threads_; uint32_t store_rpc_client_io_threads_; uint32_t transfer_engine_rpc_client_io_threads_; - - // AWS / S3 client configuration - std::string aws_region_; - std::string aws_s3_endpoint_; - std::string aws_bucket_name_; - std::string aws_access_key_id_; - std::string aws_secret_access_key_; - bool aws_use_virtual_addressing_; - bool aws_use_https_; - std::string aws_request_checksum_calculation_; - std::string aws_response_checksum_validation_; - int64_t aws_connect_timeout_ms_; - int64_t aws_request_timeout_ms_; }; } // namespace mooncake diff --git a/mooncake-common/include/environment_value_parser.h b/mooncake-common/include/environment_value_parser.h new file mode 100644 index 0000000000..3aab7ea0d5 --- /dev/null +++ b/mooncake-common/include/environment_value_parser.h @@ -0,0 +1,70 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "bool_parser.h" +#include "integer_parser.h" + +namespace mooncake { + +struct EnvironmentDoubleParseOptions { + bool allow_trailing_characters{false}; + bool allow_non_finite{false}; +}; + +inline std::optional TryParseEnvironmentDouble( + std::string_view value, EnvironmentDoubleParseOptions options = {}) { + if (value.empty()) { + return std::nullopt; + } + + const std::string text(value); + char* end = nullptr; + const int saved_errno = errno; + errno = 0; + const double parsed = std::strtod(text.c_str(), &end); + const int parse_errno = errno; + errno = saved_errno; + const bool consumed_value = end != text.c_str() && end != nullptr; + while (end != nullptr && std::isspace(static_cast(*end))) { + ++end; + } + const bool consumed_required_input = + options.allow_trailing_characters || (consumed_value && *end == '\0'); + const bool accepted_finiteness = + options.allow_non_finite || std::isfinite(parsed); + if (consumed_value && consumed_required_input && parse_errno != ERANGE && + accepted_finiteness) { + return parsed; + } + return std::nullopt; +} + +template +inline constexpr bool kUnsupportedEnvironmentValueType = false; + +template +std::optional TryParseEnvironmentValue(std::string_view value) { + if constexpr (std::is_same_v) { + return std::string(value); + } else if constexpr (std::is_same_v) { + return TryParseBool(value); + } else if constexpr (std::is_integral_v) { + return TryParseInteger( + value, {.trim_ascii_whitespace = true, .allow_leading_plus = true}); + } else if constexpr (std::is_same_v) { + return TryParseEnvironmentDouble(value); + } else { + static_assert(kUnsupportedEnvironmentValueType, + "unsupported environment value type"); + } +} + +} // namespace mooncake diff --git a/mooncake-common/include/environment_variable.h b/mooncake-common/include/environment_variable.h new file mode 100644 index 0000000000..ac879d6699 --- /dev/null +++ b/mooncake-common/include/environment_variable.h @@ -0,0 +1,10 @@ +#pragma once + +namespace mooncake { + +template +struct EnvironmentVariable { + const char* name; +}; + +} // namespace mooncake diff --git a/mooncake-common/include/environment_variables.h b/mooncake-common/include/environment_variables.h new file mode 100644 index 0000000000..ae1a5aa08d --- /dev/null +++ b/mooncake-common/include/environment_variables.h @@ -0,0 +1,271 @@ +#pragma once + +#include +#include + +#include "environment_variable.h" + +namespace mooncake { + +#define MC_DEFINE_ENV_VAR(Type, Name) \ + inline static constexpr EnvironmentVariable Name { #Name } + +struct FileStorageEnvironmentVariables { + MC_DEFINE_ENV_VAR(std::string, MOONCAKE_OFFLOAD_STORAGE_BACKEND_DESCRIPTOR); + MC_DEFINE_ENV_VAR(std::string, MOONCAKE_OFFLOAD_FILE_STORAGE_PATH); + MC_DEFINE_ENV_VAR(int64_t, MOONCAKE_OFFLOAD_LOCAL_BUFFER_SIZE_BYTES); + MC_DEFINE_ENV_VAR(int64_t, MC_STORE_PINNED_RESTORE_ARENA_SIZE_BYTES); + MC_DEFINE_ENV_VAR(int64_t, MOONCAKE_OFFLOAD_SCANMETA_ITERATOR_KEYS_LIMIT); + MC_DEFINE_ENV_VAR(int64_t, MOONCAKE_SCANMETA_ITERATOR_KEYS_LIMIT); + MC_DEFINE_ENV_VAR(int64_t, MOONCAKE_OFFLOAD_TOTAL_KEYS_LIMIT); + MC_DEFINE_ENV_VAR(int64_t, MOONCAKE_OFFLOAD_TOTAL_SIZE_LIMIT_BYTES); + MC_DEFINE_ENV_VAR(uint32_t, MOONCAKE_OFFLOAD_HEARTBEAT_INTERVAL_SECONDS); + MC_DEFINE_ENV_VAR(uint32_t, + MOONCAKE_OFFLOAD_CLIENT_BUFFER_GC_INTERVAL_SECONDS); + MC_DEFINE_ENV_VAR(uint64_t, MOONCAKE_OFFLOAD_CLIENT_BUFFER_GC_TTL_MS); + + // Keep legacy bool/ratio values as strings so their custom parsing and + // silent invalid-value behavior remain unchanged. + MC_DEFINE_ENV_VAR(std::string, + MOONCAKE_OFFLOAD_ENABLE_DISK_WATERMARK_EVICTION); + MC_DEFINE_ENV_VAR(std::string, + MOONCAKE_OFFLOAD_DISK_EVICTION_HIGH_WATERMARK_RATIO); + MC_DEFINE_ENV_VAR(std::string, MOONCAKE_DISK_EVICTION_HIGH_WATERMARK_RATIO); + MC_DEFINE_ENV_VAR(std::string, + MOONCAKE_OFFLOAD_DISK_EVICTION_LOW_WATERMARK_RATIO); + MC_DEFINE_ENV_VAR(std::string, MOONCAKE_DISK_EVICTION_LOW_WATERMARK_RATIO); + MC_DEFINE_ENV_VAR(std::string, MOONCAKE_OFFLOAD_USE_URING); + MC_DEFINE_ENV_VAR(std::string, MOONCAKE_USE_URING); +}; + +struct FilePerKeyEnvironmentVariables { + MC_DEFINE_ENV_VAR(std::string, MOONCAKE_OFFLOAD_FSDIR); + MC_DEFINE_ENV_VAR(bool, MOONCAKE_OFFLOAD_ENABLE_EVICTION); + MC_DEFINE_ENV_VAR(bool, ENABLE_EVICTION); +}; + +struct BucketBackendEnvironmentVariables { + MC_DEFINE_ENV_VAR(int64_t, MOONCAKE_OFFLOAD_BUCKET_KEYS_LIMIT); + MC_DEFINE_ENV_VAR(int64_t, MOONCAKE_OFFLOAD_BUCKET_SIZE_LIMIT_BYTES); + MC_DEFINE_ENV_VAR(int64_t, MOONCAKE_OFFLOAD_BUCKET_MAX_TOTAL_SIZE); + MC_DEFINE_ENV_VAR(int64_t, MOONCAKE_BUCKET_MAX_TOTAL_SIZE); + MC_DEFINE_ENV_VAR(int64_t, MOONCAKE_OFFLOAD_BUCKET_MAX_PHYSICAL_BYTES); + MC_DEFINE_ENV_VAR(int64_t, MOONCAKE_OFFLOAD_BUCKET_DISK_SCAN_CACHE_MS); + MC_DEFINE_ENV_VAR(std::string, MOONCAKE_OFFLOAD_BUCKET_EVICTION_POLICY); + MC_DEFINE_ENV_VAR(std::string, MOONCAKE_BUCKET_EVICTION_POLICY); +}; + +struct ClientAutoPortEnvironmentVariables { + MC_DEFINE_ENV_VAR(int, MC_STORE_CLIENT_SETUP_RETRIES); + MC_DEFINE_ENV_VAR(int, MC_STORE_CLIENT_MIN_PORT); + MC_DEFINE_ENV_VAR(int, MC_STORE_CLIENT_MAX_PORT); +}; + +struct CxlSegmentEnvironmentVariables { + // Keep the raw string so an unset value remains distinguishable from a + // present but invalid value, which the legacy path resolves to zero. + MC_DEFINE_ENV_VAR(std::string, MC_CXL_DEV_SIZE); +}; + +struct ClientNumaEnvironmentVariables { + // Keep the raw string to preserve the legacy strtol syntax and warning + // behavior. + MC_DEFINE_ENV_VAR(std::string, MC_STORE_NUMA_SOCKET_ID); +}; + +struct RegisteredPinnedMemoryEnvironmentVariables { + // Keep the raw string because the legacy parser rejects a leading '+', + // unlike the shared typed integer parser. + MC_DEFINE_ENV_VAR(std::string, MC_STORE_PIN_MEMORY_MAX_BYTES); +}; + +struct MmapArenaEnvironmentVariables { + // Keep these values as strings to preserve the existing byte-size and + // canonical-bool parsing, opt-in, fallback, and logging behavior. + MC_DEFINE_ENV_VAR(std::string, MC_MMAP_ARENA_POOL_SIZE); + MC_DEFINE_ENV_VAR(std::string, MC_DISABLE_MMAP_ARENA); +}; + +struct HugepageEnvironmentVariables { + // Keep these values as strings to preserve presence-based enablement and + // the existing byte-size parser, fallback, and logging behavior. + MC_DEFINE_ENV_VAR(std::string, MC_STORE_USE_HUGEPAGE); + MC_DEFINE_ENV_VAR(std::string, MC_STORE_HUGEPAGE_SIZE); +}; + +struct LocalHotCacheEnvironmentVariables { + // Keep these values as strings to preserve their existing per-setting + // parsing, fallback, and logging behavior. + MC_DEFINE_ENV_VAR(std::string, MC_STORE_LOCAL_HOT_CACHE_SIZE); + MC_DEFINE_ENV_VAR(std::string, MC_STORE_LOCAL_HOT_BLOCK_SIZE); + MC_DEFINE_ENV_VAR(std::string, MC_STORE_LOCAL_HOT_CACHE_USE_SHM); + MC_DEFINE_ENV_VAR(std::string, MC_STORE_LOCAL_HOT_ADMISSION_THRESHOLD); +}; + +struct ClientMetricEnvironmentVariables { + // Keep these values as strings because ClientMetricConfig preserves the + // existing per-setting fallback and logging behavior. + MC_DEFINE_ENV_VAR(std::string, MC_STORE_CLIENT_METRIC); + MC_DEFINE_ENV_VAR(std::string, MC_STORE_CLIENT_METRIC_INTERVAL); + MC_DEFINE_ENV_VAR(std::string, MC_STORE_CLIENT_METRIC_BANDWIDTH); +}; + +struct DistributedStorageEnvironmentVariables { + MC_DEFINE_ENV_VAR(std::string, MOONCAKE_DFS_ROOT_DIR); + MC_DEFINE_ENV_VAR(std::string, MOONCAKE_DISTRIBUTED_ROOT_DIR); + MC_DEFINE_ENV_VAR(std::string, MOONCAKE_DFS_FS_ADAPTER); + MC_DEFINE_ENV_VAR(std::string, MOONCAKE_DISTRIBUTED_FS_TYPE); + MC_DEFINE_ENV_VAR(std::string, MOONCAKE_DFS_ALLOCATOR); + MC_DEFINE_ENV_VAR(bool, MOONCAKE_DISTRIBUTED_HEALTH_CHECK); + MC_DEFINE_ENV_VAR(int, MOONCAKE_DFS_SHARD_COUNT); + MC_DEFINE_ENV_VAR(uint64_t, MOONCAKE_DFS_SHARD_CAPACITY); + MC_DEFINE_ENV_VAR(uint64_t, MOONCAKE_DFS_BUCKET_CAPACITY); + MC_DEFINE_ENV_VAR(int64_t, MOONCAKE_DFS_MAX_BUCKET_COUNT); + MC_DEFINE_ENV_VAR(uint64_t, MOONCAKE_DFS_ALIGNMENT); + MC_DEFINE_ENV_VAR(bool, MOONCAKE_DFS_SINGLE_TENANT); + MC_DEFINE_ENV_VAR(bool, MOONCAKE_DFS_EVICTION_ENABLED); + MC_DEFINE_ENV_VAR(double, MOONCAKE_DFS_EVICTION_HIGH_WATERMARK); + MC_DEFINE_ENV_VAR(double, MOONCAKE_DFS_EVICTION_LOW_WATERMARK); + MC_DEFINE_ENV_VAR(int, MOONCAKE_DFS_DEFERRED_FREE_SECONDS); + MC_DEFINE_ENV_VAR(int, MOONCAKE_DFS_EVICTION_CHECK_INTERVAL); +}; + +struct OssAdapterEnvironmentVariables { + MC_DEFINE_ENV_VAR(std::string, MOONCAKE_OSS_ENDPOINT); + MC_DEFINE_ENV_VAR(std::string, OSS_ENDPOINT); + MC_DEFINE_ENV_VAR(std::string, MOONCAKE_OSS_BUCKET); + MC_DEFINE_ENV_VAR(std::string, OSS_BUCKET); + MC_DEFINE_ENV_VAR(std::string, MOONCAKE_OSS_REGION); + MC_DEFINE_ENV_VAR(std::string, OSS_REGION); + MC_DEFINE_ENV_VAR(std::string, MOONCAKE_OSS_ACCESS_KEY_ID); + MC_DEFINE_ENV_VAR(std::string, OSS_ACCESS_KEY_ID); + MC_DEFINE_ENV_VAR(std::string, MOONCAKE_OSS_ACCESS_KEY_SECRET); + MC_DEFINE_ENV_VAR(std::string, OSS_ACCESS_KEY_SECRET); + MC_DEFINE_ENV_VAR(std::string, MOONCAKE_OSS_SECURITY_TOKEN); + MC_DEFINE_ENV_VAR(std::string, OSS_SESSION_TOKEN); + MC_DEFINE_ENV_VAR(bool, MOONCAKE_OSS_PATH_STYLE); + MC_DEFINE_ENV_VAR(bool, MOONCAKE_OSS_ANONYMOUS); + MC_DEFINE_ENV_VAR(int, MOONCAKE_OSS_MAX_CONNECTIONS); + MC_DEFINE_ENV_VAR(int, MOONCAKE_OSS_RECEIVE_BUFFER_SIZE); + MC_DEFINE_ENV_VAR(int, MOONCAKE_OSS_UPLOAD_BUFFER_SIZE); +}; + +struct OffsetAllocatorBackendEnvironmentVariables { + MC_DEFINE_ENV_VAR(std::string, MOONCAKE_OFFSET_EVICTION_POLICY); + MC_DEFINE_ENV_VAR(std::string, MOONCAKE_OFFSET_HIGH_RATIO); + MC_DEFINE_ENV_VAR(std::string, MOONCAKE_OFFSET_LOW_RATIO); + MC_DEFINE_ENV_VAR(int64_t, MOONCAKE_OFFSET_MAX_CAPACITY_NODES); + MC_DEFINE_ENV_VAR(int64_t, MOONCAKE_OFFSET_MAX_EVICT_PER_OFFLOAD); + MC_DEFINE_ENV_VAR(std::string, MOONCAKE_OFFSET_PERSIST_MODE); + MC_DEFINE_ENV_VAR(int64_t, MOONCAKE_OFFSET_PERSIST_INTERVAL_SECONDS); + MC_DEFINE_ENV_VAR(bool, MOONCAKE_OFFSET_RECORD_CRC); +}; + +struct ReplicaSelectionEnvironmentVariables { + // Only the exact string "1" enables scoring, unlike canonical bool parsing. + MC_DEFINE_ENV_VAR(std::string, MC_STORE_REPLICA_SCORING); +}; + +struct RpcTimeoutEnvironmentVariables { + // Preserve atoll parsing: empty/nonnumeric values become zero, and numeric + // prefixes are accepted, unlike the shared typed integer parser. + MC_DEFINE_ENV_VAR(std::string, MC_RPC_TIMEOUT_MS); + MC_DEFINE_ENV_VAR(std::string, MC_RPC_CONNECT_TIMEOUT_MS); +}; + +struct RpcProtocolEnvironmentVariables { + // Preserve the legacy exact, case-sensitive "rdma" token check. + MC_DEFINE_ENV_VAR(std::string, MC_RPC_PROTOCOL); +}; + +struct LocalFileSnapshotEnvironmentVariables { + MC_DEFINE_ENV_VAR(std::string, MOONCAKE_SNAPSHOT_LOCAL_PATH); +}; + +struct HaClusterNamespaceEnvironmentVariables { + MC_DEFINE_ENV_VAR(std::string, MC_STORE_CLUSTER_ID); +}; + +struct TransferSubmitterEnvironmentVariables { + // Keep the raw string to preserve the legacy token set, whitespace, + // invalid-value fallback, and warning behavior. + MC_DEFINE_ENV_VAR(std::string, MC_STORE_MEMCPY); +}; + +struct FilereadWorkerPoolEnvironmentVariables { + // Preserve the raw value in invalid-value warnings. + MC_DEFINE_ENV_VAR(std::string, MC_FILEREAD_WORKERS); +}; + +struct NoFRegisterEnvironmentVariables { + // Keep the raw string to preserve case normalization and warning behavior. + MC_DEFINE_ENV_VAR(std::string, MC_NOF_TRTYPE); +}; + +struct SpdkControllerEnvironmentVariables { + MC_DEFINE_ENV_VAR(uint32_t, MC_NVME_NUM_IO_QUEUES); + MC_DEFINE_ENV_VAR(uint32_t, MC_NVME_IO_QUEUE_SIZE); + MC_DEFINE_ENV_VAR(uint32_t, MC_NVME_IO_QUEUE_REQUESTS); + MC_DEFINE_ENV_VAR(uint8_t, MC_NVME_TRANSPORT_ACK_TIMEOUT); + MC_DEFINE_ENV_VAR(uint16_t, MC_NVME_ADMIN_QUEUE_SIZE); + MC_DEFINE_ENV_VAR(uint64_t, MC_NVME_FABRICS_CONNECT_TIMEOUT_US); + MC_DEFINE_ENV_VAR(bool, MC_NVME_HEADER_DIGEST); + MC_DEFINE_ENV_VAR(bool, MC_NVME_DATA_DIGEST); +}; + +struct NvmeKvConnectorEnvironmentVariables { + MC_DEFINE_ENV_VAR(std::string, MOONCAKE_NVME_KV_DEVICE_PATH); + // Keep numeric values as strings because the existing NVMe parser accepts + // base prefixes, a leading plus, and leading whitespace. + MC_DEFINE_ENV_VAR(std::string, MOONCAKE_NVME_KV_NSID); + MC_DEFINE_ENV_VAR(std::string, MOONCAKE_NVME_KV_QUEUE_DEPTH); + MC_DEFINE_ENV_VAR(std::string, MOONCAKE_NVME_KV_RUNTIME_TRANSFER_LIMIT); + MC_DEFINE_ENV_VAR(std::string, MOONCAKE_NVME_KV_TRANSPORT); +}; + +struct RedisConnectionEnvironmentVariables { + // Keep the DB index as a string so an explicitly empty value remains + // distinguishable from a nonempty malformed value. + MC_DEFINE_ENV_VAR(std::string, MC_REDIS_DB_INDEX); + MC_DEFINE_ENV_VAR(std::string, MC_REDIS_USERNAME); + MC_DEFINE_ENV_VAR(std::string, MC_REDIS_PASSWORD); +}; + +struct ClientAutoDiscoveryEnvironmentVariables { + // Keep the raw strings to preserve std::stoi prefix acceptance and the + // distinction between unset and explicitly empty filter values. + MC_DEFINE_ENV_VAR(std::string, MC_MS_AUTO_DISC); + MC_DEFINE_ENV_VAR(std::string, MC_MS_FILTERS); +}; + +struct ClientHostIdentityEnvironmentVariables { + MC_DEFINE_ENV_VAR(std::string, MOONCAKE_HOST_ID); +}; + +struct NvmeKvIoConcurrencyEnvironmentVariables { + // Keep these values as strings to preserve the existing NVMe unsigned + // syntax, zero fallback, and silent invalid-value behavior. + MC_DEFINE_ENV_VAR(std::string, MOONCAKE_NVME_KV_MAX_IO_CONCURRENCY); + MC_DEFINE_ENV_VAR(std::string, MOONCAKE_NVME_KV_IO_CONCURRENCY); + MC_DEFINE_ENV_VAR(std::string, MOONCAKE_NVME_KV_BATCH_SUBMIT_CONCURRENCY); + MC_DEFINE_ENV_VAR(std::string, MOONCAKE_NVME_KV_ROOT_SUBMIT_CONCURRENCY); + MC_DEFINE_ENV_VAR(std::string, MOONCAKE_NVME_KV_PREPARE_CONCURRENCY); +}; + +struct S3ClientEnvironmentVariables { + MC_DEFINE_ENV_VAR(std::string, MOONCAKE_AWS_REGION); + MC_DEFINE_ENV_VAR(std::string, MOONCAKE_AWS_S3_ENDPOINT); + MC_DEFINE_ENV_VAR(std::string, MOONCAKE_AWS_BUCKET_NAME); + MC_DEFINE_ENV_VAR(std::string, MOONCAKE_AWS_ACCESS_KEY_ID); + MC_DEFINE_ENV_VAR(std::string, MOONCAKE_AWS_SECRET_ACCESS_KEY); + MC_DEFINE_ENV_VAR(bool, MOONCAKE_AWS_USE_VIRTUAL_ADDRESSING); + MC_DEFINE_ENV_VAR(bool, MOONCAKE_AWS_USE_HTTPS); + MC_DEFINE_ENV_VAR(std::string, MOONCAKE_AWS_REQUEST_CHECKSUM_CALCULATION); + MC_DEFINE_ENV_VAR(std::string, MOONCAKE_AWS_RESPONSE_CHECKSUM_VALIDATION); + MC_DEFINE_ENV_VAR(int64_t, MOONCAKE_AWS_CONNECT_TIMEOUT_MS); + MC_DEFINE_ENV_VAR(int64_t, MOONCAKE_AWS_REQUEST_TIMEOUT_MS); +}; + +#undef MC_DEFINE_ENV_VAR + +} // namespace mooncake diff --git a/mooncake-common/include/ib_link_speed.h b/mooncake-common/include/ib_link_speed.h new file mode 100644 index 0000000000..7e9fba9261 --- /dev/null +++ b/mooncake-common/include/ib_link_speed.h @@ -0,0 +1,87 @@ +// Copyright 2025 KVCache.AI +// +// Licensed 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. + +#ifndef MOONCAKE_IB_LINK_SPEED_H_ +#define MOONCAKE_IB_LINK_SPEED_H_ + +namespace mooncake { + +// Per-lane signalling rate for an ibv_port_attr::active_speed encoding +// (see ibv_query_port(3)). Returns 0 for an encoding that is not known. +inline double ibLaneSpeedGbps(int active_speed) { + switch (active_speed) { + case 1: + return 2.5; // SDR + case 2: + return 5.0; // DDR + case 4: + return 10.0; // QDR + case 8: + return 10.0; // FDR10 + case 16: + return 14.0; // FDR + case 32: + return 25.0; // EDR + case 64: + return 50.0; // HDR + case 128: + return 100.0; // NDR + case 256: + return 200.0; // XDR + default: + return 0.0; + } +} + +// Lane count for an ibv_port_attr::active_width encoding. Returns 0 for an +// encoding that is not known. +inline int ibLinkWidthLanes(int active_width) { + switch (active_width) { + case 1: + return 1; + case 2: + return 4; + case 4: + return 8; + case 8: + return 12; + case 16: + return 2; + default: + return 0; + } +} + +// Link speed in Gbps from the raw ibv_port_attr encodings, or 0 when either +// encoding is unknown so the caller can fall back explicitly rather than +// scheduling against a guessed rate. +inline double ibLinkSpeedGbps(int active_speed, int active_width) { + return ibLaneSpeedGbps(active_speed) * ibLinkWidthLanes(active_width); +} + +// Port speed in Gbps, preferring the effective speed ibv_query_port_speed() +// reports (rdma-core >= 62, here in Mb/s) over the encoded link rate. +// The two differ for a VF over LAG: a PF dropping out of the bond halves +// the VF's bandwidth while its port stays ACTIVE at the same encoding, and +// only the effective speed reflects that. 0 for effective_mbps means the +// verb is unavailable or reported nothing, and the encodings decide. +inline double ibPortSpeedGbps(unsigned long long effective_mbps, + int active_speed, int active_width) { + if (effective_mbps > 0) return effective_mbps / 1000.0; + return ibLinkSpeedGbps(active_speed, active_width); +} + +} // namespace mooncake + +#endif // MOONCAKE_IB_LINK_SPEED_H_ diff --git a/mooncake-common/include/rpc_client_io_context.h b/mooncake-common/include/rpc_client_io_context.h index b9842f0c99..25373be186 100644 --- a/mooncake-common/include/rpc_client_io_context.h +++ b/mooncake-common/include/rpc_client_io_context.h @@ -1,13 +1,17 @@ #pragma once #include +#include +#include #include +#include #include #include #include #include #include #include +#include #include #include @@ -19,16 +23,152 @@ namespace mooncake { std::shared_ptr CreateRpcClientIoContextPool( uint32_t thread_count); +/** + * Teardown guard for RPC entry points that serve out of a shared pool. + * + * The ylt client pool documents only send_request as thread-safe; closing or + * destroying the pool while a request coroutine is suspended leaves the + * resumed coroutine touching freed connection state (the teardown segfault in + * #3909). Entry points take a ScopedCall; a destructor calls drain_for() so + * the pool is only released once nothing is in flight. + * + * The counters live in a shared State, not in the guard object itself. When a + * drain times out the owner tears down anyway, and a ScopedCall still in + * flight then outlives the guard; holding the state by shared_ptr keeps its + * leave() from touching freed memory (#3943 review). + */ +class RpcDrainGuard { + private: + struct State { + std::atomic stopping{false}; + std::atomic inflight{0}; + std::mutex mutex; + std::condition_variable cv; + }; + + public: + class ScopedCall { + public: + explicit ScopedCall(const RpcDrainGuard& guard) + : state_(guard.state_), ok_(try_enter(*state_)) {} + ~ScopedCall() { + if (ok_) leave(*state_); + } + ScopedCall(const ScopedCall&) = delete; + ScopedCall& operator=(const ScopedCall&) = delete; + bool ok() const { return ok_; } + + private: + static bool try_enter(State& state) { + if (state.stopping.load(std::memory_order_acquire)) return false; + state.inflight.fetch_add(1, std::memory_order_acq_rel); + // a drain that started between the two reads still sees this call + if (state.stopping.load(std::memory_order_acquire)) { + leave(state); + return false; + } + return true; + } + + static void leave(State& state) { + if (state.inflight.fetch_sub(1, std::memory_order_acq_rel) == 1) { + std::lock_guard lk(state.mutex); + state.cv.notify_all(); + } + } + + std::shared_ptr state_; + bool ok_; + }; + + // Bounded wait for in-flight calls; stops admitting new ones first. + // Returns false on timeout: the caller must keep the shared pool alive + // regardless (the registry already does), and the state itself stays + // valid until the last ScopedCall lets go of it. + bool drain_for(std::chrono::milliseconds timeout) { + state_->stopping.store(true, std::memory_order_release); + std::unique_lock lk(state_->mutex); + return state_->cv.wait_for(lk, timeout, [&] { + return state_->inflight.load(std::memory_order_acquire) == 0; + }); + } + + private: + std::shared_ptr state_ = std::make_shared(); +}; + template coro_io::io_context_pool& GetRpcClientIoContextPool(uint32_t thread_count) { static const auto io_pool = CreateRpcClientIoContextPool(thread_count); return *io_pool; } +namespace detail { + +// Process-wide client-pool registry, keyed by address. A ylt client pool owns +// background reconnect coroutines that hold references into pool storage +// (client_pool.hpp's reconnect loop), so freeing a pool while they are +// suspended is a use-after-free regardless of whether any user request is in +// flight (#3909). Pools are few in practice (one per distinct master address +// per process), so they are deliberately kept alive for the process lifetime. +// +// The registry key is the address plus the pool's behavioral knobs. Two +// holders for one address with identical configuration share a pool, which is +// the case the keep-alive exists for. The same address can also legitimately +// host different policies at once: the foreground master pool is resilient +// while an HA probe on that address must fast-fail, and keying by address +// alone would hand the probe the foreground retry budget. +inline std::shared_ptr> +SharedPoolRegistryImpl( + std::string_view address, + coro_io::client_pool::pool_config config, + coro_io::io_context_pool& io_context_pool) { + using Pool = coro_io::client_pool; + static std::mutex registry_mutex; + static std::unordered_map> registry; + std::lock_guard lock(registry_mutex); + std::string key; + key.reserve(address.size() + 64); + key.append(address); + auto append_knob = [&key](auto value) { + key.push_back('|'); + key.append(std::to_string(value)); + }; + append_knob(config.max_connection); + append_knob(config.connect_retry_count); + append_knob(config.reconnect_wait_time.count()); + append_knob(config.client_config.connect_timeout_duration.count()); + append_knob(config.client_config.request_timeout_duration.count()); + append_knob( + static_cast(config.client_config.socket_config.index())); + auto& pool = registry[key]; + if (!pool) { + pool = Pool::create(std::string(address), std::move(config), + io_context_pool); + } + return pool; +} + +// ClientRequester's offload pool collection has the same lifetime hazard: its +// pools host background reconnect coroutines, so they must outlive the owning +// RealClient. Collections are few in practice (one per RealClient), so they +// are parked here for the process lifetime instead of being freed at teardown. +inline void KeepClientPoolsAlive( + std::shared_ptr> pools) { + static std::mutex keep_mutex; + static std::vector< + std::shared_ptr>> + keep; + std::lock_guard lock(keep_mutex); + keep.push_back(std::move(pools)); +} + +} // namespace detail + /** * A replaceable client pool for callers that communicate with one target at a - * time. Requests retain a shared_ptr to the old pool while they are in flight; - * after an address switch the old pool is destroyed when those requests end. + * time. Pools live in the process-wide registry above; an address switch only + * re-points this holder, it never frees a pool. */ class RpcClientPool { public: @@ -46,8 +186,7 @@ class RpcClientPool { std::string_view address) { std::lock_guard lock(mutex_); if (!client_pool_ || address_ != address) { - client_pool_ = - ClientPool::create(address, config_, io_context_pool_); + client_pool_ = SharedPoolRegistry(address); address_ = address; } return client_pool_; @@ -59,6 +198,11 @@ class RpcClientPool { } private: + std::shared_ptr SharedPoolRegistry(std::string_view address) { + return detail::SharedPoolRegistryImpl(address, config_, + io_context_pool_); + } + mutable std::shared_mutex mutex_; coro_io::io_context_pool& io_context_pool_; PoolConfig config_; diff --git a/mooncake-common/src/CMakeLists.txt b/mooncake-common/src/CMakeLists.txt index 2730485cf1..8a698d3aab 100644 --- a/mooncake-common/src/CMakeLists.txt +++ b/mooncake-common/src/CMakeLists.txt @@ -1,4 +1,10 @@ find_package(yaml-cpp REQUIRED) +find_path(ASIO_INCLUDE_DIR asio.hpp + HINTS ${yalantinglibs_SOURCE_DIR}/include/ylt/thirdparty +) +if(NOT ASIO_INCLUDE_DIR) + message(FATAL_ERROR "standalone Asio headers not found") +endif() set(MOONCAKE_COMMON_SOURCES crc_checksum.cpp default_config.cpp environ.cpp rpc_client_io_context.cpp) @@ -7,6 +13,7 @@ add_library(asio_shared SHARED asio_impl.cpp) target_compile_definitions(asio_shared PUBLIC ASIO_SEPARATE_COMPILATION ASIO_DYN_LINK) +target_include_directories(asio_shared PUBLIC ${ASIO_INCLUDE_DIR}) set_target_properties( asio_shared @@ -17,6 +24,7 @@ set_target_properties( LIBRARY_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/mooncake-common") target_link_libraries(asio_shared PUBLIC pthread yalantinglibs::yalantinglibs) +install(TARGETS asio_shared DESTINATION lib) # Static, PIC twin of asio_shared for consumers that absorb asio into their own # shared object instead of taking a DT_NEEDED on libasio.so (see @@ -39,11 +47,10 @@ target_include_directories( PUBLIC $ $) -target_link_libraries(mooncake_common PUBLIC asio_shared yaml-cpp jsoncpp - yalantinglibs::yalantinglibs) +target_link_libraries( + mooncake_common PUBLIC asio_shared yaml-cpp JsonCpp::JsonCpp + yalantinglibs::yalantinglibs) if(BUILD_SHARED_LIBS) install(TARGETS mooncake_common DESTINATION lib) endif() - -install(TARGETS asio_shared DESTINATION lib) diff --git a/mooncake-common/src/crc_checksum.cpp b/mooncake-common/src/crc_checksum.cpp index 37c330201b..c05dca0f48 100644 --- a/mooncake-common/src/crc_checksum.cpp +++ b/mooncake-common/src/crc_checksum.cpp @@ -8,29 +8,85 @@ namespace { constexpr uint64_t kCrc64EcmaPolynomial = 0x42F0E1EBA9EA3693ULL; -constexpr std::array MakeCrc64EcmaTable() { - std::array table{}; - for (size_t i = 0; i < table.size(); ++i) { +// Advance the register by one byte with a zero input byte. Feeding one message +// byte is `(crc << 8) ^ table[0][(crc >> 56) ^ byte]`, so composing this +// function k times yields the table for a byte that still has k more byte +// positions to travel. +constexpr uint64_t AdvanceOneByte(uint64_t crc, const uint64_t* base_table) { + return (crc << 8) ^ base_table[crc >> 56]; +} + +// Slicing-by-8 tables. tables[0] is the classic byte-at-a-time table; tables[k] +// additionally advances the value by k more bytes. Total size is 16 KiB, which +// fits comfortably in L1d. +constexpr std::array, 8> MakeCrc64EcmaTables() { + std::array, 8> tables{}; + for (size_t i = 0; i < 256; ++i) { uint64_t crc = static_cast(i) << 56; for (int bit = 0; bit < 8; ++bit) { crc = (crc & (1ULL << 63)) != 0 ? (crc << 1) ^ kCrc64EcmaPolynomial : crc << 1; } - table[i] = crc; + tables[0][i] = crc; + } + for (size_t k = 1; k < 8; ++k) { + for (size_t i = 0; i < 256; ++i) { + tables[k][i] = AdvanceOneByte(tables[k - 1][i], tables[0].data()); + } } - return table; + return tables; +} + +constexpr auto kCrc64EcmaTables = MakeCrc64EcmaTables(); +constexpr const auto& kCrc64EcmaTable = kCrc64EcmaTables[0]; + +// CRC-64/ECMA is defined MSB-first, so the first byte of the message must land +// in the most significant byte of the register. Written as explicit shifts to +// stay endian-independent; compilers fold this into a load plus a byte swap. +inline uint64_t LoadBigEndian64(const uint8_t* p) { + return (static_cast(p[0]) << 56) | + (static_cast(p[1]) << 48) | + (static_cast(p[2]) << 40) | + (static_cast(p[3]) << 32) | + (static_cast(p[4]) << 24) | + (static_cast(p[5]) << 16) | + (static_cast(p[6]) << 8) | static_cast(p[7]); } -constexpr auto kCrc64EcmaTable = MakeCrc64EcmaTable(); +inline uint64_t UpdateByteAtATime(uint64_t crc, const uint8_t* bytes, + size_t size) { + for (size_t i = 0; i < size; ++i) { + const auto index = static_cast((crc >> 56) ^ bytes[i]); + crc = kCrc64EcmaTable[index] ^ (crc << 8); + } + return crc; +} } // namespace void CrcChecksum::Update(const void* data, size_t size) { const auto* bytes = static_cast(data); - for (size_t i = 0; i < size; ++i) { - const auto index = static_cast((crc_ >> 56) ^ bytes[i]); - crc_ = kCrc64EcmaTable[index] ^ (crc_ << 8); + + // The byte-at-a-time loop is latency bound: every table index depends on + // the previous iteration's register value. Slicing-by-8 folds eight byte + // steps into one, and the eight lookups are independent, so the CPU can + // issue them in parallel. Measured ~4.8x on an Ice Lake Xeon (3.13 -> 0.65 + // ns/byte). + uint64_t crc = crc_; + while (size >= 8) { + crc ^= LoadBigEndian64(bytes); + crc = kCrc64EcmaTables[7][crc >> 56] ^ + kCrc64EcmaTables[6][(crc >> 48) & 0xFF] ^ + kCrc64EcmaTables[5][(crc >> 40) & 0xFF] ^ + kCrc64EcmaTables[4][(crc >> 32) & 0xFF] ^ + kCrc64EcmaTables[3][(crc >> 24) & 0xFF] ^ + kCrc64EcmaTables[2][(crc >> 16) & 0xFF] ^ + kCrc64EcmaTables[1][(crc >> 8) & 0xFF] ^ + kCrc64EcmaTables[0][crc & 0xFF]; + bytes += 8; + size -= 8; } + crc_ = UpdateByteAtATime(crc, bytes, size); } uint64_t ComputeCrcChecksum(const void* data, size_t size) { diff --git a/mooncake-common/src/environ.cpp b/mooncake-common/src/environ.cpp index c736823171..36b5e8ebf3 100644 --- a/mooncake-common/src/environ.cpp +++ b/mooncake-common/src/environ.cpp @@ -2,12 +2,8 @@ #include #include -#include #include -#include "bool_parser.h" -#include "integer_parser.h" - namespace mooncake { namespace { @@ -38,9 +34,7 @@ Integer ReadInteger(const EnvironSource& source, const char* name, return default_value; } - const auto parsed = TryParseInteger( - std::string_view(value), - {.trim_ascii_whitespace = true, .allow_leading_plus = true}); + const auto parsed = TryParseEnvironmentValue(value); if (parsed.has_value()) { return *parsed; } @@ -64,6 +58,23 @@ size_t ReadSizeT(const EnvironSource& source, const char* name, return ReadInteger(source, name, default_value); } +double ReadDouble(const EnvironSource& source, const char* name, + double default_value) { + const char* value = source.Get(name); + if (value == nullptr || value[0] == '\0') { + return default_value; + } + + const auto parsed = TryParseEnvironmentValue(value); + if (parsed.has_value()) { + return *parsed; + } + + std::cerr << "[Mooncake] Warning: invalid value '" << value << "' for env " + << name << ", using default " << default_value << std::endl; + return default_value; +} + bool ReadBool(const EnvironSource& source, const char* name, bool default_value) { const char* value = source.Get(name); @@ -71,7 +82,7 @@ bool ReadBool(const EnvironSource& source, const char* name, return default_value; } - const auto parsed = TryParseBool(value); + const auto parsed = TryParseEnvironmentValue(value); if (parsed.has_value()) { return *parsed; } @@ -117,6 +128,10 @@ uint64_t Environ::GetUInt64(const char* name, uint64_t default_value) { return ReadInteger(GetOsEnvironSource(), name, default_value); } +double Environ::GetDouble(const char* name, double default_value) { + return ReadDouble(GetOsEnvironSource(), name, default_value); +} + size_t Environ::GetSizeT(const char* name, size_t default_value) { return ReadSizeT(GetOsEnvironSource(), name, default_value); } @@ -184,30 +199,10 @@ Environ::Environ(const EnvironSource& source) { intra_nvlink_ = ReadBool(source, "MC_INTRA_NVLINK", false); path_roundrobin_ = ReadBool(source, "MC_PATH_ROUNDROBIN", false); with_nvidia_peermem_ = ReadBool(source, "WITH_NVIDIA_PEERMEM", true); + rdma_data_direct_ = ReadBool(source, "MC_RDMA_DATA_DIRECT", false); efa_cq_threads_ = ReadInt(source, "MC_EFA_CQ_THREADS", 1); store_checksum_enabled_ = ReadBool(source, "MOONCAKE_STORE_CHECKSUM", false); - - // AWS / S3 client configuration (consumed by s3_helper.cpp) - aws_region_ = ReadString(source, "MOONCAKE_AWS_REGION", ""); - aws_s3_endpoint_ = ReadString(source, "MOONCAKE_AWS_S3_ENDPOINT", ""); - aws_bucket_name_ = ReadString(source, "MOONCAKE_AWS_BUCKET_NAME", ""); - aws_access_key_id_ = ReadString(source, "MOONCAKE_AWS_ACCESS_KEY_ID", ""); - aws_secret_access_key_ = - ReadString(source, "MOONCAKE_AWS_SECRET_ACCESS_KEY", ""); - aws_use_virtual_addressing_ = - ReadBool(source, "MOONCAKE_AWS_USE_VIRTUAL_ADDRESSING", true); - aws_use_https_ = ReadBool(source, "MOONCAKE_AWS_USE_HTTPS", true); - // Empty string preserves "unset" semantics — s3_helper keeps the AWS SDK - // default in that case rather than forcing a value. - aws_request_checksum_calculation_ = - ReadString(source, "MOONCAKE_AWS_REQUEST_CHECKSUM_CALCULATION", ""); - aws_response_checksum_validation_ = - ReadString(source, "MOONCAKE_AWS_RESPONSE_CHECKSUM_VALIDATION", ""); - aws_connect_timeout_ms_ = - ReadInt64(source, "MOONCAKE_AWS_CONNECT_TIMEOUT_MS", 10000); - aws_request_timeout_ms_ = - ReadInt64(source, "MOONCAKE_AWS_REQUEST_TIMEOUT_MS", 30000); } } // namespace mooncake diff --git a/mooncake-common/tests/CMakeLists.txt b/mooncake-common/tests/CMakeLists.txt index ee2d458547..9a003bf484 100644 --- a/mooncake-common/tests/CMakeLists.txt +++ b/mooncake-common/tests/CMakeLists.txt @@ -21,9 +21,20 @@ target_link_libraries(rpc_client_io_context_test PUBLIC mooncake_common gtest ibverbs pthread) add_test(NAME rpc_client_io_context_test COMMAND rpc_client_io_context_test) -foreach(parser_test ascii_string bool_parser integer_parser) +add_executable(rpc_drain_guard_test rpc_drain_guard_test.cpp) +target_link_libraries(rpc_drain_guard_test PUBLIC mooncake_common gtest + gtest_main pthread) +add_test(NAME rpc_drain_guard_test COMMAND rpc_drain_guard_test) + +foreach(parser_test ascii_string bool_parser environment_value_parser + integer_parser) add_executable(${parser_test}_test ${parser_test}_test.cpp) target_link_libraries(${parser_test}_test PUBLIC mooncake_common gtest gtest_main pthread) add_test(NAME ${parser_test}_test COMMAND ${parser_test}_test) endforeach() + +add_executable(ib_link_speed_test ib_link_speed_test.cpp) +target_link_libraries(ib_link_speed_test PUBLIC mooncake_common gtest + gtest_main pthread) +add_test(NAME ib_link_speed_test COMMAND ib_link_speed_test) diff --git a/mooncake-common/tests/crc_checksum_test.cpp b/mooncake-common/tests/crc_checksum_test.cpp index 69da3a8291..f6463c2633 100644 --- a/mooncake-common/tests/crc_checksum_test.cpp +++ b/mooncake-common/tests/crc_checksum_test.cpp @@ -5,8 +5,36 @@ #include #include #include +#include namespace mooncake { +namespace { + +// Reference implementation: the textbook bit-at-a-time definition of +// CRC-64/ECMA-182. Deliberately independent of the production tables so that a +// mistake in the slicing-by-8 tables cannot be masked by a shared bug. +uint64_t ReferenceCrc64Ecma(const uint8_t* data, size_t size) { + constexpr uint64_t kPolynomial = 0x42F0E1EBA9EA3693ULL; + uint64_t crc = 0; + for (size_t i = 0; i < size; ++i) { + crc ^= static_cast(data[i]) << 56; + for (int bit = 0; bit < 8; ++bit) { + crc = + (crc & (1ULL << 63)) != 0 ? (crc << 1) ^ kPolynomial : crc << 1; + } + } + return crc; +} + +std::vector MakePattern(size_t size) { + std::vector buffer(size); + for (size_t i = 0; i < size; ++i) { + buffer[i] = static_cast(i * 31 + 7); + } + return buffer; +} + +} // namespace TEST(CrcChecksumTest, MatchesCrc64EcmaKnownVector) { constexpr std::string_view value = "123456789"; @@ -29,4 +57,41 @@ TEST(CrcChecksumTest, StreamingMatchesContiguousForArbitraryLengths) { EXPECT_EQ(ComputeCrcChecksum(nullptr, 0), 0); } +// Slicing-by-8 processes whole 8-byte groups and falls back to the +// byte-at-a-time loop for the remainder, so every length modulo 8 needs +// coverage, including the sizes just below and above the group width. +TEST(CrcChecksumTest, MatchesBitwiseReferenceForEveryTailLength) { + const std::vector buffer = MakePattern(129); + for (size_t size = 0; size <= buffer.size(); ++size) { + EXPECT_EQ(ComputeCrcChecksum(buffer.data(), size), + ReferenceCrc64Ecma(buffer.data(), size)) + << "size=" << size; + } +} + +// A split can leave the register mid-group, which forces the next Update to +// start with the byte-at-a-time path. The result must still match the +// contiguous computation for every split point. +TEST(CrcChecksumTest, StreamingMatchesContiguousForEverySplitPoint) { + const std::vector buffer = MakePattern(64); + const uint64_t expected = ReferenceCrc64Ecma(buffer.data(), buffer.size()); + for (size_t split = 0; split <= buffer.size(); ++split) { + CrcChecksum streaming; + streaming.Update(buffer.data(), split); + streaming.Update(buffer.data() + split, buffer.size() - split); + EXPECT_EQ(streaming.Finalize(), expected) << "split=" << split; + } +} + +// The 8-byte load must not assume any particular alignment of the input. +TEST(CrcChecksumTest, MatchesBitwiseReferenceForUnalignedInput) { + const std::vector buffer = MakePattern(80); + for (size_t offset = 1; offset < 8; ++offset) { + const size_t size = buffer.size() - offset; + EXPECT_EQ(ComputeCrcChecksum(buffer.data() + offset, size), + ReferenceCrc64Ecma(buffer.data() + offset, size)) + << "offset=" << offset; + } +} + } // namespace mooncake diff --git a/mooncake-common/tests/environ_test.cpp b/mooncake-common/tests/environ_test.cpp index 4701f6e78e..c37e63763e 100644 --- a/mooncake-common/tests/environ_test.cpp +++ b/mooncake-common/tests/environ_test.cpp @@ -13,11 +13,13 @@ // limitations under the License. #include "environ.h" +#include "environment_variable.h" #include #include #include +#include using mooncake::Environ; @@ -32,20 +34,9 @@ class EnvironTest : public ::testing::Test { unsetenv("MC_TEST_UINT32"); unsetenv("MC_TEST_UINT64"); unsetenv("MC_TEST_SIZET"); + unsetenv("MC_TEST_DOUBLE"); unsetenv("MC_TEST_BOOL"); unsetenv("MC_TEST_STRING"); - // Make sure AWS vars don't leak in from the test runner's env. - unsetenv("MOONCAKE_AWS_REGION"); - unsetenv("MOONCAKE_AWS_S3_ENDPOINT"); - unsetenv("MOONCAKE_AWS_BUCKET_NAME"); - unsetenv("MOONCAKE_AWS_ACCESS_KEY_ID"); - unsetenv("MOONCAKE_AWS_SECRET_ACCESS_KEY"); - unsetenv("MOONCAKE_AWS_USE_VIRTUAL_ADDRESSING"); - unsetenv("MOONCAKE_AWS_USE_HTTPS"); - unsetenv("MOONCAKE_AWS_REQUEST_CHECKSUM_CALCULATION"); - unsetenv("MOONCAKE_AWS_RESPONSE_CHECKSUM_VALIDATION"); - unsetenv("MOONCAKE_AWS_CONNECT_TIMEOUT_MS"); - unsetenv("MOONCAKE_AWS_REQUEST_TIMEOUT_MS"); unsetenv("MOONCAKE_STORE_CHECKSUM"); } }; @@ -139,46 +130,44 @@ TEST_F(EnvironTest, UnsignedGettersUseRequestedDefaultForInvalidValues) { EXPECT_EQ(Environ::GetUInt64("MC_TEST_UINT64", 23), 23U); } -// --- AWS / S3 fields --- -// -// NOTE: Environ is a singleton whose constructor caches every value the -// first time Get() is called. So all AWS env vars must be set BEFORE the -// first Environ::Get() in this process. We therefore cover the populate -// path in a single test that takes the singleton's "first call" for -// itself; the default-path behavior is implicitly covered by Environ's -// constructor defaults (any earlier test would lock the cache to defaults -// and prevent us from observing populated values here). - -TEST_F(EnvironTest, AwsFieldsPopulateFromEnv) { - setenv("MOONCAKE_AWS_REGION", "us-east-1", 1); - setenv("MOONCAKE_AWS_S3_ENDPOINT", "https://s3.example.com", 1); - setenv("MOONCAKE_AWS_BUCKET_NAME", "my-bucket", 1); - setenv("MOONCAKE_AWS_ACCESS_KEY_ID", "AKIA-test", 1); - setenv("MOONCAKE_AWS_SECRET_ACCESS_KEY", "secret", 1); - setenv("MOONCAKE_AWS_USE_VIRTUAL_ADDRESSING", "0", 1); - setenv("MOONCAKE_AWS_USE_HTTPS", "0", 1); - setenv("MOONCAKE_AWS_REQUEST_CHECKSUM_CALCULATION", "when_required", 1); - setenv("MOONCAKE_AWS_RESPONSE_CHECKSUM_VALIDATION", "when_supported", 1); - setenv("MOONCAKE_AWS_CONNECT_TIMEOUT_MS", "5000", 1); - // Bogus request timeout should fall back to the registered default. - setenv("MOONCAKE_AWS_REQUEST_TIMEOUT_MS", "bogus", 1); +// --- GetDouble --- + +TEST_F(EnvironTest, GetDoubleValidValue) { + setenv("MC_TEST_DOUBLE", " 0.75 ", 1); + EXPECT_DOUBLE_EQ(Environ::GetDouble("MC_TEST_DOUBLE", 0.5), 0.75); +} + +TEST_F(EnvironTest, GetDoubleMissingOrInvalidUsesRequestedDefault) { + EXPECT_DOUBLE_EQ(Environ::GetDouble("MC_TEST_DOUBLE", 0.5), 0.5); + setenv("MC_TEST_DOUBLE", "0.75garbage", 1); + EXPECT_DOUBLE_EQ(Environ::GetDouble("MC_TEST_DOUBLE", 0.5), 0.5); + setenv("MC_TEST_DOUBLE", "nan", 1); + EXPECT_DOUBLE_EQ(Environ::GetDouble("MC_TEST_DOUBLE", 0.5), 0.5); +} + +TEST_F(EnvironTest, StoreChecksumPopulatesFromEnv) { setenv("MOONCAKE_STORE_CHECKSUM", "1", 1); const auto& e = Environ::Get(); - EXPECT_EQ(e.GetAwsRegion(), "us-east-1"); - EXPECT_EQ(e.GetAwsS3Endpoint(), "https://s3.example.com"); - EXPECT_EQ(e.GetAwsBucketName(), "my-bucket"); - EXPECT_EQ(e.GetAwsAccessKeyId(), "AKIA-test"); - EXPECT_EQ(e.GetAwsSecretAccessKey(), "secret"); - EXPECT_FALSE(e.GetAwsUseVirtualAddressing()); - EXPECT_FALSE(e.GetAwsUseHttps()); - EXPECT_EQ(e.GetAwsRequestChecksumCalculation(), "when_required"); - EXPECT_EQ(e.GetAwsResponseChecksumValidation(), "when_supported"); - EXPECT_EQ(e.GetAwsConnectTimeoutMs(), 5000); - EXPECT_EQ(e.GetAwsRequestTimeoutMs(), 30000); EXPECT_TRUE(e.GetStoreChecksumEnabled()); } +TEST_F(EnvironTest, RdmaDataDirectIsOptIn) { + class Source : public mooncake::EnvironSource { + public: + const char* value = nullptr; + const char* Get(const char* name) const override { + return std::string(name) == "MC_RDMA_DATA_DIRECT" ? value : nullptr; + } + } source; + + EXPECT_FALSE(Environ(source).GetRdmaDataDirect()); + source.value = "1"; + EXPECT_TRUE(Environ(source).GetRdmaDataDirect()); + source.value = "0"; + EXPECT_FALSE(Environ(source).GetRdmaDataDirect()); +} + // --- GetSizeT --- TEST_F(EnvironTest, GetSizeTValidValue) { @@ -281,6 +270,36 @@ TEST_F(EnvironTest, GetStringWithSpaces) { EXPECT_EQ(Environ::GetString("MC_TEST_STRING", ""), "hello world"); } +TEST_F(EnvironTest, ReadsTypedEnvironmentVariableDefinitions) { + constexpr mooncake::EnvironmentVariable number{"MC_TEST_INT64"}; + constexpr mooncake::EnvironmentVariable enabled{"MC_TEST_BOOL"}; + constexpr mooncake::EnvironmentVariable text{"MC_TEST_STRING"}; + + EXPECT_FALSE(Environ::Read(number).has_value()); + EXPECT_EQ(Environ::ReadOr(number, int64_t{17}), 17); + + setenv(number.name, "42", 1); + setenv(enabled.name, "off", 1); + setenv(text.name, "", 1); + + EXPECT_EQ(Environ::Read(number), 42); + EXPECT_EQ(Environ::Read(enabled), false); + ASSERT_TRUE(Environ::Read(text).has_value()); + EXPECT_TRUE(Environ::Read(text)->empty()); +} + +TEST_F(EnvironTest, TypedReadOrWarnsAndUsesDefaultForInvalidValues) { + constexpr mooncake::EnvironmentVariable number{"MC_TEST_INT64"}; + setenv(number.name, "invalid", 1); + + testing::internal::CaptureStderr(); + EXPECT_EQ(Environ::ReadOr(number, int64_t{17}), 17); + const std::string logs = testing::internal::GetCapturedStderr(); + + EXPECT_NE(logs.find("MC_TEST_INT64"), std::string::npos); + EXPECT_NE(logs.find("using default 17"), std::string::npos); +} + int main(int argc, char** argv) { ::testing::InitGoogleTest(&argc, argv); return RUN_ALL_TESTS(); diff --git a/mooncake-common/tests/environment_value_parser_test.cpp b/mooncake-common/tests/environment_value_parser_test.cpp new file mode 100644 index 0000000000..e2b02e6a2d --- /dev/null +++ b/mooncake-common/tests/environment_value_parser_test.cpp @@ -0,0 +1,72 @@ +#include "environment_value_parser.h" + +#include + +#include +#include +#include +#include + +namespace mooncake::test { + +TEST(EnvironmentValueParserTest, ParsesSupportedTypes) { + EXPECT_EQ(TryParseEnvironmentValue(" \t+42\r\n"), 42); + EXPECT_EQ(TryParseEnvironmentValue("ON"), true); + EXPECT_EQ(TryParseEnvironmentValue("value"), "value"); + + const auto ratio = TryParseEnvironmentValue("0.75"); + ASSERT_TRUE(ratio.has_value()); + EXPECT_DOUBLE_EQ(*ratio, 0.75); +} + +TEST(EnvironmentValueParserTest, RejectsInvalidTypedValues) { + EXPECT_FALSE(TryParseEnvironmentValue("").has_value()); + EXPECT_FALSE( + TryParseEnvironmentValue("not-an-integer").has_value()); + EXPECT_FALSE(TryParseEnvironmentValue("").has_value()); + EXPECT_FALSE(TryParseEnvironmentValue("unknown").has_value()); + EXPECT_FALSE(TryParseEnvironmentValue("").has_value()); + EXPECT_FALSE(TryParseEnvironmentValue("not-a-ratio").has_value()); +} + +TEST(EnvironmentValueParserTest, RequiresFiniteCompleteDoubleValues) { + EXPECT_FALSE(TryParseEnvironmentValue("0.75suffix").has_value()); + EXPECT_FALSE(TryParseEnvironmentValue("nan").has_value()); + + const auto ratio = TryParseEnvironmentValue(" 0.75 "); + ASSERT_TRUE(ratio.has_value()); + EXPECT_DOUBLE_EQ(*ratio, 0.75); +} + +TEST(EnvironmentValueParserTest, SupportsExplicitLenientDoubleParsing) { + const EnvironmentDoubleParseOptions options{ + .allow_trailing_characters = true, + .allow_non_finite = true, + }; + + const auto suffixed = TryParseEnvironmentDouble("0.75suffix", options); + ASSERT_TRUE(suffixed.has_value()); + EXPECT_DOUBLE_EQ(*suffixed, 0.75); + + const auto nan = TryParseEnvironmentDouble("nan", options); + ASSERT_TRUE(nan.has_value()); + EXPECT_TRUE(std::isnan(*nan)); + + EXPECT_FALSE(TryParseEnvironmentDouble(" invalid", options).has_value()); +} + +TEST(EnvironmentValueParserTest, PreservesEmptyStrings) { + const auto empty = TryParseEnvironmentValue(""); + ASSERT_TRUE(empty.has_value()); + EXPECT_TRUE(empty->empty()); +} + +TEST(EnvironmentValueParserTest, PreservesErrno) { + for (const char* value : {"0.75", "not-a-ratio", "1e9999"}) { + errno = EDOM; + static_cast(TryParseEnvironmentValue(value)); + EXPECT_EQ(errno, EDOM); + } +} + +} // namespace mooncake::test diff --git a/mooncake-common/tests/ib_link_speed_test.cpp b/mooncake-common/tests/ib_link_speed_test.cpp new file mode 100644 index 0000000000..774dbcf687 --- /dev/null +++ b/mooncake-common/tests/ib_link_speed_test.cpp @@ -0,0 +1,81 @@ +// Copyright 2025 KVCache.AI +// +// Licensed 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. + +#include "ib_link_speed.h" + +#include + +namespace mooncake { +namespace { + +struct LinkSpeedCase { + int active_speed; + int active_width; + double expected_gbps; +}; + +// active_speed / active_width are the raw ibv_port_attr encodings from +// ibv_query_port(3): speed is the per-lane rate, width is the lane count. +TEST(IbLinkSpeedTest, ConvertsPortAttrEncodingsToGbps) { + const LinkSpeedCase cases[] = { + {1, 1, 2.5}, // SDR x1 + {2, 2, 20.0}, // DDR x4 + {4, 2, 40.0}, // QDR x4 + {8, 2, 40.0}, // FDR10 x4 + {16, 2, 56.0}, // FDR x4 + {32, 2, 100.0}, // EDR x4 (ConnectX-4/5 100G) + {64, 2, 200.0}, // HDR x4 (ConnectX-6 200G) + {128, 2, 400.0}, // NDR x4 (ConnectX-7 400G) + {256, 2, 800.0}, // XDR x4 + {32, 16, 50.0}, // EDR x2 + {64, 1, 50.0}, // HDR x1 + {64, 4, 400.0}, // HDR x8 + {32, 8, 300.0}, // EDR x12 + }; + for (const auto& c : cases) { + EXPECT_DOUBLE_EQ(ibLinkSpeedGbps(c.active_speed, c.active_width), + c.expected_gbps) + << "speed=" << c.active_speed << " width=" << c.active_width; + } +} + +// An encoding the table does not know must not be guessed at: 0 tells the +// caller the speed is unknown so it can fall back explicitly. +// ibv_query_port_speed() (rdma-core >= 62) reports the port's *effective* +// speed in Mb/s: for a VF over LAG that is the bandwidth left +// after a PF drops out, which the encoded link rate cannot express. When +// available it wins; otherwise the encodings decide as before. +TEST(IbLinkSpeedTest, EffectiveSpeedWinsOverEncodedRate) { + // 400G link, but the LAG under this VF is down to one 200G PF. + EXPECT_DOUBLE_EQ(ibPortSpeedGbps(200'000, 128, 2), 200.0); + // Effective speed known, encodings unknown: still usable. + EXPECT_DOUBLE_EQ(ibPortSpeedGbps(100'000, 0, 0), 100.0); +} + +TEST(IbLinkSpeedTest, EncodedRateWhenEffectiveSpeedIsUnavailable) { + // 0 = the library predates the verb or the driver reported nothing. + EXPECT_DOUBLE_EQ(ibPortSpeedGbps(0, 128, 2), 400.0); + EXPECT_DOUBLE_EQ(ibPortSpeedGbps(0, 0, 0), 0.0); +} + +TEST(IbLinkSpeedTest, UnknownEncodingsReportZero) { + EXPECT_DOUBLE_EQ(ibLinkSpeedGbps(0, 2), 0.0); // speed unset + EXPECT_DOUBLE_EQ(ibLinkSpeedGbps(32, 0), 0.0); // width unset + EXPECT_DOUBLE_EQ(ibLinkSpeedGbps(3, 2), 0.0); // not a speed bit + EXPECT_DOUBLE_EQ(ibLinkSpeedGbps(32, 3), 0.0); // not a width bit + EXPECT_DOUBLE_EQ(ibLinkSpeedGbps(-1, 2), 0.0); // classic TE default +} + +} // namespace +} // namespace mooncake diff --git a/mooncake-common/tests/rpc_client_io_context_test.cpp b/mooncake-common/tests/rpc_client_io_context_test.cpp index c3b18c7127..29b69ddc18 100644 --- a/mooncake-common/tests/rpc_client_io_context_test.cpp +++ b/mooncake-common/tests/rpc_client_io_context_test.cpp @@ -51,7 +51,11 @@ TEST(RpcClientIoContextPoolTest, ReplacesPoolWhenTargetChanges) { auto second = pools.GetOrCreateClientPool("127.0.0.1:10002"); EXPECT_NE(first, second); first.reset(); - EXPECT_TRUE(old_pool.expired()); + // Pools live in the process-wide registry by design: a ylt pool owns + // background reconnect coroutines that reference pool storage, so freeing + // one mid-retry is a use-after-free regardless of request draining + // (#3909). Switching address only re-points the holder. + EXPECT_FALSE(old_pool.expired()); EXPECT_EQ(pools.GetClientPool(), second); } @@ -94,6 +98,45 @@ TEST(RpcClientIoContextPoolTest, SendsToNewAddressAfterSwitch) { second_server.stop(); } +TEST(RpcClientIoContextPoolTest, RegistrySharesPoolOnlyForIdenticalConfig) { + const std::string address = "127.0.0.1:59998"; + RpcClientPool first(GetFirstTestRpcClientIoContextPool()); + auto first_pool = first.GetOrCreateClientPool(address); + + // Identical configuration on a second holder shares the pool: that is + // the case the keep-alive registry exists for. + RpcClientPool same(GetFirstTestRpcClientIoContextPool()); + EXPECT_EQ(first_pool.get(), same.GetOrCreateClientPool(address).get()); + + // A different policy on the same address gets its own pool: the + // foreground master pool is resilient while an HA probe on that address + // must fast-fail, and collapsing the two hands the probe the foreground + // retry budget (#3943 meets the HA policy split from #3743). + RpcClientPool::PoolConfig different; + different.max_connection = 7; + different.client_config.connect_timeout_duration = + std::chrono::milliseconds(1234); + different.client_config.request_timeout_duration = + std::chrono::milliseconds(5678); + RpcClientPool second(GetFirstTestRpcClientIoContextPool(), different); + EXPECT_NE(first_pool.get(), second.GetOrCreateClientPool(address).get()); +} + +TEST(RpcClientIoContextPoolTest, KeepClientPoolsAliveRetainsCollection) { + using Pools = coro_io::client_pools; + std::weak_ptr weak; + { + auto pools = std::make_shared( + coro_io::client_pool::pool_config{}, + GetFirstTestRpcClientIoContextPool()); + weak = pools; + detail::KeepClientPoolsAlive(std::move(pools)); + } + // The collection must outlive its owner (#3909/#3943): pools inside host + // reconnect coroutines that reference pool storage past teardown. + EXPECT_FALSE(weak.expired()); +} + } // namespace } // namespace mooncake diff --git a/mooncake-common/tests/rpc_drain_guard_test.cpp b/mooncake-common/tests/rpc_drain_guard_test.cpp new file mode 100644 index 0000000000..3b5445c85c --- /dev/null +++ b/mooncake-common/tests/rpc_drain_guard_test.cpp @@ -0,0 +1,96 @@ +// Tests for RpcDrainGuard: the teardown guard that keeps a shared RPC pool +// from being released under a suspended request coroutine (#3909). + +#include + +#include +#include +#include +#include + +#include "rpc_client_io_context.h" + +namespace mooncake { +namespace { + +TEST(RpcDrainGuardTest, DrainWaitsForInFlightAndStopsNewCalls) { + RpcDrainGuard guard; + + auto slow_call = [&] { + RpcDrainGuard::ScopedCall call(guard); + if (!call.ok()) return false; + std::this_thread::sleep_for(std::chrono::milliseconds(150)); + return true; + }; + + bool call_result = false; + std::thread worker([&] { call_result = slow_call(); }); + // let the worker enter the guard before the drain starts + std::this_thread::sleep_for(std::chrono::milliseconds(20)); + + const auto started = std::chrono::steady_clock::now(); + EXPECT_TRUE(guard.drain_for(std::chrono::seconds(5))); + const auto waited = std::chrono::steady_clock::now() - started; + // the drain actually waited for the in-flight call, not just returned + EXPECT_GE(waited, std::chrono::milliseconds(100)); + worker.join(); + EXPECT_TRUE(call_result); + + // once drained, new calls are refused + EXPECT_FALSE(slow_call()); +} + +TEST(RpcDrainGuardTest, DrainWithNothingInFlightReturnsImmediately) { + RpcDrainGuard guard; + const auto started = std::chrono::steady_clock::now(); + EXPECT_TRUE(guard.drain_for(std::chrono::seconds(5))); + EXPECT_LT(std::chrono::steady_clock::now() - started, + std::chrono::milliseconds(500)); +} + +TEST(RpcDrainGuardTest, DrainTimeoutReportsFalse) { + RpcDrainGuard guard; + std::atomic release{false}; + std::thread worker([&] { + RpcDrainGuard::ScopedCall call(guard); + while (!release.load()) std::this_thread::yield(); + }); + std::this_thread::sleep_for(std::chrono::milliseconds(20)); + + EXPECT_FALSE(guard.drain_for(std::chrono::milliseconds(50))); + + release.store(true); + worker.join(); +} + +// A timed-out drain must leave the counters consistent: once the straggler +// exits, a fresh drain completes. +TEST(RpcDrainGuardTest, TimedOutDrainThenLateLeaveDrainsCleanly) { + RpcDrainGuard guard; + std::atomic release{false}; + std::thread worker([&] { + RpcDrainGuard::ScopedCall call(guard); + while (!release.load()) std::this_thread::yield(); + }); + std::this_thread::sleep_for(std::chrono::milliseconds(20)); + + EXPECT_FALSE(guard.drain_for(std::chrono::milliseconds(50))); + + release.store(true); + worker.join(); + EXPECT_TRUE(guard.drain_for(std::chrono::seconds(1))); +} + +// Teardown after a timed-out drain destroys the guard while a call is still +// in flight; the shared state keeps the late leave() safe. Run this under +// ASAN to give the test its teeth (#3943 review). +TEST(RpcDrainGuardTest, ScopedCallSurvivesGuard) { + auto guard = std::make_unique(); + auto call = std::make_unique(*guard); + ASSERT_TRUE(call->ok()); + guard.reset(); + call.reset(); +} + +} // namespace +} // namespace mooncake diff --git a/mooncake-conductor/CMakeLists.txt b/mooncake-conductor/CMakeLists.txt new file mode 100644 index 0000000000..d870245e7a --- /dev/null +++ b/mooncake-conductor/CMakeLists.txt @@ -0,0 +1,117 @@ +cmake_minimum_required(VERSION 3.16) + +if(NOT GLOBAL_CONFIG) + project(mooncake-conductor CXX) + set(CMAKE_CXX_STANDARD 20) + set(CMAKE_CXX_STANDARD_REQUIRED ON) + include(../mooncake-common/FindJsonCpp.cmake) + include(../mooncake-common/FindGLOG.cmake) + # Match the top-level common.cmake policy: keep debuginfo by default. + if(NOT CMAKE_BUILD_TYPE) + set(CMAKE_BUILD_TYPE "RelWithDebInfo") + endif() +endif() + +find_package(Threads REQUIRED) +find_package(OpenSSL REQUIRED COMPONENTS Crypto) + +# cppzmq and msgpack-cxx are header-only C++ bindings whose packaging varies: +# some distributions ship a CMake package config, while Debian and Ubuntu up to +# 22.04 bundle the headers into libzmq3-dev and libmsgpack-dev with no config at +# all. Prefer the config when present and otherwise locate the headers directly, +# so both layouts configure. cppzmq additionally needs the libzmq C library; +# msgpack-cxx is header-only. +find_package(cppzmq QUIET) +if(TARGET cppzmq) + set(CONDUCTOR_ZMQ_LIBS cppzmq) + message(STATUS "conductor: cppzmq via package config") +else() + find_path(CONDUCTOR_ZMQ_INCLUDE_DIR zmq.hpp) + find_library(CONDUCTOR_ZMQ_LIBRARY NAMES zmq libzmq) + if(NOT CONDUCTOR_ZMQ_INCLUDE_DIR OR NOT CONDUCTOR_ZMQ_LIBRARY) + message( + FATAL_ERROR + "cppzmq headers (zmq.hpp) or libzmq not found; install cppzmq-dev or libzmq3-dev" + ) + endif() + set(CONDUCTOR_ZMQ_LIBS ${CONDUCTOR_ZMQ_LIBRARY}) + set(CONDUCTOR_ZMQ_INCLUDES ${CONDUCTOR_ZMQ_INCLUDE_DIR}) + message( + STATUS "conductor: cppzmq via headers in ${CONDUCTOR_ZMQ_INCLUDE_DIR}") +endif() + +find_package(msgpack-cxx QUIET) +if(TARGET msgpack-cxx) + set(CONDUCTOR_MSGPACK_LIBS msgpack-cxx) + message(STATUS "conductor: msgpack-cxx via package config") +else() + find_path(CONDUCTOR_MSGPACK_INCLUDE_DIR msgpack.hpp) + if(NOT CONDUCTOR_MSGPACK_INCLUDE_DIR) + message( + FATAL_ERROR + "msgpack C++ headers (msgpack.hpp) not found; install libmsgpack-cxx-dev or libmsgpack-dev" + ) + endif() + set(CONDUCTOR_MSGPACK_INCLUDES ${CONDUCTOR_MSGPACK_INCLUDE_DIR}) + message( + STATUS + "conductor: msgpack-cxx via headers in ${CONDUCTOR_MSGPACK_INCLUDE_DIR}") +endif() + +add_library( + conductor_cpp_core STATIC + src/common/utils.cpp src/prefixindex/hash_strategy.cpp + src/prefixindex/prefix_indexer.cpp src/zmq/msg_decoder.cpp + src/zmq/zmq_client.cpp src/kvevent/object_key_parser.cpp) + +# mooncake-common headers are consumed directly instead of linking +# mooncake_common: the helpers conductor needs (ascii_string.h, +# integer_parser.h) are header-only, so this reuses them without taking on that +# target's asio/yaml-cpp/yalantinglibs dependencies. Keeps the standalone build +# resolvable against glog/JsonCpp/Threads alone. +target_include_directories( + conductor_cpp_core + PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/include + ${CMAKE_CURRENT_SOURCE_DIR}/../mooncake-common/include + ${CONDUCTOR_ZMQ_INCLUDES} ${CONDUCTOR_MSGPACK_INCLUDES}) + +target_link_libraries( + conductor_cpp_core + PUBLIC OpenSSL::Crypto ${CONDUCTOR_ZMQ_LIBS} ${CONDUCTOR_MSGPACK_LIBS} + glog::glog JsonCpp::JsonCpp Threads::Threads) + +# The /query hash chain prefers the low-level SHA256_* API, which hashes through +# a stack context instead of allocating an EVP context per call. Those +# declarations are hidden in OpenSSL no-deprecated builds, so probe for them and +# fall back to the EVP backend when unavailable. FIPS deployments should force +# the EVP path explicitly with -DCONDUCTOR_FORCE_EVP_SHA256=ON. +option(CONDUCTOR_FORCE_EVP_SHA256 "Force the EVP SHA-256 backend (e.g. FIPS)" + OFF) +if(NOT CONDUCTOR_FORCE_EVP_SHA256) + include(CheckCXXSourceCompiles) + set(CMAKE_REQUIRED_INCLUDES ${OPENSSL_INCLUDE_DIR}) + set(CMAKE_REQUIRED_LIBRARIES OpenSSL::Crypto) + check_cxx_source_compiles( + " + #define OPENSSL_SUPPRESS_DEPRECATED + #include + int main() { SHA256_CTX c; return SHA256_Init(&c); } + " + CONDUCTOR_HAS_LOWLEVEL_SHA256) + unset(CMAKE_REQUIRED_INCLUDES) + unset(CMAKE_REQUIRED_LIBRARIES) +endif() +if(CONDUCTOR_HAS_LOWLEVEL_SHA256) + target_compile_definitions(conductor_cpp_core + PRIVATE CONDUCTOR_HAS_LOWLEVEL_SHA256=1) + message(STATUS "conductor: SHA-256 backend = low-level SHA256_*") +else() + message( + STATUS + "conductor: SHA-256 backend = EVP (low-level API unavailable or forced)") +endif() + +if(BUILD_UNIT_TESTS) + enable_testing() + add_subdirectory(tests) +endif() diff --git a/mooncake-conductor/include/conductor/common/types.h b/mooncake-conductor/include/conductor/common/types.h new file mode 100644 index 0000000000..40f7f6733b --- /dev/null +++ b/mooncake-conductor/include/conductor/common/types.h @@ -0,0 +1,72 @@ +#pragma once + +#include +#include +#include +#include + +namespace mooncake::conductor::common { + +enum class PublisherKind { kVllm, kMooncake, kSglang }; + +inline constexpr std::string_view PublisherKindName(PublisherKind kind) { + switch (kind) { + case PublisherKind::kVllm: + return "vLLM"; + case PublisherKind::kMooncake: + return "Mooncake"; + case PublisherKind::kSglang: + return "SGLang"; + } + return "unknown"; +} + +inline std::optional ParsePublisherKind(std::string_view value) { + if (value == "vLLM") { + return PublisherKind::kVllm; + } + if (value == "Mooncake") { + return PublisherKind::kMooncake; + } + if (value == "SGLang") { + return PublisherKind::kSglang; + } + return std::nullopt; +} + +struct HashProfileConfig { + std::string strategy; + std::string algorithm; + std::string python_hash_seed; + std::string index_projection; + + bool operator==(const HashProfileConfig&) const = default; +}; + +struct ResolvedHashProfile { + std::string strategy; + std::string algorithm; + std::string python_hash_seed; + std::string root_digest; + std::string index_projection; + + bool operator==(const ResolvedHashProfile&) const = default; +}; + +struct ServiceConfig { + std::string endpoint; // kv publisher endpoint + std::string replay_endpoint; // replay publisher endpoint + PublisherKind publisher_kind = PublisherKind::kVllm; + std::string model_name; // Model name hosted by the service + std::string lora_name; + std::string tenant_id; // (optional), default use 'default' + std::string instance_id; // required + int64_t block_size = 0; + int dp_rank = 0; + std::optional cache_group; + ResolvedHashProfile hash_profile; + + bool operator==(const ServiceConfig&) const = default; +}; + +} // namespace mooncake::conductor::common diff --git a/mooncake-conductor/include/conductor/common/utils.h b/mooncake-conductor/include/conductor/common/utils.h new file mode 100644 index 0000000000..e46a155aef --- /dev/null +++ b/mooncake-conductor/include/conductor/common/utils.h @@ -0,0 +1,23 @@ +#pragma once + +// Conductor-private helpers. + +#include + +namespace mooncake::conductor::common { + +struct LogLevelConfig { + int min_severity; + int verbosity; + + bool operator==(const LogLevelConfig&) const = default; +}; + +LogLevelConfig ParseLogLevel(); + +std::string LoadEnv(const std::string& env_name, + const std::string& default_env); + +int LoadIntEnv(const std::string& env_name, int default_env); + +} // namespace mooncake::conductor::common diff --git a/mooncake-conductor/include/conductor/kvevent/object_key_parser.h b/mooncake-conductor/include/conductor/kvevent/object_key_parser.h new file mode 100644 index 0000000000..8e48610259 --- /dev/null +++ b/mooncake-conductor/include/conductor/kvevent/object_key_parser.h @@ -0,0 +1,32 @@ +#pragma once + +#include +#include + +#include "conductor/prefixindex/types.h" + +namespace mooncake::conductor::kvevent { + +struct ParsedSglangObjectKey { + // The key prefix through the logical hash, excluding physical suffixes + // such as _0_k/_0_v or _0_temporal. + std::string logical_key; + std::string full_hash; + std::string namespace_prefix; + std::string component_suffix; + prefixindex::ProjectedPrefix prefix; +}; + +// Parse a SGLang Mooncake object key. SGLang keys contain a complete +// lower/upper-case SHA-256 digest followed by an optional physical component +// suffix. The projected hash is the first 64 bits of the digest, represented +// as an unsigned bit pattern. +std::string ParseSglangObjectKey(const std::string& object_key, + ParsedSglangObjectKey* result); + +// Compatibility parser for vLLM and vLLM-Ascend connector keys when a +// Mooncake publisher has been configured to forward only the raw key. +std::string ParseVllmObjectKey(const std::string& object_key, + ParsedSglangObjectKey* result); + +} // namespace mooncake::conductor::kvevent diff --git a/mooncake-conductor/include/conductor/prefixindex/hash_strategy.h b/mooncake-conductor/include/conductor/prefixindex/hash_strategy.h new file mode 100644 index 0000000000..d5cb0da217 --- /dev/null +++ b/mooncake-conductor/include/conductor/prefixindex/hash_strategy.h @@ -0,0 +1,77 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#include "conductor/prefixindex/types.h" + +namespace mooncake::conductor::prefixindex { + +struct HashBlock { + std::array digest{}; + ProjectedPrefix projected; + + bool operator==(const HashBlock&) const = default; +}; + +// A lazily-evaluated block-hash chain. Blocks are hashed on demand: asking +// for block i hashes (and caches) every block up to i, so a caller that +// stops early — e.g. a prefix-index walk whose cursors have all stalled — +// never pays for the untouched tail. +class HashChain { + public: + virtual ~HashChain() = default; + + // Number of logical blocks the chain can produce. The SGLang strategies + // include their final partial block; vLLM keeps its complete-block rule. + virtual size_t BlockCount() const = 0; + + // Number of blocks hashed so far (observability/testing hook). + virtual size_t ComputedCount() const = 0; + + // Returns block index, hashing any uncomputed prefix first. Returns + // nullptr and sets error on failure; the error is sticky across calls. + virtual const HashBlock* At(size_t index, std::string* error) = 0; +}; + +class HashStrategy { + public: + virtual ~HashStrategy() = default; + + // Computes hashes for the blocks defined by the selected engine strategy. + // Returns an empty string on success and leaves out empty on failure. + virtual std::string Compute(const ContextKey& context, + std::span token_ids, + std::optional cache_salt, + std::vector* out) const = 0; + + // Creates a lazy hash chain over the same inputs as Compute. The chain + // borrows token_ids; the caller must keep it alive for the chain's + // lifetime. Returns nullptr and sets error when the inputs are invalid. + virtual std::unique_ptr CreateChain( + const ContextKey& context, std::span token_ids, + std::optional cache_salt, std::string* error) const = 0; +}; + +// Resolves a supported source profile and derives its root digest. Returns an +// empty string on success. +std::string ResolveHashProfile(const common::HashProfileConfig& config, + HashProfile* out); + +// Returns an empty string when the resolved profile is supported, well formed, +// and its root digest matches a fresh derivation from python_hash_seed. +std::string ValidateHashProfile(const HashProfile& profile); + +// Returns nullptr and sets error when the resolved profile shape is invalid or +// unsupported. This consumes the derived root without hashing the seed again. +std::unique_ptr CreateHashStrategy(const HashProfile& profile, + std::string* error); + +std::string DigestToHex(const std::array& digest); + +} // namespace mooncake::conductor::prefixindex diff --git a/mooncake-conductor/include/conductor/prefixindex/prefix_indexer.h b/mooncake-conductor/include/conductor/prefixindex/prefix_indexer.h new file mode 100644 index 0000000000..0559d5c396 --- /dev/null +++ b/mooncake-conductor/include/conductor/prefixindex/prefix_indexer.h @@ -0,0 +1,135 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "conductor/prefixindex/types.h" + +namespace mooncake::conductor::prefixindex { + +struct RegistrationResult { + bool inserted = false; + std::string error; +}; + +struct BlockPresence { + std::set gpu_owners; + std::set cpu_owners; + std::set disk_owners; + + bool Empty() const { + return gpu_owners.empty() && cpu_owners.empty() && disk_owners.empty(); + } +}; + +// Maximum number of prefixes tracked for each context. +constexpr size_t kDefaultMaxBlocks = 200000; +// Target occupancy ratio for a batched capacity eviction. +constexpr double kEvictTargetRatio = 0.9; + +struct ContextState { + explicit ContextState(HashProfile registered_profile, + size_t block_limit = kDefaultMaxBlocks) + : profile(std::move(registered_profile)), max_blocks(block_limit) {} + + // Lock order is global context-map mutex, then this mutex. Code holding + // this mutex must never reacquire the global mutex. + mutable std::shared_mutex mutex; + const HashProfile profile; + std::map> instance_ranks; + std::unordered_map blocks; + + // Store order: newest prefix at the front, oldest at the back. Query does + // not update this order, so read-only queries can keep a shared lock. + std::list write_order; + std::unordered_map::iterator> + order_pos; + const size_t max_blocks; + // Cumulative number of entries removed by capacity eviction. + int64_t evicted_by_capacity = 0; +}; + +struct RankCacheHitResult { + int64_t gpu = 0; + int64_t cpu = 0; + int64_t disk = 0; + + bool operator==(const RankCacheHitResult&) const = default; +}; + +struct CacheHitResult { + int64_t longest_match_tokens = 0; + std::map dp; + std::map rank_matches; + int64_t gpu = 0; + int64_t cpu = 0; + int64_t disk = 0; +}; + +struct ContextView { + ContextKey context; + HashProfile profile; + std::map> instance_ranks; + size_t prefix_count = 0; +}; + +struct GlobalView { + int32_t context_count = 0; + std::vector contexts; +}; + +class PrefixCacheTable { + public: + PrefixCacheTable() = default; + // Per-context block limit; zero disables capacity eviction. + explicit PrefixCacheTable(size_t block_limit) : block_limit_(block_limit) {} + PrefixCacheTable(const PrefixCacheTable&) = delete; + PrefixCacheTable& operator=(const PrefixCacheTable&) = delete; + + static RegistrationResult ValidateRegistration( + const EngineRegistration& registration); + + RegistrationResult Register(const EngineRegistration& registration); + std::string ValidateProfileBinding(const ContextKey& context, + const HashProfile& profile) const; + std::string Unregister(const ContextKey& context, + const std::string& instance_id, int64_t dp_rank); + + std::string StoreGpu(const GpuMutation& mutation); + std::string RemoveGpu(const GpuMutation& mutation); + std::string ClearGpu(const GpuClear& clear); + + std::string StoreShared(const SharedMutation& mutation); + std::string RemoveShared(const SharedMutation& mutation); + std::string ClearShared(const SharedClear& clear); + + std::map Query( + const ContextKey& context, std::span token_ids, + std::optional cache_salt = std::nullopt, + std::optional instance_filter = std::nullopt) const; + + GlobalView GetGlobalView() const; + + private: + friend class PrefixCacheTableTestPeer; + + std::shared_ptr LoadContextState( + const ContextKey& context) const; + + mutable std::shared_mutex context_map_mutex_; + std::unordered_map> contexts_; + const size_t block_limit_ = kDefaultMaxBlocks; +}; + +} // namespace mooncake::conductor::prefixindex diff --git a/mooncake-conductor/include/conductor/prefixindex/types.h b/mooncake-conductor/include/conductor/prefixindex/types.h new file mode 100644 index 0000000000..589599848d --- /dev/null +++ b/mooncake-conductor/include/conductor/prefixindex/types.h @@ -0,0 +1,114 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include "conductor/common/types.h" + +namespace mooncake::conductor::prefixindex { + +struct ContextKey { + std::string tenant_id; + std::string model_name; + std::string lora_name; + int64_t block_size = 0; + + bool operator==(const ContextKey&) const = default; +}; + +using HashProfile = common::ResolvedHashProfile; + +struct ProjectedPrefix { + uint64_t value = 0; + + auto operator<=>(const ProjectedPrefix&) const = default; +}; + +enum class StorageTier { kGpu, kCpu, kDisk }; + +struct EngineOwner { + std::string source_stream; + std::string instance_id; + int64_t dp_rank = 0; + + auto operator<=>(const EngineOwner&) const = default; +}; + +struct SharedObjectOwner { + std::string source_stream; + std::string backend_id; + std::string object_id; + + auto operator<=>(const SharedObjectOwner&) const = default; +}; + +struct EngineRegistration { + ContextKey context; + HashProfile profile; + std::string instance_id; + int64_t dp_rank = 0; + int64_t effective_block_size = 0; + std::optional cache_group; +}; + +struct GpuMutation { + ContextKey context; + std::vector prefixes; + EngineOwner owner; + int64_t effective_block_size = 0; + std::optional cache_group; +}; + +struct SharedMutation { + ContextKey context; + std::vector prefixes; + StorageTier tier = StorageTier::kCpu; + SharedObjectOwner owner; + int64_t effective_block_size = 0; + std::optional cache_group; +}; + +struct GpuClear { + ContextKey context; + EngineOwner owner; + int64_t effective_block_size = 0; + std::optional cache_group; +}; + +struct SharedClear { + ContextKey context; + SharedObjectOwner owner; + std::optional tier; + int64_t effective_block_size = 0; + std::optional cache_group; +}; + +} // namespace mooncake::conductor::prefixindex + +template <> +struct std::hash { + size_t operator()(const mooncake::conductor::prefixindex::ContextKey& + context) const noexcept { + size_t seed = 0; + auto combine = [&seed](size_t value) { + seed ^= value + 0x9e3779b97f4a7c15ULL + (seed << 6) + (seed >> 2); + }; + combine(std::hash{}(context.tenant_id)); + combine(std::hash{}(context.model_name)); + combine(std::hash{}(context.lora_name)); + combine(std::hash{}(context.block_size)); + return seed; + } +}; + +template <> +struct std::hash { + size_t operator()(mooncake::conductor::prefixindex::ProjectedPrefix prefix) + const noexcept { + return std::hash{}(prefix.value); + } +}; diff --git a/mooncake-conductor/include/conductor/zmq/event_type.h b/mooncake-conductor/include/conductor/zmq/event_type.h new file mode 100644 index 0000000000..7118205c14 --- /dev/null +++ b/mooncake-conductor/include/conductor/zmq/event_type.h @@ -0,0 +1,147 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include "conductor/common/types.h" + +namespace mooncake::conductor::zmq { + +using ExternalHash = std::variant>; + +struct VllmStoredEvent { + std::vector block_hashes; + std::optional parent_block_hash; + std::optional> token_ids; + int64_t block_size = 0; + std::optional lora_id; + std::optional medium; + std::optional lora_name; + bool extra_keys_present = false; + std::optional group_idx; + std::optional kv_cache_spec_kind; + std::optional kv_cache_spec_sliding_window; +}; + +struct VllmRemovedEvent { + std::vector block_hashes; + std::optional medium; + std::optional group_idx; +}; + +struct VllmClearedEvent {}; + +using VllmEvent = + std::variant; + +// SGLang's native KV-event protocol is msgspec array-like/tagged data. Keep +// its signed wire hashes as uint64 bit patterns internally so all index paths +// use one unsigned representation. +struct SglangStoredEvent { + std::vector block_hashes; + std::optional parent_block_hash; + std::optional> token_ids; + int64_t block_size = 0; + std::optional lora_id; + std::optional medium; + std::optional cache_salt; +}; + +struct SglangRemovedEvent { + std::vector block_hashes; + std::optional medium; +}; + +struct SglangClearedEvent {}; + +using SglangEvent = + std::variant; + +struct MooncakeEventFields { + uint64_t event_id = 0; + int64_t timestamp_milliseconds = 0; + std::optional model_name; + std::optional block_size; + std::optional additional_salt; + std::optional lora_name; + std::string tenant_id; + std::string backend_id; + std::optional medium; + int64_t data_parallel_rank = 0; +}; + +struct MooncakeObjectFields { + std::optional group_id; + std::optional object_key; + std::optional connector_block_hash; + std::optional cache_prefix; + std::optional tp_rank; + std::optional head_or_tp_rank; + std::optional pcp_rank; + std::optional dcp_rank; + std::optional pp_rank; + std::optional layer_id; + std::vector seq_hashes; + std::optional> legacy_block_hashes; + std::optional base_block_idx; +}; + +struct MooncakeStoredEvent { + MooncakeEventFields fields; + MooncakeObjectFields object; + std::optional parent_hash; + std::optional> token_ids; +}; + +struct MooncakeRemovedEvent { + MooncakeEventFields fields; + MooncakeObjectFields object; +}; + +struct MooncakeClearedEvent { + MooncakeEventFields fields; +}; + +using MooncakeEvent = std::variant; + +template +struct DecodedEvent { + std::optional event; + std::string error; + + bool ok() const { return event.has_value(); } +}; + +struct VllmEventBatch { + double timestamp_seconds = 0; + std::vector> events; + std::optional data_parallel_rank; +}; + +struct MooncakeEventBatch { + int64_t timestamp_milliseconds = 0; + std::vector> events; + std::optional data_parallel_rank; +}; + +struct SglangEventBatch { + double timestamp_seconds = 0; + std::vector> events; + std::optional data_parallel_rank; +}; + +using DecodedBatch = + std::variant; + +struct MessageMetadata { + common::PublisherKind publisher_kind = common::PublisherKind::kVllm; + std::string endpoint; + std::string topic; + int64_t sequence = -1; +}; + +} // namespace mooncake::conductor::zmq diff --git a/mooncake-conductor/include/conductor/zmq/msg_decoder.h b/mooncake-conductor/include/conductor/zmq/msg_decoder.h new file mode 100644 index 0000000000..26d128a8dd --- /dev/null +++ b/mooncake-conductor/include/conductor/zmq/msg_decoder.h @@ -0,0 +1,29 @@ +#pragma once + +// Strict envelope and recognized-field decoding for the independent vLLM and +// Mooncake map protocols. Event-local failures are materialized in the batch +// so valid siblings can still be dispatched in source order. + +#include +#include + +#include "conductor/zmq/event_type.h" + +namespace mooncake::conductor::zmq { + +template +struct BatchDecodeResult { + bool ok = false; + Batch batch; + std::string error; +}; + +using VllmEventBatchResult = BatchDecodeResult; +using MooncakeEventBatchResult = BatchDecodeResult; +using SglangEventBatchResult = BatchDecodeResult; + +VllmEventBatchResult DecodeVllmEventBatch(const char* data, size_t len); +MooncakeEventBatchResult DecodeMooncakeEventBatch(const char* data, size_t len); +SglangEventBatchResult DecodeSglangEventBatch(const char* data, size_t len); + +} // namespace mooncake::conductor::zmq diff --git a/mooncake-conductor/include/conductor/zmq/zmq_client.h b/mooncake-conductor/include/conductor/zmq/zmq_client.h new file mode 100644 index 0000000000..4538dce960 --- /dev/null +++ b/mooncake-conductor/include/conductor/zmq/zmq_client.h @@ -0,0 +1,168 @@ +#pragma once + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "conductor/zmq/event_type.h" + +namespace mooncake::conductor::zmq { + +class ZMQClientTestPeer; + +// EventHandler processes received KV events. +class EventHandler { + public: + virtual ~EventHandler() = default; + // Returns empty string on success, error message otherwise. + virtual std::string HandleBatch(const DecodedBatch& batch, + const MessageMetadata& metadata) = 0; + + // Called once when this source can no longer be recovered without risking + // out-of-order mutations. Implementations that maintain an index should + // stop serving or invalidate entries attributed to this source until a + // full resynchronization has completed. + virtual void OnSourceStale(const std::string& cache_pool_key, + const MessageMetadata& metadata, + const std::string& reason) = 0; +}; + +struct ZMQClientConfig { + std::string cache_pool_key; + std::string endpoint; + std::string replay_endpoint; + std::string model_name; + common::PublisherKind publisher_kind = common::PublisherKind::kVllm; + std::chrono::milliseconds poll_timeout{100}; + std::chrono::milliseconds replay_timeout{5000}; + // Total time allowed to close a sequence gap, including retries. + std::chrono::milliseconds replay_recovery_timeout{30000}; + std::chrono::milliseconds reconnect_delay{1000}; + // Application-level recovery buffer limits. These bound live messages held + // behind a gap as well as replay records waiting to be merged with them. + size_t max_recovery_buffered_messages = 10000; + size_t max_recovery_buffered_bytes = 64 * 1024 * 1024; + // SUB receive high-water mark. Zero leaves the ZeroMQ default unchanged. + int rcv_hwm = 200000; +}; + +// Returns empty string when valid, error message otherwise. +std::string ValidateConfig(const ZMQClientConfig& config); + +class ZMQClient { + public: + ZMQClient(ZMQClientConfig config, std::shared_ptr handler); + ~ZMQClient(); + ZMQClient(const ZMQClient&) = delete; + ZMQClient& operator=(const ZMQClient&) = delete; + + // Establishes the SUB and DEALER sockets. Returns empty string on + // success. Safe to call when already connected (no-op). + std::string Connect(); + + // Connects and starts the background event loop thread. Returns + // empty string on success. + std::string Start(); + + // Stops the event loop (stop flag + join) and closes all sockets. + // Idempotent — stop, wait for the loop to join, then clean up; safe + // to invoke repeatedly. + void Stop(); + + int64_t GetLastSequence() const; + bool IsStale() const; + std::string GetStaleReason() const; + + // Cumulative counts inferred from transport sequence gaps. + int64_t GetDroppedEvents() const { return dropped_events_.load(); } + int64_t GetGapCount() const { return gap_count_.load(); } + + private: + friend class ZMQClientTestPeer; + + void Loop(); + void HandleReconnect(); + bool IsConnected() const; + void MarkDisconnected(); + // The following require holding mu_ (exclusive): + void CleanupSocketsLocked(); + + std::string Consume(); + std::string ProcessMessage(); + + struct BufferedMessage { + std::string topic; + int64_t sequence; + std::string payload; + }; + + enum class ReplayFailure { + kNone, + kRetryable, + kUnrecoverable, + }; + + struct ReplayResult { + std::vector messages; + std::string error; + ReplayFailure failure = ReplayFailure::kNone; + + bool ok() const { return failure == ReplayFailure::kNone; } + }; + + ReplayResult RequestReplay( + int64_t from_seq, std::optional until_seq, + std::chrono::steady_clock::time_point recovery_deadline); + std::string ResetReplaySocket(); + std::string DispatchMessage(const std::string& topic, int64_t sequence, + const char* payload, size_t payload_size); + void UpdateLastSequence(int64_t sequence); + bool BufferMessage(BufferedMessage message, std::string* error); + std::string DrainBufferedMessages(bool allow_initial_baseline = false); + void StartRecovery(const std::string& reason); + std::string AttemptRecovery(); + void MarkStale(const std::string& reason); + + ZMQClientConfig config_; + std::shared_ptr event_handler_; + + ::zmq::context_t zmq_context_{1}; + std::unique_ptr<::zmq::socket_t> sub_socket_; + std::unique_ptr<::zmq::socket_t> replay_socket_; + + // State management. + mutable std::shared_mutex mu_; + bool connected_ = false; + // Highest sequence applied contiguously. It must never advance across a + // gap; buffered_messages_ holds live/replay records beyond such a gap. + int64_t last_seq_ = -1; + int64_t last_live_seq_ = -1; + std::map buffered_messages_; + size_t buffered_message_bytes_ = 0; + bool recovery_in_progress_ = false; + std::chrono::steady_clock::time_point recovery_deadline_{}; + bool stale_ = false; + std::string stale_reason_; + std::chrono::milliseconds reconnect_delay_; + // Counters updated when a transport sequence skips one or more events. + std::atomic dropped_events_{0}; + std::atomic gap_count_{0}; + + // Lifecycle. + std::atomic stop_requested_{false}; + std::thread loop_thread_; + std::mutex stop_mu_; // serialises concurrent Stop() calls +}; + +} // namespace mooncake::conductor::zmq diff --git a/mooncake-conductor/src/common/utils.cpp b/mooncake-conductor/src/common/utils.cpp new file mode 100644 index 0000000000..2b9dc0b0c6 --- /dev/null +++ b/mooncake-conductor/src/common/utils.cpp @@ -0,0 +1,63 @@ +#include "conductor/common/utils.h" + +#include + +#include +#include + +#include "ascii_string.h" +#include "integer_parser.h" + +namespace mooncake::conductor::common { + +LogLevelConfig ParseLogLevel() { + constexpr LogLevelConfig kInfo{google::GLOG_INFO, 0}; + + const char* level_env = std::getenv("CONDUCTOR_LOG_LEVEL"); + const std::string level_str = level_env ? level_env : ""; + if (level_str.empty()) { + return kInfo; + } + + const std::string lower = AsciiToLower(level_str); + if (lower == "debug") return {google::GLOG_INFO, 1}; + if (lower == "info") return kInfo; + if (lower == "warn") return {google::GLOG_WARNING, 0}; + if (lower == "error") return {google::GLOG_ERROR, 0}; + + LOG(WARNING) << "Invalid log level specified, defaulting to INFO" + << " level=" << level_str; + return kInfo; +} + +std::string LoadEnv(const std::string& env_name, + const std::string& default_env) { + const char* value = std::getenv(env_name.c_str()); + if (value == nullptr || value[0] == '\0') { + LOG(WARNING) << "environment variable is not set, using default value" + << " envName=" << env_name + << " defaultValue=" << default_env; + return default_env; + } + return value; +} + +int LoadIntEnv(const std::string& env_name, int default_env) { + const char* raw = std::getenv(env_name.c_str()); + const std::string value = raw ? raw : ""; + if (!value.empty()) { + // Strict on purpose: no surrounding whitespace, no trailing garbage. + const auto parsed = + TryParseInteger(value, {.allow_leading_plus = true}); + if (parsed.has_value()) { + return *parsed; + } + LOG(ERROR) << "invalid value for environment variable" + << " envName=" << env_name << " value=" << value; + } + LOG(WARNING) << "environment variable is not set, using default value" + << " envName=" << env_name << " defaultValue=" << default_env; + return default_env; +} + +} // namespace mooncake::conductor::common diff --git a/mooncake-conductor/src/kvevent/object_key_parser.cpp b/mooncake-conductor/src/kvevent/object_key_parser.cpp new file mode 100644 index 0000000000..a800692a22 --- /dev/null +++ b/mooncake-conductor/src/kvevent/object_key_parser.cpp @@ -0,0 +1,278 @@ +#include "conductor/kvevent/object_key_parser.h" + +#include +#include +#include + +#include "ascii_string.h" +#include "integer_parser.h" + +namespace mooncake::conductor::kvevent { +namespace { + +bool IsHex(char value) { + return (value >= '0' && value <= '9') || (value >= 'a' && value <= 'f') || + (value >= 'A' && value <= 'F'); +} + +std::vector SplitAt(std::string_view value, char separator) { + std::vector parts; + size_t begin = 0; + while (begin <= value.size()) { + const size_t end = value.find(separator, begin); + if (end == std::string_view::npos) { + parts.emplace_back(value.substr(begin)); + break; + } + parts.emplace_back(value.substr(begin, end - begin)); + begin = end + 1; + } + return parts; +} + +std::string JoinParts(const std::vector& parts, size_t begin, + size_t end) { + std::string result; + for (size_t index = begin; index < end; ++index) { + if (!result.empty()) result.push_back('@'); + result.append(parts[index]); + } + return result; +} + +bool HasLabel(std::string_view value, std::string_view label) { + return value.starts_with(label) && value.size() > label.size(); +} + +bool IsNonNegativeDecimal(std::string_view value) { + // Unsigned target, so a leading sign is rejected; the default options + // reject surrounding whitespace and require the whole view to be consumed. + return TryParseInteger(value).has_value(); +} + +bool HasDecimalLabel(std::string_view value, std::string_view label) { + return value.starts_with(label) && + IsNonNegativeDecimal(value.substr(label.size())); +} + +bool IsHashText(std::string_view value) { + if (value.size() < 16 || value.size() % 2 != 0) return false; + for (char character : value) { + if (!IsHex(character)) return false; + } + return true; +} + +std::string_view StripOptionalHexPrefix(std::string_view value) { + if (value.starts_with("0x") || value.starts_with("0X")) { + value.remove_prefix(2); + } + return value; +} + +bool IsVllmHashText(std::string_view value) { + return IsHashText(StripOptionalHexPrefix(value)); +} + +std::string FillProjectedHash(std::string_view hash, + ParsedSglangObjectKey* result, bool first_half) { + hash = StripOptionalHexPrefix(hash); + if (!IsHashText(hash)) return "object key hash is not valid hex"; + // IsHashText has restricted the input to hex digits, where ASCII lowering + // only touches A-F. + result->full_hash = AsciiToLower(hash); + result->prefix.value = 0; + const size_t offset = first_half ? 0 : result->full_hash.size() - 16; + for (size_t index = offset; index < offset + 16; ++index) { + const char character = result->full_hash[index]; + const uint64_t nibble = + character <= '9' ? static_cast(character - '0') + : static_cast(character - 'a' + 10); + result->prefix.value = (result->prefix.value << 4) | nibble; + } + return ""; +} + +} // namespace + +std::string ParseSglangObjectKey(const std::string& object_key, + ParsedSglangObjectKey* result) { + if (result == nullptr) { + return "result is null"; + } + *result = {}; + constexpr size_t kSha256HexLength = 64; + size_t hash_start = std::string::npos; + for (size_t start = 0; start + kSha256HexLength <= object_key.size(); + ++start) { + if (start != 0 && IsHex(object_key[start - 1])) { + continue; + } + bool valid = true; + for (size_t index = 0; index < kSha256HexLength; ++index) { + if (!IsHex(object_key[start + index])) { + valid = false; + break; + } + } + if (!valid || (start + kSha256HexLength < object_key.size() && + IsHex(object_key[start + kSha256HexLength]))) { + continue; + } + hash_start = start; + break; + } + if (hash_start == std::string::npos) { + return "SGLang object_key does not contain an isolated 64-hex hash"; + } + + const size_t hash_end = hash_start + kSha256HexLength; + if (hash_start > 0 && object_key[hash_start - 1] != '_') { + return "SGLang hash must be separated from its namespace by '_'"; + } + if (hash_end < object_key.size() && object_key[hash_end] != '_') { + return "SGLang hash must be followed by a component suffix"; + } + + // The scan above accepted only hex digits, so lowering touches A-F alone. + result->full_hash = AsciiToLower( + std::string_view(object_key).substr(hash_start, kSha256HexLength)); + result->namespace_prefix = object_key.substr(0, hash_start); + if (!result->namespace_prefix.empty() && + result->namespace_prefix.back() == '_') { + result->namespace_prefix.pop_back(); + } + result->logical_key = object_key.substr(0, hash_end); + result->component_suffix = object_key.substr(hash_end); + + // Base 16, so std::from_chars is used directly: TryParseInteger is + // decimal-only and would also reject the trailing hex digits left unread. + uint64_t projected = 0; + const auto [end, error] = std::from_chars( + result->full_hash.data(), result->full_hash.data() + 16, projected, 16); + if (error != std::errc{} || end != result->full_hash.data() + 16) { + return "failed to project SGLang hash"; + } + result->prefix.value = projected; + return ""; +} + +std::string ParseVllmObjectKey(const std::string& object_key, + ParsedSglangObjectKey* result) { + if (result == nullptr) return "result is null"; + *result = {}; + const auto parts = SplitAt(object_key, '@'); + + // Current vLLM-Ascend layerwise GVA keys use a compact layout without + // field labels: model@hash@head_or_tp_rank, or + // model@group_id@hash@head_or_tp_rank for multi-group models. + // Handle this before the labelled layouts, which have six or more parts. + if (parts.size() == 3 || parts.size() == 4) { + const size_t hash_index = parts.size() == 3 ? 1 : 2; + const size_t rank_index = parts.size() - 1; + const bool valid_group = + parts.size() == 3 || IsNonNegativeDecimal(parts[1]); + if (!parts[0].empty() && valid_group && + IsVllmHashText(parts[hash_index]) && + IsNonNegativeDecimal(parts[rank_index])) { + result->logical_key = object_key; + result->namespace_prefix = parts.size() == 3 + ? std::string(parts[0]) + : JoinParts(parts, 0, hash_index); + return FillProjectedHash(parts[hash_index], result, false); + } + } + + if (parts.size() < 6) { + return "vLLM object_key has too few fields"; + } + + // vLLM keys put the connector hash last. Newer connectors may append + // cache namespace metadata (group/cache_role/cache_family) before it; + // consume those labels when present while retaining the older layout. + for (size_t index = 1; index < parts.size(); ++index) { + if (!HasDecimalLabel(parts[index], "tp_rank:")) { + continue; + } + const size_t hash_index = parts.size() - 1; + if (index + 3 >= hash_index || + !HasDecimalLabel(parts[index + 1], "pcp") || + !HasDecimalLabel(parts[index + 2], "dcp") || + !HasDecimalLabel(parts[index + 3], "pp_rank:")) { + continue; + } + size_t cursor = index + 4; + if (cursor < hash_index && HasDecimalLabel(parts[cursor], "group:")) { + ++cursor; + } + if (cursor < hash_index && HasLabel(parts[cursor], "cache_role:")) { + ++cursor; + } + if (cursor < hash_index && HasLabel(parts[cursor], "cache_family:")) { + ++cursor; + } + if (cursor != hash_index || !IsVllmHashText(parts[hash_index])) { + continue; + } + result->logical_key = object_key; + result->namespace_prefix = + index > 1 ? JoinParts(parts, 0, index - 1) : ""; + return FillProjectedHash(parts[hash_index], result, false); + } + + // vLLM-Ascend keys have pcp/dcp/head_or_tp_rank, followed by optional + // pp/group/cache metadata. Layerwise keys either use the current + // `layer_id:N@hash` form or the historical `hash@N` form. + for (size_t index = 1; index < parts.size(); ++index) { + if (!HasDecimalLabel(parts[index], "pcp") || + index + 2 >= parts.size() || + !HasDecimalLabel(parts[index + 1], "dcp") || + !HasDecimalLabel(parts[index + 2], "head_or_tp_rank:")) { + continue; + } + const size_t final_part = parts.size() - 1; + auto matches_layout = [&](size_t hash_index) { + if (hash_index <= index + 2 || !IsVllmHashText(parts[hash_index])) { + return false; + } + size_t cursor = index + 3; + if (cursor < hash_index && + HasDecimalLabel(parts[cursor], "pp_rank:")) { + ++cursor; + } + if (cursor < hash_index && + HasDecimalLabel(parts[cursor], "group:")) { + ++cursor; + } + if (cursor < hash_index && HasLabel(parts[cursor], "cache_role:")) { + ++cursor; + } + if (cursor < hash_index && + HasLabel(parts[cursor], "cache_family:")) { + ++cursor; + } + if (cursor < hash_index && + HasDecimalLabel(parts[cursor], "layer_id:")) { + ++cursor; + } + return cursor == hash_index; + }; + + size_t hash_index = final_part; + if (!matches_layout(hash_index) && final_part > index + 3 && + IsNonNegativeDecimal(parts[final_part]) && + matches_layout(final_part - 1)) { + hash_index = final_part - 1; + } + if (hash_index == final_part && !matches_layout(hash_index)) { + continue; + } + result->logical_key = object_key; + result->namespace_prefix = std::string(parts[0]); + return FillProjectedHash(parts[hash_index], result, false); + } + + return "unrecognized vLLM/vLLM-Ascend object_key"; +} + +} // namespace mooncake::conductor::kvevent diff --git a/mooncake-conductor/src/prefixindex/hash_strategy.cpp b/mooncake-conductor/src/prefixindex/hash_strategy.cpp new file mode 100644 index 0000000000..ebdb5e3c38 --- /dev/null +++ b/mooncake-conductor/src/prefixindex/hash_strategy.cpp @@ -0,0 +1,1151 @@ +#include "conductor/prefixindex/hash_strategy.h" + +// Two SHA-256 backends, selected by CONDUCTOR_HAS_LOWLEVEL_SHA256 (probed by +// CMake). The low-level SHA256_* API hashes through a stack context, so it +// skips the per-call EVP context allocation and provider lookup; its +// declarations are hidden in OpenSSL no-deprecated builds and it bypasses the +// provider framework FIPS requires, hence the EVP fallback. Both backends +// dispatch to the same OpenSSL implementation and produce byte-identical +// digests. +#if CONDUCTOR_HAS_LOWLEVEL_SHA256 +#define OPENSSL_SUPPRESS_DEPRECATED +#include +#else +#include +#endif + +#include +#include +#include +#include +#include + +#include "integer_parser.h" + +namespace mooncake::conductor::prefixindex { + +namespace { + +constexpr size_t kSha256DigestSize = 32; +constexpr uint64_t kMaxPythonHashSeed = std::numeric_limits::max(); + +// --------------------------------------------------------------------------- +// Recipe boundary +// +// The vLLM v1 chain (complete-block selection, full-32-byte parent +// advancement, SHA-256 digesting, and the low64_be projection) is shared by +// every supported algorithm. Each algorithm name selects exactly one value +// codec that owns serialization of the seed root and of each +// (parent, token tuple, extra keys) block value. The codec receives the +// already-computed extra-key ordering (non-empty LoRA on every block, +// non-empty cache salt after LoRA only on the first block) and never sees +// value shapes outside the Conductor query contract. +// +// Multimodal and prompt-embedding vLLM extra keys are deliberately NOT +// representable here: the Conductor query API cannot express them, so the +// codecs reject that shape by construction instead of approximating it. +// --------------------------------------------------------------------------- + +struct VllmBlockValues { + std::span parent_digest; // full 32-byte parent digest + std::span token_ids; // exactly one complete block + const std::string* lora_name; // nullptr when no LoRA extra key + const std::string* cache_salt; // nullptr when no salt extra key +}; + +class VllmValueCodec { + public: + virtual ~VllmValueCodec() = default; + + virtual void EncodeSeed(std::string_view seed, + std::vector* out) const = 0; + virtual void EncodeBlock(const VllmBlockValues& values, + std::vector* out) const = 0; +}; + +// --------------------------------------------------------------------------- +// Canonical-CBOR codec (sha256_cbor) +// --------------------------------------------------------------------------- + +void AppendTypeAndLength(uint8_t major_type, uint64_t value, + std::vector* out) { + const uint8_t initial = static_cast(major_type << 5); + if (value < 24) { + out->push_back(static_cast(initial | value)); + return; + } + if (value <= std::numeric_limits::max()) { + out->push_back(static_cast(initial | 24)); + out->push_back(static_cast(value)); + return; + } + if (value <= std::numeric_limits::max()) { + out->push_back(static_cast(initial | 25)); + for (int shift = 8; shift >= 0; shift -= 8) { + out->push_back(static_cast(value >> shift)); + } + return; + } + if (value <= std::numeric_limits::max()) { + out->push_back(static_cast(initial | 26)); + for (int shift = 24; shift >= 0; shift -= 8) { + out->push_back(static_cast(value >> shift)); + } + return; + } + + out->push_back(static_cast(initial | 27)); + for (int shift = 56; shift >= 0; shift -= 8) { + out->push_back(static_cast(value >> shift)); + } +} + +void AppendArrayHeader(size_t size, std::vector* out) { + AppendTypeAndLength(4, static_cast(size), out); +} + +void AppendBytes(std::span value, std::vector* out) { + AppendTypeAndLength(2, static_cast(value.size()), out); + out->insert(out->end(), value.begin(), value.end()); +} + +void AppendText(std::string_view value, std::vector* out) { + AppendTypeAndLength(3, static_cast(value.size()), out); + out->insert(out->end(), value.begin(), value.end()); +} + +void AppendSignedInteger(int32_t value, std::vector* out) { + if (value >= 0) { + AppendTypeAndLength(0, static_cast(value), out); + return; + } + const int64_t signed_value = value; + AppendTypeAndLength(1, static_cast(-1 - signed_value), out); +} + +class CborVllmCodec final : public VllmValueCodec { + public: + void EncodeSeed(std::string_view seed, + std::vector* out) const override { + out->clear(); + AppendText(seed, out); + } + + void EncodeBlock(const VllmBlockValues& values, + std::vector* out) const override { + out->clear(); + AppendArrayHeader(3, out); + AppendBytes(values.parent_digest, out); + + AppendArrayHeader(values.token_ids.size(), out); + for (const int32_t token : values.token_ids) { + AppendSignedInteger(token, out); + } + + const bool has_lora = values.lora_name != nullptr; + const bool has_salt = values.cache_salt != nullptr; + if (!has_lora && !has_salt) { + out->push_back(0xf6U); + } else { + AppendArrayHeader( + static_cast(has_lora) + static_cast(has_salt), + out); + if (has_lora) { + AppendText(*values.lora_name, out); + } + if (has_salt) { + AppendText(*values.cache_salt, out); + } + } + } +}; + +// --------------------------------------------------------------------------- +// CPython Pickle protocol-5 codec (sha256) +// +// Restricted encoder for the value types the Conductor query contract can +// express: UTF-8 seed strings, full parent bytes, signed int32 token IDs, +// None, tuple containers, and LoRA/cache-salt strings. It reproduces the +// CPython pickler byte-for-byte for those shapes: +// * \x80\x05 protocol header and protocol-4+ framing (frames committed at +// the start of every object save once the pending frame reaches 64 KiB, +// and one final forced frame before/around STOP); +// * SHORT_BINUNICODE/BINUNICODE/BINUNICODE8 and SHORT_BINBYTES/BINBYTES/ +// BINBYTES8 length thresholds; +// * BININT1/BININT2/BININT integer thresholds; +// * EMPTY_TUPLE/TUPLE1/TUPLE2/TUPLE3/MARK+TUPLE arity opcodes; +// * MEMOIZE markers after every non-empty bytes/str/tuple object; +// * the \x2e STOP terminator inside the final frame. +// +// Values are always emitted fresh (no BINGET back-references). That matches +// CPython whenever the memoized objects are distinct, which is the only case +// the Conductor contract can produce; Python object-identity aliasing between +// equal strings is an explicitly unsupported shape, as are multimodal and +// prompt-embedding extra-key object graphs. +// --------------------------------------------------------------------------- + +constexpr uint8_t kPickleMark = 0x28; // MARK +constexpr uint8_t kPickleStop = 0x2e; // STOP +constexpr uint8_t kPickleEmptyTuple = 0x29; // EMPTY_TUPLE +constexpr uint8_t kPickleBinbytes = 0x42; // BINBYTES +constexpr uint8_t kPickleShortBinbytes = 0x43; // SHORT_BINBYTES +constexpr uint8_t kPickleBinint = 0x4a; // BININT +constexpr uint8_t kPickleBinint1 = 0x4b; // BININT1 +constexpr uint8_t kPickleBinint2 = 0x4d; // BININT2 +constexpr uint8_t kPickleNone = 0x4e; // NONE +constexpr uint8_t kPickleBinunicode = 0x58; // BINUNICODE +constexpr uint8_t kPickleTuple = 0x74; // TUPLE +constexpr uint8_t kPickleProto = 0x80; // PROTO +constexpr uint8_t kPickleTuple1 = 0x85; // TUPLE1 +constexpr uint8_t kPickleTuple2 = 0x86; // TUPLE2 +constexpr uint8_t kPickleTuple3 = 0x87; // TUPLE3 +constexpr uint8_t kPickleShortBinunicode = 0x8c; // SHORT_BINUNICODE +constexpr uint8_t kPickleBinunicode8 = 0x8d; // BINUNICODE8 +constexpr uint8_t kPickleBinbytes8 = 0x8e; // BINBYTES8 +constexpr uint8_t kPickleMemoize = 0x94; // MEMOIZE +constexpr uint8_t kPickleFrame = 0x95; // FRAME +constexpr uint8_t kPickleProtocol5 = 0x05; + +// CPython _Framer._FRAME_SIZE_TARGET: a pending frame is committed before the +// next object save once it reaches this size. +constexpr size_t kPickleFrameTarget = 64 * 1024; + +// Emulates CPython's protocol-4+ _Framer: object bytes accumulate in +// frame_bytes_; Checkpoint() flushes a full frame at the start of each object +// save, and Finish() emits the final forced frame. +class PickleStream { + public: + // Bytes written before framing starts (the protocol header). + void WriteRaw(uint8_t value) { out_.push_back(value); } + + void Write(uint8_t value) { frame_.push_back(value); } + + void Write(std::span bytes) { + frame_.insert(frame_.end(), bytes.begin(), bytes.end()); + } + + // Mirrors _Framer.commit_frame() at the start of Pickler.save(). + void Checkpoint() { + if (frame_.size() >= kPickleFrameTarget) { + FlushFrame(); + } + } + + // Mirrors _Framer.write_large_bytes: the current frame is force-committed + // and the large payload is written with its length header but without a + // frame opcode. `header` is the already-packed little-endian length. + void WriteLargePayload(uint8_t opcode, std::span header, + std::span payload) { + FlushFrame(); + out_.push_back(opcode); + out_.insert(out_.end(), header.begin(), header.end()); + out_.insert(out_.end(), payload.begin(), payload.end()); + } + + // Mirrors _Framer.end_framing(): force-commit whatever remains. + void Finish() { FlushFrame(); } + + const std::vector& bytes() const { return out_; } + + private: + void FlushFrame() { + if (frame_.empty()) { + return; + } + out_.push_back(kPickleFrame); + AppendLittleEndian(static_cast(frame_.size()), &out_); + out_.insert(out_.end(), frame_.begin(), frame_.end()); + frame_.clear(); + } + + static void AppendLittleEndian(uint64_t value, std::vector* out) { + for (int shift = 0; shift < 64; shift += 8) { + out->push_back(static_cast(value >> shift)); + } + } + + std::vector out_; + std::vector frame_; +}; + +void PickleAppendLittleEndian(uint64_t value, size_t byte_count, + std::vector* out) { + for (size_t index = 0; index < byte_count; ++index) { + out->push_back(static_cast(value >> (index * 8))); + } +} + +void PickleEncodeBytes(std::span value, PickleStream* stream) { + const uint64_t length = value.size(); + if (length <= 0xffU) { + stream->Write(kPickleShortBinbytes); + stream->Write(static_cast(length)); + stream->Write(value); + } else if (length > std::numeric_limits::max()) { + std::vector header; + PickleAppendLittleEndian(length, 8, &header); + stream->WriteLargePayload(kPickleBinbytes8, header, value); + } else if (length >= kPickleFrameTarget) { + std::vector header; + PickleAppendLittleEndian(length, 4, &header); + stream->WriteLargePayload(kPickleBinbytes, header, value); + } else { + stream->Write(kPickleBinbytes); + for (int shift = 0; shift < 32; shift += 8) { + stream->Write(static_cast(length >> shift)); + } + stream->Write(value); + } + stream->Write(kPickleMemoize); +} + +void PickleEncodeString(std::string_view value, PickleStream* stream) { + const auto* bytes = reinterpret_cast(value.data()); + const std::span encoded(bytes, value.size()); + const uint64_t length = encoded.size(); + if (length <= 0xffU) { + stream->Write(kPickleShortBinunicode); + stream->Write(static_cast(length)); + stream->Write(encoded); + } else if (length > std::numeric_limits::max()) { + std::vector header; + PickleAppendLittleEndian(length, 8, &header); + stream->WriteLargePayload(kPickleBinunicode8, header, encoded); + } else if (length >= kPickleFrameTarget) { + std::vector header; + PickleAppendLittleEndian(length, 4, &header); + stream->WriteLargePayload(kPickleBinunicode, header, encoded); + } else { + stream->Write(kPickleBinunicode); + for (int shift = 0; shift < 32; shift += 8) { + stream->Write(static_cast(length >> shift)); + } + stream->Write(encoded); + } + stream->Write(kPickleMemoize); +} + +void PickleEncodeInt(int32_t value, PickleStream* stream) { + if (value >= 0 && value <= 0xff) { + stream->Write(kPickleBinint1); + stream->Write(static_cast(value)); + return; + } + if (value >= 0 && value <= 0xffff) { + stream->Write(kPickleBinint2); + const uint16_t narrow = static_cast(value); + stream->Write(static_cast(narrow)); + stream->Write(static_cast(narrow >> 8)); + return; + } + stream->Write(kPickleBinint); + const uint32_t bits = static_cast(value); + for (int shift = 0; shift < 32; shift += 8) { + stream->Write(static_cast(bits >> shift)); + } +} + +// Encodes the token tuple: per-element save checkpoints, the CPython tuple +// arity opcodes, and the trailing MEMOIZE marker for non-empty tuples. +void PickleEncodeTokenTuple(std::span tokens, + PickleStream* stream) { + if (tokens.empty()) { + stream->Write(kPickleEmptyTuple); + return; + } + if (tokens.size() > 3) { + stream->Write(kPickleMark); + } + for (const int32_t token : tokens) { + stream->Checkpoint(); + PickleEncodeInt(token, stream); + } + switch (tokens.size()) { + case 1: + stream->Write(kPickleTuple1); + break; + case 2: + stream->Write(kPickleTuple2); + break; + case 3: + stream->Write(kPickleTuple3); + break; + default: + stream->Write(kPickleTuple); + break; + } + stream->Write(kPickleMemoize); +} + +// Encodes the extras slot: Python None when there are no extra keys, +// otherwise the (LoRA, salt) string tuple in vLLM's ordering. The caller +// must have executed the save-entry checkpoint for this value already. +void PickleEncodeExtras(const VllmBlockValues& values, PickleStream* stream) { + const bool has_lora = values.lora_name != nullptr; + const bool has_salt = values.cache_salt != nullptr; + if (!has_lora && !has_salt) { + stream->Write(kPickleNone); + return; + } + if (has_lora) { + stream->Checkpoint(); + PickleEncodeString(*values.lora_name, stream); + } + if (has_salt) { + stream->Checkpoint(); + PickleEncodeString(*values.cache_salt, stream); + } + stream->Write(has_lora && has_salt ? kPickleTuple2 : kPickleTuple1); + stream->Write(kPickleMemoize); +} + +class PickleVllmCodec final : public VllmValueCodec { + public: + void EncodeSeed(std::string_view seed, + std::vector* out) const override { + PickleStream stream; + stream.WriteRaw(kPickleProto); + stream.WriteRaw(kPickleProtocol5); + stream.Checkpoint(); + PickleEncodeString(seed, &stream); + stream.Write(kPickleStop); + stream.Finish(); + *out = stream.bytes(); + } + + void EncodeBlock(const VllmBlockValues& values, + std::vector* out) const override { + PickleStream stream; + stream.WriteRaw(kPickleProto); + stream.WriteRaw(kPickleProtocol5); + + // Outer (parent, tokens, extras) tuple: TUPLE3. Each Checkpoint() + // mirrors Pickler.save() entry for the corresponding value. + stream.Checkpoint(); // save((parent, tokens, extras)) + stream.Checkpoint(); // save(parent bytes) + PickleEncodeBytes(values.parent_digest, &stream); + stream.Checkpoint(); // save(token tuple) + PickleEncodeTokenTuple(values.token_ids, &stream); + stream.Checkpoint(); // save(extras) + PickleEncodeExtras(values, &stream); + stream.Write(kPickleTuple3); + stream.Write(kPickleMemoize); + + stream.Write(kPickleStop); + stream.Finish(); + *out = stream.bytes(); + } +}; + +const VllmValueCodec* CodecForAlgorithm(std::string_view algorithm) { + static const CborVllmCodec kCborCodec; + static const PickleVllmCodec kPickleCodec; + if (algorithm == "sha256_cbor") { + return &kCborCodec; + } + if (algorithm == "sha256") { + return &kPickleCodec; + } + return nullptr; +} + +bool IsContinuationByte(uint8_t value) { return (value & 0xc0U) == 0x80U; } + +bool IsValidUtf8(std::string_view value) { + const auto* bytes = reinterpret_cast(value.data()); + size_t index = 0; + while (index < value.size()) { + const uint8_t first = bytes[index]; + if (first <= 0x7fU) { + ++index; + continue; + } + + if (first >= 0xc2U && first <= 0xdfU) { + if (index + 1 >= value.size() || + !IsContinuationByte(bytes[index + 1])) { + return false; + } + index += 2; + continue; + } + + if (first >= 0xe0U && first <= 0xefU) { + if (index + 2 >= value.size() || + !IsContinuationByte(bytes[index + 1]) || + !IsContinuationByte(bytes[index + 2])) { + return false; + } + if ((first == 0xe0U && bytes[index + 1] < 0xa0U) || + (first == 0xedU && bytes[index + 1] > 0x9fU)) { + return false; + } + index += 3; + continue; + } + + if (first >= 0xf0U && first <= 0xf4U) { + if (index + 3 >= value.size() || + !IsContinuationByte(bytes[index + 1]) || + !IsContinuationByte(bytes[index + 2]) || + !IsContinuationByte(bytes[index + 3])) { + return false; + } + if ((first == 0xf0U && bytes[index + 1] < 0x90U) || + (first == 0xf4U && bytes[index + 1] > 0x8fU)) { + return false; + } + index += 4; + continue; + } + + return false; + } + return true; +} + +#if CONDUCTOR_HAS_LOWLEVEL_SHA256 + +std::string Sha256(std::span input, + std::array* digest) { + SHA256_CTX context; + if (SHA256_Init(&context) != 1 || + SHA256_Update(&context, input.data(), input.size()) != 1 || + SHA256_Final(digest->data(), &context) != 1) { + return "OpenSSL SHA-256 computation failed"; + } + return ""; +} + +#else // EVP backend: no-deprecated builds and FIPS-forced configurations. + +std::string Sha256(std::span input, + std::array* digest) { + using EvpContext = std::unique_ptr; + EvpContext context(EVP_MD_CTX_new(), EVP_MD_CTX_free); + if (!context || + EVP_DigestInit_ex(context.get(), EVP_sha256(), nullptr) != 1 || + EVP_DigestUpdate(context.get(), input.data(), input.size()) != 1) { + return "OpenSSL EVP SHA-256 initialization failed"; + } + + unsigned int digest_size = 0; + if (EVP_DigestFinal_ex(context.get(), digest->data(), &digest_size) != 1 || + digest_size != digest->size()) { + return "OpenSSL EVP SHA-256 finalization failed"; + } + return ""; +} + +// Same as Sha256 but reuses a caller-owned context across invocations, +// avoiding an EVP_MD_CTX allocation per hashed block in long hash chains. +std::string Sha256Reuse(EVP_MD_CTX* context, std::span input, + std::array* digest) { + if (EVP_MD_CTX_reset(context) != 1 || + EVP_DigestInit_ex(context, EVP_sha256(), nullptr) != 1 || + EVP_DigestUpdate(context, input.data(), input.size()) != 1) { + return "OpenSSL EVP SHA-256 initialization failed"; + } + + unsigned int digest_size = 0; + if (EVP_DigestFinal_ex(context, digest->data(), &digest_size) != 1 || + digest_size != digest->size()) { + return "OpenSSL EVP SHA-256 finalization failed"; + } + return ""; +} + +#endif + +int LowerHexValue(char value) { + if (value >= '0' && value <= '9') { + return value - '0'; + } + if (value >= 'a' && value <= 'f') { + return value - 'a' + 10; + } + return -1; +} + +std::string ValidateProfileSelectors(std::string_view strategy, + std::string_view algorithm, + std::string_view index_projection) { + if (strategy != "vllm_v1" && strategy != "sglang" && + strategy != "sglang_bigram") { + return "unsupported hash strategy: " + std::string(strategy); + } + if (strategy == "sglang" || strategy == "sglang_bigram") { + if (algorithm != "sha256_raw") { + return "unsupported SGLang hash algorithm: " + + std::string(algorithm); + } + if (index_projection != "first64_be") { + return "unsupported SGLang index projection: " + + std::string(index_projection); + } + return ""; + } + if (algorithm != "sha256" && algorithm != "sha256_cbor") { + return "unsupported hash algorithm: " + std::string(algorithm); + } + if (index_projection != "low64_be") { + return "unsupported index projection: " + std::string(index_projection); + } + return ""; +} + +std::string ValidatePythonHashSeed(std::string_view seed) { + if (!IsValidUtf8(seed)) { + return "python_hash_seed must contain valid UTF-8"; + } + if (seed == "random") { + return ""; + } + if (seed.empty()) { + return "python_hash_seed must be \"random\" or ASCII decimal text in " + "0..4294967295"; + } + + // Accepts only bare ASCII decimal digits: the default options reject a + // leading sign, surrounding whitespace, and trailing characters. Parsing + // into uint64 keeps the range check separate from the well-formedness + // check, so a numeric seed past the uint32 ceiling reports the range + // error. + const auto value = TryParseInteger(seed); + if (!value.has_value()) { + return "python_hash_seed must be \"random\" or ASCII decimal text in " + "0..4294967295"; + } + if (*value > kMaxPythonHashSeed) { + return "python_hash_seed must be in range 0..4294967295"; + } + return ""; +} + +std::string ValidateRootDigest(std::string_view root_digest) { + if (root_digest.size() != kSha256DigestSize * 2) { + return "root_digest must contain exactly 64 lowercase hex characters"; + } + for (const char value : root_digest) { + if (LowerHexValue(value) < 0) { + return "root_digest must contain exactly 64 lowercase hex " + "characters"; + } + } + return ""; +} + +std::string ValidateResolvedHashProfileShape(const HashProfile& profile) { + if (auto error = ValidateProfileSelectors( + profile.strategy, profile.algorithm, profile.index_projection); + !error.empty()) { + return error; + } + if (profile.strategy == "vllm_v1") { + if (auto error = ValidatePythonHashSeed(profile.python_hash_seed); + !error.empty()) { + return error; + } + } + return ValidateRootDigest(profile.root_digest); +} + +std::array DecodeRootDigest( + std::string_view root_digest) { + std::array result{}; + for (size_t index = 0; index < result.size(); ++index) { + const int high = LowerHexValue(root_digest[index * 2]); + const int low = LowerHexValue(root_digest[index * 2 + 1]); + result[index] = static_cast((high << 4) | low); + } + return result; +} + +ProjectedPrefix ProjectDigest( + const std::array& digest) { + uint64_t value = 0; + for (size_t index = digest.size() - sizeof(value); index < digest.size(); + ++index) { + value = (value << 8) | digest[index]; + } + return ProjectedPrefix{value}; +} + +// Lazily-hashed vLLM v1 block chain. Hashing is incremental: block i is +// computed only when first requested via At(), and every block up to i is +// cached, so prefix-index walks that stall early never hash the tail. +class VllmV1HashChain final : public HashChain { + public: + VllmV1HashChain(const VllmValueCodec* codec, + std::array root_digest, + const ContextKey& context, + std::span token_ids, + std::optional cache_salt) + : codec_(codec), + parent_(std::move(root_digest)), + lora_name_(context.lora_name), + cache_salt_(std::move(cache_salt)), + token_ids_(token_ids), + block_size_(static_cast(context.block_size)), + block_count_(token_ids.size() / block_size_) { + computed_.reserve(block_count_); + // Reused across blocks: every codec clears the buffer at the start of + // EncodeBlock, so reserving worst-case capacity once keeps the whole + // chain on a single allocation instead of one per block. + encoded_.reserve(64 + block_size_ * 9); + } + + // Validates inputs eagerly (same contract as Compute). Returns an empty + // string on success. + static std::string ValidateInputs(const ContextKey& context, + std::optional cache_salt) { + if (context.block_size <= 0 || + static_cast(context.block_size) > + std::numeric_limits::max()) { + return "block_size must be a positive size_t value"; + } + if (!IsValidUtf8(context.lora_name)) { + return "lora_name must contain valid UTF-8"; + } + if (cache_salt.has_value() && !IsValidUtf8(*cache_salt)) { + return "cache_salt must contain valid UTF-8"; + } + return ""; + } + + size_t BlockCount() const override { return block_count_; } + + size_t ComputedCount() const override { return computed_.size(); } + + const HashBlock* At(size_t index, std::string* error) override { + if (index >= block_count_) { + if (error != nullptr) { + *error = "hash chain index out of range"; + } + return nullptr; + } + if (!sticky_error_.empty()) { + if (error != nullptr) { + *error = sticky_error_; + } + return nullptr; + } +#if !CONDUCTOR_HAS_LOWLEVEL_SHA256 + if (!EnsureEvp()) { + if (error != nullptr) { + *error = sticky_error_; + } + return nullptr; + } +#endif + while (computed_.size() <= index) { + const size_t block_index = computed_.size(); + const bool has_lora = !lora_name_.empty(); + const bool has_salt = block_index == 0 && cache_salt_.has_value() && + !cache_salt_->empty(); + const VllmBlockValues values{ + .parent_digest = parent_, + .token_ids = + token_ids_.subspan(block_index * block_size_, block_size_), + .lora_name = has_lora ? &lora_name_ : nullptr, + .cache_salt = has_salt ? &*cache_salt_ : nullptr, + }; + + codec_->EncodeBlock(values, &encoded_); + + HashBlock block; +#if CONDUCTOR_HAS_LOWLEVEL_SHA256 + std::string hash_error = Sha256(encoded_, &block.digest); +#else + std::string hash_error = + Sha256Reuse(evp_.get(), encoded_, &block.digest); +#endif + if (!hash_error.empty()) { + sticky_error_ = std::move(hash_error); + if (error != nullptr) { + *error = sticky_error_; + } + return nullptr; + } + block.projected = ProjectDigest(block.digest); + parent_ = block.digest; + computed_.push_back(std::move(block)); + } + return &computed_[index]; + } + + private: +#if !CONDUCTOR_HAS_LOWLEVEL_SHA256 + bool EnsureEvp() { + if (evp_) { + return true; + } + evp_ = EvpContext(EVP_MD_CTX_new(), EVP_MD_CTX_free); + if (!evp_) { + sticky_error_ = "OpenSSL EVP MD context allocation failed"; + return false; + } + return true; + } + + using EvpContext = std::unique_ptr; +#endif + + const VllmValueCodec* codec_; + std::array parent_; + std::string lora_name_; + std::optional cache_salt_; + std::span token_ids_; + size_t block_size_; + size_t block_count_; + std::vector computed_; + std::vector encoded_; +#if !CONDUCTOR_HAS_LOWLEVEL_SHA256 + EvpContext evp_{nullptr, EVP_MD_CTX_free}; +#endif + std::string sticky_error_; +}; + +class VllmV1HashStrategy final : public HashStrategy { + public: + VllmV1HashStrategy(const VllmValueCodec* codec, + std::array root_digest) + : codec_(codec), root_digest_(std::move(root_digest)) {} + + std::string Compute(const ContextKey& context, + std::span token_ids, + std::optional cache_salt, + std::vector* out) const override { + if (out == nullptr) { + return "hash output must not be null"; + } + out->clear(); + + std::string error; + auto chain = + CreateChain(context, token_ids, std::move(cache_salt), &error); + if (!chain) { + return error; + } + std::vector computed; + computed.reserve(chain->BlockCount()); + for (size_t index = 0; index < chain->BlockCount(); ++index) { + const HashBlock* block = chain->At(index, &error); + if (block == nullptr) { + return error; + } + computed.push_back(*block); + } + + *out = std::move(computed); + return ""; + } + + std::unique_ptr CreateChain( + const ContextKey& context, std::span token_ids, + std::optional cache_salt, + std::string* error) const override { + if (std::string validation_error = + VllmV1HashChain::ValidateInputs(context, cache_salt); + !validation_error.empty()) { + if (error != nullptr) { + *error = std::move(validation_error); + } + return nullptr; + } + return std::make_unique( + codec_, root_digest_, context, token_ids, std::move(cache_salt)); + } + + private: + const VllmValueCodec* codec_; + std::array root_digest_; +}; + +ProjectedPrefix ProjectSglangDigest( + const std::array& digest) { + uint64_t value = 0; + for (size_t index = 0; index < sizeof(value); ++index) { + value = (value << 8) | digest[index]; + } + return ProjectedPrefix{value}; +} + +class SglangHashChain final : public HashChain { + public: + SglangHashChain(const ContextKey& context, + std::span token_ids, + std::optional cache_salt, bool bigram) + : token_ids_(token_ids), + block_size_(static_cast(context.block_size)), + block_count_( + bigram ? (token_ids.size() < 2 + ? 0 + : (token_ids.size() - 1) / block_size_ + + ((token_ids.size() - 1) % block_size_ != 0)) + : token_ids.size() / block_size_ + + (token_ids.size() % block_size_ != 0)), + bigram_(bigram), + cache_salt_(std::move(cache_salt)) { + computed_.reserve(block_count_); + encoded_.reserve(block_size_ * sizeof(uint32_t) * (bigram ? 2 : 1)); + if (cache_salt_.has_value()) { + std::vector root_input; + constexpr std::string_view kSaltPrefix("sglang-cache-salt-v1\0", + 21); + root_input.insert(root_input.end(), kSaltPrefix.begin(), + kSaltPrefix.end()); + root_input.insert(root_input.end(), cache_salt_->begin(), + cache_salt_->end()); + if (std::string error = Sha256(root_input, &parent_); + !error.empty()) { + sticky_error_ = std::move(error); + } else { + has_parent_ = true; + } + } + } + + static std::string ValidateInputs(const ContextKey& context, + std::optional cache_salt, + std::span token_ids) { + if (context.block_size <= 0 || + static_cast(context.block_size) > + std::numeric_limits::max()) { + return "block_size must be a positive size_t value"; + } + if (cache_salt.has_value() && !IsValidUtf8(*cache_salt)) { + return "cache_salt must contain valid UTF-8"; + } + for (int32_t token : token_ids) { + if (token < 0) { + return "SGLang token_ids must be non-negative"; + } + } + return ""; + } + + size_t BlockCount() const override { return block_count_; } + size_t ComputedCount() const override { return computed_.size(); } + + const HashBlock* At(size_t index, std::string* error) override { + if (index >= block_count_) { + if (error != nullptr) *error = "hash chain index out of range"; + return nullptr; + } + if (!sticky_error_.empty()) { + if (error != nullptr) *error = sticky_error_; + return nullptr; + } + while (computed_.size() <= index) { + const size_t block_index = computed_.size(); + encoded_.clear(); + const size_t logical_length = + bigram_ ? token_ids_.size() - 1 : token_ids_.size(); + const size_t block_begin = block_index * block_size_; + const size_t block_end = + std::min(block_begin + block_size_, logical_length); + for (size_t token_index = block_begin; token_index < block_end; + ++token_index) { + const int32_t token = token_ids_[token_index]; + const uint32_t value = static_cast(token); + encoded_.push_back(static_cast(value)); + encoded_.push_back(static_cast(value >> 8)); + encoded_.push_back(static_cast(value >> 16)); + encoded_.push_back(static_cast(value >> 24)); + if (bigram_) { + const uint32_t next_value = + static_cast(token_ids_[token_index + 1]); + encoded_.push_back(static_cast(next_value)); + encoded_.push_back(static_cast(next_value >> 8)); + encoded_.push_back(static_cast(next_value >> 16)); + encoded_.push_back(static_cast(next_value >> 24)); + } + } + std::vector input; + input.reserve((has_parent_ ? parent_.size() : 0) + encoded_.size()); + if (has_parent_) { + input.insert(input.end(), parent_.begin(), parent_.end()); + } + input.insert(input.end(), encoded_.begin(), encoded_.end()); + + HashBlock hashed; + if (std::string hash_error = Sha256(input, &hashed.digest); + !hash_error.empty()) { + sticky_error_ = std::move(hash_error); + if (error != nullptr) *error = sticky_error_; + return nullptr; + } + hashed.projected = ProjectSglangDigest(hashed.digest); + parent_ = hashed.digest; + has_parent_ = true; + computed_.push_back(std::move(hashed)); + } + return &computed_[index]; + } + + private: + std::span token_ids_; + size_t block_size_; + size_t block_count_; + bool bigram_; + std::optional cache_salt_; + std::array parent_{}; + bool has_parent_ = false; + std::vector computed_; + std::vector encoded_; + std::string sticky_error_; +}; + +class SglangHashStrategy final : public HashStrategy { + public: + explicit SglangHashStrategy(bool bigram) : bigram_(bigram) {} + + std::string Compute(const ContextKey& context, + std::span token_ids, + std::optional cache_salt, + std::vector* out) const override { + if (out == nullptr) return "hash output must not be null"; + out->clear(); + std::string error; + auto chain = + CreateChain(context, token_ids, std::move(cache_salt), &error); + if (!chain) return error; + out->reserve(chain->BlockCount()); + for (size_t index = 0; index < chain->BlockCount(); ++index) { + const auto* block = chain->At(index, &error); + if (block == nullptr) return error; + out->push_back(*block); + } + return ""; + } + + std::unique_ptr CreateChain( + const ContextKey& context, std::span token_ids, + std::optional cache_salt, + std::string* error) const override { + if (std::string validation = + SglangHashChain::ValidateInputs(context, cache_salt, token_ids); + !validation.empty()) { + if (error != nullptr) *error = std::move(validation); + return nullptr; + } + return std::make_unique( + context, token_ids, std::move(cache_salt), bigram_); + } + + private: + bool bigram_ = false; +}; + +} // namespace + +std::string ResolveHashProfile(const common::HashProfileConfig& config, + HashProfile* out) { + if (out == nullptr) { + return "resolved hash profile output must not be null"; + } + *out = {}; + + if (auto error = ValidateProfileSelectors(config.strategy, config.algorithm, + config.index_projection); + !error.empty()) { + return error; + } + if (config.strategy == "sglang" || config.strategy == "sglang_bigram") { + // SGLang has no process-level Python seed/root in its token hash + // chain. Keep a deterministic sentinel for the existing profile + // registration wire contract; cache_salt, when present, supplies the + // actual chain root at query time. + *out = {.strategy = config.strategy, + .algorithm = config.algorithm, + .python_hash_seed = config.python_hash_seed, + .root_digest = std::string(64, '0'), + .index_projection = config.index_projection}; + return ""; + } + + if (auto error = ValidatePythonHashSeed(config.python_hash_seed); + !error.empty()) { + return error; + } + + const VllmValueCodec* codec = CodecForAlgorithm(config.algorithm); + if (codec == nullptr) { + return "unsupported hash algorithm: " + config.algorithm; + } + + std::vector encoded_seed; + codec->EncodeSeed(config.python_hash_seed, &encoded_seed); + std::array root_digest{}; + if (auto error = Sha256(encoded_seed, &root_digest); !error.empty()) { + return error; + } + + *out = {.strategy = config.strategy, + .algorithm = config.algorithm, + .python_hash_seed = config.python_hash_seed, + .root_digest = DigestToHex(root_digest), + .index_projection = config.index_projection}; + return ""; +} + +std::string ValidateHashProfile(const HashProfile& profile) { + if (auto error = ValidateResolvedHashProfileShape(profile); + !error.empty()) { + return error; + } + + HashProfile expected; + const common::HashProfileConfig source{ + .strategy = profile.strategy, + .algorithm = profile.algorithm, + .python_hash_seed = profile.python_hash_seed, + .index_projection = profile.index_projection, + }; + if (auto error = ResolveHashProfile(source, &expected); !error.empty()) { + return error; + } + if (profile.root_digest != expected.root_digest) { + return "root_digest does not match python_hash_seed and hash selectors"; + } + return ""; +} + +std::unique_ptr CreateHashStrategy(const HashProfile& profile, + std::string* error) { + const std::string validation_error = + ValidateResolvedHashProfileShape(profile); + if (error != nullptr) { + *error = validation_error; + } + if (!validation_error.empty()) { + return nullptr; + } + if (profile.strategy == "sglang" || profile.strategy == "sglang_bigram") { + return std::make_unique(profile.strategy == + "sglang_bigram"); + } + const VllmValueCodec* codec = CodecForAlgorithm(profile.algorithm); + if (codec == nullptr) { + if (error != nullptr) { + *error = "unsupported hash algorithm: " + profile.algorithm; + } + return nullptr; + } + return std::make_unique( + codec, DecodeRootDigest(profile.root_digest)); +} + +std::string DigestToHex(const std::array& digest) { + static constexpr char kHexDigits[] = "0123456789abcdef"; + std::string result; + result.resize(digest.size() * 2); + for (size_t index = 0; index < digest.size(); ++index) { + result[index * 2] = kHexDigits[digest[index] >> 4]; + result[index * 2 + 1] = kHexDigits[digest[index] & 0x0fU]; + } + return result; +} + +} // namespace mooncake::conductor::prefixindex diff --git a/mooncake-conductor/src/prefixindex/prefix_indexer.cpp b/mooncake-conductor/src/prefixindex/prefix_indexer.cpp new file mode 100644 index 0000000000..2a3b914a65 --- /dev/null +++ b/mooncake-conductor/src/prefixindex/prefix_indexer.cpp @@ -0,0 +1,641 @@ +#include "conductor/prefixindex/prefix_indexer.h" + +#include + +#include +#include +#include +#include + +#include "conductor/prefixindex/hash_strategy.h" + +namespace mooncake::conductor::prefixindex { + +namespace { + +std::string ValidateContext(const ContextKey& context) { + if (context.tenant_id.empty()) { + return "tenant_id is required"; + } + if (context.model_name.empty()) { + return "model_name is required"; + } + if (context.block_size <= 0) { + return "block_size must be positive"; + } + return ""; +} + +std::string ValidateLayout(const ContextKey& context, + int64_t effective_block_size, + std::optional cache_group) { + if (auto error = ValidateContext(context); !error.empty()) { + return error; + } + if (effective_block_size <= 0) { + return "effective_block_size must be positive"; + } + if (effective_block_size != context.block_size) { + return "effective_block_size must equal ContextKey block_size"; + } + if (cache_group.has_value() && *cache_group != 0) { + return "only cache group 0 is supported"; + } + return ""; +} + +std::string ValidateEngineOwner(const EngineOwner& owner) { + if (owner.source_stream.empty()) { + return "engine owner source_stream is required"; + } + if (owner.instance_id.empty()) { + return "engine owner instance_id is required"; + } + if (owner.dp_rank < 0) { + return "engine owner dp_rank must be non-negative"; + } + return ""; +} + +std::string ValidateSharedOwner(const SharedObjectOwner& owner) { + if (owner.source_stream.empty()) { + return "shared owner source_stream is required"; + } + if (owner.backend_id.empty()) { + return "shared owner backend_id is required"; + } + if (owner.object_id.empty()) { + return "shared owner object_id is required"; + } + return ""; +} + +std::string ValidateGpuMutation(const GpuMutation& mutation) { + if (auto error = + ValidateLayout(mutation.context, mutation.effective_block_size, + mutation.cache_group); + !error.empty()) { + return error; + } + return ValidateEngineOwner(mutation.owner); +} + +std::string ValidateGpuClear(const GpuClear& clear) { + if (auto error = ValidateLayout(clear.context, clear.effective_block_size, + clear.cache_group); + !error.empty()) { + return error; + } + return ValidateEngineOwner(clear.owner); +} + +bool IsSharedTier(StorageTier tier) { + return tier == StorageTier::kCpu || tier == StorageTier::kDisk; +} + +std::string ValidateSharedMutation(const SharedMutation& mutation) { + if (auto error = + ValidateLayout(mutation.context, mutation.effective_block_size, + mutation.cache_group); + !error.empty()) { + return error; + } + if (!IsSharedTier(mutation.tier)) { + return "shared mutation tier must be CPU or DISK"; + } + return ValidateSharedOwner(mutation.owner); +} + +std::string ValidateSharedClear(const SharedClear& clear) { + if (auto error = ValidateLayout(clear.context, clear.effective_block_size, + clear.cache_group); + !error.empty()) { + return error; + } + if (clear.tier.has_value() && !IsSharedTier(*clear.tier)) { + return "shared clear tier must be CPU, DISK, or omitted"; + } + return ValidateSharedOwner(clear.owner); +} + +std::set& SharedOwners(BlockPresence& presence, + StorageTier tier) { + return tier == StorageTier::kCpu ? presence.cpu_owners + : presence.disk_owners; +} + +// Remove empty blocks and their order metadata. The caller holds state.mutex. +void EraseEmptyBlocks(ContextState& state) { + std::erase_if(state.blocks, [&state](const auto& item) { + if (!item.second.Empty()) { + return false; + } + auto pos = state.order_pos.find(item.first); + if (pos != state.order_pos.end()) { + state.write_order.erase(pos->second); + state.order_pos.erase(pos); + } + return true; + }); +} + +// Remove a prefix from the insertion-order metadata. The caller holds +// state.mutex. +void ForgetOrder(ContextState& state, ProjectedPrefix prefix) { + auto pos = state.order_pos.find(prefix); + if (pos != state.order_pos.end()) { + state.write_order.erase(pos->second); + state.order_pos.erase(pos); + } +} + +// Move a prefix to the front of the insertion-order list, adding it if absent. +// The caller holds the state write lock. +void TouchOrder(ContextState& state, ProjectedPrefix prefix) { + auto pos = state.order_pos.find(prefix); + if (pos != state.order_pos.end()) { + state.write_order.splice(state.write_order.begin(), state.write_order, + pos->second); + return; + } + state.write_order.push_front(prefix); + state.order_pos.emplace(prefix, state.write_order.begin()); +} + +// Evict oldest entries in batches until the target occupancy is reached. The +// caller holds the state write lock. +void EvictIfOverCapacity(ContextState& state) { + if (state.max_blocks == 0 || state.blocks.size() <= state.max_blocks) { + return; + } + const size_t target = + static_cast(state.max_blocks * kEvictTargetRatio); + while (state.blocks.size() > target && !state.write_order.empty()) { + const ProjectedPrefix oldest = state.write_order.back(); + state.write_order.pop_back(); + state.order_pos.erase(oldest); + state.blocks.erase(oldest); + ++state.evicted_by_capacity; + } + LOG_EVERY_N(WARNING, 100) + << "Prefix index hit the capacity limit; oldest entries dropped." + << " limit=" << state.max_blocks << " now=" << state.blocks.size() + << " cumulative_evicted=" << state.evicted_by_capacity + << " (non-zero means stored/removed events are out of sync)"; +} + +int64_t TokensForBlocks(size_t block_count, int64_t block_size) { + const uint64_t max_blocks = + static_cast(std::numeric_limits::max() / block_size); + if (block_count > max_blocks) { + return std::numeric_limits::max(); + } + return static_cast(block_count) * block_size; +} + +// Tokens reusable from a matched run of blocks. The SGLang chains end in a +// partial block, so a run that reaches the last block covers only the tokens +// the query actually carries. Reporting a whole block there would hand the +// router reusable tokens that no indexed block holds, and the engine would +// then skip recomputing slots whose KV belongs to some other request. +int64_t MatchedTokens(size_t block_count, int64_t block_size, + size_t queried_tokens) { + const int64_t queried = + queried_tokens > + static_cast(std::numeric_limits::max()) + ? std::numeric_limits::max() + : static_cast(queried_tokens); + return std::min(TokensForBlocks(block_count, block_size), queried); +} + +} // namespace + +RegistrationResult PrefixCacheTable::ValidateRegistration( + const EngineRegistration& registration) { + if (auto error = ValidateLayout(registration.context, + registration.effective_block_size, + registration.cache_group); + !error.empty()) { + return {.error = std::move(error)}; + } + if (registration.instance_id.empty()) { + return {.error = "instance_id is required"}; + } + if (registration.dp_rank < 0) { + return {.error = "dp_rank must be non-negative"}; + } + if (auto error = ValidateHashProfile(registration.profile); + !error.empty()) { + return {.error = std::move(error)}; + } + return {}; +} + +RegistrationResult PrefixCacheTable::Register( + const EngineRegistration& registration) { + if (auto validation = ValidateRegistration(registration); + !validation.error.empty()) { + return validation; + } + + auto candidate = + std::make_shared(registration.profile, block_limit_); + candidate->instance_ranks[registration.instance_id].insert( + registration.dp_rank); + + std::shared_ptr state; + { + std::unique_lock map_lock(context_map_mutex_); + auto [it, inserted] = + contexts_.try_emplace(registration.context, std::move(candidate)); + if (inserted) { + return {.inserted = true, .error = ""}; + } + state = it->second; + } + + std::unique_lock state_lock(state->mutex); + if (state->profile != registration.profile) { + return {.error = + "registration conflicts with the ContextKey hash profile"}; + } + const bool inserted = state->instance_ranks[registration.instance_id] + .insert(registration.dp_rank) + .second; + return {.inserted = inserted, .error = ""}; +} + +std::shared_ptr PrefixCacheTable::LoadContextState( + const ContextKey& context) const { + std::shared_lock map_lock(context_map_mutex_); + auto it = contexts_.find(context); + return it == contexts_.end() ? nullptr : it->second; +} + +std::string PrefixCacheTable::ValidateProfileBinding( + const ContextKey& context, const HashProfile& profile) const { + if (auto error = ValidateContext(context); !error.empty()) { + return error; + } + if (auto error = ValidateHashProfile(profile); !error.empty()) { + return error; + } + auto state = LoadContextState(context); + if (!state) { + return "ContextKey is not registered"; + } + + std::shared_lock state_lock(state->mutex); + if (state->profile != profile) { + return "hash profile conflicts with the registered ContextKey profile"; + } + return ""; +} + +std::string PrefixCacheTable::Unregister(const ContextKey& context, + const std::string& instance_id, + int64_t dp_rank) { + if (auto error = ValidateContext(context); !error.empty()) { + return error; + } + if (instance_id.empty()) { + return "instance_id is required"; + } + if (dp_rank < 0) { + return "dp_rank must be non-negative"; + } + + auto state = LoadContextState(context); + if (!state) { + return ""; + } + + std::unique_lock state_lock(state->mutex); + auto instance = state->instance_ranks.find(instance_id); + if (instance != state->instance_ranks.end()) { + instance->second.erase(dp_rank); + if (instance->second.empty()) { + state->instance_ranks.erase(instance); + } + } + + for (auto& [unused_prefix, presence] : state->blocks) { + (void)unused_prefix; + std::erase_if(presence.gpu_owners, [&](const EngineOwner& owner) { + return owner.instance_id == instance_id && owner.dp_rank == dp_rank; + }); + } + EraseEmptyBlocks(*state); + return ""; +} + +std::string PrefixCacheTable::StoreGpu(const GpuMutation& mutation) { + if (auto error = ValidateGpuMutation(mutation); !error.empty()) { + return error; + } + auto state = LoadContextState(mutation.context); + if (!state) { + return "ContextKey is not registered"; + } + + std::unique_lock state_lock(state->mutex); + auto instance = state->instance_ranks.find(mutation.owner.instance_id); + if (instance == state->instance_ranks.end() || + !instance->second.contains(mutation.owner.dp_rank)) { + return "engine owner instance/rank is not registered"; + } + for (ProjectedPrefix prefix : mutation.prefixes) { + state->blocks[prefix].gpu_owners.insert(mutation.owner); + TouchOrder(*state, prefix); + } + EvictIfOverCapacity(*state); + return ""; +} + +std::string PrefixCacheTable::RemoveGpu(const GpuMutation& mutation) { + if (auto error = ValidateGpuMutation(mutation); !error.empty()) { + return error; + } + auto state = LoadContextState(mutation.context); + if (!state) { + return ""; + } + + std::unique_lock state_lock(state->mutex); + // Only prefixes in the mutation can become empty, so avoid a full scan. + for (ProjectedPrefix prefix : mutation.prefixes) { + auto block = state->blocks.find(prefix); + if (block != state->blocks.end()) { + block->second.gpu_owners.erase(mutation.owner); + if (block->second.Empty()) { + state->blocks.erase(block); + ForgetOrder(*state, prefix); + } + } + } + return ""; +} + +std::string PrefixCacheTable::ClearGpu(const GpuClear& clear) { + if (auto error = ValidateGpuClear(clear); !error.empty()) { + return error; + } + auto state = LoadContextState(clear.context); + if (!state) { + return ""; + } + + std::unique_lock state_lock(state->mutex); + for (auto& [unused_prefix, presence] : state->blocks) { + (void)unused_prefix; + presence.gpu_owners.erase(clear.owner); + } + EraseEmptyBlocks(*state); + return ""; +} + +std::string PrefixCacheTable::StoreShared(const SharedMutation& mutation) { + if (auto error = ValidateSharedMutation(mutation); !error.empty()) { + return error; + } + auto state = LoadContextState(mutation.context); + if (!state) { + return "ContextKey is not registered"; + } + + std::unique_lock state_lock(state->mutex); + for (ProjectedPrefix prefix : mutation.prefixes) { + SharedOwners(state->blocks[prefix], mutation.tier) + .insert(mutation.owner); + TouchOrder(*state, prefix); + } + EvictIfOverCapacity(*state); + return ""; +} + +std::string PrefixCacheTable::RemoveShared(const SharedMutation& mutation) { + if (auto error = ValidateSharedMutation(mutation); !error.empty()) { + return error; + } + auto state = LoadContextState(mutation.context); + if (!state) { + return ""; + } + + std::unique_lock state_lock(state->mutex); + // Only prefixes in the mutation can become empty, so avoid a full scan. + for (ProjectedPrefix prefix : mutation.prefixes) { + auto block = state->blocks.find(prefix); + if (block != state->blocks.end()) { + SharedOwners(block->second, mutation.tier).erase(mutation.owner); + if (block->second.Empty()) { + state->blocks.erase(block); + ForgetOrder(*state, prefix); + } + } + } + return ""; +} + +std::string PrefixCacheTable::ClearShared(const SharedClear& clear) { + if (auto error = ValidateSharedClear(clear); !error.empty()) { + return error; + } + auto state = LoadContextState(clear.context); + if (!state) { + return ""; + } + + std::unique_lock state_lock(state->mutex); + for (auto& [unused_prefix, presence] : state->blocks) { + (void)unused_prefix; + if (!clear.tier.has_value() || *clear.tier == StorageTier::kCpu) { + presence.cpu_owners.erase(clear.owner); + } + if (!clear.tier.has_value() || *clear.tier == StorageTier::kDisk) { + presence.disk_owners.erase(clear.owner); + } + } + EraseEmptyBlocks(*state); + return ""; +} + +std::map PrefixCacheTable::Query( + const ContextKey& context, std::span token_ids, + std::optional cache_salt, + std::optional instance_filter) const { + std::map results; + auto state = LoadContextState(context); + if (!state) { + return results; + } + + // The profile is immutable, and the shared_ptr keeps state alive while the + // hash strategy and chain are built without holding state.mutex. + std::string strategy_error; + auto strategy = CreateHashStrategy(state->profile, &strategy_error); + if (!strategy) { + LOG(ERROR) << "Registered hash profile became invalid: " + << strategy_error; + return results; + } + + std::string chain_error; + auto chain = strategy->CreateChain(context, token_ids, + std::move(cache_salt), &chain_error); + if (!chain) { + LOG(ERROR) << "Query hash chain setup failed: " << chain_error; + return results; + } + const size_t block_count = chain->BlockCount(); + // Reusable logical KV positions carried by this query. The bigram chains + // hash token pairs, so n raw tokens cover n - 1 positions and the chain is + // built over that length. Capping at the raw count there would still let a + // full match report one position no indexed block holds. + const size_t queried_tokens = + (state->profile.strategy == "sglang_bigram" && !token_ids.empty()) + ? token_ids.size() - 1 + : token_ids.size(); + + // Resolve the optional filter and copy rank sets before probing. The copies + // remain valid while the probe releases and reacquires state.mutex. + std::map> selected_instances; + { + std::shared_lock select_lock(state->mutex); + if (instance_filter.has_value()) { + auto instance = state->instance_ranks.find(*instance_filter); + if (instance == state->instance_ranks.end()) { + return results; + } + selected_instances.emplace(instance->first, instance->second); + } else { + selected_instances = state->instance_ranks; + } + } + if (selected_instances.empty()) { + return results; + } + + // Probe indexed block presence in chunks. Hashing runs outside the lock; + // each chunk holds a shared lock only for table lookups. + constexpr size_t kProbeChunkMin = 8; + constexpr size_t kProbeChunkMax = 512; + size_t chunk = kProbeChunkMin; + size_t probe_depth = 0; + bool probe_stalled = false; + while (!probe_stalled && probe_depth < block_count) { + const size_t chunk_end = std::min(probe_depth + chunk, block_count); + chunk = std::min(chunk * 2, kProbeChunkMax); + // Compute hashes without holding state.mutex; retain any error for the + // final check below. + for (size_t i = probe_depth; i < chunk_end; ++i) { + if (chain->At(i, &chain_error) == nullptr) { + probe_stalled = true; + break; + } + } + std::shared_lock probe_lock(state->mutex); + while (probe_depth < chunk_end) { + const HashBlock* hashed = chain->At(probe_depth, &chain_error); + if (hashed == nullptr || + !state->blocks.contains(hashed->projected)) { + probe_stalled = true; + break; + } + ++probe_depth; + } + } + + std::shared_lock state_lock(state->mutex); + + // Hashes needed by the probe are memoized, so the final read-locked walk + // performs only vector access and indexed lookups. + auto advance_cursor = [&](size_t& cursor, const auto& present) { + while (cursor < block_count) { + const HashBlock* hashed = chain->At(cursor, &chain_error); + if (hashed == nullptr) { + cursor = block_count; // stall every remaining walk + return; + } + auto block = state->blocks.find(hashed->projected); + if (block == state->blocks.end() || !present(block->second)) { + break; + } + ++cursor; + } + }; + + for (const auto& [instance_id, ranks] : selected_instances) { + CacheHitResult result; + + for (int64_t rank : ranks) { + auto gpu_present = [&](const BlockPresence& block) { + return std::any_of( + block.gpu_owners.begin(), block.gpu_owners.end(), + [&](const EngineOwner& owner) { + return owner.instance_id == instance_id && + owner.dp_rank == rank; + }); + }; + + size_t cursor = 0; + advance_cursor(cursor, gpu_present); + + RankCacheHitResult rank_match; + rank_match.gpu = + MatchedTokens(cursor, context.block_size, queried_tokens); + + advance_cursor(cursor, [](const BlockPresence& block) { + return !block.cpu_owners.empty(); + }); + rank_match.cpu = + MatchedTokens(cursor, context.block_size, queried_tokens); + + advance_cursor(cursor, [](const BlockPresence& block) { + return !block.disk_owners.empty(); + }); + rank_match.disk = + MatchedTokens(cursor, context.block_size, queried_tokens); + + result.dp.emplace(rank, rank_match.gpu); + result.rank_matches.emplace(rank, rank_match); + result.gpu = std::max(result.gpu, rank_match.gpu); + result.cpu = std::max(result.cpu, rank_match.cpu); + result.disk = std::max(result.disk, rank_match.disk); + } + result.longest_match_tokens = result.disk; + results.emplace(instance_id, std::move(result)); + } + if (!chain_error.empty()) { + LOG(ERROR) << "Query hash computation failed: " << chain_error; + return {}; + } + return results; +} + +GlobalView PrefixCacheTable::GetGlobalView() const { + GlobalView view; + std::vector>> contexts; + { + std::shared_lock map_lock(context_map_mutex_); + contexts.reserve(contexts_.size()); + for (const auto& item : contexts_) { + contexts.push_back(item); + } + } + + view.context_count = static_cast(contexts.size()); + view.contexts.reserve(contexts.size()); + for (const auto& [context, state] : contexts) { + std::shared_lock state_lock(state->mutex); + ContextView context_view; + context_view.context = context; + context_view.profile = state->profile; + context_view.instance_ranks = state->instance_ranks; + context_view.prefix_count = state->blocks.size(); + view.contexts.push_back(std::move(context_view)); + } + return view; +} + +} // namespace mooncake::conductor::prefixindex diff --git a/mooncake-conductor/src/zmq/msg_decoder.cpp b/mooncake-conductor/src/zmq/msg_decoder.cpp new file mode 100644 index 0000000000..1ab75d016a --- /dev/null +++ b/mooncake-conductor/src/zmq/msg_decoder.cpp @@ -0,0 +1,1036 @@ +#include "conductor/zmq/msg_decoder.h" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace mooncake::conductor::zmq { + +namespace { + +using msgpack::object; +using msgpack::type::object_type; + +template +struct ValueResult { + std::optional value; + std::string error; + + static ValueResult Ok(T value) { + return {.value = std::move(value), .error = ""}; + } + static ValueResult Err(std::string error) { + return {.value = std::nullopt, .error = std::move(error)}; + } +}; + +std::string TypeName(const object& value) { + switch (value.type) { + case object_type::NIL: + return "nil"; + case object_type::BOOLEAN: + return "boolean"; + case object_type::POSITIVE_INTEGER: + return "positive integer"; + case object_type::NEGATIVE_INTEGER: + return "negative integer"; + case object_type::FLOAT32: + case object_type::FLOAT64: + return "float"; + case object_type::STR: + return "string"; + case object_type::BIN: + return "binary"; + case object_type::ARRAY: + return "array"; + case object_type::MAP: + return "map"; + case object_type::EXT: + return "extension"; + } + return "unknown"; +} + +class MapReader { + public: + MapReader(const object& value, + const std::set& recognized_fields) { + if (value.type != object_type::MAP) { + error_ = "expected event map, got " + TypeName(value); + return; + } + for (uint32_t index = 0; index < value.via.map.size; ++index) { + const auto& item = value.via.map.ptr[index]; + if (item.key.type != object_type::STR) { + error_ = "event map key at index " + std::to_string(index) + + " must be a string"; + return; + } + const std::string key(item.key.via.str.ptr, item.key.via.str.size); + if (!recognized_fields.contains(key)) { + continue; + } + if (!fields_.emplace(key, &item.val).second) { + error_ = "duplicate recognized key: " + key; + return; + } + } + } + + const std::string& error() const { return error_; } + + const object* Get(std::string_view name) const { + auto it = fields_.find(std::string(name)); + return it == fields_.end() ? nullptr : it->second; + } + + private: + std::map fields_; + std::string error_; +}; + +ValueResult ParseString(const object& value) { + if (value.type != object_type::STR) { + return ValueResult::Err("expected string, got " + + TypeName(value)); + } + return ValueResult::Ok( + std::string(value.via.str.ptr, value.via.str.size)); +} + +ValueResult> ParseNullableString( + const object& value) { + if (value.type == object_type::NIL) { + return ValueResult>::Ok(std::nullopt); + } + auto parsed = ParseString(value); + if (!parsed.value.has_value()) { + return ValueResult>::Err(parsed.error); + } + return ValueResult>::Ok( + std::move(*parsed.value)); +} + +ValueResult ParseUint64(const object& value) { + if (value.type != object_type::POSITIVE_INTEGER) { + return ValueResult::Err("expected unsigned integer, got " + + TypeName(value)); + } + return ValueResult::Ok(value.via.u64); +} + +ValueResult ParseInt64(const object& value) { + if (value.type == object_type::NEGATIVE_INTEGER) { + return ValueResult::Ok(value.via.i64); + } + if (value.type == object_type::POSITIVE_INTEGER && + value.via.u64 <= + static_cast(std::numeric_limits::max())) { + return ValueResult::Ok(static_cast(value.via.u64)); + } + return ValueResult::Err("expected signed 64-bit integer, got " + + TypeName(value)); +} + +ValueResult> ParseNullableInt64(const object& value) { + if (value.type == object_type::NIL) { + return ValueResult>::Ok(std::nullopt); + } + auto parsed = ParseInt64(value); + if (!parsed.value.has_value()) { + return ValueResult>::Err(parsed.error); + } + return ValueResult>::Ok(*parsed.value); +} + +ValueResult> ParseNullableUint64(const object& value) { + if (value.type == object_type::NIL) { + return ValueResult>::Ok(std::nullopt); + } + auto parsed = ParseUint64(value); + if (!parsed.value.has_value()) { + return ValueResult>::Err(parsed.error); + } + return ValueResult>::Ok(*parsed.value); +} + +// SGLang's event API exposes hashes as signed int64 values. The Conductor +// keeps the exact 64-bit bit pattern in uint64_t so high-bit hashes remain +// compatible with the other event sources. +ValueResult ParseSglangHash(const object& value) { + if (value.type == object_type::NEGATIVE_INTEGER) { + return ValueResult::Ok(static_cast(value.via.i64)); + } + if (value.type == object_type::POSITIVE_INTEGER && + value.via.u64 <= + static_cast(std::numeric_limits::max())) { + return ValueResult::Ok(value.via.u64); + } + return ValueResult::Err("expected signed 64-bit hash, got " + + TypeName(value)); +} + +ValueResult> ParseNullableSglangHash( + const object& value) { + if (value.type == object_type::NIL) { + return ValueResult>::Ok(std::nullopt); + } + auto parsed = ParseSglangHash(value); + if (!parsed.value.has_value()) { + return ValueResult>::Err(parsed.error); + } + return ValueResult>::Ok(*parsed.value); +} + +ValueResult ParseExternalHash(const object& value) { + if (value.type == object_type::POSITIVE_INTEGER) { + return ValueResult::Ok(value.via.u64); + } + if (value.type == object_type::BIN) { + const auto* begin = reinterpret_cast(value.via.bin.ptr); + return ValueResult::Ok( + std::vector(begin, begin + value.via.bin.size)); + } + return ValueResult::Err( + "expected unsigned integer or binary hash, got " + TypeName(value)); +} + +ValueResult> ParseNullableExternalHash( + const object& value) { + if (value.type == object_type::NIL) { + return ValueResult>::Ok(std::nullopt); + } + auto parsed = ParseExternalHash(value); + if (!parsed.value.has_value()) { + return ValueResult>::Err(parsed.error); + } + return ValueResult>::Ok( + std::move(*parsed.value)); +} + +template +ValueResult> ParseArray(const object& value, Parser parser) { + if (value.type != object_type::ARRAY) { + return ValueResult>::Err("expected array, got " + + TypeName(value)); + } + std::vector result; + result.reserve(value.via.array.size); + for (uint32_t index = 0; index < value.via.array.size; ++index) { + auto parsed = parser(value.via.array.ptr[index]); + if (!parsed.value.has_value()) { + return ValueResult>::Err( + "element " + std::to_string(index) + ": " + parsed.error); + } + result.push_back(std::move(*parsed.value)); + } + return ValueResult>::Ok(std::move(result)); +} + +ValueResult> ParseExternalHashes( + const object& value) { + return ParseArray(value, ParseExternalHash); +} + +ValueResult> ParseUint64Array(const object& value) { + return ParseArray(value, ParseUint64); +} + +ValueResult> ParseInt32Array(const object& value) { + return ParseArray(value, [](const object& item) { + auto parsed = ParseInt64(item); + if (!parsed.value.has_value()) { + return ValueResult::Err(parsed.error); + } + if (*parsed.value < std::numeric_limits::min() || + *parsed.value > std::numeric_limits::max()) { + return ValueResult::Err("integer is outside int32 range"); + } + return ValueResult::Ok(static_cast(*parsed.value)); + }); +} + +ValueResult>> ParseNullableInt32Array( + const object& value) { + if (value.type == object_type::NIL) { + return ValueResult>>::Ok( + std::nullopt); + } + auto parsed = ParseInt32Array(value); + if (!parsed.value.has_value()) { + return ValueResult>>::Err( + parsed.error); + } + return ValueResult>>::Ok( + std::move(*parsed.value)); +} + +template +bool ParseRequired(const MapReader& reader, std::string_view field, + Parser parser, T* output, std::string* error) { + const object* value = reader.Get(field); + if (value == nullptr) { + *error = "missing required key: " + std::string(field); + return false; + } + auto parsed = parser(*value); + if (!parsed.value.has_value()) { + *error = "invalid " + std::string(field) + ": " + parsed.error; + return false; + } + *output = std::move(*parsed.value); + return true; +} + +template +bool ParseOptional(const MapReader& reader, std::string_view field, + Parser parser, std::optional* output, + std::string* error) { + const object* value = reader.Get(field); + if (value == nullptr) { + output->reset(); + return true; + } + auto parsed = parser(*value); + if (!parsed.value.has_value()) { + *error = "invalid " + std::string(field) + ": " + parsed.error; + return false; + } + *output = std::move(*parsed.value); + return true; +} + +template +bool ParseOptionalNullable(const MapReader& reader, std::string_view field, + Parser parser, std::optional* output, + std::string* error) { + const object* value = reader.Get(field); + if (value == nullptr) { + output->reset(); + return true; + } + auto parsed = parser(*value); + if (!parsed.value.has_value()) { + *error = "invalid " + std::string(field) + ": " + parsed.error; + return false; + } + *output = std::move(*parsed.value); + return true; +} + +const std::set kVllmFields = { + "type", "block_hashes", "parent_block_hash", + "token_ids", "block_size", "lora_id", + "medium", "lora_name", "extra_keys", + "group_idx", "kv_cache_spec_kind", "kv_cache_spec_sliding_window", +}; + +const std::set kVllmRemovedFields = {"type", "block_hashes", + "medium", "group_idx"}; + +std::string ValidateVllmExtraKeys(const object& value, size_t block_count, + bool* present) { + if (value.type == object_type::NIL) { + *present = false; + return ""; + } + if (value.type != object_type::ARRAY) { + return "expected array or nil, got " + TypeName(value); + } + if (value.via.array.size != block_count) { + return "expected one entry per block hash, got " + + std::to_string(value.via.array.size) + " entries for " + + std::to_string(block_count) + " block hashes"; + } + for (uint32_t index = 0; index < value.via.array.size; ++index) { + const object& item = value.via.array.ptr[index]; + if (item.type != object_type::NIL && item.type != object_type::ARRAY) { + return "element " + std::to_string(index) + + ": expected array or nil, got " + TypeName(item); + } + } + *present = true; + return ""; +} + +ValueResult ParseVllmEvent(const object& raw) { + MapReader reader(raw, kVllmFields); + if (!reader.error().empty()) { + return ValueResult::Err(reader.error()); + } + + std::string error; + std::string type; + if (!ParseRequired(reader, "type", ParseString, &type, &error)) { + return ValueResult::Err(error); + } + + if (type == "BlockStored") { + VllmStoredEvent event; + if (!ParseRequired(reader, "block_hashes", ParseExternalHashes, + &event.block_hashes, &error) || + !ParseRequired(reader, "parent_block_hash", + ParseNullableExternalHash, &event.parent_block_hash, + &error) || + !ParseRequired(reader, "token_ids", ParseNullableInt32Array, + &event.token_ids, &error) || + !ParseRequired(reader, "block_size", ParseInt64, &event.block_size, + &error) || + !ParseRequired(reader, "lora_id", ParseNullableInt64, + &event.lora_id, &error) || + !ParseRequired(reader, "medium", ParseNullableString, &event.medium, + &error) || + !ParseRequired(reader, "lora_name", ParseNullableString, + &event.lora_name, &error) || + !ParseOptionalNullable(reader, "group_idx", ParseNullableInt64, + &event.group_idx, &error) || + !ParseOptionalNullable(reader, "kv_cache_spec_kind", + ParseNullableString, + &event.kv_cache_spec_kind, &error) || + !ParseOptionalNullable( + reader, "kv_cache_spec_sliding_window", ParseNullableInt64, + &event.kv_cache_spec_sliding_window, &error)) { + return ValueResult::Err(error); + } + if (const object* extra_keys = reader.Get("extra_keys"); + extra_keys != nullptr) { + if (std::string extra_keys_error = ValidateVllmExtraKeys( + *extra_keys, event.block_hashes.size(), + &event.extra_keys_present); + !extra_keys_error.empty()) { + return ValueResult::Err("invalid extra_keys: " + + extra_keys_error); + } + } + return ValueResult::Ok(std::move(event)); + } + + if (type == "BlockRemoved") { + for (std::string_view field : kVllmFields) { + if (!kVllmRemovedFields.contains(field) && + reader.Get(field) != nullptr) { + return ValueResult::Err( + "BlockRemoved contains recognized key: " + + std::string(field)); + } + } + VllmRemovedEvent event; + if (!ParseRequired(reader, "block_hashes", ParseExternalHashes, + &event.block_hashes, &error) || + !ParseRequired(reader, "medium", ParseNullableString, &event.medium, + &error) || + !ParseOptionalNullable(reader, "group_idx", ParseNullableInt64, + &event.group_idx, &error)) { + return ValueResult::Err(error); + } + return ValueResult::Ok(std::move(event)); + } + + if (type == "AllBlocksCleared") { + for (std::string_view field : kVllmFields) { + if (field != "type" && reader.Get(field) != nullptr) { + return ValueResult::Err( + "AllBlocksCleared contains recognized key: " + + std::string(field)); + } + } + return ValueResult::Ok(VllmClearedEvent{}); + } + + return ValueResult::Err("unknown vLLM event tag: " + type); +} + +const std::set kMooncakeFields = { + "event_id", + "timestamp", + "event_type", + "type", + "model_name", + "block_size", + "additional_salt", + "lora_name", + "tenant_id", + "backend_id", + "medium", + "dp_rank", + "group_id", + "object_key", + "connector_block_hash", + "cache_prefix", + "tp_rank", + "head_or_tp_rank", + "pcp_rank", + "dcp_rank", + "pp_rank", + "layer_id", + "seq_hashes", + "block_hashes", + "base_block_idx", + "parent_hash", + "token_ids", + "parent_block_hash", +}; + +bool ParseMooncakeCommon(const MapReader& reader, int64_t batch_timestamp, + std::string_view event_type, + MooncakeEventFields* fields, std::string* error) { + if (!ParseRequired(reader, "event_id", ParseUint64, &fields->event_id, + error) || + !ParseRequired(reader, "timestamp", ParseInt64, + &fields->timestamp_milliseconds, error) || + !ParseRequired(reader, "model_name", ParseNullableString, + &fields->model_name, error) || + !ParseRequired(reader, "block_size", ParseNullableInt64, + &fields->block_size, error) || + !ParseRequired(reader, "additional_salt", ParseNullableString, + &fields->additional_salt, error) || + !ParseRequired(reader, "lora_name", ParseNullableString, + &fields->lora_name, error) || + !ParseRequired(reader, "tenant_id", ParseString, &fields->tenant_id, + error) || + !ParseRequired(reader, "backend_id", ParseString, &fields->backend_id, + error) || + !ParseRequired(reader, "medium", ParseNullableString, &fields->medium, + error) || + !ParseRequired(reader, "dp_rank", ParseInt64, + &fields->data_parallel_rank, error)) { + return false; + } + if (fields->timestamp_milliseconds != batch_timestamp) { + *error = "event timestamp conflicts with batch timestamp"; + return false; + } + if (fields->data_parallel_rank < 0) { + *error = "event dp_rank must be non-negative"; + return false; + } + + if (const object* legacy_type = reader.Get("type"); + legacy_type != nullptr) { + auto parsed = ParseString(*legacy_type); + if (!parsed.value.has_value()) { + *error = "invalid type: " + parsed.error; + return false; + } + const std::string_view expected = + event_type == "stored" + ? "BlockStored" + : (event_type == "removed" ? "BlockRemoved" + : "AllBlocksCleared"); + if (*parsed.value != expected) { + *error = "legacy type conflicts with event_type"; + return false; + } + } + return true; +} + +bool ParseMooncakeObject(const MapReader& reader, MooncakeObjectFields* object, + std::string* error) { + if (!ParseRequired(reader, "group_id", ParseNullableString, + &object->group_id, error) || + !ParseRequired(reader, "seq_hashes", ParseUint64Array, + &object->seq_hashes, error) || + !ParseRequired(reader, "base_block_idx", ParseNullableInt64, + &object->base_block_idx, error) || + !ParseOptional(reader, "object_key", ParseString, &object->object_key, + error) || + !ParseOptional(reader, "connector_block_hash", ParseString, + &object->connector_block_hash, error) || + !ParseOptional(reader, "cache_prefix", ParseString, + &object->cache_prefix, error) || + !ParseOptionalNullable(reader, "tp_rank", ParseNullableInt64, + &object->tp_rank, error) || + !ParseOptionalNullable(reader, "head_or_tp_rank", ParseNullableInt64, + &object->head_or_tp_rank, error) || + !ParseOptionalNullable(reader, "pcp_rank", ParseNullableInt64, + &object->pcp_rank, error) || + !ParseOptionalNullable(reader, "dcp_rank", ParseNullableInt64, + &object->dcp_rank, error) || + !ParseOptionalNullable(reader, "pp_rank", ParseNullableInt64, + &object->pp_rank, error) || + !ParseOptionalNullable(reader, "layer_id", ParseNullableInt64, + &object->layer_id, error) || + !ParseOptional(reader, "block_hashes", ParseUint64Array, + &object->legacy_block_hashes, error)) { + return false; + } + if (object->legacy_block_hashes.has_value() && + *object->legacy_block_hashes != object->seq_hashes) { + *error = "legacy block_hashes conflicts with seq_hashes"; + return false; + } + return true; +} + +ValueResult ParseMooncakeEvent(const object& raw, + int64_t batch_timestamp) { + MapReader reader(raw, kMooncakeFields); + if (!reader.error().empty()) { + return ValueResult::Err(reader.error()); + } + std::string error; + std::string event_type; + if (!ParseRequired(reader, "event_type", ParseString, &event_type, + &error)) { + return ValueResult::Err(error); + } + if (event_type != "stored" && event_type != "removed" && + event_type != "cleared") { + return ValueResult::Err("unknown Mooncake event tag: " + + event_type); + } + + MooncakeEventFields fields; + if (!ParseMooncakeCommon(reader, batch_timestamp, event_type, &fields, + &error)) { + return ValueResult::Err(error); + } + + if (event_type == "cleared") { + static const std::set kObjectOnlyFields = { + "group_id", "object_key", "connector_block_hash", + "cache_prefix", "tp_rank", "head_or_tp_rank", + "pcp_rank", "dcp_rank", "pp_rank", + "layer_id", "seq_hashes", "block_hashes", + "base_block_idx", "parent_hash", "token_ids", + "parent_block_hash", + }; + for (std::string_view field : kObjectOnlyFields) { + if (reader.Get(field) != nullptr) { + return ValueResult::Err( + "cleared event contains recognized object key: " + + std::string(field)); + } + } + return ValueResult::Ok( + MooncakeClearedEvent{.fields = std::move(fields)}); + } + + MooncakeObjectFields object_fields; + if (!ParseMooncakeObject(reader, &object_fields, &error)) { + return ValueResult::Err(error); + } + + if (event_type == "removed") { + if (reader.Get("parent_hash") != nullptr || + reader.Get("token_ids") != nullptr || + reader.Get("parent_block_hash") != nullptr) { + return ValueResult::Err( + "removed event contains recognized stored-only key"); + } + return ValueResult::Ok(MooncakeRemovedEvent{ + .fields = std::move(fields), .object = std::move(object_fields)}); + } + + MooncakeStoredEvent event{.fields = std::move(fields), + .object = std::move(object_fields)}; + if (!ParseRequired(reader, "parent_hash", ParseNullableUint64, + &event.parent_hash, &error) || + !ParseRequired(reader, "token_ids", ParseNullableInt32Array, + &event.token_ids, &error)) { + return ValueResult::Err(error); + } + if (const object* legacy_parent = reader.Get("parent_block_hash"); + legacy_parent != nullptr) { + auto parsed = ParseNullableUint64(*legacy_parent); + if (!parsed.value.has_value()) { + return ValueResult::Err( + "invalid parent_block_hash: " + parsed.error); + } + if (*parsed.value != event.parent_hash) { + return ValueResult::Err( + "parent_block_hash conflicts with parent_hash"); + } + } + return ValueResult::Ok(std::move(event)); +} + +ValueResult>> ParseSglangTokenIds( + const object& value) { + if (value.type == object_type::NIL) { + return ValueResult>>::Ok( + std::nullopt); + } + if (value.type != object_type::ARRAY) { + return ValueResult>>::Err( + "expected array or nil, got " + TypeName(value)); + } + std::vector result; + for (uint32_t index = 0; index < value.via.array.size; ++index) { + const object& item = value.via.array.ptr[index]; + if (item.type == object_type::ARRAY) { + if (item.via.array.size != 2) { + return ValueResult>>::Err( + "token tuple must contain two integers"); + } + for (uint32_t tuple_index = 0; tuple_index < 2; ++tuple_index) { + auto parsed = ParseInt64(item.via.array.ptr[tuple_index]); + if (!parsed.value.has_value() || + *parsed.value < std::numeric_limits::min() || + *parsed.value > std::numeric_limits::max()) { + return ValueResult>>:: + Err("token tuple element is outside int32 range"); + } + result.push_back(static_cast(*parsed.value)); + } + continue; + } + auto parsed = ParseInt64(item); + if (!parsed.value.has_value() || + *parsed.value < std::numeric_limits::min() || + *parsed.value > std::numeric_limits::max()) { + return ValueResult>>::Err( + "token id is outside int32 range"); + } + result.push_back(static_cast(*parsed.value)); + } + return ValueResult>>::Ok( + std::move(result)); +} + +ValueResult ParseSglangEvent(const object& raw) { + if (raw.type != object_type::ARRAY || raw.via.array.size == 0) { + return ValueResult::Err( + "expected tagged SGLang event array"); + } + const auto& array = raw.via.array; + if (array.ptr[0].type != object_type::STR) { + return ValueResult::Err( + "SGLang event tag must be a string"); + } + const std::string tag(array.ptr[0].via.str.ptr, array.ptr[0].via.str.size); + + if (tag == "AllBlocksCleared") { + if (array.size != 1) { + return ValueResult::Err( + "AllBlocksCleared must not contain fields"); + } + return ValueResult::Ok(SglangClearedEvent{}); + } + + if (tag == "BlockRemoved") { + if (array.size < 2) { + return ValueResult::Err( + "BlockRemoved has too few fields"); + } + auto hashes = ParseArray(array.ptr[1], ParseSglangHash); + if (!hashes.value.has_value()) { + return ValueResult::Err( + "invalid SGLang block_hashes: " + hashes.error); + } + auto medium = + array.size >= 3 + ? ParseNullableString(array.ptr[2]) + : ValueResult>::Ok(std::nullopt); + if (!medium.value.has_value()) { + return ValueResult::Err("invalid SGLang medium: " + + medium.error); + } + return ValueResult::Ok( + SglangRemovedEvent{.block_hashes = std::move(*hashes.value), + .medium = std::move(*medium.value)}); + } + + if (tag == "BlockStored") { + // BlockStored's inherited fields are array-like and ordered as in + // SGLang's BlockStored struct. A metadata extension, when present, + // is trailing and intentionally ignored by the base decoder. + if (array.size < 6) { + return ValueResult::Err( + "BlockStored has too few fields"); + } + auto hashes = ParseArray(array.ptr[1], ParseSglangHash); + if (!hashes.value.has_value()) { + return ValueResult::Err( + "invalid SGLang block_hashes: " + hashes.error); + } + auto parent = ParseNullableSglangHash(array.ptr[2]); + if (!parent.value.has_value()) { + return ValueResult::Err( + "invalid SGLang parent_block_hash: " + parent.error); + } + auto tokens = ParseSglangTokenIds(array.ptr[3]); + if (!tokens.value.has_value()) { + return ValueResult::Err("invalid SGLang token_ids: " + + tokens.error); + } + auto block_size = ParseInt64(array.ptr[4]); + if (!block_size.value.has_value() || *block_size.value <= 0) { + return ValueResult::Err("invalid SGLang block_size: " + + block_size.error); + } + auto lora_id = ParseNullableInt64(array.ptr[5]); + if (!lora_id.value.has_value()) { + return ValueResult::Err("invalid SGLang lora_id: " + + lora_id.error); + } + auto medium = + array.size >= 7 + ? ParseNullableString(array.ptr[6]) + : ValueResult>::Ok(std::nullopt); + if (!medium.value.has_value()) { + return ValueResult::Err("invalid SGLang medium: " + + medium.error); + } + + std::optional cache_salt; + if (array.size > 7 && array.ptr[7].type != object_type::NIL) { + // BlockStoredWithMetadata currently serializes metadata as a + // one-field array-like struct. Accept a map as well for forward + // compatibility with msgspec configuration changes. + const auto& metadata = array.ptr[7]; + if (metadata.type == object_type::ARRAY && + metadata.via.array.size >= 1 && + metadata.via.array.ptr[0].type == object_type::STR) { + cache_salt = + std::string(metadata.via.array.ptr[0].via.str.ptr, + metadata.via.array.ptr[0].via.str.size); + } else if (metadata.type == object_type::MAP) { + for (uint32_t i = 0; i < metadata.via.map.size; ++i) { + const auto& item = metadata.via.map.ptr[i]; + if (item.key.type == object_type::STR && + std::string_view(item.key.via.str.ptr, + item.key.via.str.size) == + "cache_salt") { + auto parsed = ParseString(item.val); + if (!parsed.value.has_value()) { + return ValueResult::Err( + "invalid SGLang cache_salt: " + parsed.error); + } + cache_salt = std::move(*parsed.value); + } + } + } + } + return ValueResult::Ok( + SglangStoredEvent{.block_hashes = std::move(*hashes.value), + .parent_block_hash = std::move(*parent.value), + .token_ids = std::move(*tokens.value), + .block_size = *block_size.value, + .lora_id = std::move(*lora_id.value), + .medium = std::move(*medium.value), + .cache_salt = std::move(cache_salt)}); + } + + return ValueResult::Err("unknown SGLang event tag: " + tag); +} + +template +BatchDecodeResult EnvelopeError(std::string error) { + return {.ok = false, .batch = {}, .error = std::move(error)}; +} + +ValueResult UnpackOne(const char* data, size_t len) { + if (data == nullptr || len == 0) { + return ValueResult::Err("empty payload"); + } + try { + size_t offset = 0; + auto handle = msgpack::unpack(data, len, offset); + if (offset != len) { + return ValueResult::Err( + "trailing bytes after MessagePack value"); + } + return ValueResult::Ok(std::move(handle)); + } catch (const std::exception& error) { + return ValueResult::Err(error.what()); + } +} + +bool ValidateEnvelopeShape(const object& root, const object** timestamp, + const object** events, const object** dp_rank, + std::string* error) { + if (root.type != object_type::ARRAY) { + *error = "expected three-element array envelope, got " + TypeName(root); + return false; + } + if (root.via.array.size != 3) { + *error = "expected three-element array envelope, got " + + std::to_string(root.via.array.size) + " elements"; + return false; + } + *timestamp = &root.via.array.ptr[0]; + *events = &root.via.array.ptr[1]; + *dp_rank = &root.via.array.ptr[2]; + if ((*events)->type != object_type::ARRAY) { + *error = "events must be an array, got " + TypeName(**events); + return false; + } + return true; +} + +ValueResult> ParseBatchDpRank(const object& value) { + auto parsed = ParseNullableInt64(value); + if (!parsed.value.has_value()) { + return parsed; + } + if (parsed.value->has_value() && **parsed.value < 0) { + return ValueResult>::Err( + "data_parallel_rank must be non-negative or nil"); + } + return parsed; +} + +} // namespace + +VllmEventBatchResult DecodeVllmEventBatch(const char* data, size_t len) { + auto unpacked = UnpackOne(data, len); + if (!unpacked.value.has_value()) { + return EnvelopeError( + "failed to decode vLLM envelope: " + unpacked.error); + } + const object* timestamp = nullptr; + const object* events = nullptr; + const object* dp_rank = nullptr; + std::string error; + if (!ValidateEnvelopeShape(unpacked.value->get(), ×tamp, &events, + &dp_rank, &error)) { + return EnvelopeError(error); + } + if (timestamp->type != object_type::FLOAT32 && + timestamp->type != object_type::FLOAT64) { + return EnvelopeError( + "vLLM timestamp must be a float, got " + TypeName(*timestamp)); + } + if (!std::isfinite(timestamp->via.f64)) { + return EnvelopeError("vLLM timestamp must be finite"); + } + auto parsed_rank = ParseBatchDpRank(*dp_rank); + if (!parsed_rank.value.has_value()) { + return EnvelopeError( + "invalid vLLM data_parallel_rank: " + parsed_rank.error); + } + + VllmEventBatch batch{.timestamp_seconds = timestamp->via.f64, + .events = {}, + .data_parallel_rank = *parsed_rank.value}; + batch.events.reserve(events->via.array.size); + for (uint32_t index = 0; index < events->via.array.size; ++index) { + auto parsed = ParseVllmEvent(events->via.array.ptr[index]); + if (parsed.value.has_value()) { + batch.events.push_back( + {.event = std::move(*parsed.value), .error = ""}); + } else { + batch.events.push_back({.event = std::nullopt, + .error = "event " + std::to_string(index) + + ": " + parsed.error}); + } + } + return {.ok = true, .batch = std::move(batch), .error = ""}; +} + +MooncakeEventBatchResult DecodeMooncakeEventBatch(const char* data, + size_t len) { + auto unpacked = UnpackOne(data, len); + if (!unpacked.value.has_value()) { + return EnvelopeError( + "failed to decode Mooncake envelope: " + unpacked.error); + } + const object* timestamp = nullptr; + const object* events = nullptr; + const object* dp_rank = nullptr; + std::string error; + if (!ValidateEnvelopeShape(unpacked.value->get(), ×tamp, &events, + &dp_rank, &error)) { + return EnvelopeError(error); + } + auto parsed_timestamp = ParseInt64(*timestamp); + if (!parsed_timestamp.value.has_value() || *parsed_timestamp.value < 0) { + return EnvelopeError( + "Mooncake timestamp must be a non-negative integer"); + } + auto parsed_rank = ParseBatchDpRank(*dp_rank); + if (!parsed_rank.value.has_value()) { + return EnvelopeError( + "invalid Mooncake data_parallel_rank: " + parsed_rank.error); + } + + MooncakeEventBatch batch{ + .timestamp_milliseconds = *parsed_timestamp.value, + .events = {}, + .data_parallel_rank = *parsed_rank.value, + }; + batch.events.reserve(events->via.array.size); + for (uint32_t index = 0; index < events->via.array.size; ++index) { + auto parsed = ParseMooncakeEvent(events->via.array.ptr[index], + batch.timestamp_milliseconds); + if (parsed.value.has_value()) { + batch.events.push_back( + {.event = std::move(*parsed.value), .error = ""}); + } else { + batch.events.push_back({.event = std::nullopt, + .error = "event " + std::to_string(index) + + ": " + parsed.error}); + } + } + return {.ok = true, .batch = std::move(batch), .error = ""}; +} + +SglangEventBatchResult DecodeSglangEventBatch(const char* data, size_t len) { + auto unpacked = UnpackOne(data, len); + if (!unpacked.value.has_value()) { + return EnvelopeError( + "failed to decode SGLang envelope: " + unpacked.error); + } + const object* timestamp = nullptr; + const object* events = nullptr; + const object* dp_rank = nullptr; + std::string error; + if (!ValidateEnvelopeShape(unpacked.value->get(), ×tamp, &events, + &dp_rank, &error)) { + return EnvelopeError(error); + } + // A SGLang batch is array-like at both the envelope and event levels. + // Treat a map event as a protocol mismatch so ZMQClient can fall back to + // the Mooncake map decoder for an SGLang-backed Mooncake Store. Malformed + // array events still remain event-local errors below. + for (uint32_t index = 0; index < events->via.array.size; ++index) { + if (events->via.array.ptr[index].type != object_type::ARRAY) { + return EnvelopeError( + "SGLang event entries must be arrays"); + } + } + if (timestamp->type != object_type::FLOAT32 && + timestamp->type != object_type::FLOAT64) { + return EnvelopeError( + "SGLang timestamp must be a float"); + } + if (!std::isfinite(timestamp->via.f64)) { + return EnvelopeError( + "SGLang timestamp must be finite"); + } + auto parsed_rank = ParseBatchDpRank(*dp_rank); + if (!parsed_rank.value.has_value()) { + return EnvelopeError( + "invalid SGLang data_parallel_rank: " + parsed_rank.error); + } + + SglangEventBatch batch{.timestamp_seconds = timestamp->via.f64, + .events = {}, + .data_parallel_rank = *parsed_rank.value}; + batch.events.reserve(events->via.array.size); + for (uint32_t index = 0; index < events->via.array.size; ++index) { + auto parsed = ParseSglangEvent(events->via.array.ptr[index]); + if (parsed.value.has_value()) { + batch.events.push_back( + {.event = std::move(*parsed.value), .error = ""}); + } else { + batch.events.push_back({.event = std::nullopt, + .error = "event " + std::to_string(index) + + ": " + parsed.error}); + } + } + return {.ok = true, .batch = std::move(batch), .error = ""}; +} + +} // namespace mooncake::conductor::zmq diff --git a/mooncake-conductor/src/zmq/zmq_client.cpp b/mooncake-conductor/src/zmq/zmq_client.cpp new file mode 100644 index 0000000000..471256d4ea --- /dev/null +++ b/mooncake-conductor/src/zmq/zmq_client.cpp @@ -0,0 +1,875 @@ +#include "conductor/zmq/zmq_client.h" + +#include +#include + +#include +#include +#include +#include +#include +#include + +#include "conductor/zmq/msg_decoder.h" + +namespace mooncake::conductor::zmq { + +namespace { + +// 8-byte big-endian sequence number frames. +uint64_t BigEndianToU64(const unsigned char* b) { + uint64_t v = 0; + for (int i = 0; i < 8; ++i) { + v = (v << 8) | b[i]; + } + return v; +} + +void U64ToBigEndian(uint64_t v, unsigned char* out) { + for (int i = 7; i >= 0; --i) { + out[i] = static_cast(v & 0xFF); + v >>= 8; + } +} + +bool ReplayEnabled(const ZMQClientConfig& config) { + return (config.publisher_kind == common::PublisherKind::kVllm || + config.publisher_kind == common::PublisherKind::kSglang) && + !config.replay_endpoint.empty(); +} + +} // namespace + +std::string ValidateConfig(const ZMQClientConfig& config) { + if (config.endpoint.empty()) { + return "endpoint is required"; + } + if (config.replay_timeout <= std::chrono::milliseconds::zero()) { + return "replay_timeout must be positive"; + } + if (config.replay_recovery_timeout <= std::chrono::milliseconds::zero()) { + return "replay_recovery_timeout must be positive"; + } + if (config.max_recovery_buffered_messages == 0) { + return "max_recovery_buffered_messages must be positive"; + } + if (config.max_recovery_buffered_bytes == 0) { + return "max_recovery_buffered_bytes must be positive"; + } + return ""; +} + +ZMQClient::ZMQClient(ZMQClientConfig config, + std::shared_ptr handler) + : config_(std::move(config)), + event_handler_(std::move(handler)), + reconnect_delay_(config_.reconnect_delay) {} + +ZMQClient::~ZMQClient() { Stop(); } + +std::string ZMQClient::Start() { + // Attempt initial connection + if (auto err = Connect(); !err.empty()) { + return "initial connection failed: " + err; + } + + loop_thread_ = std::thread([this] { Loop(); }); + + LOG(INFO) << "ZMQ client started service=" << config_.cache_pool_key + << " endpoint=" << config_.endpoint << " publisher_kind=" + << common::PublisherKindName(config_.publisher_kind); + return ""; +} + +void ZMQClient::Stop() { + std::lock_guard stop_lock(stop_mu_); + stop_requested_.store(true); + if (loop_thread_.joinable()) { + loop_thread_.join(); + } + + { + std::unique_lock lock(mu_); + CleanupSocketsLocked(); + } + + LOG(INFO) << "ZMQ client stopped service=" << config_.cache_pool_key; +} + +// Loop is the main background loop handling events and reconnections. +// Fixed reconnect interval, single loop structure. +void ZMQClient::Loop() { + while (true) { + // Check if we should stop + if (stop_requested_.load()) { + return; + } + + // 1. If disconnected, wait for the delay then try to reconnect + if (!IsConnected()) { + HandleReconnect(); + continue; + } + + // 2. If connected, consume events + if (auto err = Consume(); !err.empty()) { + LOG(ERROR) << "Consumption error service=" << config_.cache_pool_key + << " error=" << err; + MarkDisconnected(); + } + } +} + +void ZMQClient::HandleReconnect() { + LOG(INFO) << "Attempting to reconnect to the service. service=" + << config_.cache_pool_key + << " reconnectDelay=" << reconnect_delay_.count() << "ms"; + + // Poll the stop flag in slices so Stop() is honored within ~one poll + // interval. + const auto deadline = + std::chrono::steady_clock::now() + config_.reconnect_delay; + while (std::chrono::steady_clock::now() < deadline) { + if (stop_requested_.load()) { + return; + } + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + } + + if (auto err = Connect(); !err.empty()) { + LOG(ERROR) << "Reconnect failed service=" << config_.cache_pool_key + << " error=" << err; + return; + } + + int64_t last_seq; + bool stale; + { + std::shared_lock lock(mu_); + last_seq = last_seq_; + stale = stale_; + } + if (ReplayEnabled(config_) && last_seq >= 0 && + last_seq != std::numeric_limits::max() && !stale) { + LOG(INFO) << "Reconnected service=" << config_.cache_pool_key + << " resuming_from=" << last_seq + 1; + StartRecovery("connection was interrupted"); + if (auto err = AttemptRecovery(); !err.empty()) { + LOG(ERROR) << "Failed to process replay after reconnect service=" + << config_.cache_pool_key << " error=" << err; + } + } +} + +std::string ZMQClient::Connect() { + std::unique_lock lock(mu_); + + if (connected_) { + return ""; + } + + // Ensure clean state + CleanupSocketsLocked(); + + try { + auto sock = std::make_unique<::zmq::socket_t>(zmq_context_, + ::zmq::socket_type::sub); + // Enable IPv6 for dual-stack support + sock->set(::zmq::sockopt::ipv6, 1); + // Set the receive HWM before connect; ZeroMQ applies it at connection + // setup. + if (config_.rcv_hwm > 0) { + sock->set(::zmq::sockopt::rcvhwm, config_.rcv_hwm); + } + sock->connect(config_.endpoint); + // Important: Subscribe to all topics + sock->set(::zmq::sockopt::subscribe, ""); + + sub_socket_ = std::move(sock); + if (ReplayEnabled(config_)) { + auto replay_socket = std::make_unique<::zmq::socket_t>( + zmq_context_, ::zmq::socket_type::dealer); + replay_socket->set(::zmq::sockopt::ipv6, 1); + replay_socket->connect(config_.replay_endpoint); + replay_socket_ = std::move(replay_socket); + } + connected_ = true; + + reconnect_delay_ = config_.reconnect_delay; + } catch (const ::zmq::error_t& e) { + CleanupSocketsLocked(); + return std::string("failed to connect to ") + config_.endpoint + ": " + + e.what(); + } + + LOG(INFO) << "Successfully connected to publisher service=" + << config_.cache_pool_key << " endpoint=" << config_.endpoint + << " publisher_kind=" + << common::PublisherKindName(config_.publisher_kind) + << " live_only=" << !ReplayEnabled(config_); + if (!config_.replay_endpoint.empty() && !ReplayEnabled(config_)) { + LOG(WARNING) << "Ignoring replay_endpoint for publisher kind=" + << common::PublisherKindName(config_.publisher_kind) + << "; replay is supported only for vLLM and SGLang"; + } + + return ""; +} + +std::string ZMQClient::Consume() { + // Grab the socket pointer under the read lock and poll outside the + // lock; the socket is only destroyed by Stop() (after this thread + // joins) or by Connect() on this same thread, so that is safe here. + ::zmq::socket_t* socket; + { + std::shared_lock lock(mu_); + socket = sub_socket_.get(); + } + if (socket == nullptr) { + return "socket is nil"; + } + + try { + ::zmq::pollitem_t items[] = {{socket->handle(), 0, ZMQ_POLLIN, 0}}; + const int rc = ::zmq::poll(items, 1, config_.poll_timeout); + if (rc == 0) { + return AttemptRecovery(); + } + if (!(items[0].revents & ZMQ_POLLIN)) { + return ""; + } + } catch (const ::zmq::error_t& e) { + return std::string("poll error: ") + e.what(); + } + + if (auto err = ProcessMessage(); !err.empty()) { + return "failed to process message: " + err; + } + + return ""; +} + +std::string ZMQClient::ProcessMessage() { + ::zmq::socket_t* socket; + { + std::shared_lock lock(mu_); + socket = sub_socket_.get(); + } + if (socket == nullptr) { + return "socket is nil"; + } + + // Once the first frame is readable, the complete multipart message is + // available. Consume it through the final frame so malformed frame counts + // cannot block shutdown or leak a tail into the next message. + std::vector<::zmq::message_t> frames; + try { + const auto frame_count = ::zmq::recv_multipart( + *socket, std::back_inserter(frames), ::zmq::recv_flags::none); + if (!frame_count) { + return "failed to receive multipart message"; + } + } catch (const ::zmq::error_t& e) { + return std::string("recv error: ") + e.what(); + } + + if (frames.size() != 3) { + return "invalid multipart frame count: expected 3, got " + + std::to_string(frames.size()); + } + + auto& topic_msg = frames[0]; + auto& seq_msg = frames[1]; + auto& payload_msg = frames[2]; + + if (seq_msg.size() != 8) { + return "invalid sequence length"; + } + const uint64_t raw_seq = + BigEndianToU64(static_cast(seq_msg.data())); + if (raw_seq > static_cast(std::numeric_limits::max())) { + return "sequence exceeds int64 range"; + } + const int64_t seq = static_cast(raw_seq); + + const std::string topic(static_cast(topic_msg.data()), + topic_msg.size()); + int64_t last_live_seq; + bool stale; + { + std::shared_lock lock(mu_); + last_live_seq = last_live_seq_; + stale = stale_; + } + + if (stale) { + VLOG(1) << "Dropping event from stale source service=" + << config_.cache_pool_key << " seq=" << seq; + return ""; + } + + const bool new_gap = last_live_seq != -1 && + last_live_seq != std::numeric_limits::max() && + seq > last_live_seq + 1; + if (new_gap) { + const int64_t missed = seq - last_live_seq - 1; + const int64_t total = dropped_events_.fetch_add(missed) + missed; + const int64_t gaps = gap_count_.fetch_add(1) + 1; + LOG(WARNING) << "Event gap detected service=" << config_.cache_pool_key + << " missed=" << missed << " last=" << last_live_seq + << " current=" << seq << " cumulative_dropped=" << total + << " gaps=" << gaps; + if (!ReplayEnabled(config_)) { + LOG(WARNING) << "No replay_endpoint configured; " << missed + << " events are permanently lost from the index " + "service=" + << config_.cache_pool_key; + MarkStale( + "sequence gap cannot be recovered without a replay " + "endpoint"); + return ""; + } + } + { + std::unique_lock lock(mu_); + last_live_seq_ = std::max(last_live_seq_, seq); + } + + if (!ReplayEnabled(config_)) { + if (auto err = DispatchMessage( + topic, seq, static_cast(payload_msg.data()), + payload_msg.size()); + !err.empty()) { + // The handler may have applied only part of the batch. The + // source therefore cannot safely advance or retry this sequence + // without a full resynchronization. + MarkStale("failed to dispatch live sequence " + + std::to_string(seq) + ": " + err); + return ""; + } + UpdateLastSequence(seq); + return ""; + } + + std::string buffer_error; + if (!BufferMessage({.topic = topic, + .sequence = seq, + .payload = std::string( + static_cast(payload_msg.data()), + payload_msg.size())}, + &buffer_error)) { + if (!buffer_error.empty()) { + MarkStale(buffer_error); + return ""; + } + return AttemptRecovery(); + } + + bool allow_initial_baseline = false; + { + std::shared_lock lock(mu_); + allow_initial_baseline = last_seq_ == -1 && !recovery_in_progress_; + } + if (auto err = DrainBufferedMessages(allow_initial_baseline); + !err.empty()) { + return err; + } + + bool gap_remains = false; + { + std::shared_lock lock(mu_); + gap_remains = !buffered_messages_.empty() && last_seq_ >= 0 && + last_seq_ != std::numeric_limits::max() && + buffered_messages_.begin()->first > last_seq_ + 1; + } + if (gap_remains) { + StartRecovery("live sequence gap detected"); + } + return AttemptRecovery(); +} + +std::string ZMQClient::DispatchMessage(const std::string& topic, + int64_t sequence, const char* payload, + size_t payload_size) { + const MessageMetadata metadata{ + .publisher_kind = config_.publisher_kind, + .endpoint = config_.endpoint, + .topic = topic, + .sequence = sequence, + }; + + DecodedBatch batch; + std::string decode_error; + if (config_.publisher_kind == common::PublisherKind::kMooncake) { + auto decoded = DecodeMooncakeEventBatch(payload, payload_size); + if (decoded.ok) { + batch = std::move(decoded.batch); + } else { + decode_error = std::move(decoded.error); + } + } else if (config_.publisher_kind == common::PublisherKind::kSglang) { + auto decoded = DecodeSglangEventBatch(payload, payload_size); + if (decoded.ok) { + batch = std::move(decoded.batch); + } else { + // A SGLang-backed Mooncake Store still uses the Mooncake map + // envelope. Native SGLang is attempted first; fall back to the + // Mooncake decoder for that deployment mode. + auto mooncake = DecodeMooncakeEventBatch(payload, payload_size); + if (mooncake.ok) { + batch = std::move(mooncake.batch); + } else { + decode_error = "SGLang decode failed: " + decoded.error + + "; Mooncake fallback failed: " + mooncake.error; + } + } + } else { + auto decoded = DecodeVllmEventBatch(payload, payload_size); + if (decoded.ok) { + batch = std::move(decoded.batch); + } else { + decode_error = std::move(decoded.error); + } + } + if (!decode_error.empty()) { + LOG(WARNING) << "Rejected KV event envelope endpoint=" + << metadata.endpoint << " topic=" << metadata.topic + << " seq=" << metadata.sequence << " publisher_kind=" + << common::PublisherKindName(metadata.publisher_kind) + << " error=" << decode_error; + return ""; + } + if (event_handler_ == nullptr) { + return "event handler is nil"; + } + if (auto err = event_handler_->HandleBatch(batch, metadata); !err.empty()) { + return "event handler failed: " + err; + } + + VLOG(1) << "Processed batch service=" << config_.cache_pool_key + << " seq=" << sequence << " topic=" << topic; + return ""; +} + +void ZMQClient::UpdateLastSequence(int64_t sequence) { + std::unique_lock lock(mu_); + last_seq_ = std::max(last_seq_, sequence); +} + +bool ZMQClient::BufferMessage(BufferedMessage message, std::string* error) { + const size_t message_bytes = message.topic.size() + message.payload.size(); + std::unique_lock lock(mu_); + if (stale_ || message.sequence <= last_seq_) { + return false; + } + if (const auto existing = buffered_messages_.find(message.sequence); + existing != buffered_messages_.end()) { + if (existing->second.payload != message.payload) { + *error = "conflicting payloads for sequence " + + std::to_string(message.sequence); + } + return false; + } + if (buffered_messages_.size() + 1 > + config_.max_recovery_buffered_messages) { + *error = "recovery message buffer limit exceeded"; + return false; + } + if (message_bytes > config_.max_recovery_buffered_bytes || + buffered_message_bytes_ > + config_.max_recovery_buffered_bytes - message_bytes) { + *error = "recovery byte buffer limit exceeded"; + return false; + } + buffered_message_bytes_ += message_bytes; + buffered_messages_.emplace(message.sequence, std::move(message)); + return true; +} + +std::string ZMQClient::DrainBufferedMessages(bool allow_initial_baseline) { + while (true) { + BufferedMessage message; + { + std::unique_lock lock(mu_); + if (stale_ || buffered_messages_.empty()) return ""; + + auto next = buffered_messages_.end(); + if (last_seq_ == -1) { + if (!allow_initial_baseline) return ""; + next = buffered_messages_.begin(); + } else { + if (last_seq_ == std::numeric_limits::max()) { + return ""; + } + next = buffered_messages_.find(last_seq_ + 1); + if (next == buffered_messages_.end()) return ""; + } + + message = std::move(next->second); + buffered_message_bytes_ -= + message.topic.size() + message.payload.size(); + buffered_messages_.erase(next); + } + + if (auto err = + DispatchMessage(message.topic, message.sequence, + message.payload.data(), message.payload.size()); + !err.empty()) { + MarkStale("failed to dispatch buffered sequence " + + std::to_string(message.sequence) + ": " + err); + return ""; + } + UpdateLastSequence(message.sequence); + allow_initial_baseline = false; + } +} + +void ZMQClient::StartRecovery(const std::string& reason) { + bool started = false; + { + std::unique_lock lock(mu_); + if (!stale_ && !recovery_in_progress_) { + recovery_in_progress_ = true; + recovery_deadline_ = std::chrono::steady_clock::now() + + config_.replay_recovery_timeout; + started = true; + } + } + if (started) { + LOG(WARNING) << "Starting bounded replay recovery service=" + << config_.cache_pool_key << " reason=" << reason + << " timeout_ms=" + << config_.replay_recovery_timeout.count(); + } +} + +std::string ZMQClient::AttemptRecovery() { + if (!ReplayEnabled(config_)) return ""; + + while (true) { + int64_t from_seq; + std::optional until_seq; + std::chrono::steady_clock::time_point deadline; + { + std::shared_lock lock(mu_); + if (stale_ || !recovery_in_progress_) return ""; + deadline = recovery_deadline_; + if (last_seq_ == std::numeric_limits::max()) { + lock.unlock(); + MarkStale("sequence space exhausted during replay recovery"); + return ""; + } + from_seq = last_seq_ + 1; + if (!buffered_messages_.empty()) { + until_seq = buffered_messages_.begin()->first; + } + } + + if (std::chrono::steady_clock::now() >= deadline) { + MarkStale("replay recovery deadline exceeded"); + return ""; + } + + // A message may have become contiguous after replay records from the + // previous iteration were inserted. + if (until_seq.has_value() && *until_seq == from_seq) { + if (auto err = DrainBufferedMessages(); !err.empty()) return err; + continue; + } + + auto result = RequestReplay(from_seq, until_seq, deadline); + if (!result.ok()) { + LOG(WARNING) << "Replay recovery request failed service=" + << config_.cache_pool_key << " from=" << from_seq + << (until_seq.has_value() + ? " until=" + std::to_string(*until_seq) + : "") + << " error=" << result.error; + if (result.failure == ReplayFailure::kUnrecoverable || + std::chrono::steady_clock::now() >= deadline) { + MarkStale("unable to recover sequence range starting at " + + std::to_string(from_seq) + ": " + result.error); + } + return ""; + } + + const int64_t before = GetLastSequence(); + for (auto& message : result.messages) { + std::string buffer_error; + if (!BufferMessage(std::move(message), &buffer_error) && + !buffer_error.empty()) { + MarkStale(buffer_error); + return ""; + } + } + if (auto err = DrainBufferedMessages(); !err.empty()) return err; + + bool gap_remains; + { + std::unique_lock lock(mu_); + if (stale_) return ""; + gap_remains = !buffered_messages_.empty() && last_seq_ >= 0 && + last_seq_ != std::numeric_limits::max() && + buffered_messages_.begin()->first > last_seq_ + 1; + if (!gap_remains) { + recovery_in_progress_ = false; + } + } + if (!gap_remains) { + LOG(INFO) << "Replay recovery completed service=" + << config_.cache_pool_key + << " last_sequence=" << GetLastSequence(); + return ""; + } + if (GetLastSequence() == before) { + MarkStale("replay completed without closing the sequence gap"); + return ""; + } + } +} + +void ZMQClient::MarkStale(const std::string& reason) { + MessageMetadata metadata; + { + std::unique_lock lock(mu_); + if (stale_) return; + stale_ = true; + stale_reason_ = reason; + recovery_in_progress_ = false; + buffered_messages_.clear(); + buffered_message_bytes_ = 0; + metadata = { + .publisher_kind = config_.publisher_kind, + .endpoint = config_.endpoint, + .topic = "", + .sequence = last_seq_, + }; + } + + LOG(ERROR) << "ZMQ event source marked stale service=" + << config_.cache_pool_key << " endpoint=" << config_.endpoint + << " last_sequence=" << metadata.sequence + << " reason=" << reason; + if (event_handler_ != nullptr) { + event_handler_->OnSourceStale(config_.cache_pool_key, metadata, reason); + } +} + +ZMQClient::ReplayResult ZMQClient::RequestReplay( + int64_t from_seq, std::optional until_seq, + std::chrono::steady_clock::time_point recovery_deadline) { + ::zmq::socket_t* socket; + { + std::shared_lock lock(mu_); + socket = replay_socket_.get(); + } + if (socket == nullptr) { + return {.error = "replay socket is nil", + .failure = ReplayFailure::kRetryable}; + } + + auto fail = [this](std::string error, ReplayFailure failure) { + if (auto reset_error = ResetReplaySocket(); !reset_error.empty()) { + error += "; failed to reset replay socket: " + reset_error; + } + return ReplayResult{.error = std::move(error), .failure = failure}; + }; + + unsigned char req[8]; + U64ToBigEndian(static_cast(from_seq), req); + + try { + // A DEALER must add the empty delimiter that a REQ socket would add + // automatically. vLLM's ROUTER expects [identity, empty, from_seq]. + const std::string empty; + const std::array<::zmq::const_buffer, 2> request = { + ::zmq::buffer(empty), + ::zmq::buffer(req, sizeof(req)), + }; + if (!::zmq::send_multipart(*socket, request)) { + return fail("failed to send replay request", + ReplayFailure::kRetryable); + } + + size_t existing_messages; + size_t existing_bytes; + { + std::shared_lock lock(mu_); + existing_messages = buffered_messages_.size(); + existing_bytes = buffered_message_bytes_; + } + + std::vector messages; + size_t replay_bytes = 0; + int64_t next_expected = from_seq; + while (true) { + const auto now = std::chrono::steady_clock::now(); + if (now >= recovery_deadline) { + return fail("replay recovery deadline exceeded", + ReplayFailure::kRetryable); + } + auto receive_timeout = + std::min(config_.replay_timeout, + std::chrono::duration_cast( + recovery_deadline - now)); + receive_timeout = + std::max(receive_timeout, std::chrono::milliseconds(1)); + socket->set( + ::zmq::sockopt::rcvtimeo, + static_cast(std::min( + receive_timeout.count(), std::numeric_limits::max()))); + + std::vector<::zmq::message_t> frames; + const auto frame_count = ::zmq::recv_multipart( + *socket, std::back_inserter(frames), ::zmq::recv_flags::none); + if (!frame_count) { + return fail("failed to receive replay response: timed out", + ReplayFailure::kRetryable); + } + // The ROUTER sends [identity, empty, sequence, payload]. The + // DEALER strips only the routing identity. + if (frames.size() != 3 || !frames[0].empty()) { + return fail("invalid replay response frame count or delimiter", + ReplayFailure::kRetryable); + } + + auto& seq_msg = frames[1]; + auto& payload_msg = frames[2]; + if (seq_msg.size() != 8) { + return fail("invalid replay sequence length", + ReplayFailure::kRetryable); + } + const uint64_t raw_seq = BigEndianToU64( + static_cast(seq_msg.data())); + if (raw_seq == std::numeric_limits::max()) { + if (!payload_msg.empty()) { + return fail("invalid replay end marker", + ReplayFailure::kRetryable); + } + if (until_seq.has_value() && next_expected < *until_seq) { + return fail( + "replay buffer did not contain every missing sequence", + ReplayFailure::kUnrecoverable); + } + LOG(INFO) << "Replay completed service=" + << config_.cache_pool_key << " from=" << from_seq + << " replayed=" << messages.size(); + return {.messages = std::move(messages)}; + } + if (raw_seq > + static_cast(std::numeric_limits::max())) { + return fail("replay sequence exceeds int64 range", + ReplayFailure::kRetryable); + } + + const int64_t replay_seq = static_cast(raw_seq); + if (replay_seq < from_seq || + (until_seq.has_value() && replay_seq >= *until_seq)) { + continue; + } + if (replay_seq < next_expected) { + continue; + } + if (replay_seq > next_expected) { + return fail("replay response skipped a sequence", + ReplayFailure::kUnrecoverable); + } + if (replay_seq == std::numeric_limits::max()) { + return fail("replay sequence cannot be incremented", + ReplayFailure::kUnrecoverable); + } + next_expected = replay_seq + 1; + + const size_t payload_size = payload_msg.size(); + const bool message_limit_exceeded = + existing_messages >= config_.max_recovery_buffered_messages || + messages.size() >= + config_.max_recovery_buffered_messages - existing_messages; + const bool byte_limit_exceeded = + existing_bytes > config_.max_recovery_buffered_bytes || + replay_bytes > + config_.max_recovery_buffered_bytes - existing_bytes || + payload_size > config_.max_recovery_buffered_bytes - + existing_bytes - replay_bytes; + if (message_limit_exceeded || byte_limit_exceeded) { + return fail("replay response exceeds recovery buffer limits", + ReplayFailure::kUnrecoverable); + } + replay_bytes += payload_size; + messages.push_back({.topic = "", + .sequence = replay_seq, + .payload = std::string(static_cast( + payload_msg.data()), + payload_size)}); + } + } catch (const ::zmq::error_t& e) { + return fail(std::string("replay request failed: ") + e.what(), + ReplayFailure::kRetryable); + } +} + +std::string ZMQClient::ResetReplaySocket() { + std::unique_lock lock(mu_); + if (replay_socket_) { + try { + replay_socket_->close(); + } catch (const ::zmq::error_t& e) { + replay_socket_.reset(); + return e.what(); + } + replay_socket_.reset(); + } + if (!connected_ || !ReplayEnabled(config_)) { + return ""; + } + + try { + auto socket = std::make_unique<::zmq::socket_t>( + zmq_context_, ::zmq::socket_type::dealer); + socket->set(::zmq::sockopt::ipv6, 1); + socket->connect(config_.replay_endpoint); + replay_socket_ = std::move(socket); + } catch (const ::zmq::error_t& e) { + return e.what(); + } + return ""; +} + +void ZMQClient::CleanupSocketsLocked() { + if (sub_socket_) { + sub_socket_->close(); + sub_socket_.reset(); + } + if (replay_socket_) { + replay_socket_->close(); + replay_socket_.reset(); + } + connected_ = false; +} + +void ZMQClient::MarkDisconnected() { + std::unique_lock lock(mu_); + connected_ = false; +} + +bool ZMQClient::IsConnected() const { + std::shared_lock lock(mu_); + return connected_; +} + +int64_t ZMQClient::GetLastSequence() const { + std::shared_lock lock(mu_); + return last_seq_; +} + +bool ZMQClient::IsStale() const { + std::shared_lock lock(mu_); + return stale_; +} + +std::string ZMQClient::GetStaleReason() const { + std::shared_lock lock(mu_); + return stale_reason_; +} + +} // namespace mooncake::conductor::zmq diff --git a/mooncake-conductor/tests/CMakeLists.txt b/mooncake-conductor/tests/CMakeLists.txt new file mode 100644 index 0000000000..c66fc50f74 --- /dev/null +++ b/mooncake-conductor/tests/CMakeLists.txt @@ -0,0 +1,28 @@ +# Unit tests for mooncake-conductor (gtest, BUILD_UNIT_TESTS). + +if(TARGET gtest_main) + set(CONDUCTOR_GTEST_LIBS gtest gtest_main) +else() + find_package(GTest REQUIRED) + set(CONDUCTOR_GTEST_LIBS GTest::gtest GTest::gtest_main) +endif() + +add_executable( + conductor_test + common_utils_test.cpp + json_uint64_test.cpp + model_context_test.cpp + compute_hash_test.cpp + prefix_indexer_test.cpp + msg_decoder_test.cpp + zmq_client_test.cpp) + +target_link_libraries(conductor_test PRIVATE conductor_cpp_core + ${CONDUCTOR_GTEST_LIBS}) + +# Tests load golden vectors relative to this directory. +target_compile_definitions( + conductor_test + PRIVATE CONDUCTOR_TEST_FIXTURE_DIR="${CMAKE_CURRENT_SOURCE_DIR}/fixtures") + +add_test(NAME conductor_test COMMAND conductor_test) diff --git a/mooncake-conductor/tests/common_utils_test.cpp b/mooncake-conductor/tests/common_utils_test.cpp new file mode 100644 index 0000000000..62e5baf71f --- /dev/null +++ b/mooncake-conductor/tests/common_utils_test.cpp @@ -0,0 +1,102 @@ +// Tests for common utility helpers: LoadEnv/LoadIntEnv/ParseLogLevel. + +#include +#include + +#include + +#include "conductor/common/utils.h" + +namespace { + +using mooncake::conductor::common::LoadEnv; +using mooncake::conductor::common::LoadIntEnv; +using mooncake::conductor::common::LogLevelConfig; +using mooncake::conductor::common::ParseLogLevel; + +constexpr LogLevelConfig kDebug{google::GLOG_INFO, 1}; +constexpr LogLevelConfig kInfo{google::GLOG_INFO, 0}; +constexpr LogLevelConfig kWarn{google::GLOG_WARNING, 0}; +constexpr LogLevelConfig kError{google::GLOG_ERROR, 0}; + +class EnvGuard { + public: + explicit EnvGuard(const char* name) : name_(name) { unsetenv(name); } + ~EnvGuard() { unsetenv(name_); } + void Set(const char* value) { setenv(name_, value, 1); } + + private: + const char* name_; +}; + +TEST(ParseLogLevel, DefaultsToInfoWhenUnset) { + EnvGuard guard("CONDUCTOR_LOG_LEVEL"); + EXPECT_EQ(ParseLogLevel(), kInfo); +} + +TEST(ParseLogLevel, ParsesAllLevelsCaseInsensitively) { + EnvGuard guard("CONDUCTOR_LOG_LEVEL"); + guard.Set("DEBUG"); + EXPECT_EQ(ParseLogLevel(), kDebug); + guard.Set("debug"); + EXPECT_EQ(ParseLogLevel(), kDebug); + guard.Set("Info"); + EXPECT_EQ(ParseLogLevel(), kInfo); + guard.Set("WARN"); + EXPECT_EQ(ParseLogLevel(), kWarn); + guard.Set("error"); + EXPECT_EQ(ParseLogLevel(), kError); +} + +TEST(ParseLogLevel, InvalidValueFallsBackToInfo) { + EnvGuard guard("CONDUCTOR_LOG_LEVEL"); + guard.Set("verbose"); + EXPECT_EQ(ParseLogLevel(), kInfo); +} + +// DEBUG is the one level glog has no direct severity for: it shares INFO and +// is distinguished by FLAGS_v, so pin that mapping explicitly. +TEST(ParseLogLevel, DebugSharesInfoSeverityAndRaisesVerbosity) { + EnvGuard guard("CONDUCTOR_LOG_LEVEL"); + guard.Set("DEBUG"); + const auto debug = ParseLogLevel(); + guard.Set("INFO"); + const auto info = ParseLogLevel(); + EXPECT_EQ(debug.min_severity, info.min_severity); + EXPECT_GT(debug.verbosity, info.verbosity); +} + +TEST(LoadEnv, ReturnsValueWhenSet) { + EnvGuard guard("CONDUCTOR_TEST_STR"); + guard.Set("hello"); + EXPECT_EQ(LoadEnv("CONDUCTOR_TEST_STR", "default"), "hello"); +} + +TEST(LoadEnv, ReturnsDefaultWhenUnsetOrEmpty) { + EnvGuard guard("CONDUCTOR_TEST_STR"); + EXPECT_EQ(LoadEnv("CONDUCTOR_TEST_STR", "default"), "default"); + guard.Set(""); + EXPECT_EQ(LoadEnv("CONDUCTOR_TEST_STR", "default"), "default"); +} + +TEST(LoadIntEnv, ReturnsParsedValue) { + EnvGuard guard("CONDUCTOR_TEST_INT"); + guard.Set("42"); + EXPECT_EQ(LoadIntEnv("CONDUCTOR_TEST_INT", -1), 42); + guard.Set("-7"); + EXPECT_EQ(LoadIntEnv("CONDUCTOR_TEST_INT", -1), -7); +} + +TEST(LoadIntEnv, ReturnsDefaultOnUnsetOrInvalid) { + EnvGuard guard("CONDUCTOR_TEST_INT"); + EXPECT_EQ(LoadIntEnv("CONDUCTOR_TEST_INT", 13333), 13333); + guard.Set("not-a-number"); + EXPECT_EQ(LoadIntEnv("CONDUCTOR_TEST_INT", 13333), 13333); + // Surrounding whitespace is rejected in numeric parsing. + guard.Set(" 42"); + EXPECT_EQ(LoadIntEnv("CONDUCTOR_TEST_INT", 13333), 13333); + guard.Set("42x"); + EXPECT_EQ(LoadIntEnv("CONDUCTOR_TEST_INT", 13333), 13333); +} + +} // namespace diff --git a/mooncake-conductor/tests/compute_hash_test.cpp b/mooncake-conductor/tests/compute_hash_test.cpp new file mode 100644 index 0000000000..18f7b3d91f --- /dev/null +++ b/mooncake-conductor/tests/compute_hash_test.cpp @@ -0,0 +1,869 @@ +#include + +#include + +#include +#include +#include +#include +#include +#include + +#include "conductor/prefixindex/hash_strategy.h" +#include "test_fixtures.h" + +namespace { + +using mooncake::conductor::common::HashProfileConfig; +using mooncake::conductor::prefixindex::ContextKey; +using mooncake::conductor::prefixindex::CreateHashStrategy; +using mooncake::conductor::prefixindex::DigestToHex; +using mooncake::conductor::prefixindex::HashBlock; +using mooncake::conductor::prefixindex::HashProfile; +using mooncake::conductor::prefixindex::ResolveHashProfile; +using mooncake::conductor::prefixindex::ValidateHashProfile; +using mooncake::conductor::test::LoadJsonFixture; +using mooncake::conductor::test::ParseU64; + +constexpr char kSeedZeroRoot[] = + "4e1195df020de59e0d65a33a4279f1183e7ae4e5d980e309f8b55adff2e61c3e"; +constexpr char kPaddedSeedZeroRoot[] = + "8d912e4e62b3cc377b1d1c7a14ef61dffbdaa0990237035c05401c29414c4172"; +constexpr char kPickleSeedZeroRoot[] = + "1973e23848344dc43a988a9b478663803cfffe1243480253f9a3cf004b14aa7c"; + +HashProfile ProfileFrom(const Json::Value& value) { + HashProfile profile; + profile.strategy = value["strategy"].asString(); + profile.algorithm = value["algorithm"].asString(); + profile.python_hash_seed = value["python_hash_seed"].asString(); + profile.root_digest = value["root_digest"].asString(); + profile.index_projection = value["index_projection"].asString(); + return profile; +} + +HashProfileConfig SourceProfile(std::string python_hash_seed = "0", + std::string algorithm = "sha256_cbor") { + return {.strategy = "vllm_v1", + .algorithm = std::move(algorithm), + .python_hash_seed = std::move(python_hash_seed), + .index_projection = "low64_be"}; +} + +HashProfile ValidProfile() { + return HashProfile{ + .strategy = "vllm_v1", + .algorithm = "sha256_cbor", + .python_hash_seed = "0", + .root_digest = kSeedZeroRoot, + .index_projection = "low64_be", + }; +} + +HashProfile ValidPickleProfile() { + return HashProfile{ + .strategy = "vllm_v1", + .algorithm = "sha256", + .python_hash_seed = "0", + .root_digest = kPickleSeedZeroRoot, + .index_projection = "low64_be", + }; +} + +HashProfile ResolvedProfile(std::string python_hash_seed, + std::string algorithm = "sha256_cbor") { + HashProfile profile; + const std::string error = ResolveHashProfile( + SourceProfile(std::move(python_hash_seed), std::move(algorithm)), + &profile); + EXPECT_TRUE(error.empty()) << error; + return profile; +} + +std::vector TokensFrom(const Json::Value& values) { + std::vector tokens; + tokens.reserve(values.size()); + for (const auto& value : values) { + tokens.push_back(static_cast(value.asInt64())); + } + return tokens; +} + +// Expands the compact repeat encoding used by large fixture cases. +std::vector CaseTokens(const Json::Value& test_case) { + if (test_case.isMember("token_ids_repeat")) { + const Json::Value& repeat = test_case["token_ids_repeat"]; + return std::vector( + static_cast(repeat["count"].asUInt64()), + static_cast(repeat["value"].asInt64())); + } + return TokensFrom(test_case["token_ids"]); +} + +std::string CaseLora(const Json::Value& test_case) { + if (test_case.isMember("lora_name_repeat")) { + const Json::Value& repeat = test_case["lora_name_repeat"]; + return std::string(static_cast(repeat["count"].asUInt64()), + repeat["value"].asString().front()); + } + return test_case["lora_name"].asString(); +} + +std::optional SaltFrom(const Json::Value& value) { + if (value.isNull()) { + return std::nullopt; + } + return value.asString(); +} + +std::vector HexToBytes(const std::string& hex) { + std::vector bytes; + bytes.reserve(hex.size() / 2); + auto nibble = [](char value) -> int { + if (value >= '0' && value <= '9') { + return value - '0'; + } + if (value >= 'a' && value <= 'f') { + return value - 'a' + 10; + } + return -1; + }; + for (size_t index = 0; index + 1 < hex.size(); index += 2) { + const int high = nibble(hex[index]); + const int low = nibble(hex[index + 1]); + EXPECT_GE(high, 0); + EXPECT_GE(low, 0); + bytes.push_back(static_cast((high << 4) | low)); + } + return bytes; +} + +// Recomputes SHA-256 over the fixture's pinned serialized bytes so the test +// compares serialized bytes as well as digests. +std::string Sha256Hex(const std::vector& input) { + std::array digest{}; + unsigned int digest_size = 0; + EVP_MD_CTX* context = EVP_MD_CTX_new(); + EXPECT_NE(context, nullptr); + EXPECT_EQ(EVP_DigestInit_ex(context, EVP_sha256(), nullptr), 1); + EXPECT_EQ(EVP_DigestUpdate(context, input.data(), input.size()), 1); + EXPECT_EQ(EVP_DigestFinal_ex(context, digest.data(), &digest_size), 1); + EVP_MD_CTX_free(context); + EXPECT_EQ(digest_size, digest.size()); + return DigestToHex(digest); +} + +TEST(HashProfileResolver, MatchesSeedRootGoldenVectors) { + const Json::Value fixture = LoadJsonFixture("hash_golden_vectors.json"); + const Json::Value& vectors = fixture["seed_root_vectors"]; + ASSERT_TRUE(vectors.isArray()); + ASSERT_GE(vectors.size(), 2u); + + for (const auto& vector : vectors) { + const std::string seed = vector["python_hash_seed"].asString(); + SCOPED_TRACE(seed); + HashProfile resolved; + const std::string error = + ResolveHashProfile(SourceProfile(seed), &resolved); + ASSERT_TRUE(error.empty()) << error; + EXPECT_EQ(resolved.python_hash_seed, seed); + EXPECT_EQ(resolved.root_digest, vector["root_digest"].asString()); + } +} + +TEST(HashProfileResolver, MatchesPickleSeedRootGoldenVectors) { + const Json::Value fixture = + LoadJsonFixture("hash_golden_vectors_sha256.json"); + const Json::Value& vectors = fixture["seed_root_vectors"]; + ASSERT_TRUE(vectors.isArray()); + ASSERT_GE(vectors.size(), 4u); + + for (const auto& vector : vectors) { + const std::string seed = vector["python_hash_seed"].asString(); + SCOPED_TRACE(seed); + HashProfile resolved; + const std::string error = + ResolveHashProfile(SourceProfile(seed, "sha256"), &resolved); + ASSERT_TRUE(error.empty()) << error; + EXPECT_EQ(resolved.algorithm, "sha256"); + EXPECT_EQ(resolved.python_hash_seed, seed); + EXPECT_EQ(resolved.root_digest, vector["root_digest"].asString()); + // The serialized seed bytes pinned by the fixture must be exactly + // what the digest was computed over. + EXPECT_EQ(Sha256Hex(HexToBytes(vector["pickle_hex"].asString())), + vector["root_digest"].asString()); + } +} + +TEST(HashProfileResolver, AcceptsSupportedSeedsAndPreservesExactText) { + struct SeedCase { + const char* seed; + const char* root_digest; + }; + const SeedCase cases[] = { + {"0", kSeedZeroRoot}, + {"00", kPaddedSeedZeroRoot}, + {"random", + "78d6ac7e28de859e492449dcea03e3807377d69998c5af819fed33a6df490cad"}, + {"4294967295", + "177f280a5695322a18f16c96a26dc99d9c03f905940103dfe24a9c646fe446a8"}, + }; + + for (const SeedCase& test_case : cases) { + SCOPED_TRACE(test_case.seed); + const HashProfile resolved = ResolvedProfile(test_case.seed); + EXPECT_EQ(resolved.strategy, "vllm_v1"); + EXPECT_EQ(resolved.algorithm, "sha256_cbor"); + EXPECT_EQ(resolved.python_hash_seed, test_case.seed); + EXPECT_EQ(resolved.root_digest, test_case.root_digest); + EXPECT_EQ(resolved.index_projection, "low64_be"); + EXPECT_TRUE(ValidateHashProfile(resolved).empty()); + } + + EXPECT_NE(ResolvedProfile("0"), ResolvedProfile("00")); +} + +TEST(HashProfileResolver, AcceptsPickleSeedsAndPreservesExactText) { + const HashProfile resolved = ResolvedProfile("0", "sha256"); + EXPECT_EQ(resolved.strategy, "vllm_v1"); + EXPECT_EQ(resolved.algorithm, "sha256"); + EXPECT_EQ(resolved.python_hash_seed, "0"); + EXPECT_EQ(resolved.root_digest, kPickleSeedZeroRoot); + EXPECT_EQ(resolved.index_projection, "low64_be"); + EXPECT_TRUE(ValidateHashProfile(resolved).empty()); + + // Seed text is never normalized, under either supported algorithm. + EXPECT_NE(ResolvedProfile("0", "sha256"), ResolvedProfile("00", "sha256")); + // Identical seed text under different algorithms yields different roots. + EXPECT_NE(ResolvedProfile("0", "sha256").root_digest, + ResolvedProfile("0", "sha256_cbor").root_digest); +} + +TEST(HashProfileResolver, RejectsMalformedSeedTextAndClearsOutput) { + const std::vector invalid = { + "", "+1", "-1", " 0", "0 ", "0\n", + "1.0", "Random", "random ", "4294967296", "not-a-seed", "\xe9\x9b\xb6", + }; + + for (const std::string& seed : invalid) { + SCOPED_TRACE(seed); + for (const std::string& algorithm : {"sha256_cbor", "sha256"}) { + HashProfile resolved = ValidProfile(); + EXPECT_FALSE( + ResolveHashProfile(SourceProfile(seed, algorithm), &resolved) + .empty()); + EXPECT_EQ(resolved, HashProfile{}); + } + } + + EXPECT_FALSE(ResolveHashProfile(SourceProfile(), nullptr).empty()); +} + +TEST(HashProfileResolver, RejectsInvalidUtf8AndUnsupportedSelectors) { + for (const std::string seed : + {std::string("\xc0\xaf", 2), std::string("\xed\xa0\x80", 3)}) { + HashProfile resolved; + const std::string error = + ResolveHashProfile(SourceProfile(seed), &resolved); + EXPECT_NE(error.find("valid UTF-8"), std::string::npos); + } + + std::vector unsupported; + auto source = SourceProfile(); + source.strategy = "vllm_v2"; + unsupported.push_back(source); + source = SourceProfile(); + source.algorithm = "md5"; + unsupported.push_back(source); + source = SourceProfile(); + source.algorithm = "sha512"; + unsupported.push_back(source); + source = SourceProfile(); + source.algorithm = "xxhash"; + unsupported.push_back(source); + source = SourceProfile(); + source.algorithm = "SHA256"; + unsupported.push_back(source); + source = SourceProfile(); + source.algorithm = ""; + unsupported.push_back(source); + source = SourceProfile(); + source.index_projection = "high64_be"; + unsupported.push_back(source); + + for (const HashProfileConfig& candidate : unsupported) { + HashProfile resolved; + EXPECT_FALSE(ResolveHashProfile(candidate, &resolved).empty()); + EXPECT_EQ(resolved, HashProfile{}); + } +} + +TEST(HashProfile, AcceptsOnlyTheSupportedResolvedProfile) { + for (const HashProfile& profile : {ValidProfile(), ValidPickleProfile()}) { + SCOPED_TRACE(profile.algorithm); + EXPECT_TRUE(ValidateHashProfile(profile).empty()); + + std::string error = "stale error"; + auto strategy = CreateHashStrategy(profile, &error); + EXPECT_NE(strategy, nullptr); + EXPECT_TRUE(error.empty()); + EXPECT_NE(CreateHashStrategy(profile, nullptr), nullptr); + } +} + +TEST(HashProfile, RejectsUnsupportedAndMalformedResolvedShapes) { + std::vector> cases; + + HashProfile profile = ValidProfile(); + profile.strategy = "vllm_v2"; + cases.emplace_back("strategy", profile); + + profile = ValidProfile(); + profile.algorithm = "md5"; + cases.emplace_back("algorithm", profile); + + profile = ValidProfile(); + profile.index_projection = "high64_be"; + cases.emplace_back("projection", profile); + + profile = ValidProfile(); + profile.python_hash_seed.clear(); + cases.emplace_back("empty seed", profile); + + profile = ValidProfile(); + profile.root_digest.pop_back(); + cases.emplace_back("short root", profile); + + profile = ValidProfile(); + profile.root_digest.push_back('0'); + cases.emplace_back("long root", profile); + + profile = ValidProfile(); + profile.root_digest[1] = 'E'; + cases.emplace_back("uppercase root", profile); + + profile = ValidProfile(); + profile.root_digest[0] = 'g'; + cases.emplace_back("non-hex root", profile); + + for (const auto& [name, candidate] : cases) { + SCOPED_TRACE(name); + const std::string validation_error = ValidateHashProfile(candidate); + EXPECT_FALSE(validation_error.empty()); + + std::string factory_error; + EXPECT_EQ(CreateHashStrategy(candidate, &factory_error), nullptr); + EXPECT_EQ(factory_error, validation_error); + } +} + +TEST(HashProfile, SemanticValidationRejectsForgedSeedRootPair) { + HashProfile forged = ValidProfile(); + forged.root_digest = kPaddedSeedZeroRoot; + + const std::string validation_error = ValidateHashProfile(forged); + EXPECT_NE(validation_error.find("does not match"), std::string::npos); + + std::string factory_error = "stale error"; + EXPECT_NE(CreateHashStrategy(forged, &factory_error), nullptr); + EXPECT_TRUE(factory_error.empty()); +} + +TEST(HashProfile, SemanticValidationUsesTheSelectedRecipe) { + // A root derived with one recipe must not validate under the other + // algorithm even when every other field is well formed. + HashProfile mismatched = ValidPickleProfile(); + mismatched.algorithm = "sha256_cbor"; + EXPECT_NE(ValidateHashProfile(mismatched).find("does not match"), + std::string::npos); + + mismatched = ValidProfile(); + mismatched.algorithm = "sha256"; + EXPECT_NE(ValidateHashProfile(mismatched).find("does not match"), + std::string::npos); +} + +TEST(HashStrategyGolden, MatchesVllmAndCbor2Vectors) { + const Json::Value fixture = LoadJsonFixture("hash_golden_vectors.json"); + const HashProfile profile = ProfileFrom(fixture["profile"]); + ASSERT_TRUE(ValidateHashProfile(profile).empty()); + + std::string factory_error; + auto strategy = CreateHashStrategy(profile, &factory_error); + ASSERT_NE(strategy, nullptr) << factory_error; + + const Json::Value& cases = fixture["cases"]; + ASSERT_GT(cases.size(), 0u); + for (const auto& test_case : cases) { + SCOPED_TRACE(test_case["name"].asString()); + ContextKey context{ + .tenant_id = "default", + .model_name = "golden-model", + .lora_name = test_case["lora_name"].asString(), + .block_size = test_case["block_size"].asInt64(), + }; + const std::vector tokens = TokensFrom(test_case["token_ids"]); + + std::vector blocks; + const std::string error = strategy->Compute( + context, tokens, SaltFrom(test_case["cache_salt"]), &blocks); + ASSERT_TRUE(error.empty()) << error; + + const Json::Value& expected = test_case["expected"]; + ASSERT_EQ(blocks.size(), expected.size()); + for (Json::ArrayIndex index = 0; index < expected.size(); ++index) { + const std::string digest = DigestToHex(blocks[index].digest); + EXPECT_EQ(digest, expected[index]["digest"].asString()) + << "block=" << index; + EXPECT_EQ(digest.substr(48), + expected[index]["projected_hex"].asString()) + << "block=" << index; + EXPECT_EQ(blocks[index].projected.value, + ParseU64(expected[index]["projected_decimal"])) + << "block=" << index; + } + } +} + +TEST(HashStrategyGolden, MatchesVllmPickleVectors) { + const Json::Value fixture = + LoadJsonFixture("hash_golden_vectors_sha256.json"); + const HashProfile profile = ProfileFrom(fixture["profile"]); + ASSERT_EQ(profile.algorithm, "sha256"); + ASSERT_TRUE(ValidateHashProfile(profile).empty()); + + std::string factory_error; + auto strategy = CreateHashStrategy(profile, &factory_error); + ASSERT_NE(strategy, nullptr) << factory_error; + + const Json::Value& cases = fixture["cases"]; + ASSERT_GT(cases.size(), 0u); + for (const auto& test_case : cases) { + SCOPED_TRACE(test_case["name"].asString()); + ContextKey context{ + .tenant_id = "default", + .model_name = "golden-model", + .lora_name = CaseLora(test_case), + .block_size = test_case["block_size"].asInt64(), + }; + const std::vector tokens = CaseTokens(test_case); + + std::vector blocks; + const std::string error = strategy->Compute( + context, tokens, SaltFrom(test_case["cache_salt"]), &blocks); + ASSERT_TRUE(error.empty()) << error; + + const Json::Value& expected = test_case["expected"]; + ASSERT_EQ(blocks.size(), expected.size()); + for (Json::ArrayIndex index = 0; index < expected.size(); ++index) { + const std::string digest = DigestToHex(blocks[index].digest); + EXPECT_EQ(digest, expected[index]["digest"].asString()) + << "block=" << index; + EXPECT_EQ(digest.substr(48), + expected[index]["projected_hex"].asString()) + << "block=" << index; + EXPECT_EQ(blocks[index].projected.value, + ParseU64(expected[index]["projected_decimal"])) + << "block=" << index; + if (expected[index].isMember("pickle_hex")) { + // The fixture pins the exact serialized Pickle bytes; their + // SHA-256 must reproduce the pinned digest. + EXPECT_EQ(Sha256Hex(HexToBytes( + expected[index]["pickle_hex"].asString())), + expected[index]["digest"].asString()) + << "block=" << index; + } + } + } +} + +TEST(HashStrategyGolden, PickleMultiBlockChainDoesNotReuseLow64AsParent) { + const Json::Value fixture = + LoadJsonFixture("hash_golden_vectors_sha256.json"); + const Json::Value& test_case = fixture["cases"][0]; + ASSERT_EQ(test_case["name"].asString(), "spec_unsalted"); + + std::string factory_error; + auto strategy = + CreateHashStrategy(ProfileFrom(fixture["profile"]), &factory_error); + ASSERT_NE(strategy, nullptr) << factory_error; + + ContextKey context{ + .tenant_id = "default", + .model_name = "golden-model", + .lora_name = "", + .block_size = test_case["block_size"].asInt64(), + }; + const std::vector tokens = CaseTokens(test_case); + std::vector blocks; + ASSERT_TRUE( + strategy->Compute(context, tokens, std::nullopt, &blocks).empty()); + ASSERT_EQ(blocks.size(), 2u); + + const std::string second_digest = DigestToHex(blocks[1].digest); + EXPECT_EQ(second_digest, test_case["expected"][1]["digest"].asString()); + EXPECT_NE(second_digest, + test_case["incorrect_low64_parent_digest"].asString()); +} + +TEST(HashStrategyGolden, AlgorithmsProduceDistinctDigestsForIdenticalTokens) { + const Json::Value cbor_fixture = + LoadJsonFixture("hash_golden_vectors.json"); + const Json::Value pickle_fixture = + LoadJsonFixture("hash_golden_vectors_sha256.json"); + + std::string factory_error; + auto cbor_strategy = CreateHashStrategy( + ProfileFrom(cbor_fixture["profile"]), &factory_error); + ASSERT_NE(cbor_strategy, nullptr) << factory_error; + auto pickle_strategy = CreateHashStrategy( + ProfileFrom(pickle_fixture["profile"]), &factory_error); + ASSERT_NE(pickle_strategy, nullptr) << factory_error; + + ContextKey context{ + .tenant_id = "default", + .model_name = "golden-model", + .lora_name = "", + .block_size = 4, + }; + const std::vector tokens{1, 2, 3, 4, 5, 6, 7, 8}; + std::vector cbor_blocks; + std::vector pickle_blocks; + ASSERT_TRUE( + cbor_strategy->Compute(context, tokens, std::nullopt, &cbor_blocks) + .empty()); + ASSERT_TRUE( + pickle_strategy->Compute(context, tokens, std::nullopt, &pickle_blocks) + .empty()); + ASSERT_EQ(cbor_blocks.size(), pickle_blocks.size()); + + // Identical tokens under the two recipes must produce distinct digests + // while each recipe matches its own vLLM reference vectors. + for (size_t index = 0; index < cbor_blocks.size(); ++index) { + EXPECT_NE(cbor_blocks[index], pickle_blocks[index]) + << "block=" << index; + EXPECT_EQ(DigestToHex(cbor_blocks[index].digest), + cbor_fixture["cases"][0]["expected"] + [static_cast(index)]["digest"] + .asString()); + EXPECT_EQ(DigestToHex(pickle_blocks[index].digest), + pickle_fixture["cases"][0]["expected"] + [static_cast(index)]["digest"] + .asString()); + } +} + +TEST(HashStrategyGolden, MultiBlockChainDoesNotReuseLow64AsParent) { + const Json::Value fixture = LoadJsonFixture("hash_golden_vectors.json"); + const Json::Value& test_case = fixture["cases"][0]; + ASSERT_EQ(test_case["name"].asString(), "spec_unsalted"); + + std::string factory_error; + auto strategy = + CreateHashStrategy(ProfileFrom(fixture["profile"]), &factory_error); + ASSERT_NE(strategy, nullptr) << factory_error; + + ContextKey context{ + .tenant_id = "default", + .model_name = "golden-model", + .lora_name = "", + .block_size = test_case["block_size"].asInt64(), + }; + const std::vector tokens = TokensFrom(test_case["token_ids"]); + std::vector blocks; + ASSERT_TRUE( + strategy->Compute(context, tokens, std::nullopt, &blocks).empty()); + ASSERT_EQ(blocks.size(), 2u); + + const std::string second_digest = DigestToHex(blocks[1].digest); + EXPECT_EQ(second_digest, test_case["expected"][1]["digest"].asString()); + EXPECT_NE(second_digest, + test_case["incorrect_low64_parent_digest"].asString()); +} + +TEST(HashStrategy, EmptySaltHasNoExtraKey) { + for (const HashProfile& profile : {ValidProfile(), ValidPickleProfile()}) { + SCOPED_TRACE(profile.algorithm); + std::string factory_error; + auto strategy = CreateHashStrategy(profile, &factory_error); + ASSERT_NE(strategy, nullptr) << factory_error; + + ContextKey context{ + .tenant_id = "default", + .model_name = "model", + .lora_name = "", + .block_size = 4, + }; + const std::vector tokens{1, 2, 3, 4, 5, 6, 7, 8}; + std::vector omitted_salt; + std::vector empty_salt; + ASSERT_TRUE( + strategy->Compute(context, tokens, std::nullopt, &omitted_salt) + .empty()); + ASSERT_TRUE( + strategy->Compute(context, tokens, std::string{}, &empty_salt) + .empty()); + EXPECT_EQ(empty_salt, omitted_salt); + } +} + +TEST(HashStrategy, RejectsInvalidComputeInputsWithoutPartialOutput) { + std::string factory_error; + auto strategy = CreateHashStrategy(ValidProfile(), &factory_error); + ASSERT_NE(strategy, nullptr) << factory_error; + + ContextKey context{ + .tenant_id = "default", + .model_name = "model", + .lora_name = "", + .block_size = 0, + }; + const std::vector tokens{1, 2, 3, 4}; + std::vector blocks(1); + EXPECT_FALSE( + strategy->Compute(context, tokens, std::nullopt, &blocks).empty()); + EXPECT_TRUE(blocks.empty()); + + context.block_size = -4; + blocks.resize(1); + EXPECT_FALSE( + strategy->Compute(context, tokens, std::nullopt, &blocks).empty()); + EXPECT_TRUE(blocks.empty()); + + context.block_size = 4; + context.lora_name = std::string("\xc0\xaf", 2); + blocks.resize(1); + EXPECT_FALSE( + strategy->Compute(context, tokens, std::nullopt, &blocks).empty()); + EXPECT_TRUE(blocks.empty()); + + context.lora_name.clear(); + const std::string invalid_salt("\xed\xa0\x80", 3); + blocks.resize(1); + EXPECT_FALSE( + strategy->Compute(context, tokens, invalid_salt, &blocks).empty()); + EXPECT_TRUE(blocks.empty()); + + EXPECT_FALSE( + strategy->Compute(context, tokens, std::nullopt, nullptr).empty()); +} + +TEST(HashStrategy, ResolvedSeedChangesTheChain) { + const HashProfile alternate_profile = ResolvedProfile("00"); + + std::string factory_error; + auto default_strategy = CreateHashStrategy(ValidProfile(), &factory_error); + ASSERT_NE(default_strategy, nullptr) << factory_error; + auto alternate_strategy = + CreateHashStrategy(alternate_profile, &factory_error); + ASSERT_NE(alternate_strategy, nullptr) << factory_error; + + ContextKey context{ + .tenant_id = "default", + .model_name = "model", + .lora_name = "", + .block_size = 4, + }; + const std::vector tokens{1, 2, 3, 4}; + std::vector default_blocks; + std::vector alternate_blocks; + ASSERT_TRUE(default_strategy + ->Compute(context, tokens, std::nullopt, &default_blocks) + .empty()); + ASSERT_TRUE(alternate_strategy + ->Compute(context, tokens, std::nullopt, &alternate_blocks) + .empty()); + ASSERT_EQ(default_blocks.size(), 1u); + ASSERT_EQ(alternate_blocks.size(), 1u); + EXPECT_NE(default_blocks, alternate_blocks); +} + +TEST(HashStrategy, DigestToHexPreservesLeadingZerosAndUsesLowercase) { + std::array digest{}; + digest[0] = 0x01; + digest[30] = 0xcd; + digest[31] = 0xef; + + const std::string encoded = DigestToHex(digest); + ASSERT_EQ(encoded.size(), 64u); + EXPECT_EQ(encoded.substr(0, 4), "0100"); + EXPECT_EQ(encoded.substr(60), "cdef"); +} + +TEST(HashChain, MatchesEagerCompute) { + for (const HashProfile& profile : {ValidProfile(), ValidPickleProfile()}) { + SCOPED_TRACE(profile.algorithm); + std::string factory_error; + auto strategy = CreateHashStrategy(profile, &factory_error); + ASSERT_NE(strategy, nullptr) << factory_error; + + ContextKey context{ + .tenant_id = "default", + .model_name = "model", + .lora_name = "lora-a", + .block_size = 4, + }; + const std::vector tokens{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}; + const std::string salt = "salty"; + + std::vector eager; + ASSERT_TRUE(strategy->Compute(context, tokens, salt, &eager).empty()); + ASSERT_EQ(eager.size(), 2u); + + std::string chain_error; + auto chain = strategy->CreateChain(context, tokens, salt, &chain_error); + ASSERT_NE(chain, nullptr) << chain_error; + EXPECT_EQ(chain->BlockCount(), eager.size()); + for (size_t index = 0; index < eager.size(); ++index) { + const HashBlock* block = chain->At(index, &chain_error); + ASSERT_NE(block, nullptr) << chain_error; + EXPECT_EQ(*block, eager[index]) << "block=" << index; + } + } +} + +TEST(HashChain, ComputesOnlyRequestedPrefix) { + std::string factory_error; + auto strategy = CreateHashStrategy(ValidProfile(), &factory_error); + ASSERT_NE(strategy, nullptr) << factory_error; + + ContextKey context{ + .tenant_id = "default", + .model_name = "model", + .lora_name = "", + .block_size = 4, + }; + const std::vector tokens(400, 7); // 100 complete blocks + + std::string chain_error; + auto chain = + strategy->CreateChain(context, tokens, std::nullopt, &chain_error); + ASSERT_NE(chain, nullptr) << chain_error; + EXPECT_EQ(chain->BlockCount(), 100u); + EXPECT_EQ(chain->ComputedCount(), 0u); + + ASSERT_NE(chain->At(0, &chain_error), nullptr) << chain_error; + EXPECT_EQ(chain->ComputedCount(), 1u); + + ASSERT_NE(chain->At(2, &chain_error), nullptr) << chain_error; + EXPECT_EQ(chain->ComputedCount(), 3u); + + // Already-computed blocks must not be rehashed. + ASSERT_NE(chain->At(2, &chain_error), nullptr) << chain_error; + EXPECT_EQ(chain->ComputedCount(), 3u); + + EXPECT_EQ(chain->At(100, &chain_error), nullptr); + EXPECT_FALSE(chain_error.empty()); + EXPECT_EQ(chain->ComputedCount(), 3u); +} + +TEST(HashChain, RejectsInvalidInputsAtSetup) { + std::string factory_error; + auto strategy = CreateHashStrategy(ValidProfile(), &factory_error); + ASSERT_NE(strategy, nullptr) << factory_error; + + ContextKey context{ + .tenant_id = "default", + .model_name = "model", + .lora_name = "", + .block_size = 0, + }; + const std::vector tokens{1, 2, 3, 4}; + + std::string chain_error; + EXPECT_EQ( + strategy->CreateChain(context, tokens, std::nullopt, &chain_error), + nullptr); + EXPECT_FALSE(chain_error.empty()); + + context.block_size = 4; + const std::string invalid_salt("\xed\xa0\x80", 3); + EXPECT_EQ( + strategy->CreateChain(context, tokens, invalid_salt, &chain_error), + nullptr); +} + +TEST(SglangHashChain, MatchesNativeSha256TokenChain) { + HashProfile profile; + const HashProfileConfig source{ + .strategy = "sglang", + .algorithm = "sha256_raw", + .python_hash_seed = "0", + .index_projection = "first64_be", + }; + ASSERT_TRUE(ResolveHashProfile(source, &profile).empty()); + ASSERT_TRUE(ValidateHashProfile(profile).empty()); + + std::string error; + auto strategy = CreateHashStrategy(profile, &error); + ASSERT_NE(strategy, nullptr) << error; + const ContextKey context{.tenant_id = "default", + .model_name = "model", + .lora_name = "", + .block_size = 4}; + const std::vector tokens{1, 2, 3, 4}; + std::vector blocks; + ASSERT_TRUE( + strategy->Compute(context, tokens, std::nullopt, &blocks).empty()); + ASSERT_EQ(blocks.size(), 1u); + EXPECT_EQ(blocks[0].projected.value, 0xcf97adeedb59e05bULL); + + blocks.clear(); + ASSERT_TRUE( + strategy->Compute(context, tokens, std::string("salty"), &blocks) + .empty()); + ASSERT_EQ(blocks.size(), 1u); + EXPECT_EQ(blocks[0].projected.value, 0xd98f7292c18ec8ddULL); +} + +TEST(SglangHashChain, IncludesPartialFinalBlock) { + HashProfile profile; + const HashProfileConfig source{ + .strategy = "sglang", + .algorithm = "sha256_raw", + .python_hash_seed = "0", + .index_projection = "first64_be", + }; + ASSERT_TRUE(ResolveHashProfile(source, &profile).empty()); + + std::string error; + auto strategy = CreateHashStrategy(profile, &error); + ASSERT_NE(strategy, nullptr) << error; + const ContextKey context{.tenant_id = "default", + .model_name = "model", + .lora_name = "", + .block_size = 2}; + const std::vector tokens{1, 2, 3}; + std::vector blocks; + ASSERT_TRUE( + strategy->Compute(context, tokens, std::nullopt, &blocks).empty()); + EXPECT_EQ(blocks.size(), 2u); +} + +TEST(SglangHashChain, MatchesBigramGolden) { + HashProfile profile; + const HashProfileConfig source{ + .strategy = "sglang_bigram", + .algorithm = "sha256_raw", + .python_hash_seed = "0", + .index_projection = "first64_be", + }; + ASSERT_TRUE(ResolveHashProfile(source, &profile).empty()); + ASSERT_TRUE(ValidateHashProfile(profile).empty()); + + std::string error; + auto strategy = CreateHashStrategy(profile, &error); + ASSERT_NE(strategy, nullptr) << error; + const ContextKey context{.tenant_id = "default", + .model_name = "model", + .lora_name = "", + .block_size = 4}; + const std::vector tokens{10, 20, 30, 40}; + std::vector blocks; + ASSERT_TRUE( + strategy->Compute(context, tokens, std::nullopt, &blocks).empty()); + ASSERT_EQ(blocks.size(), 1u); + EXPECT_EQ(blocks[0].projected.value, 15710792592378487421ULL); +} + +} // namespace diff --git a/mooncake-conductor/tests/fixtures/generate_hash_golden_vectors.py b/mooncake-conductor/tests/fixtures/generate_hash_golden_vectors.py new file mode 100644 index 0000000000..fed187c335 --- /dev/null +++ b/mooncake-conductor/tests/fixtures/generate_hash_golden_vectors.py @@ -0,0 +1,334 @@ +#!/usr/bin/env python3 +"""Generates hash_golden_vectors_sha256.json from the vLLM `sha256` recipe. + +The `sha256` vLLM prefix-cache hash is SHA-256 over +``pickle.dumps(value, protocol=pickle.HIGHEST_PROTOCOL)``; for the supported +Python range (3.10-3.14) HIGHEST_PROTOCOL is protocol 5. This script uses the +real CPython pickler as the normative oracle and replicates +vLLM's ``hash_block_tokens`` value shapes: + + root = sha256(pickle(seed_string)) + block = sha256(pickle((parent_digest_bytes, tuple(block_token_ids), + extra_keys))) + +with ``extra_keys`` equal to None, or the tuple ``(lora_name,)`` / +``(lora_name, cache_salt)`` (salt only on the first block), matching +vLLM's ``lora + mm + cache_salt + prompt_embeds`` ordering for the subset the +Conductor query contract can express. + +The checked-in fixture pins the generator environment below. Regenerate with: + + python3 generate_hash_golden_vectors.py + +from this directory and diff the result; only regenerate intentionally. +""" + +import hashlib +import json +import pickle +import platform +import sys +from pathlib import Path + +PROTOCOL = pickle.HIGHEST_PROTOCOL +assert PROTOCOL == 5, f"expected Pickle protocol 5, got {PROTOCOL}" + +# Pinned reference: vLLM v1 kv_cache_utils.hash_block_tokens + +# utils/hashing.py sha256, verified against checkout +# v0.22.1rc0-459-g462ef83d5 (commit 462ef83d58e6fadeb6e216dc583554a6980a0af9). +VLLM_REFERENCE = ( + "vLLM v0.22.1rc0-459-g462ef83d5 hash_block_tokens value shapes with " + "utils/hashing.py sha256 = SHA256(pickle.dumps(value, protocol=5))" +) + +# Serialized block bytes are recorded in full for every case small enough to +# keep the fixture reviewable. Cases that must cross CPython's 64 KiB frame +# target (frame boundary and large-payload paths) record pickle_len instead; +# their digest is still the SHA-256 of the exact serialized bytes, so a digest +# match certifies byte equality. +PICKLE_HEX_SIZE_LIMIT = 8192 + +SEEDS = ["0", "00", "random", "4294967295"] + + +def sha256_pickle(value) -> bytes: + return hashlib.sha256(pickle.dumps(value, protocol=PROTOCOL)).digest() + + +def extras_for(lora_name: str, cache_salt, block_index: int): + keys = [] + if lora_name: + keys.append(lora_name) + if block_index == 0 and cache_salt: + keys.append(cache_salt) + return tuple(keys) if keys else None + + +def block_entry(pickle_bytes: bytes) -> dict: + digest = hashlib.sha256(pickle_bytes).digest() + entry = { + "digest": digest.hex(), + "projected_hex": digest[-8:].hex(), + "projected_decimal": str(int.from_bytes(digest[-8:], "big")), + } + if len(pickle_bytes) <= PICKLE_HEX_SIZE_LIMIT: + entry["pickle_hex"] = pickle_bytes.hex() + else: + entry["pickle_len"] = len(pickle_bytes) + return entry + + +def expand_tokens(case: dict): + if "token_ids_repeat" in case: + spec = case["token_ids_repeat"] + return [spec["value"]] * spec["count"] + return list(case["token_ids"]) + + +CASES = [ + { + "name": "spec_unsalted", + "block_size": 4, + "lora_name": "", + "cache_salt": None, + "token_ids": [1, 2, 3, 4, 5, 6, 7, 8], + }, + { + "name": "single_token_tuple", + "block_size": 1, + "lora_name": "", + "cache_salt": None, + "token_ids": [7], + }, + { + "name": "two_token_tuple", + "block_size": 2, + "lora_name": "", + "cache_salt": None, + "token_ids": [7, 8], + }, + { + "name": "three_token_tuple", + "block_size": 3, + "lora_name": "", + "cache_salt": None, + "token_ids": [7, 8, 9], + }, + { + "name": "empty_extras_encode_as_none", + "block_size": 4, + "lora_name": "", + "cache_salt": None, + "token_ids": [10, 20, 30, 40], + }, + { + "name": "signed_integer_boundaries", + "block_size": 8, + "lora_name": "", + "cache_salt": None, + "token_ids": [-2147483648, -1, 0, 255, 256, 65535, 65536, 2147483647], + }, + { + "name": "utf8_multibyte", + "block_size": 4, + "lora_name": "模型-适配器", + "cache_salt": "盐值-🚀", + "token_ids": [1, 2, 3, 4], + }, + { + "name": "lora_length_255", + "block_size": 4, + "lora_name": "a" * 255, + "cache_salt": None, + "token_ids": [1, 2, 3, 4], + }, + { + "name": "lora_length_256", + "block_size": 4, + "lora_name": "a" * 256, + "cache_salt": None, + "token_ids": [1, 2, 3, 4], + }, + { + "name": "salt_length_255", + "block_size": 4, + "lora_name": "", + "cache_salt": "s" * 255, + "token_ids": [1, 2, 3, 4], + }, + { + "name": "salt_length_256", + "block_size": 4, + "lora_name": "", + "cache_salt": "s" * 256, + "token_ids": [1, 2, 3, 4], + }, + { + "name": "lora_every_block", + "block_size": 4, + "lora_name": "adapter-A", + "cache_salt": None, + "token_ids": [1, 2, 3, 4, 5, 6, 7, 8], + }, + { + "name": "salt_first_block_only", + "block_size": 4, + "lora_name": "", + "cache_salt": "tenant-salt", + "token_ids": [1, 2, 3, 4, 5, 6, 7, 8], + }, + { + "name": "lora_then_salt", + "block_size": 4, + "lora_name": "adapter-A", + "cache_salt": "tenant-salt", + "token_ids": [1, 2, 3, 4, 5, 6, 7, 8], + }, + { + "name": "multi_block_chain", + "block_size": 4, + "lora_name": "", + "cache_salt": None, + "token_ids": list(range(1, 17)), + }, + { + "name": "incomplete_tail", + "block_size": 4, + "lora_name": "", + "cache_salt": None, + "token_ids": [1, 2, 3, 4, 5, 6], + }, + { + "name": "no_complete_block", + "block_size": 4, + "lora_name": "", + "cache_salt": None, + "token_ids": [1, 2, 3], + }, + { + # 22000 BININT2 tokens serialize to ~66 KiB inside the token tuple, + # forcing CPython's _Framer to commit a frame mid-value. + "name": "frame_boundary_token_stream", + "block_size": 22000, + "lora_name": "", + "cache_salt": None, + "token_ids_repeat": {"value": 256, "count": 22000}, + }, + { + # A >= 64 KiB extra-key string takes CPython's write_large_bytes path: + # the pending frame is force-committed and the string is written + # without a frame opcode. + "name": "large_lora_large_payload", + "block_size": 4, + "lora_name": "a" * 70000, + "cache_salt": None, + "token_ids": [1, 2, 3, 4], + }, +] + + +def main() -> None: + seed_root_vectors = [] + for seed in SEEDS: + serialized = pickle.dumps(seed, protocol=PROTOCOL) + seed_root_vectors.append( + { + "python_hash_seed": seed, + "pickle_hex": serialized.hex(), + "root_digest": hashlib.sha256(serialized).hexdigest(), + } + ) + + root_digest = sha256_pickle("0") + cases = [] + for case in CASES: + tokens = expand_tokens(case) + block_size = case["block_size"] + lora_name = case["lora_name"] + cache_salt = case["cache_salt"] + + expected = [] + parent = root_digest + for block_index in range(len(tokens) // block_size): + block_tokens = tokens[ + block_index * block_size : (block_index + 1) * block_size + ] + value = ( + parent, + tuple(block_tokens), + extras_for(lora_name, cache_salt, block_index), + ) + serialized = pickle.dumps(value, protocol=PROTOCOL) + expected.append(block_entry(serialized)) + parent = hashlib.sha256(serialized).digest() + + entry = { + "name": case["name"], + "block_size": block_size, + "lora_name": lora_name if len(lora_name) <= 512 else "", + "cache_salt": cache_salt, + "expected": expected, + } + if len(lora_name) > 512: + entry["lora_name_repeat"] = {"value": "a", "count": len(lora_name)} + if "token_ids_repeat" in case: + entry["token_ids_repeat"] = case["token_ids_repeat"] + else: + entry["token_ids"] = tokens + + if case["name"] == "spec_unsalted": + # Digest of the second block when the low-64 projection is + # incorrectly reused as the parent instead of the full digest. + first_digest = bytes.fromhex(expected[0]["digest"]) + wrong_value = ( + first_digest[-8:], + tuple(tokens[4:8]), + None, + ) + entry["incorrect_low64_parent_digest"] = hashlib.sha256( + pickle.dumps(wrong_value, protocol=PROTOCOL) + ).hexdigest() + + cases.append(entry) + + fixture = { + "description": ( + "Golden vectors for the supported vLLM v1 sha256 (CPython Pickle " + "protocol 5) seed-derived root and block-hash profile. pickle_hex " + "records the exact serialized bytes hashed for small values; large " + "frame-boundary values record pickle_len and are certified by " + "their digest." + ), + "generator": VLLM_REFERENCE, + "generator_metadata": { + "python_version": platform.python_version(), + "pickle_protocol": PROTOCOL, + "pickle_highest_protocol": pickle.HIGHEST_PROTOCOL, + }, + "profile": { + "strategy": "vllm_v1", + "algorithm": "sha256", + "python_hash_seed": "0", + "root_digest": root_digest.hex(), + "index_projection": "low64_be", + }, + "seed_root_vectors": seed_root_vectors, + "cases": cases, + } + + out = Path(__file__).with_name("hash_golden_vectors_sha256.json") + # ensure_ascii=False keeps the non-ASCII cache-salt cases readable, so both + # the encoding and the line ending are explicit rather than + # platform-dependent: the default encoding is not guaranteed to represent + # those characters, and LF keeps regeneration byte-identical everywhere so + # the "regenerate and diff" check above reports only real changes. + out.write_text( + json.dumps(fixture, indent=2, ensure_ascii=False) + "\n", + encoding="utf-8", + newline="\n", + ) + print(f"wrote {out}") + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/mooncake-conductor/tests/fixtures/hash_golden_vectors.json b/mooncake-conductor/tests/fixtures/hash_golden_vectors.json new file mode 100644 index 0000000000..1acf63937f --- /dev/null +++ b/mooncake-conductor/tests/fixtures/hash_golden_vectors.json @@ -0,0 +1,232 @@ +{ + "description": "Golden vectors for the supported vLLM v1 sha256_cbor seed-derived root and block-hash profile.", + "generator": "vLLM 0.22.0 hash_block_tokens semantics with cbor2 6.1.1 canonical=True", + "profile": { + "strategy": "vllm_v1", + "algorithm": "sha256_cbor", + "python_hash_seed": "0", + "root_digest": "4e1195df020de59e0d65a33a4279f1183e7ae4e5d980e309f8b55adff2e61c3e", + "index_projection": "low64_be" + }, + "seed_root_vectors": [ + { + "python_hash_seed": "0", + "canonical_cbor_hex": "6130", + "root_digest": "4e1195df020de59e0d65a33a4279f1183e7ae4e5d980e309f8b55adff2e61c3e" + }, + { + "python_hash_seed": "00", + "canonical_cbor_hex": "623030", + "root_digest": "8d912e4e62b3cc377b1d1c7a14ef61dffbdaa0990237035c05401c29414c4172" + } + ], + "cases": [ + { + "name": "spec_unsalted", + "block_size": 4, + "lora_name": "", + "cache_salt": null, + "token_ids": [1, 2, 3, 4, 5, 6, 7, 8], + "expected": [ + { + "digest": "c9d58ba695280d69b243e1e0df813136ca9196b286fb1a021e0b2e028ef071cb", + "projected_hex": "1e0b2e028ef071cb", + "projected_decimal": "2164874634404590027" + }, + { + "digest": "24125b23e68883b5c2141db2959d48433fe6bde2f26bd914efad121d154ab2d6", + "projected_hex": "efad121d154ab2d6", + "projected_decimal": "17270480062156288726" + } + ], + "incorrect_low64_parent_digest": "067bc227e5aa13cb174be22cd7016b7b21b663eddba6241928d7a7d1ffc0aa1e" + }, + { + "name": "lora_every_block", + "block_size": 4, + "lora_name": "adapter-A", + "cache_salt": null, + "token_ids": [1, 2, 3, 4, 5, 6, 7, 8], + "expected": [ + { + "digest": "1424c0ec536f82188b592f263601d4e48c0255081c5f1e9225163c6d611a5222", + "projected_hex": "25163c6d611a5222", + "projected_decimal": "2672389869369184802" + }, + { + "digest": "4a2ec941640a8f2470869ec2b3cf6bdd90e2be83b21807b628ac9abe9341eea9", + "projected_hex": "28ac9abe9341eea9", + "projected_decimal": "2930887600816385705" + } + ] + }, + { + "name": "salt_first_block", + "block_size": 4, + "lora_name": "", + "cache_salt": "tenant-salt", + "token_ids": [1, 2, 3, 4, 5, 6, 7, 8], + "expected": [ + { + "digest": "3be45574566e2f3c51c9f254d9627cf113382b627aeaefe8b661d66cfa089359", + "projected_hex": "b661d66cfa089359", + "projected_decimal": "13142020951183496025" + }, + { + "digest": "22dea68f80ff7f40243dfb906a33db02571905c78a3b42153a56de5ad08c661a", + "projected_hex": "3a56de5ad08c661a", + "projected_decimal": "4203791783824221722" + } + ] + }, + { + "name": "lora_then_salt", + "block_size": 4, + "lora_name": "adapter-A", + "cache_salt": "tenant-salt", + "token_ids": [1, 2, 3, 4, 5, 6, 7, 8], + "expected": [ + { + "digest": "22977da6c71098507264dbf4161d451c2d98494c5c146717f36787e184b36040", + "projected_hex": "f36787e184b36040", + "projected_decimal": "17539136676481425472" + }, + { + "digest": "6a3cd7de2626c2d2982eb5bc9d4878a49745c3784bde8a2334ffac47286bb866", + "projected_hex": "34ffac47286bb866", + "projected_decimal": "3818960430654273638" + } + ] + }, + { + "name": "signed_integer_boundaries", + "block_size": 12, + "lora_name": "", + "cache_salt": null, + "token_ids": [ + -2147483648, + -257, + -256, + -25, + -24, + -1, + 0, + 23, + 24, + 255, + 256, + 2147483647 + ], + "expected": [ + { + "digest": "d4e5cc975a84b5ae7299d36910284bf256ea1eade2689dba6391b08d86e006d7", + "projected_hex": "6391b08d86e006d7", + "projected_decimal": "7174709803277616855" + } + ] + }, + { + "name": "utf8_length_23", + "block_size": 4, + "lora_name": "aaaaaaaaaaaaaaaaaaaaaaa", + "cache_salt": null, + "token_ids": [1, 2, 3, 4], + "expected": [ + { + "digest": "3cc96f507898959352d1d123c849d0ab4b11846c8de3c67123d72e78957adb07", + "projected_hex": "23d72e78957adb07", + "projected_decimal": "2582584006759078663" + } + ] + }, + { + "name": "utf8_length_24", + "block_size": 4, + "lora_name": "aaaaaaaaaaaaaaaaaaaaaaaa", + "cache_salt": null, + "token_ids": [1, 2, 3, 4], + "expected": [ + { + "digest": "553263ebbf33660ecba67f9fcabea06d20cca3a71c79abf6e1894d39c7fa8ff8", + "projected_hex": "e1894d39c7fa8ff8", + "projected_decimal": "16251605640906706936" + } + ] + }, + { + "name": "utf8_multibyte", + "block_size": 4, + "lora_name": "\u6a21\u578b-\u9002\u914d\u5668", + "cache_salt": "\u76d0\u503c-\ud83d\ude80", + "token_ids": [1, 2, 3, 4], + "expected": [ + { + "digest": "69af00b1b180a366c69003a00527c2baf6bbc71880abc3b1d3b60fa2f553cdca", + "projected_hex": "d3b60fa2f553cdca", + "projected_decimal": "15255397980339162570" + } + ] + }, + { + "name": "definite_array_length_24", + "block_size": 24, + "lora_name": "", + "cache_salt": null, + "token_ids": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23 + ], + "expected": [ + { + "digest": "792917e681c347b21bebfdd57cabcca6b79c865f83ccd2cfbd9cb56ffed2b088", + "projected_hex": "bd9cb56ffed2b088", + "projected_decimal": "13662994862156460168" + } + ] + }, + { + "name": "incomplete_tail", + "block_size": 4, + "lora_name": "", + "cache_salt": null, + "token_ids": [1, 2, 3, 4, 5, 6], + "expected": [ + { + "digest": "c9d58ba695280d69b243e1e0df813136ca9196b286fb1a021e0b2e028ef071cb", + "projected_hex": "1e0b2e028ef071cb", + "projected_decimal": "2164874634404590027" + } + ] + }, + { + "name": "no_complete_block", + "block_size": 4, + "lora_name": "", + "cache_salt": null, + "token_ids": [1, 2, 3], + "expected": [] + } + ] +} diff --git a/mooncake-conductor/tests/fixtures/hash_golden_vectors_sha256.json b/mooncake-conductor/tests/fixtures/hash_golden_vectors_sha256.json new file mode 100644 index 0000000000..06b4e16f96 --- /dev/null +++ b/mooncake-conductor/tests/fixtures/hash_golden_vectors_sha256.json @@ -0,0 +1,485 @@ +{ + "description": "Golden vectors for the supported vLLM v1 sha256 (CPython Pickle protocol 5) seed-derived root and block-hash profile. pickle_hex records the exact serialized bytes hashed for small values; large frame-boundary values record pickle_len and are certified by their digest.", + "generator": "vLLM v0.22.1rc0-459-g462ef83d5 hash_block_tokens value shapes with utils/hashing.py sha256 = SHA256(pickle.dumps(value, protocol=5))", + "generator_metadata": { + "python_version": "3.12.13", + "pickle_protocol": 5, + "pickle_highest_protocol": 5 + }, + "profile": { + "strategy": "vllm_v1", + "algorithm": "sha256", + "python_hash_seed": "0", + "root_digest": "1973e23848344dc43a988a9b478663803cfffe1243480253f9a3cf004b14aa7c", + "index_projection": "low64_be" + }, + "seed_root_vectors": [ + { + "python_hash_seed": "0", + "pickle_hex": "80059505000000000000008c0130942e", + "root_digest": "1973e23848344dc43a988a9b478663803cfffe1243480253f9a3cf004b14aa7c" + }, + { + "python_hash_seed": "00", + "pickle_hex": "80059506000000000000008c023030942e", + "root_digest": "67c61e0f70ad5d3fc0eacb59744008ac6f2a8bd2edad43540a743eaf0fb4993c" + }, + { + "python_hash_seed": "random", + "pickle_hex": "8005950a000000000000008c0672616e646f6d942e", + "root_digest": "fa1326fc0ad784c54e10f808051a77f999f7b675b12fba49cbef92b3e1a09cb7" + }, + { + "python_hash_seed": "4294967295", + "pickle_hex": "8005950e000000000000008c0a34323934393637323935942e", + "root_digest": "670c2eca46f5f7e11b9dce5c3bd2ff0e9e19567fd45b50c2db5cc0c9aae470d9" + } + ], + "cases": [ + { + "name": "spec_unsalted", + "block_size": 4, + "lora_name": "", + "cache_salt": null, + "expected": [ + { + "digest": "762b2e7e1520febd3a0aedb7358e8602facf3924440e18538fce057e23bae91d", + "projected_hex": "8fce057e23bae91d", + "projected_decimal": "10362225831949560093", + "pickle_hex": "800595320000000000000043201973e23848344dc43a988a9b478663803cfffe1243480253f9a3cf004b14aa7c94284b014b024b034b0474944e87942e" + }, + { + "digest": "9e62b5a7ec298f33cb4c7d9e00b659874bb2432d23ad6879ad1746592a2b036b", + "projected_hex": "ad1746592a2b036b", + "projected_decimal": "12472515041799373675", + "pickle_hex": "80059532000000000000004320762b2e7e1520febd3a0aedb7358e8602facf3924440e18538fce057e23bae91d94284b054b064b074b0874944e87942e" + } + ], + "token_ids": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8 + ], + "incorrect_low64_parent_digest": "22f70d17411c10aa544ecdac1d2df0fc1d7c7ac2d21efe1aebe32028620638c6" + }, + { + "name": "single_token_tuple", + "block_size": 1, + "lora_name": "", + "cache_salt": null, + "expected": [ + { + "digest": "4e7493abc7315520e17ce180ee400439b7dc83e7406fb239462766705eb058c8", + "projected_hex": "462766705eb058c8", + "projected_decimal": "5055121739557656776", + "pickle_hex": "8005952b0000000000000043201973e23848344dc43a988a9b478663803cfffe1243480253f9a3cf004b14aa7c944b0785944e87942e" + } + ], + "token_ids": [ + 7 + ] + }, + { + "name": "two_token_tuple", + "block_size": 2, + "lora_name": "", + "cache_salt": null, + "expected": [ + { + "digest": "aa9e79304bb6921235ea2e187b25f9bb43d9044ec232e3d1c07d7e21e51e64ee", + "projected_hex": "c07d7e21e51e64ee", + "projected_decimal": "13870381111413990638", + "pickle_hex": "8005952d0000000000000043201973e23848344dc43a988a9b478663803cfffe1243480253f9a3cf004b14aa7c944b074b0886944e87942e" + } + ], + "token_ids": [ + 7, + 8 + ] + }, + { + "name": "three_token_tuple", + "block_size": 3, + "lora_name": "", + "cache_salt": null, + "expected": [ + { + "digest": "6368e68a46c970c8ec2ebab3eb9d2fe7ddc2730165bb64d698aaed2907396794", + "projected_hex": "98aaed2907396794", + "projected_decimal": "11000865800276502420", + "pickle_hex": "8005952f0000000000000043201973e23848344dc43a988a9b478663803cfffe1243480253f9a3cf004b14aa7c944b074b084b0987944e87942e" + } + ], + "token_ids": [ + 7, + 8, + 9 + ] + }, + { + "name": "empty_extras_encode_as_none", + "block_size": 4, + "lora_name": "", + "cache_salt": null, + "expected": [ + { + "digest": "569de0636f6064532b63312eab095a4577e72dc55286dbfb43821ec866309e47", + "projected_hex": "43821ec866309e47", + "projected_decimal": "4864484393570311751", + "pickle_hex": "800595320000000000000043201973e23848344dc43a988a9b478663803cfffe1243480253f9a3cf004b14aa7c94284b0a4b144b1e4b2874944e87942e" + } + ], + "token_ids": [ + 10, + 20, + 30, + 40 + ] + }, + { + "name": "signed_integer_boundaries", + "block_size": 8, + "lora_name": "", + "cache_salt": null, + "expected": [ + { + "digest": "6c9e5f944f0a93319b3ba9478115a9447de2f6d4ef1cd1c0b19a7ebd607353e8", + "projected_hex": "b19a7ebd607353e8", + "projected_decimal": "12797680642958775272", + "pickle_hex": "800595480000000000000043201973e23848344dc43a988a9b478663803cfffe1243480253f9a3cf004b14aa7c94284a000000804affffffff4b004bff4d00014dffff4a000001004affffff7f74944e87942e" + } + ], + "token_ids": [ + -2147483648, + -1, + 0, + 255, + 256, + 65535, + 65536, + 2147483647 + ] + }, + { + "name": "utf8_multibyte", + "block_size": 4, + "lora_name": "模型-适配器", + "cache_salt": "盐值-🚀", + "expected": [ + { + "digest": "cc1a2ab147f91a34070bb430e35dbc6ca6261a93b490a2307d0b4b31696a247a", + "projected_hex": "7d0b4b31696a247a", + "projected_decimal": "9010378155078853754", + "pickle_hex": "800595540000000000000043201973e23848344dc43a988a9b478663803cfffe1243480253f9a3cf004b14aa7c94284b014b024b034b0474948c10e6a8a1e59e8b2de98082e9858de599a8948c0be79b90e580bc2df09f9a8094869487942e" + } + ], + "token_ids": [ + 1, + 2, + 3, + 4 + ] + }, + { + "name": "lora_length_255", + "block_size": 4, + "lora_name": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "cache_salt": null, + "expected": [ + { + "digest": "866b277c9d76457e35bd6a5dd3ec0d5b598faa9e36d108608f6e925149b1524b", + "projected_hex": "8f6e925149b1524b", + "projected_decimal": "10335359072688230987", + "pickle_hex": "800595350100000000000043201973e23848344dc43a988a9b478663803cfffe1243480253f9a3cf004b14aa7c94284b014b024b034b0474948cff61616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616194859487942e" + } + ], + "token_ids": [ + 1, + 2, + 3, + 4 + ] + }, + { + "name": "lora_length_256", + "block_size": 4, + "lora_name": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "cache_salt": null, + "expected": [ + { + "digest": "0c934ab8aa543128da871a91a46387ed907ed3ca8b39e5c137da510f80a106df", + "projected_hex": "37da510f80a106df", + "projected_decimal": "4024618344033355487", + "pickle_hex": "800595390100000000000043201973e23848344dc43a988a9b478663803cfffe1243480253f9a3cf004b14aa7c94284b014b024b034b04749458000100006161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616194859487942e" + } + ], + "token_ids": [ + 1, + 2, + 3, + 4 + ] + }, + { + "name": "salt_length_255", + "block_size": 4, + "lora_name": "", + "cache_salt": "sssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssss", + "expected": [ + { + "digest": "167d69abb219f81883e1fc5e554564bab043051f849914feb058487c8bffc398", + "projected_hex": "b058487c8bffc398", + "projected_decimal": "12706986048387793816", + "pickle_hex": "800595350100000000000043201973e23848344dc43a988a9b478663803cfffe1243480253f9a3cf004b14aa7c94284b014b024b034b0474948cff73737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737394859487942e" + } + ], + "token_ids": [ + 1, + 2, + 3, + 4 + ] + }, + { + "name": "salt_length_256", + "block_size": 4, + "lora_name": "", + "cache_salt": "ssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssss", + "expected": [ + { + "digest": "0875d6e8c04ec7bb02928dc8f4b9e336aecb8adfae9078a9b6b54fe557b6857d", + "projected_hex": "b6b54fe557b6857d", + "projected_decimal": "13165516932125197693", + "pickle_hex": "800595390100000000000043201973e23848344dc43a988a9b478663803cfffe1243480253f9a3cf004b14aa7c94284b014b024b034b04749458000100007373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737394859487942e" + } + ], + "token_ids": [ + 1, + 2, + 3, + 4 + ] + }, + { + "name": "lora_every_block", + "block_size": 4, + "lora_name": "adapter-A", + "cache_salt": null, + "expected": [ + { + "digest": "5c5d9e04e03de9f2f4a9f38766c4d0fb0e207ccf5d2fb3b272c50117f336ae0c", + "projected_hex": "72c50117f336ae0c", + "projected_decimal": "8270017493112106508", + "pickle_hex": "8005953f0000000000000043201973e23848344dc43a988a9b478663803cfffe1243480253f9a3cf004b14aa7c94284b014b024b034b0474948c09616461707465722d4194859487942e" + }, + { + "digest": "bde66cc24757972cc88e0fe62079d85934868c2733874ffa1c54ff679604f61a", + "projected_hex": "1c54ff679604f61a", + "projected_decimal": "2041537351469299226", + "pickle_hex": "8005953f0000000000000043205c5d9e04e03de9f2f4a9f38766c4d0fb0e207ccf5d2fb3b272c50117f336ae0c94284b054b064b074b0874948c09616461707465722d4194859487942e" + } + ], + "token_ids": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8 + ] + }, + { + "name": "salt_first_block_only", + "block_size": 4, + "lora_name": "", + "cache_salt": "tenant-salt", + "expected": [ + { + "digest": "373c09c29ab08b91b13bf1a67e0a461990572d476016c064b6ba6b75c163ceb1", + "projected_hex": "b6ba6b75c163ceb1", + "projected_decimal": "13166954614070955697", + "pickle_hex": "800595410000000000000043201973e23848344dc43a988a9b478663803cfffe1243480253f9a3cf004b14aa7c94284b014b024b034b0474948c0b74656e616e742d73616c7494859487942e" + }, + { + "digest": "141900dd6fe59d22764357a125ecf4d670923bb12ad705a520de0e98c7338d5d", + "projected_hex": "20de0e98c7338d5d", + "projected_decimal": "2368346503383321949", + "pickle_hex": "80059532000000000000004320373c09c29ab08b91b13bf1a67e0a461990572d476016c064b6ba6b75c163ceb194284b054b064b074b0874944e87942e" + } + ], + "token_ids": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8 + ] + }, + { + "name": "lora_then_salt", + "block_size": 4, + "lora_name": "adapter-A", + "cache_salt": "tenant-salt", + "expected": [ + { + "digest": "dacd8a5addba8cf4b89589bedab04999b3676299a455ba3e085cc7777be6b328", + "projected_hex": "085cc7777be6b328", + "projected_decimal": "602575766154556200", + "pickle_hex": "8005954d0000000000000043201973e23848344dc43a988a9b478663803cfffe1243480253f9a3cf004b14aa7c94284b014b024b034b0474948c09616461707465722d41948c0b74656e616e742d73616c7494869487942e" + }, + { + "digest": "55575bd9b63ced6a3dc36c355afbb5f551599533795c26d27d8b5fc836681618", + "projected_hex": "7d8b5fc836681618", + "projected_decimal": "9046429590014662168", + "pickle_hex": "8005953f000000000000004320dacd8a5addba8cf4b89589bedab04999b3676299a455ba3e085cc7777be6b32894284b054b064b074b0874948c09616461707465722d4194859487942e" + } + ], + "token_ids": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8 + ] + }, + { + "name": "multi_block_chain", + "block_size": 4, + "lora_name": "", + "cache_salt": null, + "expected": [ + { + "digest": "762b2e7e1520febd3a0aedb7358e8602facf3924440e18538fce057e23bae91d", + "projected_hex": "8fce057e23bae91d", + "projected_decimal": "10362225831949560093", + "pickle_hex": "800595320000000000000043201973e23848344dc43a988a9b478663803cfffe1243480253f9a3cf004b14aa7c94284b014b024b034b0474944e87942e" + }, + { + "digest": "9e62b5a7ec298f33cb4c7d9e00b659874bb2432d23ad6879ad1746592a2b036b", + "projected_hex": "ad1746592a2b036b", + "projected_decimal": "12472515041799373675", + "pickle_hex": "80059532000000000000004320762b2e7e1520febd3a0aedb7358e8602facf3924440e18538fce057e23bae91d94284b054b064b074b0874944e87942e" + }, + { + "digest": "fd503875de30430b3c7e35f7ecca2fe995f2d55487bfef31707e09b175f917a0", + "projected_hex": "707e09b175f917a0", + "projected_decimal": "8105927037106591648", + "pickle_hex": "800595320000000000000043209e62b5a7ec298f33cb4c7d9e00b659874bb2432d23ad6879ad1746592a2b036b94284b094b0a4b0b4b0c74944e87942e" + }, + { + "digest": "ac30858a66380467632841b758acc6cbe95ff9cfd7b9d226aa33de029248adff", + "projected_hex": "aa33de029248adff", + "projected_decimal": "12264390312885530111", + "pickle_hex": "80059532000000000000004320fd503875de30430b3c7e35f7ecca2fe995f2d55487bfef31707e09b175f917a094284b0d4b0e4b0f4b1074944e87942e" + } + ], + "token_ids": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16 + ] + }, + { + "name": "incomplete_tail", + "block_size": 4, + "lora_name": "", + "cache_salt": null, + "expected": [ + { + "digest": "762b2e7e1520febd3a0aedb7358e8602facf3924440e18538fce057e23bae91d", + "projected_hex": "8fce057e23bae91d", + "projected_decimal": "10362225831949560093", + "pickle_hex": "800595320000000000000043201973e23848344dc43a988a9b478663803cfffe1243480253f9a3cf004b14aa7c94284b014b024b034b0474944e87942e" + } + ], + "token_ids": [ + 1, + 2, + 3, + 4, + 5, + 6 + ] + }, + { + "name": "no_complete_block", + "block_size": 4, + "lora_name": "", + "cache_salt": null, + "expected": [], + "token_ids": [ + 1, + 2, + 3 + ] + }, + { + "name": "frame_boundary_token_stream", + "block_size": 22000, + "lora_name": "", + "cache_salt": null, + "expected": [ + { + "digest": "7f245129165bb9ba11747856a9a908740bf7b29c0033da4958281d87b252f469", + "projected_hex": "58281d87b252f469", + "projected_decimal": "6352359743055656041", + "pickle_len": 66062 + } + ], + "token_ids_repeat": { + "value": 256, + "count": 22000 + } + }, + { + "name": "large_lora_large_payload", + "block_size": 4, + "lora_name": "", + "cache_salt": null, + "expected": [ + { + "digest": "f30f906062e861807059e2099b689baf2c9484128a0c2fd40a5fdd1e1032f47f", + "projected_hex": "0a5fdd1e1032f47f", + "projected_decimal": "747559184357323903", + "pickle_len": 70077 + } + ], + "lora_name_repeat": { + "value": "a", + "count": 70000 + }, + "token_ids": [ + 1, + 2, + 3, + 4 + ] + } + ] +} diff --git a/mooncake-conductor/tests/fixtures/verify_fixture_against_vllm.py b/mooncake-conductor/tests/fixtures/verify_fixture_against_vllm.py new file mode 100644 index 0000000000..bba62974ab --- /dev/null +++ b/mooncake-conductor/tests/fixtures/verify_fixture_against_vllm.py @@ -0,0 +1,150 @@ +#!/usr/bin/env python3 +"""Verifies hash_golden_vectors_sha256.json against the checked-out vLLM. + +Loads ``vllm/utils/hashing.py`` directly from a vLLM source checkout (without +importing the heavy ``vllm`` package) and uses its ``sha256`` function as the +normative oracle: SHA-256 over ``pickle.dumps(value, protocol=5)``. The block +value shapes mirror ``vllm/v1/core/kv_cache_utils.py::hash_block_tokens``. + +Usage: + + python3 verify_fixture_against_vllm.py + +Prints the Python/vLLM/protocol metadata it ran against and exits non-zero on +any mismatch. +""" + +import importlib.util +import json +import pickle +import platform +import subprocess +import sys +import types +from pathlib import Path + +FIXTURE = Path(__file__).with_name("hash_golden_vectors_sha256.json") + + +def load_vllm_hashing(checkout: Path): + hashing_py = checkout / "vllm" / "utils" / "hashing.py" + if not hashing_py.is_file(): + raise SystemExit(f"vLLM hashing.py not found at {hashing_py}") + # hashing.py imports cbor2 at module scope; the sha256 recipe under test + # never touches it, so a stub is sufficient when cbor2 is absent. + if "cbor2" not in sys.modules: + try: + import cbor2 # noqa: F401 + except ImportError: + sys.modules["cbor2"] = types.ModuleType("cbor2") + spec = importlib.util.spec_from_file_location("vllm_hashing", hashing_py) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module.sha256 + + +def vllm_revision(checkout: Path) -> str: + try: + return subprocess.run( + ["git", "-C", str(checkout), "describe", "--tags", "--always"], + capture_output=True, + text=True, + check=True, + ).stdout.strip() + except (subprocess.CalledProcessError, FileNotFoundError): + return "unknown" + + +def expand_tokens(case: dict): + if "token_ids_repeat" in case: + spec = case["token_ids_repeat"] + return [spec["value"]] * spec["count"] + return list(case["token_ids"]) + + +def expand_lora(case: dict) -> str: + if "lora_name_repeat" in case: + spec = case["lora_name_repeat"] + return spec["value"] * spec["count"] + return case["lora_name"] + + +def extras_for(lora_name: str, cache_salt, block_index: int): + keys = [] + if lora_name: + keys.append(lora_name) + if block_index == 0 and cache_salt: + keys.append(cache_salt) + return tuple(keys) if keys else None + + +def main() -> int: + if len(sys.argv) != 2: + raise SystemExit(f"usage: {Path(__file__).name} ") + checkout = Path(sys.argv[1]) + vllm_sha256 = load_vllm_hashing(checkout) + # The fixture carries non-ASCII cache salts, so the encoding is explicit + # rather than platform-dependent. + fixture = json.loads(FIXTURE.read_text(encoding="utf-8")) + + print(f"python: {platform.python_version()}") + print(f"pickle protocol: {pickle.HIGHEST_PROTOCOL}") + print(f"vLLM checkout: {checkout} ({vllm_revision(checkout)})") + + failures = 0 + + def check(label: str, actual: str, expected: str) -> None: + nonlocal failures + if actual != expected: + failures += 1 + print(f"MISMATCH {label}:\n actual {actual}\n expected {expected}") + + for vector in fixture["seed_root_vectors"]: + seed = vector["python_hash_seed"] + check(f"seed root {seed!r}", vllm_sha256(seed).hex(), vector["root_digest"]) + check( + f"seed pickle bytes {seed!r}", + pickle.dumps(seed, protocol=5).hex(), + vector["pickle_hex"], + ) + + root = bytes.fromhex(fixture["profile"]["root_digest"]) + for case in fixture["cases"]: + tokens = expand_tokens(case) + block_size = case["block_size"] + lora_name = expand_lora(case) + cache_salt = case["cache_salt"] + parent = root + for index, entry in enumerate(case["expected"]): + block_tokens = tuple(tokens[index * block_size : (index + 1) * block_size]) + value = ( + parent, + block_tokens, + extras_for(lora_name, cache_salt, index), + ) + serialized = pickle.dumps(value, protocol=5) + digest = vllm_sha256(value).hex() + check(f"{case['name']} block {index}", digest, entry["digest"]) + if "pickle_hex" in entry: + check( + f"{case['name']} block {index} pickle bytes", + serialized.hex(), + entry["pickle_hex"], + ) + else: + check( + f"{case['name']} block {index} pickle length", + str(len(serialized)), + str(entry["pickle_len"]), + ) + parent = bytes.fromhex(digest) + + if failures: + print(f"{failures} mismatch(es)") + return 1 + print("all seed roots, block digests, and serialized bytes match vLLM") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/mooncake-conductor/tests/json_uint64_test.cpp b/mooncake-conductor/tests/json_uint64_test.cpp new file mode 100644 index 0000000000..78f25d94cf --- /dev/null +++ b/mooncake-conductor/tests/json_uint64_test.cpp @@ -0,0 +1,62 @@ +// JSON library validation: /global_view serialises uint64 conductor +// hashes as JSON numbers and must not lose precision above 2^53. This +// locks in the jsoncpp choice; if this test ever fails after a jsoncpp +// upgrade, the library must be replaced with one that preserves full +// 64-bit integer precision. + +#include +#include + +#include +#include + +namespace { + +std::string WriteCompact(const Json::Value& v) { + Json::StreamWriterBuilder wb; + wb["indentation"] = ""; + return Json::writeString(wb, v); +} + +Json::Value Parse(const std::string& s) { + Json::CharReaderBuilder rb; + Json::Value out; + std::string errs; + std::istringstream iss(s); + EXPECT_TRUE(Json::parseFromStream(rb, iss, &out, &errs)) << errs; + return out; +} + +TEST(JsonUint64, BoundaryValuesRoundTrip) { + const uint64_t cases[] = { + 0, + 1, + (1ULL << 53) - 1, // largest double-exact integer + (1ULL << 53), + (1ULL << 53) + 1, // first value a double-based library corrupts + (1ULL << 63), + 0xFFFFFFFFFFFFFFFFULL, // uint64 max boundary value + }; + for (const uint64_t v : cases) { + Json::Value doc; + doc["h"] = Json::Value::UInt64(v); + const std::string text = WriteCompact(doc); + const Json::Value back = Parse(text); + ASSERT_TRUE(back["h"].isUInt64()) << "value=" << v; + EXPECT_EQ(back["h"].asUInt64(), v) << "text=" << text; + } +} + +// The /global_view hashmap serialises uint64 values inside objects keyed +// by decimal strings; verify the exact shape survives a round-trip. +TEST(JsonUint64, HashmapShapeRoundTrip) { + Json::Value mapping(Json::objectValue); + mapping["11185915045167517441"] = + Json::Value::UInt64(9404987693367201964ULL); + const std::string text = WriteCompact(mapping); + EXPECT_EQ(text, "{\"11185915045167517441\":9404987693367201964}"); + const Json::Value back = Parse(text); + EXPECT_EQ(back["11185915045167517441"].asUInt64(), 9404987693367201964ULL); +} + +} // namespace diff --git a/mooncake-conductor/tests/model_context_test.cpp b/mooncake-conductor/tests/model_context_test.cpp new file mode 100644 index 0000000000..785e137b22 --- /dev/null +++ b/mooncake-conductor/tests/model_context_test.cpp @@ -0,0 +1,107 @@ +#include + +#include +#include +#include +#include +#include + +#include "conductor/prefixindex/types.h" + +namespace { + +using mooncake::conductor::common::HashProfileConfig; +using mooncake::conductor::common::ResolvedHashProfile; +using mooncake::conductor::prefixindex::ContextKey; +using mooncake::conductor::prefixindex::EngineRegistration; +using mooncake::conductor::prefixindex::GpuMutation; + +template +concept HasInstanceId = requires(T value) { value.instance_id; }; + +template +concept HasCacheSalt = requires(T value) { value.cache_salt; }; + +template +concept HasAdditionalSalt = requires(T value) { value.additional_salt; }; + +template +concept HasCacheGroups = requires(T value) { value.cache_groups; }; + +template +concept HasPythonHashSeed = requires(T value) { value.python_hash_seed; }; + +template +concept HasRootDigest = requires(T value) { value.root_digest; }; + +static_assert(!HasInstanceId); +static_assert(!HasCacheSalt); +static_assert(!HasAdditionalSalt); +static_assert(!HasPythonHashSeed); +static_assert(!HasCacheGroups); +static_assert(!HasCacheGroups); +static_assert(HasPythonHashSeed); +static_assert(!HasRootDigest); +static_assert(HasPythonHashSeed); +static_assert(HasRootDigest); +static_assert(std::same_as>); +static_assert( + std::same_as>); + +ContextKey BaseContext() { + return {.tenant_id = "tenant-a", + .model_name = "model-a", + .lora_name = "lora-a", + .block_size = 16}; +} + +TEST(ContextKey, IdenticalCopiesCompareAndHashEqual) { + const ContextKey first = BaseContext(); + const ContextKey second = first; + + EXPECT_EQ(first, second); + EXPECT_EQ(std::hash{}(first), std::hash{}(second)); +} + +struct FieldCase { + const char* name; + void (*change)(ContextKey*); +}; + +const FieldCase kFields[] = { + {"tenant_id", [](ContextKey* context) { context->tenant_id = "tenant-b"; }}, + {"model_name", + [](ContextKey* context) { context->model_name = "model-b"; }}, + {"lora_name", [](ContextKey* context) { context->lora_name = "lora-b"; }}, + {"block_size", [](ContextKey* context) { context->block_size = 32; }}, +}; + +TEST(ContextKey, ExactlyFourFieldsParticipateInEqualityAndLookup) { + std::unordered_map contexts; + contexts.emplace(BaseContext(), 1); + + for (const FieldCase& field : kFields) { + SCOPED_TRACE(field.name); + ContextKey changed = BaseContext(); + field.change(&changed); + EXPECT_NE(changed, BaseContext()); + EXPECT_FALSE(contexts.contains(changed)); + } +} + +TEST(ContextKey, OwnerAndRequestSaltLiveOutsideTheKey) { + ContextKey shared = BaseContext(); + const std::string first_instance = "instance-a"; + const std::string second_instance = "instance-b"; + const std::string first_salt = "salt-a"; + const std::string second_salt = "salt-b"; + + EXPECT_NE(first_instance, second_instance); + EXPECT_NE(first_salt, second_salt); + EXPECT_EQ(shared, BaseContext()); + EXPECT_EQ(std::hash{}(shared), + std::hash{}(BaseContext())); +} + +} // namespace diff --git a/mooncake-conductor/tests/msg_decoder_test.cpp b/mooncake-conductor/tests/msg_decoder_test.cpp new file mode 100644 index 0000000000..c56a45742a --- /dev/null +++ b/mooncake-conductor/tests/msg_decoder_test.cpp @@ -0,0 +1,1278 @@ +// Tests for the current vLLM msgspec and Mooncake publisher map protocols. + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "conductor/zmq/msg_decoder.h" +#include "conductor/kvevent/object_key_parser.h" + +namespace { + +using mooncake::conductor::kvevent::ParsedSglangObjectKey; +using mooncake::conductor::kvevent::ParseSglangObjectKey; +using mooncake::conductor::kvevent::ParseVllmObjectKey; +using mooncake::conductor::zmq::DecodeMooncakeEventBatch; +using mooncake::conductor::zmq::DecodeSglangEventBatch; +using mooncake::conductor::zmq::DecodeVllmEventBatch; +using mooncake::conductor::zmq::MooncakeClearedEvent; +using mooncake::conductor::zmq::MooncakeEventFields; +using mooncake::conductor::zmq::MooncakeRemovedEvent; +using mooncake::conductor::zmq::MooncakeStoredEvent; +using mooncake::conductor::zmq::SglangClearedEvent; +using mooncake::conductor::zmq::SglangEventBatch; +using mooncake::conductor::zmq::SglangRemovedEvent; +using mooncake::conductor::zmq::SglangStoredEvent; +using mooncake::conductor::zmq::VllmClearedEvent; +using mooncake::conductor::zmq::VllmRemovedEvent; +using mooncake::conductor::zmq::VllmStoredEvent; + +using Packer = msgpack::packer; + +constexpr int64_t kMooncakeTimestamp = 1700000000123LL; +constexpr int64_t kMooncakeDpRank = 2; +constexpr uint64_t kProjectedHash = 0x18191a1b1c1d1e1fULL; + +std::string BytesFromHex(std::string_view hex) { + if (hex.size() % 2 != 0) { + throw std::invalid_argument("hex input must have an even length"); + } + const auto nibble = [](char value) -> uint8_t { + if (value >= '0' && value <= '9') { + return static_cast(value - '0'); + } + if (value >= 'a' && value <= 'f') { + return static_cast(value - 'a' + 10); + } + if (value >= 'A' && value <= 'F') { + return static_cast(value - 'A' + 10); + } + throw std::invalid_argument("invalid hex digit"); + }; + + std::string bytes; + bytes.reserve(hex.size() / 2); + for (size_t index = 0; index < hex.size(); index += 2) { + bytes.push_back(static_cast((nibble(hex[index]) << 4) | + nibble(hex[index + 1]))); + } + return bytes; +} + +template +std::string PackVllmBatch(uint32_t event_count, PackEvents pack_events, + std::optional dp_rank = 3) { + std::stringstream buffer; + Packer packer(buffer); + packer.pack_array(3); + packer.pack_double(1.25); + packer.pack_array(event_count); + pack_events(packer); + if (dp_rank.has_value()) { + packer.pack(*dp_rank); + } else { + packer.pack_nil(); + } + return buffer.str(); +} + +template +std::string PackMooncakeBatch( + uint32_t event_count, PackEvents pack_events, + std::optional dp_rank = kMooncakeDpRank) { + std::stringstream buffer; + Packer packer(buffer); + packer.pack_array(3); + packer.pack(kMooncakeTimestamp); + packer.pack_array(event_count); + pack_events(packer); + if (dp_rank.has_value()) { + packer.pack(*dp_rank); + } else { + packer.pack_nil(); + } + return buffer.str(); +} + +void PackBinary(Packer& packer, const std::vector& bytes) { + packer.pack_bin(bytes.size()); + packer.pack_bin_body(reinterpret_cast(bytes.data()), + bytes.size()); +} + +void PackVllmStored(Packer& packer, bool include_unknown = false) { + packer.pack_map(include_unknown ? 9 : 8); + packer.pack("type"); + packer.pack("BlockStored"); + packer.pack("block_hashes"); + packer.pack_array(1); + packer.pack_uint64(42); + packer.pack("parent_block_hash"); + packer.pack_nil(); + packer.pack("token_ids"); + packer.pack_array(2); + packer.pack_int32(1); + packer.pack_int32(2); + packer.pack("block_size"); + packer.pack_int64(2); + packer.pack("lora_id"); + packer.pack_nil(); + packer.pack("medium"); + packer.pack("GPU"); + packer.pack("lora_name"); + packer.pack_nil(); + if (include_unknown) { + packer.pack("future_store_metadata"); + packer.pack_map(1); + packer.pack("version"); + packer.pack_int64(2); + } +} + +void PackVllmRemoved(Packer& packer) { + packer.pack_map(4); + packer.pack("type"); + packer.pack("BlockRemoved"); + packer.pack("block_hashes"); + packer.pack_array(1); + packer.pack_uint64(42); + packer.pack("medium"); + packer.pack("GPU"); + packer.pack("group_idx"); + packer.pack_int64(0); +} + +void PackVllmCleared(Packer& packer) { + packer.pack_map(1); + packer.pack("type"); + packer.pack("AllBlocksCleared"); +} + +void PackMooncakeCommon(Packer& packer, uint64_t event_id, + std::string_view event_type, + std::string_view legacy_type, std::string_view medium) { + packer.pack("event_id"); + packer.pack_uint64(event_id); + packer.pack("timestamp"); + packer.pack(kMooncakeTimestamp); + packer.pack("event_type"); + packer.pack(std::string(event_type)); + packer.pack("type"); + packer.pack(std::string(legacy_type)); + packer.pack("model_name"); + packer.pack("model-a"); + packer.pack("block_size"); + packer.pack_int64(16); + packer.pack("additional_salt"); + packer.pack_nil(); + packer.pack("lora_name"); + packer.pack_nil(); + packer.pack("tenant_id"); + packer.pack("tenant-a"); + packer.pack("backend_id"); + packer.pack("backend-a"); + packer.pack("medium"); + if (medium.empty()) { + packer.pack_nil(); + } else { + packer.pack(std::string(medium)); + } + packer.pack("dp_rank"); + packer.pack(kMooncakeDpRank); +} + +void PackMooncakeObject(Packer& packer, std::string_view object_key) { + packer.pack("group_id"); + packer.pack("0"); + packer.pack("object_key"); + packer.pack(std::string(object_key)); + packer.pack("connector_block_hash"); + packer.pack( + "000102030405060708090a0b0c0d0e0f" + "101112131415161718191a1b1c1d1e1f"); + packer.pack("cache_prefix"); + packer.pack("prefix-a"); + packer.pack("tp_rank"); + packer.pack_int64(1); + packer.pack("head_or_tp_rank"); + packer.pack_int64(1); + packer.pack("pcp_rank"); + packer.pack_int64(0); + packer.pack("dcp_rank"); + packer.pack_int64(0); + packer.pack("pp_rank"); + packer.pack_int64(3); + packer.pack("layer_id"); + packer.pack_int64(31); + packer.pack("seq_hashes"); + packer.pack_array(1); + packer.pack_uint64(kProjectedHash); + packer.pack("block_hashes"); + packer.pack_array(1); + packer.pack_uint64(kProjectedHash); +} + +void PackMooncakeStored(Packer& packer, bool include_unknown = false) { + // Default publisher configuration emits legacy compatibility fields and + // all connector metadata represented by this deterministic context. + packer.pack_map(include_unknown ? 29 : 28); + PackMooncakeCommon(packer, 7, "stored", "BlockStored", "cpu"); + PackMooncakeObject(packer, "object-a"); + packer.pack("base_block_idx"); + packer.pack_nil(); + packer.pack("parent_hash"); + packer.pack_nil(); + packer.pack("token_ids"); + packer.pack_nil(); + packer.pack("parent_block_hash"); + packer.pack_nil(); + if (include_unknown) { + packer.pack("future_connector_metadata"); + packer.pack_true(); + } +} + +void PackMooncakeRemoved(Packer& packer) { + packer.pack_map(25); + PackMooncakeCommon(packer, 8, "removed", "BlockRemoved", "disk"); + PackMooncakeObject(packer, "object-a"); + packer.pack("base_block_idx"); + packer.pack_nil(); +} + +void PackMooncakeCleared(Packer& packer, bool include_unknown = false) { + packer.pack_map(include_unknown ? 13 : 12); + PackMooncakeCommon(packer, 9, "cleared", "AllBlocksCleared", ""); + if (include_unknown) { + packer.pack("future_clear_metadata"); + packer.pack("ignored"); + } +} + +template +const Event* GetEvent(const mooncake::conductor::zmq::DecodedEvent< + mooncake::conductor::zmq::VllmEvent>& decoded) { + if (!decoded.event.has_value()) { + return nullptr; + } + return std::get_if(&*decoded.event); +} + +template +const Event* GetEvent(const mooncake::conductor::zmq::DecodedEvent< + mooncake::conductor::zmq::MooncakeEvent>& decoded) { + if (!decoded.event.has_value()) { + return nullptr; + } + return std::get_if(&*decoded.event); +} + +void ExpectMooncakeCommon(const MooncakeEventFields& fields, uint64_t event_id, + std::string_view medium) { + EXPECT_EQ(fields.event_id, event_id); + EXPECT_EQ(fields.timestamp_milliseconds, kMooncakeTimestamp); + ASSERT_TRUE(fields.model_name.has_value()); + EXPECT_EQ(*fields.model_name, "model-a"); + ASSERT_TRUE(fields.block_size.has_value()); + EXPECT_EQ(*fields.block_size, 16); + EXPECT_FALSE(fields.additional_salt.has_value()); + EXPECT_FALSE(fields.lora_name.has_value()); + EXPECT_EQ(fields.tenant_id, "tenant-a"); + EXPECT_EQ(fields.backend_id, "backend-a"); + if (medium.empty()) { + EXPECT_FALSE(fields.medium.has_value()); + } else { + ASSERT_TRUE(fields.medium.has_value()); + EXPECT_EQ(*fields.medium, medium); + } + EXPECT_EQ(fields.data_parallel_rank, kMooncakeDpRank); +} + +void ExpectErrorContains(const std::string& error, std::string_view expected) { + EXPECT_NE(error.find(expected), std::string::npos) << error; +} + +TEST(DecodeVllmEventBatch, DecodesCanonicalMsgspecProducerFixture) { + // Captured from vLLM's msgspec encoder in distributed/kv_events.py. + const std::string payload = BytesFromHex( + "93cb3ff40000000000009389a474797065ab426c6f636b53746f726564ac626c" + "6f636b5f686173686573912ab1706172656e745f626c6f636b5f68617368c0a9" + "746f6b656e5f696473920102aa626c6f636b5f73697a6502a76c6f72615f6964" + "c0a66d656469756da3475055a96c6f72615f6e616d65c0a967726f75705f6964" + "780084a474797065ac426c6f636b52656d6f766564ac626c6f636b5f68617368" + "6573912aa66d656469756da3475055a967726f75705f6964780081a474797065" + "b0416c6c426c6f636b73436c656172656403"); + + const auto result = DecodeVllmEventBatch(payload.data(), payload.size()); + ASSERT_TRUE(result.ok) << result.error; + EXPECT_DOUBLE_EQ(result.batch.timestamp_seconds, 1.25); + ASSERT_TRUE(result.batch.data_parallel_rank.has_value()); + EXPECT_EQ(*result.batch.data_parallel_rank, 3); + ASSERT_EQ(result.batch.events.size(), 3u); + + const auto* stored = GetEvent(result.batch.events[0]); + ASSERT_NE(stored, nullptr) << result.batch.events[0].error; + ASSERT_EQ(stored->block_hashes.size(), 1u); + ASSERT_TRUE(std::holds_alternative(stored->block_hashes[0])); + EXPECT_EQ(std::get(stored->block_hashes[0]), 42u); + EXPECT_FALSE(stored->parent_block_hash.has_value()); + ASSERT_TRUE(stored->token_ids.has_value()); + EXPECT_EQ(*stored->token_ids, (std::vector{1, 2})); + EXPECT_EQ(stored->block_size, 2); + EXPECT_FALSE(stored->lora_id.has_value()); + ASSERT_TRUE(stored->medium.has_value()); + EXPECT_EQ(*stored->medium, "GPU"); + EXPECT_FALSE(stored->lora_name.has_value()); + ASSERT_TRUE(stored->group_idx.has_value()); + EXPECT_EQ(*stored->group_idx, 0); + + const auto* removed = GetEvent(result.batch.events[1]); + ASSERT_NE(removed, nullptr) << result.batch.events[1].error; + ASSERT_EQ(removed->block_hashes.size(), 1u); + EXPECT_EQ(std::get(removed->block_hashes[0]), 42u); + ASSERT_TRUE(removed->medium.has_value()); + EXPECT_EQ(*removed->medium, "GPU"); + ASSERT_TRUE(removed->group_idx.has_value()); + EXPECT_EQ(*removed->group_idx, 0); + + EXPECT_NE(GetEvent(result.batch.events[2]), nullptr) + << result.batch.events[2].error; +} + +TEST(DecodeVllmEventBatch, PreservesIntegerAndBinaryHashesAndNullableFields) { + std::vector full_hash(32); + for (size_t index = 0; index < full_hash.size(); ++index) { + full_hash[index] = static_cast(index); + } + const std::string payload = PackVllmBatch( + 1, + [&](Packer& packer) { + packer.pack_map(13); + packer.pack("type"); + packer.pack("BlockStored"); + packer.pack("block_hashes"); + packer.pack_array(2); + packer.pack_uint64(std::numeric_limits::max()); + PackBinary(packer, full_hash); + packer.pack("parent_block_hash"); + PackBinary(packer, full_hash); + packer.pack("token_ids"); + packer.pack_nil(); + packer.pack("block_size"); + packer.pack_int64(16); + packer.pack("lora_id"); + packer.pack_nil(); + packer.pack("medium"); + packer.pack_nil(); + packer.pack("lora_name"); + packer.pack_nil(); + packer.pack("extra_keys"); + packer.pack_array(2); + packer.pack_nil(); + packer.pack_array(1); + packer.pack("future-key"); + packer.pack("group_idx"); + packer.pack_nil(); + packer.pack("kv_cache_spec_kind"); + packer.pack("full"); + packer.pack("kv_cache_spec_sliding_window"); + packer.pack_nil(); + packer.pack("future_store_metadata"); + packer.pack_true(); + }, + std::nullopt); + + const auto result = DecodeVllmEventBatch(payload.data(), payload.size()); + ASSERT_TRUE(result.ok) << result.error; + EXPECT_FALSE(result.batch.data_parallel_rank.has_value()); + ASSERT_EQ(result.batch.events.size(), 1u); + const auto* stored = GetEvent(result.batch.events[0]); + ASSERT_NE(stored, nullptr) << result.batch.events[0].error; + ASSERT_EQ(stored->block_hashes.size(), 2u); + EXPECT_EQ(std::get(stored->block_hashes[0]), + std::numeric_limits::max()); + EXPECT_EQ(std::get>(stored->block_hashes[1]), + full_hash); + ASSERT_TRUE(stored->parent_block_hash.has_value()); + EXPECT_EQ(std::get>(*stored->parent_block_hash), + full_hash); + EXPECT_FALSE(stored->token_ids.has_value()); + EXPECT_FALSE(stored->medium.has_value()); + EXPECT_TRUE(stored->extra_keys_present); + EXPECT_FALSE(stored->group_idx.has_value()); + ASSERT_TRUE(stored->kv_cache_spec_kind.has_value()); + EXPECT_EQ(*stored->kv_cache_spec_kind, "full"); + EXPECT_FALSE(stored->kv_cache_spec_sliding_window.has_value()); +} + +TEST(DecodeMooncakeEventBatch, DecodesDeterministicPublisherMapFixture) { + const std::string payload = PackMooncakeBatch(3, [](Packer& packer) { + PackMooncakeStored(packer); + PackMooncakeRemoved(packer); + PackMooncakeCleared(packer); + }); + + const auto result = + DecodeMooncakeEventBatch(payload.data(), payload.size()); + ASSERT_TRUE(result.ok) << result.error; + EXPECT_EQ(result.batch.timestamp_milliseconds, kMooncakeTimestamp); + ASSERT_TRUE(result.batch.data_parallel_rank.has_value()); + EXPECT_EQ(*result.batch.data_parallel_rank, kMooncakeDpRank); + ASSERT_EQ(result.batch.events.size(), 3u); + + const auto* stored = GetEvent(result.batch.events[0]); + ASSERT_NE(stored, nullptr) << result.batch.events[0].error; + ExpectMooncakeCommon(stored->fields, 7, "cpu"); + ASSERT_TRUE(stored->object.group_id.has_value()); + EXPECT_EQ(*stored->object.group_id, "0"); + ASSERT_TRUE(stored->object.object_key.has_value()); + EXPECT_EQ(*stored->object.object_key, "object-a"); + ASSERT_TRUE(stored->object.connector_block_hash.has_value()); + EXPECT_EQ(*stored->object.connector_block_hash, + "000102030405060708090a0b0c0d0e0f" + "101112131415161718191a1b1c1d1e1f"); + ASSERT_TRUE(stored->object.cache_prefix.has_value()); + EXPECT_EQ(*stored->object.cache_prefix, "prefix-a"); + EXPECT_EQ(stored->object.seq_hashes, + (std::vector{kProjectedHash})); + ASSERT_TRUE(stored->object.legacy_block_hashes.has_value()); + EXPECT_EQ(*stored->object.legacy_block_hashes, stored->object.seq_hashes); + ASSERT_TRUE(stored->object.tp_rank.has_value()); + EXPECT_EQ(*stored->object.tp_rank, 1); + ASSERT_TRUE(stored->object.head_or_tp_rank.has_value()); + EXPECT_EQ(*stored->object.head_or_tp_rank, 1); + ASSERT_TRUE(stored->object.pcp_rank.has_value()); + EXPECT_EQ(*stored->object.pcp_rank, 0); + ASSERT_TRUE(stored->object.dcp_rank.has_value()); + EXPECT_EQ(*stored->object.dcp_rank, 0); + ASSERT_TRUE(stored->object.pp_rank.has_value()); + EXPECT_EQ(*stored->object.pp_rank, 3); + ASSERT_TRUE(stored->object.layer_id.has_value()); + EXPECT_EQ(*stored->object.layer_id, 31); + EXPECT_FALSE(stored->object.base_block_idx.has_value()); + EXPECT_FALSE(stored->parent_hash.has_value()); + EXPECT_FALSE(stored->token_ids.has_value()); + + const auto* removed = + GetEvent(result.batch.events[1]); + ASSERT_NE(removed, nullptr) << result.batch.events[1].error; + ExpectMooncakeCommon(removed->fields, 8, "disk"); + ASSERT_TRUE(removed->object.object_key.has_value()); + EXPECT_EQ(*removed->object.object_key, "object-a"); + EXPECT_EQ(removed->object.seq_hashes, + (std::vector{kProjectedHash})); + EXPECT_FALSE(removed->object.base_block_idx.has_value()); + + const auto* cleared = + GetEvent(result.batch.events[2]); + ASSERT_NE(cleared, nullptr) << result.batch.events[2].error; + ExpectMooncakeCommon(cleared->fields, 9, ""); +} + +TEST(DecodeMooncakeEventBatch, AcceptsUnknownKeysAndNullableBatchDpRank) { + const std::string payload = PackMooncakeBatch( + 2, + [](Packer& packer) { + PackMooncakeStored(packer, true); + PackMooncakeCleared(packer, true); + }, + std::nullopt); + + const auto result = + DecodeMooncakeEventBatch(payload.data(), payload.size()); + ASSERT_TRUE(result.ok) << result.error; + EXPECT_FALSE(result.batch.data_parallel_rank.has_value()); + ASSERT_EQ(result.batch.events.size(), 2u); + EXPECT_NE(GetEvent(result.batch.events[0]), nullptr) + << result.batch.events[0].error; + EXPECT_NE(GetEvent(result.batch.events[1]), nullptr) + << result.batch.events[1].error; +} + +TEST(DecodeVllmEventBatch, RejectsMalformedEnvelopeMetadata) { + struct Case { + std::string name; + std::string payload; + std::string error; + }; + std::vector cases; + + { + std::stringstream buffer; + Packer packer(buffer); + packer.pack_map(0); + cases.push_back( + {"map root", buffer.str(), "three-element array envelope"}); + } + { + std::stringstream buffer; + Packer packer(buffer); + packer.pack_array(2); + packer.pack_double(1.25); + packer.pack_array(0); + cases.push_back({"two elements", buffer.str(), "three-element"}); + } + { + std::stringstream buffer; + Packer packer(buffer); + packer.pack_array(4); + packer.pack_double(1.25); + packer.pack_array(0); + packer.pack_nil(); + packer.pack_nil(); + cases.push_back({"four elements", buffer.str(), "three-element"}); + } + { + std::stringstream buffer; + Packer packer(buffer); + packer.pack_array(3); + packer.pack_int64(1); + packer.pack_array(0); + packer.pack_nil(); + cases.push_back( + {"integer timestamp", buffer.str(), "timestamp must be a float"}); + } + { + std::stringstream buffer; + Packer packer(buffer); + packer.pack_array(3); + packer.pack_double(std::numeric_limits::infinity()); + packer.pack_array(0); + packer.pack_nil(); + cases.push_back( + {"non-finite timestamp", buffer.str(), "must be finite"}); + } + { + std::stringstream buffer; + Packer packer(buffer); + packer.pack_array(3); + packer.pack_double(1.25); + packer.pack("not-events"); + packer.pack_nil(); + cases.push_back( + {"events not array", buffer.str(), "events must be an array"}); + } + { + std::stringstream buffer; + Packer packer(buffer); + packer.pack_array(3); + packer.pack_double(1.25); + packer.pack_array(0); + packer.pack_int64(-1); + cases.push_back({"negative DP", buffer.str(), "non-negative or nil"}); + } + { + std::stringstream buffer; + Packer packer(buffer); + packer.pack_array(3); + packer.pack_double(1.25); + packer.pack_array(0); + packer.pack("rank"); + cases.push_back({"string DP", buffer.str(), "data_parallel_rank"}); + } + + for (const auto& test : cases) { + SCOPED_TRACE(test.name); + const auto result = + DecodeVllmEventBatch(test.payload.data(), test.payload.size()); + EXPECT_FALSE(result.ok); + ExpectErrorContains(result.error, test.error); + EXPECT_TRUE(result.batch.events.empty()); + } +} + +TEST(DecodeMooncakeEventBatch, RejectsMalformedEnvelopeMetadata) { + struct Case { + std::string name; + std::string payload; + std::string error; + }; + std::vector cases; + + { + std::stringstream buffer; + Packer packer(buffer); + packer.pack("not-an-envelope"); + cases.push_back( + {"string root", buffer.str(), "three-element array envelope"}); + } + { + std::stringstream buffer; + Packer packer(buffer); + packer.pack_array(2); + packer.pack(kMooncakeTimestamp); + packer.pack_array(0); + cases.push_back({"two elements", buffer.str(), "three-element"}); + } + { + std::stringstream buffer; + Packer packer(buffer); + packer.pack_array(4); + packer.pack(kMooncakeTimestamp); + packer.pack_array(0); + packer.pack_nil(); + packer.pack_nil(); + cases.push_back({"four elements", buffer.str(), "three-element"}); + } + { + std::stringstream buffer; + Packer packer(buffer); + packer.pack_array(3); + packer.pack_double(1.25); + packer.pack_array(0); + packer.pack_nil(); + cases.push_back({"float timestamp", buffer.str(), + "timestamp must be a non-negative integer"}); + } + { + std::stringstream buffer; + Packer packer(buffer); + packer.pack_array(3); + packer.pack_int64(-1); + packer.pack_array(0); + packer.pack_nil(); + cases.push_back({"negative timestamp", buffer.str(), + "timestamp must be a non-negative integer"}); + } + { + std::stringstream buffer; + Packer packer(buffer); + packer.pack_array(3); + packer.pack(kMooncakeTimestamp); + packer.pack_map(0); + packer.pack_nil(); + cases.push_back( + {"events not array", buffer.str(), "events must be an array"}); + } + { + std::stringstream buffer; + Packer packer(buffer); + packer.pack_array(3); + packer.pack(kMooncakeTimestamp); + packer.pack_array(0); + packer.pack_int64(-1); + cases.push_back({"negative DP", buffer.str(), "non-negative or nil"}); + } + { + std::stringstream buffer; + Packer packer(buffer); + packer.pack_array(3); + packer.pack(kMooncakeTimestamp); + packer.pack_array(0); + packer.pack_false(); + cases.push_back({"boolean DP", buffer.str(), "data_parallel_rank"}); + } + + for (const auto& test : cases) { + SCOPED_TRACE(test.name); + const auto result = + DecodeMooncakeEventBatch(test.payload.data(), test.payload.size()); + EXPECT_FALSE(result.ok); + ExpectErrorContains(result.error, test.error); + EXPECT_TRUE(result.batch.events.empty()); + } +} + +TEST(DecodeVllmEventBatch, + RejectsPositionalDuplicateMissingWrongTypeAndUnknownTagLocally) { + const std::string payload = PackVllmBatch(6, [](Packer& packer) { + packer.pack_array(1); + packer.pack("BlockStored"); + + packer.pack_map(2); + packer.pack("type"); + packer.pack("BlockStored"); + packer.pack("type"); + packer.pack("BlockStored"); + + packer.pack_map(1); + packer.pack("unrecognized_type"); + packer.pack("BlockStored"); + + packer.pack_map(1); + packer.pack("type"); + packer.pack_int64(7); + + packer.pack_map(1); + packer.pack("type"); + packer.pack("BlockUpdated"); + + PackVllmCleared(packer); + }); + + const auto result = DecodeVllmEventBatch(payload.data(), payload.size()); + ASSERT_TRUE(result.ok) << result.error; + ASSERT_EQ(result.batch.events.size(), 6u); + const std::array errors = { + "expected event map", "duplicate recognized key: type", + "missing required key: type", "invalid type: expected string", + "unknown vLLM event tag"}; + for (size_t index = 0; index < errors.size(); ++index) { + SCOPED_TRACE(index); + EXPECT_FALSE(result.batch.events[index].ok()); + ExpectErrorContains(result.batch.events[index].error, errors[index]); + } + EXPECT_NE(GetEvent(result.batch.events[5]), nullptr) + << result.batch.events[5].error; +} + +TEST(DecodeVllmEventBatch, + RejectsStoreOnlyRecognizedFieldsOnRemovedAndAllowsUnknownKeys) { + constexpr std::array kStoreOnlyFields = { + "parent_block_hash", "token_ids", + "block_size", "lora_id", + "lora_name", "extra_keys", + "kv_cache_spec_kind", "kv_cache_spec_sliding_window", + }; + const std::string payload = + PackVllmBatch(kStoreOnlyFields.size() + 1, [&](Packer& packer) { + for (std::string_view field : kStoreOnlyFields) { + packer.pack_map(5); + packer.pack("type"); + packer.pack("BlockRemoved"); + packer.pack("block_hashes"); + packer.pack_array(1); + packer.pack_uint64(42); + packer.pack("medium"); + packer.pack("GPU"); + packer.pack("group_idx"); + packer.pack_int64(0); + packer.pack(std::string(field)); + packer.pack_nil(); + } + + packer.pack_map(5); + packer.pack("type"); + packer.pack("BlockRemoved"); + packer.pack("block_hashes"); + packer.pack_array(1); + packer.pack_uint64(42); + packer.pack("medium"); + packer.pack("GPU"); + packer.pack("group_idx"); + packer.pack_int64(0); + packer.pack("future_remove_metadata"); + packer.pack_map(1); + packer.pack("version"); + packer.pack_int64(2); + }); + + const auto result = DecodeVllmEventBatch(payload.data(), payload.size()); + ASSERT_TRUE(result.ok) << result.error; + ASSERT_EQ(result.batch.events.size(), kStoreOnlyFields.size() + 1); + for (size_t index = 0; index < kStoreOnlyFields.size(); ++index) { + SCOPED_TRACE(index); + EXPECT_FALSE(result.batch.events[index].ok()); + ExpectErrorContains(result.batch.events[index].error, + "BlockRemoved contains recognized key: " + + std::string(kStoreOnlyFields[index])); + } + EXPECT_NE(GetEvent(result.batch.events.back()), nullptr) + << result.batch.events.back().error; +} + +TEST(DecodeVllmEventBatch, + RejectsInvalidExtraKeysEntriesAndCardinalityLocally) { + const std::string payload = PackVllmBatch(3, [](Packer& packer) { + const auto pack_stored_prefix = [&](uint32_t block_count) { + packer.pack_map(9); + packer.pack("type"); + packer.pack("BlockStored"); + packer.pack("block_hashes"); + packer.pack_array(block_count); + for (uint32_t index = 0; index < block_count; ++index) { + packer.pack_uint64(42 + index); + } + packer.pack("parent_block_hash"); + packer.pack_nil(); + packer.pack("token_ids"); + packer.pack_nil(); + packer.pack("block_size"); + packer.pack_int64(16); + packer.pack("lora_id"); + packer.pack_nil(); + packer.pack("medium"); + packer.pack("GPU"); + packer.pack("lora_name"); + packer.pack_nil(); + packer.pack("extra_keys"); + }; + + pack_stored_prefix(1); + packer.pack_array(1); + packer.pack("not-an-extra-key-tuple"); + + pack_stored_prefix(2); + packer.pack_array(1); + packer.pack_nil(); + + PackVllmCleared(packer); + }); + + const auto result = DecodeVllmEventBatch(payload.data(), payload.size()); + ASSERT_TRUE(result.ok) << result.error; + ASSERT_EQ(result.batch.events.size(), 3u); + EXPECT_FALSE(result.batch.events[0].ok()); + ExpectErrorContains(result.batch.events[0].error, + "invalid extra_keys: element 0: expected array or nil"); + EXPECT_FALSE(result.batch.events[1].ok()); + ExpectErrorContains( + result.batch.events[1].error, + "invalid extra_keys: expected one entry per block hash"); + EXPECT_NE(GetEvent(result.batch.events[2]), nullptr) + << result.batch.events[2].error; +} + +TEST(DecodeMooncakeEventBatch, + RejectsPositionalDuplicateMissingWrongTypeAndUnknownTagLocally) { + const std::string payload = PackMooncakeBatch(6, [](Packer& packer) { + packer.pack_array(1); + packer.pack("BlockStoreEvent"); + + packer.pack_map(2); + packer.pack("event_type"); + packer.pack("stored"); + packer.pack("event_type"); + packer.pack("stored"); + + packer.pack_map(1); + packer.pack("unrecognized_event_type"); + packer.pack("stored"); + + packer.pack_map(1); + packer.pack("event_type"); + packer.pack_int64(7); + + packer.pack_map(1); + packer.pack("event_type"); + packer.pack("updated"); + + PackMooncakeCleared(packer); + }); + + const auto result = + DecodeMooncakeEventBatch(payload.data(), payload.size()); + ASSERT_TRUE(result.ok) << result.error; + ASSERT_EQ(result.batch.events.size(), 6u); + const std::array errors = { + "expected event map", "duplicate recognized key: event_type", + "missing required key: event_type", + "invalid event_type: expected string", "unknown Mooncake event tag"}; + for (size_t index = 0; index < errors.size(); ++index) { + SCOPED_TRACE(index); + EXPECT_FALSE(result.batch.events[index].ok()); + ExpectErrorContains(result.batch.events[index].error, errors[index]); + } + EXPECT_NE(GetEvent(result.batch.events[5]), nullptr) + << result.batch.events[5].error; +} + +TEST(DecodeVllmEventBatch, MalformedMiddleAndFinalEventsRemainIsolated) { + const std::string middle = PackVllmBatch(3, [](Packer& packer) { + PackVllmStored(packer, true); + packer.pack_map(4); + packer.pack("type"); + packer.pack("BlockRemoved"); + packer.pack("block_hashes"); + packer.pack_array(1); + packer.pack_uint64(42); + packer.pack("medium"); + packer.pack("GPU"); + packer.pack("medium"); + packer.pack("CPU"); + PackVllmRemoved(packer); + }); + const auto middle_result = + DecodeVllmEventBatch(middle.data(), middle.size()); + ASSERT_TRUE(middle_result.ok) << middle_result.error; + ASSERT_EQ(middle_result.batch.events.size(), 3u); + EXPECT_NE(GetEvent(middle_result.batch.events[0]), nullptr) + << middle_result.batch.events[0].error; + EXPECT_FALSE(middle_result.batch.events[1].ok()); + ExpectErrorContains(middle_result.batch.events[1].error, + "duplicate recognized key: medium"); + EXPECT_NE(GetEvent(middle_result.batch.events[2]), + nullptr) + << middle_result.batch.events[2].error; + + const std::string final = PackVllmBatch(2, [](Packer& packer) { + PackVllmStored(packer); + packer.pack_map(2); + packer.pack("type"); + packer.pack("BlockRemoved"); + packer.pack("block_hashes"); + packer.pack_array(1); + packer.pack_uint64(42); + }); + const auto final_result = DecodeVllmEventBatch(final.data(), final.size()); + ASSERT_TRUE(final_result.ok) << final_result.error; + ASSERT_EQ(final_result.batch.events.size(), 2u); + EXPECT_NE(GetEvent(final_result.batch.events[0]), nullptr) + << final_result.batch.events[0].error; + EXPECT_FALSE(final_result.batch.events[1].ok()); + ExpectErrorContains(final_result.batch.events[1].error, + "missing required key: medium"); +} + +TEST(DecodeMooncakeEventBatch, MalformedMiddleAndFinalEventsRemainIsolated) { + const std::string middle = PackMooncakeBatch(3, [](Packer& packer) { + PackMooncakeStored(packer, true); + packer.pack_map(2); + packer.pack("event_type"); + packer.pack("removed"); + packer.pack("event_type"); + packer.pack("removed"); + PackMooncakeRemoved(packer); + }); + const auto middle_result = + DecodeMooncakeEventBatch(middle.data(), middle.size()); + ASSERT_TRUE(middle_result.ok) << middle_result.error; + ASSERT_EQ(middle_result.batch.events.size(), 3u); + EXPECT_NE(GetEvent(middle_result.batch.events[0]), + nullptr) + << middle_result.batch.events[0].error; + EXPECT_FALSE(middle_result.batch.events[1].ok()); + ExpectErrorContains(middle_result.batch.events[1].error, + "duplicate recognized key: event_type"); + EXPECT_NE(GetEvent(middle_result.batch.events[2]), + nullptr) + << middle_result.batch.events[2].error; + + const std::string final = PackMooncakeBatch(2, [](Packer& packer) { + PackMooncakeStored(packer); + packer.pack_map(1); + packer.pack("event_type"); + packer.pack("stored"); + }); + const auto final_result = + DecodeMooncakeEventBatch(final.data(), final.size()); + ASSERT_TRUE(final_result.ok) << final_result.error; + ASSERT_EQ(final_result.batch.events.size(), 2u); + EXPECT_NE(GetEvent(final_result.batch.events[0]), + nullptr) + << final_result.batch.events[0].error; + EXPECT_FALSE(final_result.batch.events[1].ok()); + ExpectErrorContains(final_result.batch.events[1].error, + "missing required key: event_id"); +} + +TEST(DecodeVllmEventBatch, RejectsWrongRecognizedHashTypeLocally) { + const std::string payload = PackVllmBatch(2, [](Packer& packer) { + packer.pack_map(8); + packer.pack("type"); + packer.pack("BlockStored"); + packer.pack("block_hashes"); + packer.pack_array(1); + packer.pack_int64(-1); + packer.pack("parent_block_hash"); + packer.pack_nil(); + packer.pack("token_ids"); + packer.pack_nil(); + packer.pack("block_size"); + packer.pack_int64(16); + packer.pack("lora_id"); + packer.pack_nil(); + packer.pack("medium"); + packer.pack("GPU"); + packer.pack("lora_name"); + packer.pack_nil(); + PackVllmCleared(packer); + }); + + const auto result = DecodeVllmEventBatch(payload.data(), payload.size()); + ASSERT_TRUE(result.ok) << result.error; + ASSERT_EQ(result.batch.events.size(), 2u); + EXPECT_FALSE(result.batch.events[0].ok()); + ExpectErrorContains(result.batch.events[0].error, + "expected unsigned integer or binary hash"); + EXPECT_NE(GetEvent(result.batch.events[1]), nullptr) + << result.batch.events[1].error; +} + +TEST(DecodeMooncakeEventBatch, RejectsWrongRecognizedFieldTypeLocally) { + const std::string payload = PackMooncakeBatch(2, [](Packer& packer) { + packer.pack_map(12); + packer.pack("event_id"); + packer.pack_uint64(9); + packer.pack("timestamp"); + packer.pack(kMooncakeTimestamp); + packer.pack("event_type"); + packer.pack("cleared"); + packer.pack("type"); + packer.pack("AllBlocksCleared"); + packer.pack("model_name"); + packer.pack("model-a"); + packer.pack("block_size"); + packer.pack_int64(16); + packer.pack("additional_salt"); + packer.pack_nil(); + packer.pack("lora_name"); + packer.pack_nil(); + packer.pack("tenant_id"); + packer.pack("tenant-a"); + packer.pack("backend_id"); + packer.pack("backend-a"); + packer.pack("medium"); + packer.pack_int64(7); + packer.pack("dp_rank"); + packer.pack(kMooncakeDpRank); + PackMooncakeCleared(packer); + }); + + const auto result = + DecodeMooncakeEventBatch(payload.data(), payload.size()); + ASSERT_TRUE(result.ok) << result.error; + ASSERT_EQ(result.batch.events.size(), 2u); + EXPECT_FALSE(result.batch.events[0].ok()); + ExpectErrorContains(result.batch.events[0].error, + "invalid medium: expected string"); + EXPECT_NE(GetEvent(result.batch.events[1]), nullptr) + << result.batch.events[1].error; +} + +TEST(MessagePackEnvelope, RejectsEmptyGarbageAndTrailingBytes) { + const auto empty_vllm = DecodeVllmEventBatch(nullptr, 0); + EXPECT_FALSE(empty_vllm.ok); + ExpectErrorContains(empty_vllm.error, "empty payload"); + + const char garbage[] = "\xc1not-msgpack"; + const auto garbage_mooncake = + DecodeMooncakeEventBatch(garbage, sizeof(garbage) - 1); + EXPECT_FALSE(garbage_mooncake.ok); + ExpectErrorContains(garbage_mooncake.error, + "failed to decode Mooncake envelope"); + + std::string trailing = PackVllmBatch(0, [](Packer&) {}); + trailing.push_back('\0'); + const auto trailing_vllm = + DecodeVllmEventBatch(trailing.data(), trailing.size()); + EXPECT_FALSE(trailing_vllm.ok); + ExpectErrorContains(trailing_vllm.error, "trailing bytes"); +} + +TEST(DecodeSglangEventBatch, DecodesTaggedArrayAndPreservesHashBits) { + std::stringstream buffer; + Packer packer(buffer); + packer.pack_array(3); + packer.pack_double(12.5); + packer.pack_array(3); + + // BlockStored(tag, block_hashes, parent, token_ids, block_size, lora_id, + // medium). The first hash is -1 on the wire and must become UINT64_MAX. + packer.pack_array(7); + packer.pack("BlockStored"); + packer.pack_array(1); + packer.pack_int64(-1); + packer.pack_nil(); + packer.pack_array(2); + packer.pack_int32(1); + packer.pack_int32(2); + packer.pack_int64(2); + packer.pack_nil(); + packer.pack("GPU"); + + packer.pack_array(3); + packer.pack("BlockRemoved"); + packer.pack_array(1); + packer.pack_int64(-1); + packer.pack("GPU"); + + packer.pack_array(1); + packer.pack("AllBlocksCleared"); + packer.pack_int64(4); + + const std::string payload = buffer.str(); + const auto result = DecodeSglangEventBatch(payload.data(), payload.size()); + ASSERT_TRUE(result.ok) << result.error; + ASSERT_EQ(result.batch.events.size(), 3u); + const auto* stored = + std::get_if(&*result.batch.events[0].event); + ASSERT_NE(stored, nullptr) << result.batch.events[0].error; + ASSERT_EQ(stored->block_hashes.size(), 1u); + EXPECT_EQ(stored->block_hashes[0], std::numeric_limits::max()); + const auto* removed = + std::get_if(&*result.batch.events[1].event); + ASSERT_NE(removed, nullptr) << result.batch.events[1].error; + EXPECT_EQ(removed->block_hashes[0], std::numeric_limits::max()); + EXPECT_NE(std::get_if(&*result.batch.events[2].event), + nullptr); +} + +TEST(DecodeSglangEventBatch, AcceptsOmittedMediumAndBigramTokens) { + std::stringstream buffer; + Packer packer(buffer); + packer.pack_array(3); + packer.pack_double(1.0); + packer.pack_array(2); + packer.pack_array(6); + packer.pack("BlockStored"); + packer.pack_array(1); + packer.pack_int64(7); + packer.pack_nil(); + packer.pack_array(1); + packer.pack_array(2); + packer.pack_int32(11); + packer.pack_int32(12); + packer.pack_int64(2); + packer.pack_nil(); + packer.pack_array(2); + packer.pack("BlockRemoved"); + packer.pack_array(1); + packer.pack_int64(7); + packer.pack_int64(0); + + const std::string payload = buffer.str(); + const auto result = DecodeSglangEventBatch(payload.data(), payload.size()); + ASSERT_TRUE(result.ok) << result.error; + ASSERT_TRUE(result.batch.events[0].ok()) << result.batch.events[0].error; + const auto* stored = + std::get_if(&*result.batch.events[0].event); + ASSERT_NE(stored, nullptr); + ASSERT_TRUE(stored->token_ids.has_value()); + EXPECT_EQ(*stored->token_ids, (std::vector{11, 12})); + EXPECT_FALSE(stored->medium.has_value()); + ASSERT_TRUE(result.batch.events[1].ok()) << result.batch.events[1].error; + const auto* removed = + std::get_if(&*result.batch.events[1].event); + ASSERT_NE(removed, nullptr); + EXPECT_FALSE(removed->medium.has_value()); +} + +TEST(DecodeSglangEventBatch, RejectsMooncakeMapEventsForProtocolFallback) { + const std::string payload = PackMooncakeBatch( + 1, [](Packer& packer) { PackMooncakeStored(packer); }); + + const auto result = DecodeSglangEventBatch(payload.data(), payload.size()); + EXPECT_FALSE(result.ok); + ExpectErrorContains(result.error, "event entries must be arrays"); +} + +TEST(DecodeSglangEventBatch, RejectsUnsignedHashOutsideSignedWireRange) { + std::stringstream buffer; + Packer packer(buffer); + packer.pack_array(3); + packer.pack_double(1.0); + packer.pack_array(1); + packer.pack_array(3); + packer.pack("BlockRemoved"); + packer.pack_array(1); + packer.pack_uint64(std::numeric_limits::max()); + packer.pack_nil(); + packer.pack_nil(); + + const std::string payload = buffer.str(); + const auto result = DecodeSglangEventBatch(payload.data(), payload.size()); + ASSERT_TRUE(result.ok) << result.error; + ASSERT_EQ(result.batch.events.size(), 1u); + EXPECT_FALSE(result.batch.events[0].ok()); + ExpectErrorContains(result.batch.events[0].error, + "expected signed 64-bit hash"); +} + +TEST(SglangObjectKeyParser, CanonicalizesPhysicalComponents) { + const std::string hash = + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; + ParsedSglangObjectKey key_k; + ParsedSglangObjectKey key_v; + ASSERT_TRUE( + ParseSglangObjectKey("backend_model_" + hash + "_0_k", &key_k).empty()); + ASSERT_TRUE( + ParseSglangObjectKey("backend_model_" + hash + "_0_v", &key_v).empty()); + EXPECT_EQ(key_k.logical_key, key_v.logical_key); + EXPECT_EQ(key_k.full_hash, hash); + EXPECT_EQ(key_k.prefix.value, key_v.prefix.value); + EXPECT_EQ(key_k.component_suffix, "_0_k"); + EXPECT_EQ(key_v.component_suffix, "_0_v"); +} + +TEST(VllmObjectKeyParser, AcceptsOptionalHexPrefixAndRejectsMalformedKeys) { + const std::string hash = "0123456789abcdef000000000000002a"; + ParsedSglangObjectKey parsed; + ASSERT_TRUE(ParseVllmObjectKey( + "model-a@tp_rank:0@pcp0@dcp0@pp_rank:0@0x" + hash, &parsed) + .empty()); + EXPECT_EQ(parsed.full_hash, hash); + EXPECT_EQ(parsed.prefix.value, 0x000000000000002aULL); + + EXPECT_FALSE(ParseVllmObjectKey("model-a@tp_rank:0@pcp0", &parsed).empty()); +} + +TEST(VllmObjectKeyParser, PreservesMultiSegmentCachePrefix) { + const std::string hash = "0123456789abcdef000000000000002a"; + ParsedSglangObjectKey parsed; + ASSERT_TRUE( + ParseVllmObjectKey( + "prefix-a@prefix-b@model-a@tp_rank:0@pcp0@dcp0@pp_rank:0@" + hash, + &parsed) + .empty()); + EXPECT_EQ(parsed.namespace_prefix, "prefix-a@prefix-b"); + EXPECT_EQ(parsed.full_hash, hash); +} + +TEST(VllmObjectKeyParser, AcceptsCurrentAscendCacheMetadataLayouts) { + const std::string hash = "0123456789abcdef000000000000002e"; + ParsedSglangObjectKey parsed; + + ASSERT_TRUE( + ParseVllmObjectKey("model-a@pcp1@dcp2@head_or_tp_rank:3@pp_rank:0@" + "group:0@cache_role:kv@cache_family:default@" + + hash, + &parsed) + .empty()); + EXPECT_EQ(parsed.full_hash, hash); + EXPECT_EQ(parsed.prefix.value, 0x000000000000002eULL); + + ASSERT_TRUE( + ParseVllmObjectKey("model-a@pcp1@dcp2@head_or_tp_rank:3@group:0@" + "cache_role:kv@cache_family:c2@layer_id:7@" + + hash, + &parsed) + .empty()); + EXPECT_EQ(parsed.full_hash, hash); + EXPECT_EQ(parsed.prefix.value, 0x000000000000002eULL); +} + +TEST(VllmObjectKeyParser, AcceptsCompactAscendLayerwiseLayouts) { + const std::string hash = "0123456789abcdef000000000000002f"; + ParsedSglangObjectKey parsed; + + ASSERT_TRUE(ParseVllmObjectKey("model-a@" + hash + "@3", &parsed).empty()); + EXPECT_EQ(parsed.namespace_prefix, "model-a"); + EXPECT_EQ(parsed.full_hash, hash); + EXPECT_EQ(parsed.prefix.value, 0x000000000000002fULL); + + ASSERT_TRUE( + ParseVllmObjectKey("model-a@7@" + hash + "@3", &parsed).empty()); + EXPECT_EQ(parsed.namespace_prefix, "model-a@7"); + EXPECT_EQ(parsed.full_hash, hash); + EXPECT_EQ(parsed.prefix.value, 0x000000000000002fULL); +} + +TEST(VllmObjectKeyParser, RejectsMalformedNumericMetadata) { + const std::string hash = "0123456789abcdef000000000000002e"; + ParsedSglangObjectKey parsed; + const std::array malformed = { + "model-a@pcp-x@dcp0@head_or_tp_rank:0@pp_rank:0@" + hash, + "model-a@pcp0@dcp-1@head_or_tp_rank:0@pp_rank:0@" + hash, + "model-a@pcp0@dcp0@head_or_tp_rank:x@pp_rank:0@" + hash, + "model-a@pcp0@dcp0@head_or_tp_rank:0@pp_rank:-1@" + hash, + "model-a@pcp0@dcp0@head_or_tp_rank:0@group:x@" + hash, + "model-a@pcp0@dcp0@head_or_tp_rank:0@layer_id:x@" + hash, + }; + for (const auto& key : malformed) { + SCOPED_TRACE(key); + EXPECT_FALSE(ParseVllmObjectKey(key, &parsed).empty()); + } +} + +} // namespace diff --git a/mooncake-conductor/tests/prefix_indexer_test.cpp b/mooncake-conductor/tests/prefix_indexer_test.cpp new file mode 100644 index 0000000000..6c988ba5ef --- /dev/null +++ b/mooncake-conductor/tests/prefix_indexer_test.cpp @@ -0,0 +1,1115 @@ +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "conductor/prefixindex/hash_strategy.h" +#include "conductor/prefixindex/prefix_indexer.h" +#include "prefix_indexer_test_peer.h" + +namespace { + +using mooncake::conductor::prefixindex::BlockPresenceSnapshot; +using mooncake::conductor::prefixindex::CacheHitResult; +using mooncake::conductor::prefixindex::ContextKey; +using mooncake::conductor::prefixindex::EngineOwner; +using mooncake::conductor::prefixindex::EngineRegistration; +using mooncake::conductor::prefixindex::GpuClear; +using mooncake::conductor::prefixindex::GpuMutation; +using mooncake::conductor::prefixindex::HashBlock; +using mooncake::conductor::prefixindex::HashProfile; +using mooncake::conductor::prefixindex::PrefixCacheTable; +using mooncake::conductor::prefixindex::PrefixCacheTableSnapshot; +using mooncake::conductor::prefixindex::PrefixCacheTableTestPeer; +using mooncake::conductor::prefixindex::ProjectedPrefix; +using mooncake::conductor::prefixindex::RankCacheHitResult; +using mooncake::conductor::prefixindex::SharedClear; +using mooncake::conductor::prefixindex::SharedMutation; +using mooncake::conductor::prefixindex::SharedObjectOwner; +using mooncake::conductor::prefixindex::StorageTier; + +constexpr char kRootDigest[] = + "4e1195df020de59e0d65a33a4279f1183e7ae4e5d980e309f8b55adff2e61c3e"; +constexpr char kPaddedSeedRootDigest[] = + "8d912e4e62b3cc377b1d1c7a14ef61dffbdaa0990237035c05401c29414c4172"; +constexpr char kPickleRootDigest[] = + "1973e23848344dc43a988a9b478663803cfffe1243480253f9a3cf004b14aa7c"; + +ContextKey TestContext(int64_t block_size = 16) { + return {.tenant_id = "tenant-a", + .model_name = "model-a", + .lora_name = "", + .block_size = block_size}; +} + +HashProfile TestProfile() { + return {.strategy = "vllm_v1", + .algorithm = "sha256_cbor", + .python_hash_seed = "0", + .root_digest = kRootDigest, + .index_projection = "low64_be"}; +} + +HashProfile PaddedSeedProfile() { + return {.strategy = "vllm_v1", + .algorithm = "sha256_cbor", + .python_hash_seed = "00", + .root_digest = kPaddedSeedRootDigest, + .index_projection = "low64_be"}; +} + +HashProfile PickleProfile() { + return {.strategy = "vllm_v1", + .algorithm = "sha256", + .python_hash_seed = "0", + .root_digest = kPickleRootDigest, + .index_projection = "low64_be"}; +} + +// SGLang chains carry no Python seed root, so the resolver pins an all-zero +// sentinel digest for the registration wire contract. +HashProfile SglangProfile() { + return {.strategy = "sglang", + .algorithm = "sha256_raw", + .python_hash_seed = "0", + .root_digest = std::string(64, '0'), + .index_projection = "first64_be"}; +} + +// The bigram chains hash token pairs, so they carry one fewer logical position +// than the prompt has tokens. +HashProfile SglangBigramProfile() { + return {.strategy = "sglang_bigram", + .algorithm = "sha256_raw", + .python_hash_seed = "0", + .root_digest = std::string(64, '0'), + .index_projection = "first64_be"}; +} + +EngineRegistration Registration(const std::string& instance_id = "instance-a", + int64_t dp_rank = 0) { + const ContextKey context = TestContext(); + return {.context = context, + .profile = TestProfile(), + .instance_id = instance_id, + .dp_rank = dp_rank, + .effective_block_size = context.block_size, + .cache_group = 0}; +} + +EngineOwner GpuOwner(const std::string& instance_id = "instance-a", + int64_t dp_rank = 0, + const std::string& stream = "stream-a") { + return {.source_stream = stream, + .instance_id = instance_id, + .dp_rank = dp_rank}; +} + +SharedObjectOwner SharedOwner(const std::string& object_id = "object-a", + const std::string& stream = "pool-stream", + const std::string& backend = "backend-a") { + return { + .source_stream = stream, .backend_id = backend, .object_id = object_id}; +} + +ProjectedPrefix Prefix(uint64_t value) { return {.value = value}; } + +RankCacheHitResult RankMatch(int64_t gpu, int64_t cpu, int64_t disk) { + return {.gpu = gpu, .cpu = cpu, .disk = disk}; +} + +GpuMutation Gpu(const std::vector& prefixes, + EngineOwner owner = GpuOwner()) { + const ContextKey context = TestContext(); + return {.context = context, + .prefixes = prefixes, + .owner = std::move(owner), + .effective_block_size = context.block_size, + .cache_group = 0}; +} + +SharedMutation Shared(const std::vector& prefixes, + StorageTier tier, + SharedObjectOwner owner = SharedOwner()) { + const ContextKey context = TestContext(); + return {.context = context, + .prefixes = prefixes, + .tier = tier, + .owner = std::move(owner), + .effective_block_size = context.block_size, + .cache_group = 0}; +} + +GpuClear ClearFor(EngineOwner owner = GpuOwner()) { + const ContextKey context = TestContext(); + return {.context = context, + .owner = std::move(owner), + .effective_block_size = context.block_size, + .cache_group = 0}; +} + +SharedClear ClearFor(SharedObjectOwner owner, + std::optional tier = std::nullopt) { + const ContextKey context = TestContext(); + return {.context = context, + .owner = std::move(owner), + .tier = tier, + .effective_block_size = context.block_size, + .cache_group = 0}; +} + +std::vector Tokens(size_t count) { + std::vector tokens; + tokens.reserve(count); + for (size_t i = 0; i < count; ++i) { + tokens.push_back(static_cast(i + 1)); + } + return tokens; +} + +std::vector Hashes( + const std::vector& tokens, + std::optional cache_salt = std::nullopt) { + std::string error; + auto strategy = mooncake::conductor::prefixindex::CreateHashStrategy( + TestProfile(), &error); + EXPECT_TRUE(error.empty()) << error; + if (!strategy) { + return {}; + } + + std::vector blocks; + error = strategy->Compute(TestContext(), tokens, std::move(cache_salt), + &blocks); + EXPECT_TRUE(error.empty()) << error; + + std::vector prefixes; + prefixes.reserve(blocks.size()); + for (const HashBlock& block : blocks) { + prefixes.push_back(block.projected); + } + return prefixes; +} + +std::vector SglangHashes(const std::vector& tokens) { + std::string error; + auto strategy = mooncake::conductor::prefixindex::CreateHashStrategy( + SglangProfile(), &error); + EXPECT_TRUE(error.empty()) << error; + if (!strategy) { + return {}; + } + + std::vector blocks; + error = strategy->Compute(TestContext(), tokens, std::nullopt, &blocks); + EXPECT_TRUE(error.empty()) << error; + + std::vector prefixes; + prefixes.reserve(blocks.size()); + for (const HashBlock& block : blocks) { + prefixes.push_back(block.projected); + } + return prefixes; +} + +std::vector SglangBigramHashes( + const std::vector& tokens) { + std::string error; + auto strategy = mooncake::conductor::prefixindex::CreateHashStrategy( + SglangBigramProfile(), &error); + EXPECT_TRUE(error.empty()) << error; + if (!strategy) { + return {}; + } + + std::vector blocks; + error = strategy->Compute(TestContext(), tokens, std::nullopt, &blocks); + EXPECT_TRUE(error.empty()) << error; + + std::vector prefixes; + prefixes.reserve(blocks.size()); + for (const HashBlock& block : blocks) { + prefixes.push_back(block.projected); + } + return prefixes; +} + +void RegisterOrFail(PrefixCacheTable& table, + const EngineRegistration& registration) { + const auto result = table.Register(registration); + ASSERT_TRUE(result.error.empty()) << result.error; +} + +BlockPresenceSnapshot Presence(const PrefixCacheTable& table, + ProjectedPrefix prefix) { + const PrefixCacheTableSnapshot table_snapshot = + PrefixCacheTableTestPeer::Snapshot(table); + return table_snapshot.contexts.at(TestContext()).blocks.at(prefix); +} + +TEST(Registration, InvalidInputsDoNotCreateContextState) { + std::vector invalid; + + auto non_positive = Registration(); + non_positive.context.block_size = 0; + non_positive.effective_block_size = 0; + invalid.push_back(non_positive); + + auto mismatch = Registration(); + mismatch.effective_block_size = 8; + invalid.push_back(mismatch); + + auto unsupported_group = Registration(); + unsupported_group.cache_group = 1; + invalid.push_back(unsupported_group); + + auto empty_instance = Registration(); + empty_instance.instance_id.clear(); + invalid.push_back(empty_instance); + + auto negative_rank = Registration(); + negative_rank.dp_rank = -1; + invalid.push_back(negative_rank); + + auto malformed_profile = Registration(); + malformed_profile.profile.root_digest = "not-a-digest"; + invalid.push_back(malformed_profile); + + PrefixCacheTable table; + for (const auto& registration : invalid) { + SCOPED_TRACE(registration.instance_id); + const auto validation = + PrefixCacheTable::ValidateRegistration(registration); + EXPECT_FALSE(validation.error.empty()); + const auto result = table.Register(registration); + EXPECT_FALSE(result.error.empty()); + EXPECT_FALSE(result.inserted); + } + EXPECT_EQ(table.GetGlobalView().context_count, 0); + EXPECT_TRUE(PrefixCacheTableTestPeer::Snapshot(table).contexts.empty()); +} + +TEST(Registration, ForgedSeedRootPairIsRejectedWithoutMutation) { + PrefixCacheTable table; + auto forged = Registration(); + forged.profile.root_digest = kPaddedSeedRootDigest; + + const auto validation = PrefixCacheTable::ValidateRegistration(forged); + EXPECT_NE(validation.error.find("does not match"), std::string::npos); + const auto rejected = table.Register(forged); + EXPECT_NE(rejected.error.find("does not match"), std::string::npos); + EXPECT_FALSE(rejected.inserted); + EXPECT_TRUE(PrefixCacheTableTestPeer::Snapshot(table).contexts.empty()); + + RegisterOrFail(table, Registration()); + const auto registered = PrefixCacheTableTestPeer::Snapshot(table); + EXPECT_NE(table.ValidateProfileBinding(TestContext(), forged.profile) + .find("does not match"), + std::string::npos); + EXPECT_EQ(PrefixCacheTableTestPeer::Snapshot(table), registered); + + forged.instance_id = "instance-b"; + const auto conflicting = table.Register(forged); + EXPECT_NE(conflicting.error.find("does not match"), std::string::npos); + EXPECT_FALSE(conflicting.inserted); + EXPECT_EQ(PrefixCacheTableTestPeer::Snapshot(table), registered); +} + +TEST(Registration, TracksEveryInstanceAndRankIdempotently) { + PrefixCacheTable table; + + auto first = table.Register(Registration("instance-a", 0)); + ASSERT_TRUE(first.error.empty()) << first.error; + EXPECT_TRUE(first.inserted); + + auto duplicate = table.Register(Registration("instance-a", 0)); + ASSERT_TRUE(duplicate.error.empty()) << duplicate.error; + EXPECT_FALSE(duplicate.inserted); + + auto omitted_group = Registration("instance-a", 0); + omitted_group.cache_group.reset(); + auto omitted_duplicate = table.Register(omitted_group); + ASSERT_TRUE(omitted_duplicate.error.empty()) << omitted_duplicate.error; + EXPECT_FALSE(omitted_duplicate.inserted); + + auto second_rank = table.Register(Registration("instance-a", 2)); + ASSERT_TRUE(second_rank.error.empty()) << second_rank.error; + EXPECT_TRUE(second_rank.inserted); + + auto second_instance = table.Register(Registration("instance-b", 1)); + ASSERT_TRUE(second_instance.error.empty()) << second_instance.error; + EXPECT_TRUE(second_instance.inserted); + + const auto snapshot = PrefixCacheTableTestPeer::Snapshot(table); + ASSERT_EQ(snapshot.contexts.size(), 1u); + const auto& state = snapshot.contexts.at(TestContext()); + EXPECT_EQ(state.profile, TestProfile()); + EXPECT_EQ(state.instance_ranks.at("instance-a"), (std::set{0, 2})); + EXPECT_EQ(state.instance_ranks.at("instance-b"), (std::set{1})); + EXPECT_TRUE(state.blocks.empty()); +} + +TEST(Registration, ConflictingProfilePreservesCompleteState) { + PrefixCacheTable table; + RegisterOrFail(table, Registration()); + ASSERT_EQ(table.StoreGpu(Gpu({Prefix(1)})), ""); + const auto before = PrefixCacheTableTestPeer::Snapshot(table); + + auto conflicting = Registration("instance-b", 1); + conflicting.profile = PaddedSeedProfile(); + const auto result = table.Register(conflicting); + + EXPECT_FALSE(result.error.empty()); + EXPECT_FALSE(result.inserted); + EXPECT_EQ(PrefixCacheTableTestPeer::Snapshot(table), before); +} + +TEST(Registration, ProfileBindingValidationIsExactAndLookupOnly) { + PrefixCacheTable table; + const auto empty_before = PrefixCacheTableTestPeer::Snapshot(table); + + EXPECT_FALSE( + table.ValidateProfileBinding(TestContext(), TestProfile()).empty()); + EXPECT_EQ(PrefixCacheTableTestPeer::Snapshot(table), empty_before); + + RegisterOrFail(table, Registration()); + const auto registered = PrefixCacheTableTestPeer::Snapshot(table); + EXPECT_EQ(table.ValidateProfileBinding(TestContext(), TestProfile()), ""); + + const HashProfile conflict = PaddedSeedProfile(); + EXPECT_FALSE(table.ValidateProfileBinding(TestContext(), conflict).empty()); + EXPECT_EQ(PrefixCacheTableTestPeer::Snapshot(table), registered); +} + +TEST(Registration, MixedAlgorithmsUnderOneContextAreRejected) { + // The resolved profile is immutable per ContextKey: the same seed under + // the other supported algorithm is still a conflict, in both orders. + { + PrefixCacheTable table; + RegisterOrFail(table, Registration()); + const auto before = PrefixCacheTableTestPeer::Snapshot(table); + + auto conflicting = Registration("instance-b", 1); + conflicting.profile = PickleProfile(); + const auto result = table.Register(conflicting); + EXPECT_FALSE(result.error.empty()); + EXPECT_FALSE(result.inserted); + EXPECT_EQ(PrefixCacheTableTestPeer::Snapshot(table), before); + + EXPECT_FALSE( + table.ValidateProfileBinding(TestContext(), PickleProfile()) + .empty()); + EXPECT_EQ(PrefixCacheTableTestPeer::Snapshot(table), before); + } + { + PrefixCacheTable table; + auto pickle_registration = Registration(); + pickle_registration.profile = PickleProfile(); + RegisterOrFail(table, pickle_registration); + const auto before = PrefixCacheTableTestPeer::Snapshot(table); + EXPECT_EQ(table.ValidateProfileBinding(TestContext(), PickleProfile()), + ""); + + auto conflicting = Registration("instance-b", 1); + const auto result = table.Register(conflicting); + EXPECT_FALSE(result.error.empty()); + EXPECT_FALSE(result.inserted); + EXPECT_EQ(PrefixCacheTableTestPeer::Snapshot(table), before); + } +} + +TEST(Mutations, StoreRequiresKnownContextAndRegisteredGpuRank) { + PrefixCacheTable table; + const auto gpu = Gpu({Prefix(1)}); + const auto shared = Shared({Prefix(1)}, StorageTier::kCpu); + + EXPECT_FALSE(table.StoreGpu(gpu).empty()); + EXPECT_FALSE(table.StoreShared(shared).empty()); + EXPECT_EQ(table.GetGlobalView().context_count, 0); + + RegisterOrFail(table, Registration("instance-a", 1)); + EXPECT_FALSE(table.StoreGpu(gpu).empty()); + EXPECT_TRUE(PrefixCacheTableTestPeer::Snapshot(table) + .contexts.at(TestContext()) + .blocks.empty()); +} + +TEST(Mutations, InvalidGroupTierAndOwnersPreserveState) { + PrefixCacheTable table; + RegisterOrFail(table, Registration()); + ASSERT_EQ(table.StoreGpu(Gpu({Prefix(1)})), ""); + const auto before = PrefixCacheTableTestPeer::Snapshot(table); + + auto bad_group = Gpu({Prefix(2)}); + bad_group.cache_group = 3; + EXPECT_FALSE(table.StoreGpu(bad_group).empty()); + + auto bad_owner = Gpu({Prefix(2)}); + bad_owner.owner.source_stream.clear(); + EXPECT_FALSE(table.StoreGpu(bad_owner).empty()); + + auto bad_tier = Shared({Prefix(2)}, StorageTier::kGpu); + EXPECT_FALSE(table.StoreShared(bad_tier).empty()); + + EXPECT_EQ(PrefixCacheTableTestPeer::Snapshot(table), before); +} + +TEST(Mutations, DuplicateGpuStoreAndRemoveAreIdempotent) { + PrefixCacheTable table; + RegisterOrFail(table, Registration()); + const ProjectedPrefix prefix = Prefix(7); + const auto mutation = Gpu({prefix}); + + ASSERT_EQ(table.StoreGpu(mutation), ""); + ASSERT_EQ(table.StoreGpu(mutation), ""); + EXPECT_EQ(Presence(table, prefix).gpu_owners, + (std::set{GpuOwner()})); + + auto absent_owner = Gpu({prefix}, GpuOwner("instance-b", 0, "stream-b")); + ASSERT_EQ(table.RemoveGpu(absent_owner), ""); + EXPECT_EQ(Presence(table, prefix).gpu_owners, + (std::set{GpuOwner()})); + + ASSERT_EQ(table.RemoveGpu(mutation), ""); + ASSERT_EQ(table.RemoveGpu(mutation), ""); + EXPECT_TRUE(PrefixCacheTableTestPeer::Snapshot(table) + .contexts.at(TestContext()) + .blocks.empty()); +} + +TEST(Mutations, CollidingSharedOwnersRemainIndependentlyRemovable) { + PrefixCacheTable table; + RegisterOrFail(table, Registration()); + const ProjectedPrefix collision = Prefix(0x123456789abcdef0ULL); + const SharedObjectOwner first = SharedOwner("object-a"); + const SharedObjectOwner second = SharedOwner("object-b"); + + ASSERT_EQ(table.StoreShared(Shared({collision}, StorageTier::kCpu, first)), + ""); + ASSERT_EQ(table.StoreShared(Shared({collision}, StorageTier::kCpu, second)), + ""); + ASSERT_EQ(table.StoreShared(Shared({collision}, StorageTier::kCpu, first)), + ""); + EXPECT_EQ(Presence(table, collision).cpu_owners, + (std::set{first, second})); + + ASSERT_EQ(table.RemoveShared(Shared({collision}, StorageTier::kCpu, first)), + ""); + EXPECT_EQ(Presence(table, collision).cpu_owners, + (std::set{second})); + + ASSERT_EQ( + table.RemoveShared(Shared({collision}, StorageTier::kCpu, second)), ""); + EXPECT_TRUE(PrefixCacheTableTestPeer::Snapshot(table) + .contexts.at(TestContext()) + .blocks.empty()); +} + +TEST(Mutations, BlockLivesUntilEveryTierOwnerSetIsEmpty) { + PrefixCacheTable table; + RegisterOrFail(table, Registration()); + const ProjectedPrefix prefix = Prefix(11); + const auto gpu = Gpu({prefix}); + const auto cpu = Shared({prefix}, StorageTier::kCpu, SharedOwner("cpu")); + const auto disk = Shared({prefix}, StorageTier::kDisk, SharedOwner("disk")); + + ASSERT_EQ(table.StoreGpu(gpu), ""); + ASSERT_EQ(table.StoreShared(cpu), ""); + ASSERT_EQ(table.StoreShared(disk), ""); + ASSERT_EQ(table.RemoveGpu(gpu), ""); + EXPECT_TRUE(Presence(table, prefix).gpu_owners.empty()); + EXPECT_FALSE(Presence(table, prefix).cpu_owners.empty()); + EXPECT_FALSE(Presence(table, prefix).disk_owners.empty()); + + ASSERT_EQ(table.RemoveShared(cpu), ""); + EXPECT_TRUE(Presence(table, prefix).cpu_owners.empty()); + EXPECT_FALSE(Presence(table, prefix).disk_owners.empty()); + + ASSERT_EQ(table.RemoveShared(disk), ""); + EXPECT_TRUE(PrefixCacheTableTestPeer::Snapshot(table) + .contexts.at(TestContext()) + .blocks.empty()); +} + +TEST(Mutations, GpuAndSharedClearAreExactlyOwnerScoped) { + PrefixCacheTable table; + RegisterOrFail(table, Registration("instance-a", 0)); + RegisterOrFail(table, Registration("instance-b", 1)); + const ProjectedPrefix prefix = Prefix(21); + const EngineOwner engine_a = GpuOwner("instance-a", 0, "stream-a"); + const EngineOwner engine_a_other_stream = + GpuOwner("instance-a", 0, "stream-a-other"); + const EngineOwner engine_b = GpuOwner("instance-b", 1, "stream-b"); + const SharedObjectOwner shared_a = SharedOwner("object-a"); + const SharedObjectOwner shared_b = SharedOwner("object-b"); + + ASSERT_EQ(table.StoreGpu(Gpu({prefix}, engine_a)), ""); + ASSERT_EQ(table.StoreGpu(Gpu({prefix}, engine_a_other_stream)), ""); + ASSERT_EQ(table.StoreGpu(Gpu({prefix}, engine_b)), ""); + ASSERT_EQ(table.StoreShared(Shared({prefix}, StorageTier::kCpu, shared_a)), + ""); + ASSERT_EQ(table.StoreShared(Shared({prefix}, StorageTier::kDisk, shared_a)), + ""); + ASSERT_EQ(table.StoreShared(Shared({prefix}, StorageTier::kCpu, shared_b)), + ""); + + ASSERT_EQ(table.ClearGpu(ClearFor(engine_a)), ""); + EXPECT_EQ(Presence(table, prefix).gpu_owners, + (std::set{engine_a_other_stream, engine_b})); + EXPECT_EQ(Presence(table, prefix).cpu_owners, + (std::set{shared_a, shared_b})); + + ASSERT_EQ(table.ClearShared(ClearFor(shared_a, StorageTier::kCpu)), ""); + EXPECT_EQ(Presence(table, prefix).cpu_owners, + (std::set{shared_b})); + EXPECT_EQ(Presence(table, prefix).disk_owners, + (std::set{shared_a})); + EXPECT_EQ(Presence(table, prefix).gpu_owners, + (std::set{engine_a_other_stream, engine_b})); + + ASSERT_EQ(table.ClearShared(ClearFor(shared_a)), ""); + EXPECT_TRUE(Presence(table, prefix).disk_owners.empty()); + EXPECT_EQ(Presence(table, prefix).gpu_owners, + (std::set{engine_a_other_stream, engine_b})); +} + +TEST(Mutations, UnknownRemoveClearAndUnregisterNeverCreateState) { + PrefixCacheTable table; + const ContextKey context = TestContext(); + + EXPECT_EQ(table.RemoveGpu(Gpu({Prefix(1)})), ""); + EXPECT_EQ(table.ClearGpu(ClearFor()), ""); + EXPECT_EQ(table.RemoveShared(Shared({Prefix(1)}, StorageTier::kCpu)), ""); + EXPECT_EQ(table.ClearShared(ClearFor(SharedOwner())), ""); + EXPECT_EQ(table.Unregister(context, "instance-a", 0), ""); + + EXPECT_FALSE(PrefixCacheTableTestPeer::ContextExists(table, context)); + EXPECT_TRUE(PrefixCacheTableTestPeer::Snapshot(table).contexts.empty()); +} + +TEST(Unregister, RemovesOnlySelectedRankGpuOwners) { + PrefixCacheTable table; + RegisterOrFail(table, Registration("instance-a", 0)); + RegisterOrFail(table, Registration("instance-a", 1)); + RegisterOrFail(table, Registration("instance-b", 0)); + const ProjectedPrefix prefix = Prefix(31); + const EngineOwner a0 = GpuOwner("instance-a", 0, "stream-a0"); + const EngineOwner a0_second_stream = + GpuOwner("instance-a", 0, "stream-a0-second"); + const EngineOwner a1 = GpuOwner("instance-a", 1, "stream-a1"); + const EngineOwner b0 = GpuOwner("instance-b", 0, "stream-b0"); + const SharedObjectOwner shared = SharedOwner(); + + ASSERT_EQ(table.StoreGpu(Gpu({prefix}, a0)), ""); + ASSERT_EQ(table.StoreGpu(Gpu({prefix}, a0_second_stream)), ""); + ASSERT_EQ(table.StoreGpu(Gpu({prefix}, a1)), ""); + ASSERT_EQ(table.StoreGpu(Gpu({prefix}, b0)), ""); + ASSERT_EQ(table.StoreShared(Shared({prefix}, StorageTier::kCpu, shared)), + ""); + + ASSERT_EQ(table.Unregister(TestContext(), "instance-a", 0), ""); + auto snapshot = PrefixCacheTableTestPeer::Snapshot(table); + const auto& state = snapshot.contexts.at(TestContext()); + EXPECT_EQ(state.instance_ranks.at("instance-a"), (std::set{1})); + EXPECT_EQ(state.instance_ranks.at("instance-b"), (std::set{0})); + EXPECT_EQ(state.blocks.at(prefix).gpu_owners, + (std::set{a1, b0})); + EXPECT_EQ(state.blocks.at(prefix).cpu_owners, + (std::set{shared})); + + ASSERT_EQ(table.Unregister(TestContext(), "instance-a", 1), ""); + snapshot = PrefixCacheTableTestPeer::Snapshot(table); + EXPECT_FALSE(snapshot.contexts.at(TestContext()) + .instance_ranks.contains("instance-a")); + EXPECT_EQ(snapshot.contexts.at(TestContext()).blocks.at(prefix).gpu_owners, + (std::set{b0})); + EXPECT_EQ(snapshot.contexts.at(TestContext()).blocks.at(prefix).cpu_owners, + (std::set{shared})); +} + +TEST(Query, ExactTwoInstanceSharedCacheExample) { + PrefixCacheTable table; + RegisterOrFail(table, Registration("instance-1", 0)); + RegisterOrFail(table, Registration("instance-2", 1)); + const auto tokens = Tokens(48); + const auto hashes = Hashes(tokens); + ASSERT_EQ(hashes.size(), 3u); + + ASSERT_EQ(table.StoreGpu(Gpu({hashes[0], hashes[1]}, + GpuOwner("instance-1", 0, "engine-1"))), + ""); + ASSERT_EQ(table.StoreShared( + Shared(hashes, StorageTier::kCpu, SharedOwner("cpu-object"))), + ""); + ASSERT_EQ(table.StoreShared(Shared(hashes, StorageTier::kDisk, + SharedOwner("disk-object"))), + ""); + + const auto results = table.Query(TestContext(), tokens); + ASSERT_EQ(results.size(), 2u); + + const CacheHitResult& first = results.at("instance-1"); + EXPECT_EQ(first.longest_match_tokens, 48); + EXPECT_EQ(first.gpu, 32); + EXPECT_EQ(first.dp, (std::map{{0, 32}})); + EXPECT_EQ( + first.rank_matches, + (std::map{{0, RankMatch(32, 48, 48)}})); + EXPECT_EQ(first.cpu, 48); + EXPECT_EQ(first.disk, 48); + + const CacheHitResult& second = results.at("instance-2"); + EXPECT_EQ(second.longest_match_tokens, 48); + EXPECT_EQ(second.gpu, 0); + EXPECT_EQ(second.dp, (std::map{{1, 0}})); + EXPECT_EQ( + second.rank_matches, + (std::map{{1, RankMatch(0, 48, 48)}})); + EXPECT_EQ(second.cpu, 48); + EXPECT_EQ(second.disk, 48); +} + +TEST(Query, TrailingPartialBlockNeverReportsMoreThanPromptTokens) { + PrefixCacheTable table; + auto registration = Registration(); + registration.profile = SglangProfile(); + RegisterOrFail(table, registration); + + // 40 tokens over a 16-token block size: two whole blocks plus a trailing + // block that only covers 8 tokens. + const auto tokens = Tokens(40); + const auto hashes = SglangHashes(tokens); + ASSERT_EQ(hashes.size(), 3u); + + ASSERT_EQ(table.StoreGpu(Gpu({hashes[0], hashes[1], hashes[2]})), ""); + ASSERT_EQ(table.StoreShared(Shared(hashes, StorageTier::kCpu)), ""); + ASSERT_EQ(table.StoreShared(Shared(hashes, StorageTier::kDisk)), ""); + + const auto result = table.Query(TestContext(), tokens).at("instance-a"); + EXPECT_EQ(result.longest_match_tokens, 40); + EXPECT_EQ(result.gpu, 40); + EXPECT_EQ(result.dp, (std::map{{0, 40}})); + EXPECT_EQ( + result.rank_matches, + (std::map{{0, RankMatch(40, 40, 40)}})); + EXPECT_EQ(result.cpu, 40); + EXPECT_EQ(result.disk, 40); +} + +TEST(Query, WholeBlockRunIsUnaffectedByThePromptLengthClamp) { + PrefixCacheTable table; + auto registration = Registration(); + registration.profile = SglangProfile(); + RegisterOrFail(table, registration); + + const auto tokens = Tokens(40); + const auto hashes = SglangHashes(tokens); + ASSERT_EQ(hashes.size(), 3u); + + // Only the two whole blocks are indexed; the partial tail misses. + ASSERT_EQ( + table.StoreShared(Shared({hashes[0], hashes[1]}, StorageTier::kDisk)), + ""); + + const auto result = table.Query(TestContext(), tokens).at("instance-a"); + EXPECT_EQ(result.longest_match_tokens, 32); + EXPECT_EQ(result.gpu, 0); + EXPECT_EQ(result.cpu, 0); + EXPECT_EQ(result.disk, 32); +} + +TEST(Query, BigramFullMatchReportsOneFewerPositionThanPromptTokens) { + PrefixCacheTable table; + auto registration = Registration(); + registration.profile = SglangBigramProfile(); + RegisterOrFail(table, registration); + + // 40 raw tokens over a 16-token block size. The bigram chain covers 39 + // logical positions, so it is two whole blocks plus a trailing block of 7. + const auto tokens = Tokens(40); + const auto hashes = SglangBigramHashes(tokens); + ASSERT_EQ(hashes.size(), 3u); + + ASSERT_EQ(table.StoreGpu(Gpu({hashes[0], hashes[1], hashes[2]})), ""); + ASSERT_EQ(table.StoreShared(Shared(hashes, StorageTier::kCpu)), ""); + ASSERT_EQ(table.StoreShared(Shared(hashes, StorageTier::kDisk)), ""); + + // 39, not 40 and not the 48 a whole-block count would report. + const auto result = table.Query(TestContext(), tokens).at("instance-a"); + EXPECT_EQ(result.longest_match_tokens, 39); + EXPECT_EQ(result.gpu, 39); + EXPECT_EQ(result.dp, (std::map{{0, 39}})); + EXPECT_EQ( + result.rank_matches, + (std::map{{0, RankMatch(39, 39, 39)}})); + EXPECT_EQ(result.cpu, 39); + EXPECT_EQ(result.disk, 39); +} + +TEST(Query, BigramWholeBlockRunIsUnaffectedByTheLogicalLengthClamp) { + PrefixCacheTable table; + auto registration = Registration(); + registration.profile = SglangBigramProfile(); + RegisterOrFail(table, registration); + + const auto tokens = Tokens(40); + const auto hashes = SglangBigramHashes(tokens); + ASSERT_EQ(hashes.size(), 3u); + + // Only the two whole blocks are indexed; the partial tail misses. The + // clamp must not pull this below the 32 positions those blocks hold. + ASSERT_EQ( + table.StoreShared(Shared({hashes[0], hashes[1]}, StorageTier::kDisk)), + ""); + + const auto result = table.Query(TestContext(), tokens).at("instance-a"); + EXPECT_EQ(result.longest_match_tokens, 32); + EXPECT_EQ(result.gpu, 0); + EXPECT_EQ(result.cpu, 0); + EXPECT_EQ(result.disk, 32); +} + +TEST(Query, GpuCpuAndDiskExtendOneCumulativePrefix) { + PrefixCacheTable table; + RegisterOrFail(table, Registration()); + const auto tokens = Tokens(64); + const auto hashes = Hashes(tokens); + ASSERT_EQ(hashes.size(), 4u); + + ASSERT_EQ(table.StoreGpu(Gpu({hashes[0], hashes[1]})), ""); + ASSERT_EQ(table.StoreShared(Shared({hashes[2]}, StorageTier::kCpu)), ""); + ASSERT_EQ(table.StoreShared(Shared({hashes[3]}, StorageTier::kDisk)), ""); + + const auto result = table.Query(TestContext(), tokens).at("instance-a"); + EXPECT_EQ(result.longest_match_tokens, 64); + EXPECT_EQ(result.gpu, 32); + EXPECT_EQ(result.dp, (std::map{{0, 32}})); + EXPECT_EQ( + result.rank_matches, + (std::map{{0, RankMatch(32, 48, 64)}})); + EXPECT_EQ(result.cpu, 48); + EXPECT_EQ(result.disk, 64); +} + +TEST(Query, EmptyCpuPhaseFallsThroughToDiskAtSameBlock) { + PrefixCacheTable table; + RegisterOrFail(table, Registration()); + const auto tokens = Tokens(48); + const auto hashes = Hashes(tokens); + ASSERT_EQ(hashes.size(), 3u); + + ASSERT_EQ(table.StoreGpu(Gpu({hashes[0]})), ""); + ASSERT_EQ( + table.StoreShared(Shared({hashes[1], hashes[2]}, StorageTier::kDisk)), + ""); + + const auto result = table.Query(TestContext(), tokens).at("instance-a"); + EXPECT_EQ(result.longest_match_tokens, 48); + EXPECT_EQ(result.gpu, 16); + EXPECT_EQ(result.dp, (std::map{{0, 16}})); + EXPECT_EQ( + result.rank_matches, + (std::map{{0, RankMatch(16, 16, 48)}})); + EXPECT_EQ(result.cpu, 16); + EXPECT_EQ(result.disk, 48); +} + +TEST(Query, CompleteGpuCoverageCarriesThroughLowerTierBoundaries) { + PrefixCacheTable table; + RegisterOrFail(table, Registration()); + const auto tokens = Tokens(48); + const auto hashes = Hashes(tokens); + ASSERT_EQ(hashes.size(), 3u); + + ASSERT_EQ(table.StoreGpu(Gpu(hashes)), ""); + + const auto result = table.Query(TestContext(), tokens).at("instance-a"); + EXPECT_EQ(result.longest_match_tokens, 48); + EXPECT_EQ(result.gpu, 48); + EXPECT_EQ(result.dp, (std::map{{0, 48}})); + EXPECT_EQ( + result.rank_matches, + (std::map{{0, RankMatch(48, 48, 48)}})); + EXPECT_EQ(result.cpu, 48); + EXPECT_EQ(result.disk, 48); +} + +TEST(Query, DuplicateTierPresenceIsAttributedOnce) { + PrefixCacheTable table; + RegisterOrFail(table, Registration()); + const auto tokens = Tokens(48); + const auto hashes = Hashes(tokens); + ASSERT_EQ(hashes.size(), 3u); + + ASSERT_EQ(table.StoreGpu(Gpu({hashes[0]})), ""); + ASSERT_EQ( + table.StoreShared(Shared({hashes[0], hashes[1]}, StorageTier::kCpu)), + ""); + ASSERT_EQ(table.StoreShared(Shared(hashes, StorageTier::kDisk)), ""); + + const auto result = table.Query(TestContext(), tokens).at("instance-a"); + EXPECT_EQ(result.longest_match_tokens, 48); + EXPECT_EQ(result.gpu, 16); + EXPECT_EQ(result.dp, (std::map{{0, 16}})); + EXPECT_EQ( + result.rank_matches, + (std::map{{0, RankMatch(16, 32, 48)}})); + EXPECT_EQ(result.cpu, 32); + EXPECT_EQ(result.disk, 48); +} + +TEST(Query, LowerTierPhaseNeverReturnsToHigherTier) { + PrefixCacheTable table; + RegisterOrFail(table, Registration()); + const auto tokens = Tokens(64); + const auto hashes = Hashes(tokens); + ASSERT_EQ(hashes.size(), 4u); + + ASSERT_EQ(table.StoreGpu(Gpu({hashes[0]})), ""); + ASSERT_EQ(table.StoreShared(Shared({hashes[2]}, StorageTier::kCpu)), ""); + ASSERT_EQ( + table.StoreShared(Shared({hashes[1], hashes[3]}, StorageTier::kDisk)), + ""); + + const auto result = table.Query(TestContext(), tokens).at("instance-a"); + EXPECT_EQ(result.longest_match_tokens, 32); + EXPECT_EQ(result.gpu, 16); + EXPECT_EQ(result.dp, (std::map{{0, 16}})); + EXPECT_EQ( + result.rank_matches, + (std::map{{0, RankMatch(16, 16, 32)}})); + EXPECT_EQ(result.cpu, 16); + EXPECT_EQ(result.disk, 32); +} + +TEST(Query, DiskMissIgnoresAllLaterIsolatedBlocks) { + PrefixCacheTable table; + RegisterOrFail(table, Registration()); + const auto tokens = Tokens(80); + const auto hashes = Hashes(tokens); + ASSERT_EQ(hashes.size(), 5u); + + ASSERT_EQ(table.StoreGpu(Gpu({hashes[0], hashes[4]})), ""); + ASSERT_EQ( + table.StoreShared(Shared({hashes[1], hashes[4]}, StorageTier::kCpu)), + ""); + ASSERT_EQ( + table.StoreShared(Shared({hashes[3], hashes[4]}, StorageTier::kDisk)), + ""); + + const auto result = table.Query(TestContext(), tokens).at("instance-a"); + EXPECT_EQ(result.longest_match_tokens, 32); + EXPECT_EQ(result.gpu, 16); + EXPECT_EQ(result.dp, (std::map{{0, 16}})); + EXPECT_EQ( + result.rank_matches, + (std::map{{0, RankMatch(16, 32, 32)}})); + EXPECT_EQ(result.cpu, 32); + EXPECT_EQ(result.disk, 32); +} + +TEST(Query, DifferentRanksNeverFabricateOneGpuPrefix) { + PrefixCacheTable table; + RegisterOrFail(table, Registration("instance-a", 0)); + RegisterOrFail(table, Registration("instance-a", 1)); + const auto tokens = Tokens(32); + const auto hashes = Hashes(tokens); + ASSERT_EQ(hashes.size(), 2u); + + ASSERT_EQ( + table.StoreGpu(Gpu({hashes[0]}, GpuOwner("instance-a", 0, "rank-0"))), + ""); + ASSERT_EQ( + table.StoreGpu(Gpu({hashes[1]}, GpuOwner("instance-a", 1, "rank-1"))), + ""); + + const auto result = table.Query(TestContext(), tokens).at("instance-a"); + EXPECT_EQ(result.longest_match_tokens, 16); + EXPECT_EQ(result.gpu, 16); + EXPECT_EQ(result.dp, (std::map{{0, 16}, {1, 0}})); + EXPECT_EQ(result.rank_matches, + (std::map{{0, RankMatch(16, 16, 16)}, + {1, RankMatch(0, 0, 0)}})); + EXPECT_EQ(result.dp.size(), result.rank_matches.size()); + EXPECT_EQ(result.cpu, 16); + EXPECT_EQ(result.disk, 16); +} + +TEST(Query, InstanceSummaryIsRealizedByMaximumGpuRank) { + PrefixCacheTable table; + RegisterOrFail(table, Registration("instance-a", 0)); + RegisterOrFail(table, Registration("instance-a", 1)); + const auto tokens = Tokens(64); + const auto hashes = Hashes(tokens); + ASSERT_EQ(hashes.size(), 4u); + + ASSERT_EQ(table.StoreGpu(Gpu({hashes[0], hashes[1]}, + GpuOwner("instance-a", 0, "rank-0"))), + ""); + ASSERT_EQ( + table.StoreGpu(Gpu({hashes[0]}, GpuOwner("instance-a", 1, "rank-1"))), + ""); + ASSERT_EQ(table.StoreShared(Shared({hashes[2]}, StorageTier::kCpu)), ""); + ASSERT_EQ(table.StoreShared(Shared({hashes[3]}, StorageTier::kDisk)), ""); + + const auto result = table.Query(TestContext(), tokens).at("instance-a"); + EXPECT_EQ(result.dp, (std::map{{0, 32}, {1, 16}})); + EXPECT_EQ(result.rank_matches, + (std::map{ + {0, RankMatch(32, 48, 64)}, {1, RankMatch(16, 16, 16)}})); + EXPECT_EQ(result.dp.size(), result.rank_matches.size()); + for (const auto& [rank, gpu] : result.dp) { + ASSERT_TRUE(result.rank_matches.contains(rank)); + EXPECT_EQ(gpu, result.rank_matches.at(rank).gpu); + } + EXPECT_EQ(result.gpu, result.rank_matches.at(0).gpu); + EXPECT_EQ(result.cpu, result.rank_matches.at(0).cpu); + EXPECT_EQ(result.disk, result.rank_matches.at(0).disk); + EXPECT_EQ(result.longest_match_tokens, result.rank_matches.at(0).disk); +} + +TEST(Query, RegisteredZeroHitRanksAndIncompleteTailAreRetained) { + PrefixCacheTable table; + RegisterOrFail(table, Registration("instance-a", 0)); + RegisterOrFail(table, Registration("instance-a", 2)); + const auto incomplete_tokens = Tokens(31); + + const auto results = table.Query(TestContext(), incomplete_tokens); + ASSERT_EQ(results.size(), 1u); + const auto& result = results.at("instance-a"); + EXPECT_EQ(result.longest_match_tokens, 0); + EXPECT_EQ(result.dp, (std::map{{0, 0}, {2, 0}})); + EXPECT_EQ(result.rank_matches, + (std::map{{0, RankMatch(0, 0, 0)}, + {2, RankMatch(0, 0, 0)}})); + EXPECT_EQ(result.gpu, 0); + EXPECT_EQ(result.cpu, 0); + EXPECT_EQ(result.disk, 0); +} + +TEST(Query, InstanceFilterAndUnknownContextAreLookupOnly) { + PrefixCacheTable table; + RegisterOrFail(table, Registration("instance-a", 0)); + RegisterOrFail(table, Registration("instance-b", 1)); + const auto before = PrefixCacheTableTestPeer::Snapshot(table); + + const auto filtered = + table.Query(TestContext(), Tokens(16), std::nullopt, "instance-b"); + ASSERT_EQ(filtered.size(), 1u); + EXPECT_TRUE(filtered.contains("instance-b")); + + EXPECT_TRUE( + table.Query(TestContext(), Tokens(16), std::nullopt, "unknown-instance") + .empty()); + ContextKey unknown = TestContext(); + unknown.model_name = "missing"; + EXPECT_TRUE(table.Query(unknown, Tokens(16)).empty()); + EXPECT_EQ(PrefixCacheTableTestPeer::Snapshot(table), before); +} + +TEST(Query, CacheSaltChangesHashesWithoutChangingContextIdentity) { + PrefixCacheTable table; + RegisterOrFail(table, Registration()); + const auto tokens = Tokens(16); + const auto unsalted = Hashes(tokens); + ASSERT_EQ(table.StoreGpu(Gpu(unsalted)), ""); + + const auto hit = table.Query(TestContext(), tokens).at("instance-a"); + EXPECT_EQ(hit.longest_match_tokens, 16); + + const auto salted = + table.Query(TestContext(), tokens, std::string("request-salt")) + .at("instance-a"); + EXPECT_EQ(salted.longest_match_tokens, 0); + EXPECT_EQ(table.GetGlobalView().context_count, 1); +} + +TEST(GlobalView, ReportsProfileRegistrationAndOwnerMapSize) { + PrefixCacheTable table; + RegisterOrFail(table, Registration("instance-a", 0)); + RegisterOrFail(table, Registration("instance-b", 1)); + ASSERT_EQ(table.StoreGpu(Gpu({Prefix(1), Prefix(2)})), ""); + + const auto view = table.GetGlobalView(); + ASSERT_EQ(view.context_count, 1); + ASSERT_EQ(view.contexts.size(), 1u); + EXPECT_EQ(view.contexts[0].context, TestContext()); + EXPECT_EQ(view.contexts[0].profile, TestProfile()); + EXPECT_EQ(view.contexts[0].instance_ranks.at("instance-a"), + (std::set{0})); + EXPECT_EQ(view.contexts[0].instance_ranks.at("instance-b"), + (std::set{1})); + EXPECT_EQ(view.contexts[0].prefix_count, 2u); +} + +TEST(Capacity, EvictsOldestWrittenPrefixesWhenOverLimit) { + PrefixCacheTable table(10); + RegisterOrFail(table, Registration()); + + for (uint64_t i = 1; i <= 14; ++i) { + ASSERT_EQ(table.StoreGpu(Gpu({Prefix(i)})), ""); + } + + const auto snapshot = PrefixCacheTableTestPeer::Snapshot(table); + const auto& blocks = snapshot.contexts.at(TestContext()).blocks; + EXPECT_LE(blocks.size(), 10u); + EXPECT_TRUE(blocks.contains(Prefix(14))); + EXPECT_FALSE(blocks.contains(Prefix(1))); +} + +TEST(Capacity, UnlimitedWhenBlockLimitIsZero) { + PrefixCacheTable table(0); + RegisterOrFail(table, Registration()); + for (uint64_t i = 1; i <= 50; ++i) { + ASSERT_EQ(table.StoreGpu(Gpu({Prefix(i)})), ""); + } + EXPECT_EQ(PrefixCacheTableTestPeer::Snapshot(table) + .contexts.at(TestContext()) + .blocks.size(), + 50u); +} + +TEST(Capacity, OrderTrackingStaysInSyncWithBlocks) { + PrefixCacheTable table(10); + RegisterOrFail(table, Registration()); + const ContextKey context = TestContext(); + + for (uint64_t i = 1; i <= 6; ++i) { + ASSERT_EQ(table.StoreGpu(Gpu({Prefix(i)})), ""); + } + auto sizes = PrefixCacheTableTestPeer::Order(table, context); + EXPECT_EQ(sizes.blocks, 6u); + EXPECT_EQ(sizes.write_order, 6u); + EXPECT_EQ(sizes.order_pos, 6u); + + for (uint64_t i = 1; i <= 3; ++i) { + ASSERT_EQ(table.RemoveGpu(Gpu({Prefix(i)})), ""); + } + sizes = PrefixCacheTableTestPeer::Order(table, context); + EXPECT_EQ(sizes.blocks, 3u); + EXPECT_EQ(sizes.write_order, 3u); + EXPECT_EQ(sizes.order_pos, 3u); + + ASSERT_EQ(table.ClearGpu(ClearFor()), ""); + sizes = PrefixCacheTableTestPeer::Order(table, context); + EXPECT_EQ(sizes.blocks, 0u); + EXPECT_EQ(sizes.write_order, 0u); + EXPECT_EQ(sizes.order_pos, 0u); + + for (uint64_t i = 20; i <= 40; ++i) { + ASSERT_EQ(table.StoreGpu(Gpu({Prefix(i)})), ""); + } + sizes = PrefixCacheTableTestPeer::Order(table, context); + EXPECT_LE(sizes.blocks, 10u); + EXPECT_EQ(sizes.write_order, sizes.blocks); + EXPECT_EQ(sizes.order_pos, sizes.blocks); + EXPECT_GT(sizes.evicted_by_capacity, 0); +} + +} // namespace diff --git a/mooncake-conductor/tests/prefix_indexer_test_peer.h b/mooncake-conductor/tests/prefix_indexer_test_peer.h new file mode 100644 index 0000000000..5903fe7bee --- /dev/null +++ b/mooncake-conductor/tests/prefix_indexer_test_peer.h @@ -0,0 +1,115 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "conductor/prefixindex/prefix_indexer.h" + +namespace mooncake::conductor::prefixindex { + +struct BlockPresenceSnapshot { + std::set gpu_owners; + std::set cpu_owners; + std::set disk_owners; + + bool operator==(const BlockPresenceSnapshot&) const = default; +}; + +struct ContextStateSnapshot { + HashProfile profile; + std::map> instance_ranks; + std::unordered_map blocks; + + bool operator==(const ContextStateSnapshot&) const = default; +}; + +struct PrefixCacheTableSnapshot { + std::unordered_map contexts; + + bool operator==(const PrefixCacheTableSnapshot&) const = default; +}; + +class PrefixCacheTableTestPeer { + public: + static bool ContextExists(const PrefixCacheTable& table, + const ContextKey& context) { + return table.LoadContextState(context) != nullptr; + } + + static std::unique_lock LockContextState( + const PrefixCacheTable& table, const ContextKey& context) { + auto state = table.LoadContextState(context); + if (state == nullptr) return {}; + return std::unique_lock(state->mutex); + } + + static std::optional Presence( + const PrefixCacheTable& table, const ContextKey& context, + ProjectedPrefix prefix) { + auto state = table.LoadContextState(context); + if (state == nullptr) return std::nullopt; + + std::shared_lock state_lock(state->mutex); + const auto block = state->blocks.find(prefix); + if (block == state->blocks.end()) return std::nullopt; + return BlockPresenceSnapshot{block->second.gpu_owners, + block->second.cpu_owners, + block->second.disk_owners}; + } + + // Snapshot sizes for validating order metadata invariants. + struct OrderSizes { + size_t write_order = 0; + size_t order_pos = 0; + size_t blocks = 0; + int64_t evicted_by_capacity = 0; + }; + + static OrderSizes Order(const PrefixCacheTable& table, + const ContextKey& context) { + auto state = table.LoadContextState(context); + if (state == nullptr) return {}; + std::shared_lock state_lock(state->mutex); + return {.write_order = state->write_order.size(), + .order_pos = state->order_pos.size(), + .blocks = state->blocks.size(), + .evicted_by_capacity = state->evicted_by_capacity}; + } + + static PrefixCacheTableSnapshot Snapshot(const PrefixCacheTable& table) { + PrefixCacheTableSnapshot snapshot; + std::vector>> + contexts; + { + std::shared_lock map_lock(table.context_map_mutex_); + contexts.reserve(table.contexts_.size()); + for (const auto& item : table.contexts_) { + contexts.push_back(item); + } + } + + for (const auto& [context, state] : contexts) { + std::shared_lock state_lock(state->mutex); + ContextStateSnapshot state_snapshot; + state_snapshot.profile = state->profile; + state_snapshot.instance_ranks = state->instance_ranks; + for (const auto& [prefix, presence] : state->blocks) { + state_snapshot.blocks.emplace( + prefix, BlockPresenceSnapshot{presence.gpu_owners, + presence.cpu_owners, + presence.disk_owners}); + } + snapshot.contexts.emplace(context, std::move(state_snapshot)); + } + return snapshot; + } +}; + +} // namespace mooncake::conductor::prefixindex diff --git a/mooncake-conductor/tests/test_fixtures.h b/mooncake-conductor/tests/test_fixtures.h new file mode 100644 index 0000000000..44c16a47be --- /dev/null +++ b/mooncake-conductor/tests/test_fixtures.h @@ -0,0 +1,43 @@ +#pragma once + +// Shared helpers for loading JSON golden-vector fixtures. uint64 values are +// stored as decimal strings so that values above 2^53 survive JSON +// round-trips without precision loss. + +#include + +#include +#include +#include +#include + +#include "integer_parser.h" + +namespace mooncake::conductor::test { + +inline Json::Value LoadJsonFixture(const std::string& filename) { + const std::string path = + std::string(CONDUCTOR_TEST_FIXTURE_DIR) + "/" + filename; + std::ifstream in(path); + if (!in) { + throw std::runtime_error("cannot open fixture: " + path); + } + Json::Value root; + Json::CharReaderBuilder builder; + std::string errs; + if (!Json::parseFromStream(builder, in, &root, &errs)) { + throw std::runtime_error("cannot parse fixture " + path + ": " + errs); + } + return root; +} + +inline uint64_t ParseU64(const Json::Value& v) { + const std::string text = v.asString(); + const auto parsed = TryParseInteger(text); + if (!parsed.has_value()) { + throw std::runtime_error("fixture value is not a uint64: " + text); + } + return *parsed; +} + +} // namespace mooncake::conductor::test diff --git a/mooncake-conductor/tests/zmq_client_test.cpp b/mooncake-conductor/tests/zmq_client_test.cpp new file mode 100644 index 0000000000..1adc878b01 --- /dev/null +++ b/mooncake-conductor/tests/zmq_client_test.cpp @@ -0,0 +1,1174 @@ +// Tests for ZMQClient source routing, metadata propagation, lifecycle, +// sequence tracking, gap handling, and replay-after-reconnect. + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "conductor/zmq/zmq_client.h" + +namespace mooncake::conductor::zmq { + +class ZMQClientTestPeer { + public: + static void SetEndpoint(ZMQClient& client, std::string endpoint) { + std::unique_lock lock(client.mu_); + client.config_.endpoint = std::move(endpoint); + } + + static void SetLastSequence(ZMQClient& client, int64_t sequence) { + std::unique_lock lock(client.mu_); + client.last_seq_ = sequence; + } + + static void HandleReconnect(ZMQClient& client) { client.HandleReconnect(); } + + static void MarkDisconnected(ZMQClient& client) { + client.MarkDisconnected(); + } + + static bool IsConnected(ZMQClient& client) { return client.IsConnected(); } + + static bool HasReplaySocket(ZMQClient& client) { + std::shared_lock lock(client.mu_); + return client.replay_socket_ != nullptr; + } +}; + +} // namespace mooncake::conductor::zmq + +namespace { + +using mooncake::conductor::common::PublisherKind; +using mooncake::conductor::zmq::DecodedBatch; +using mooncake::conductor::zmq::EventHandler; +using mooncake::conductor::zmq::MessageMetadata; +using mooncake::conductor::zmq::MooncakeEventBatch; +using mooncake::conductor::zmq::MooncakeStoredEvent; +using mooncake::conductor::zmq::ValidateConfig; +using mooncake::conductor::zmq::VllmEventBatch; +using mooncake::conductor::zmq::VllmRemovedEvent; +using mooncake::conductor::zmq::VllmStoredEvent; +using mooncake::conductor::zmq::ZMQClient; +using mooncake::conductor::zmq::ZMQClientConfig; +using mooncake::conductor::zmq::ZMQClientTestPeer; + +struct HandledBatch { + DecodedBatch batch; + MessageMetadata metadata; +}; + +class MockEventHandler : public EventHandler { + public: + std::string HandleBatch(const DecodedBatch& batch, + const MessageMetadata& metadata) override { + std::lock_guard lock(mu_); + if (!handle_error_.empty()) return handle_error_; + batches_.push_back({batch, metadata}); + if (const auto* vllm = std::get_if(&batch)) { + for (const auto& decoded : vllm->events) { + if (!decoded.event.has_value()) continue; + if (const auto* stored = + std::get_if(&*decoded.event)) { + for (const auto& hash : stored->block_hashes) { + if (const auto* value = std::get_if(&hash)) { + present_hashes_.insert(*value); + } + } + } else if (const auto* removed = + std::get_if(&*decoded.event)) { + for (const auto& hash : removed->block_hashes) { + if (const auto* value = std::get_if(&hash)) { + present_hashes_.erase(*value); + } + } + } + } + } + return ""; + } + + void SetHandleError(std::string error) { + std::lock_guard lock(mu_); + handle_error_ = std::move(error); + } + + void OnSourceStale(const std::string& cache_pool_key, + const MessageMetadata& metadata, + const std::string& reason) override { + std::lock_guard lock(mu_); + ++stale_notifications_; + stale_cache_pool_key_ = cache_pool_key; + stale_metadata_ = metadata; + stale_reason_ = reason; + } + + std::optional FindBatch(int64_t sequence, + const std::string& endpoint) { + std::lock_guard lock(mu_); + for (const auto& handled : batches_) { + if (handled.metadata.sequence == sequence && + handled.metadata.endpoint == endpoint) { + return handled; + } + } + return std::nullopt; + } + + bool WaitForBatch(int64_t sequence, const std::string& endpoint, + std::chrono::milliseconds timeout) { + const auto deadline = std::chrono::steady_clock::now() + timeout; + while (std::chrono::steady_clock::now() < deadline) { + if (FindBatch(sequence, endpoint).has_value()) return true; + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + } + return FindBatch(sequence, endpoint).has_value(); + } + + size_t CountBatches(int64_t sequence, const std::string& endpoint) { + std::lock_guard lock(mu_); + return static_cast(std::count_if( + batches_.begin(), batches_.end(), [&](const HandledBatch& handled) { + return handled.metadata.sequence == sequence && + handled.metadata.endpoint == endpoint; + })); + } + + std::vector Sequences(const std::string& endpoint, + int64_t minimum = 0) { + std::lock_guard lock(mu_); + std::vector sequences; + for (const auto& handled : batches_) { + if (handled.metadata.endpoint == endpoint && + handled.metadata.sequence >= minimum) { + sequences.push_back(handled.metadata.sequence); + } + } + return sequences; + } + + bool ContainsHash(uint64_t hash) { + std::lock_guard lock(mu_); + return present_hashes_.contains(hash); + } + + size_t StaleNotificationCount() { + std::lock_guard lock(mu_); + return stale_notifications_; + } + + bool WasSourceMarkedStale(const std::string& cache_pool_key, + const std::string& endpoint, + int64_t last_sequence) { + std::lock_guard lock(mu_); + return stale_cache_pool_key_ == cache_pool_key && + stale_metadata_.endpoint == endpoint && + stale_metadata_.sequence == last_sequence && + !stale_reason_.empty(); + } + + private: + std::mutex mu_; + std::vector batches_; + std::unordered_set present_hashes_; + std::string handle_error_; + size_t stale_notifications_ = 0; + std::string stale_cache_pool_key_; + MessageMetadata stale_metadata_; + std::string stale_reason_; +}; + +std::string PackVllmStoredBatch(uint64_t hash, int64_t dp_rank = 3) { + std::stringstream buf; + msgpack::packer pk(buf); + pk.pack_array(3); + pk.pack_double(1.25); + pk.pack_array(1); + pk.pack_map(9); + pk.pack(std::string("type")); + pk.pack(std::string("BlockStored")); + pk.pack(std::string("block_hashes")); + pk.pack_array(1); + pk.pack_uint64(hash); + pk.pack(std::string("parent_block_hash")); + pk.pack_nil(); + pk.pack(std::string("token_ids")); + pk.pack_array(2); + pk.pack_int32(1); + pk.pack_int32(2); + pk.pack(std::string("block_size")); + pk.pack_int64(2); + pk.pack(std::string("lora_id")); + pk.pack_nil(); + pk.pack(std::string("medium")); + pk.pack(std::string("GPU")); + pk.pack(std::string("lora_name")); + pk.pack_nil(); + pk.pack(std::string("group_idx")); + pk.pack_int64(0); + pk.pack_int64(dp_rank); + return buf.str(); +} + +std::string PackVllmRemovedBatch(uint64_t hash, int64_t dp_rank = 3) { + std::stringstream buf; + msgpack::packer pk(buf); + pk.pack_array(3); + pk.pack_double(1.25); + pk.pack_array(1); + pk.pack_map(4); + pk.pack(std::string("type")); + pk.pack(std::string("BlockRemoved")); + pk.pack(std::string("block_hashes")); + pk.pack_array(1); + pk.pack_uint64(hash); + pk.pack(std::string("medium")); + pk.pack(std::string("GPU")); + pk.pack(std::string("group_idx")); + pk.pack_int64(0); + pk.pack_int64(dp_rank); + return buf.str(); +} + +std::string PackSglangStoredBatch(int64_t hash, int64_t dp_rank = 3) { + std::stringstream buf; + msgpack::packer pk(buf); + pk.pack_array(3); + pk.pack_double(1.25); + pk.pack_array(1); + pk.pack_array(7); + pk.pack(std::string("BlockStored")); + pk.pack_array(1); + pk.pack_int64(hash); + pk.pack_nil(); + pk.pack_array(2); + pk.pack_int32(1); + pk.pack_int32(2); + pk.pack_int64(2); + pk.pack_nil(); + pk.pack(std::string("GPU")); + pk.pack_int64(dp_rank); + return buf.str(); +} + +std::string PackMooncakeStoredBatch(uint64_t event_id, uint64_t hash, + int64_t batch_dp_rank = 7) { + constexpr int64_t kTimestampMilliseconds = 1700000000123; + std::stringstream buf; + msgpack::packer pk(buf); + pk.pack_array(3); + pk.pack_int64(kTimestampMilliseconds); + pk.pack_array(1); + pk.pack_map(24); + pk.pack(std::string("event_id")); + pk.pack_uint64(event_id); + pk.pack(std::string("timestamp")); + pk.pack_int64(kTimestampMilliseconds); + pk.pack(std::string("event_type")); + pk.pack(std::string("stored")); + pk.pack(std::string("type")); + pk.pack(std::string("BlockStored")); + pk.pack(std::string("model_name")); + pk.pack(std::string("test-model")); + pk.pack(std::string("block_size")); + pk.pack_int64(2); + pk.pack(std::string("additional_salt")); + pk.pack_nil(); + pk.pack(std::string("lora_name")); + pk.pack_nil(); + pk.pack(std::string("tenant_id")); + pk.pack(std::string("tenant-a")); + pk.pack(std::string("backend_id")); + pk.pack(std::string("backend-a")); + pk.pack(std::string("medium")); + pk.pack(std::string("cpu")); + pk.pack(std::string("dp_rank")); + pk.pack_int64(7); + pk.pack(std::string("group_id")); + pk.pack_nil(); + pk.pack(std::string("object_key")); + pk.pack(std::string("object-42")); + pk.pack(std::string("connector_block_hash")); + pk.pack(std::string("0001020304050607000000000000002a")); + pk.pack(std::string("cache_prefix")); + pk.pack(std::string("prefix")); + pk.pack(std::string("tp_rank")); + pk.pack_int64(1); + pk.pack(std::string("head_or_tp_rank")); + pk.pack_nil(); + pk.pack(std::string("pp_rank")); + pk.pack_int64(0); + pk.pack(std::string("seq_hashes")); + pk.pack_array(1); + pk.pack_uint64(hash); + pk.pack(std::string("base_block_idx")); + pk.pack_int64(0); + pk.pack(std::string("parent_hash")); + pk.pack_nil(); + pk.pack(std::string("parent_block_hash")); + pk.pack_nil(); + pk.pack(std::string("token_ids")); + pk.pack_nil(); + pk.pack_int64(batch_dp_rank); + return buf.str(); +} + +class MockPublisher { + public: + MockPublisher() + : ctx_(1), + pub_(ctx_, ::zmq::socket_type::pub), + router_(ctx_, ::zmq::socket_type::router) { + pub_.set(::zmq::sockopt::ipv6, 1); + pub_.bind("tcp://127.0.0.1:*"); + router_.set(::zmq::sockopt::ipv6, 1); + router_.bind("tcp://127.0.0.1:*"); + replay_thread_ = std::thread([this] { HandleReplay(); }); + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + } + + ~MockPublisher() { Close(); } + + void Close() { + if (!closed_.exchange(true)) { + if (replay_thread_.joinable()) replay_thread_.join(); + pub_.close(); + router_.close(); + } + } + + std::string PubEndpoint() { + return pub_.get(::zmq::sockopt::last_endpoint); + } + + std::string RouterEndpoint() { + return router_.get(::zmq::sockopt::last_endpoint); + } + + void Publish(const std::string& topic, const std::string& payload, + uint64_t sequence) { + unsigned char sequence_bytes[8]; + for (int index = 7; index >= 0; --index) { + sequence_bytes[index] = static_cast(sequence & 0xFF); + sequence >>= 8; + } + std::array<::zmq::const_buffer, 3> frames = { + ::zmq::buffer(topic), + ::zmq::buffer(sequence_bytes, sizeof(sequence_bytes)), + ::zmq::buffer(payload), + }; + ::zmq::send_multipart(pub_, frames); + } + + void PublishFrames(const std::vector& frames) { + std::vector<::zmq::const_buffer> buffers; + buffers.reserve(frames.size()); + for (const auto& frame : frames) { + buffers.push_back(::zmq::buffer(frame)); + } + ::zmq::send_multipart(pub_, buffers); + } + + size_t ReplayRequestCount() const { return replay_requests_.load(); } + + size_t ReplayEventCount() const { return replay_events_.load(); } + + void SetReplayEndDelay(std::chrono::milliseconds delay) { + replay_end_delay_ms_.store(delay.count()); + } + + void SetReplayMaxSequence(uint64_t sequence) { + replay_max_sequence_.store(sequence); + } + + uint64_t LastReplayFromSequence() const { + return last_replay_from_sequence_.load(); + } + + bool WaitForReplayRequests(size_t count, + std::chrono::milliseconds timeout) const { + const auto deadline = std::chrono::steady_clock::now() + timeout; + while (ReplayRequestCount() < count && + std::chrono::steady_clock::now() < deadline) { + std::this_thread::sleep_for(std::chrono::milliseconds(20)); + } + return ReplayRequestCount() >= count; + } + + private: + void HandleReplay() { + router_.set(::zmq::sockopt::rcvtimeo, 100); + while (!closed_.load()) { + std::vector<::zmq::message_t> frames; + const auto count = ::zmq::recv_multipart( + router_, std::back_inserter(frames), ::zmq::recv_flags::none); + if (!count) continue; + // REQ adds the empty delimiter automatically; Conductor's DEALER + // adds it explicitly. ROUTER therefore sees the vLLM shape below. + if (frames.size() != 3 || !frames[1].empty() || + frames[2].size() != 8) { + continue; + } + + const auto* bytes = + static_cast(frames[2].data()); + uint64_t from_sequence = 0; + for (int index = 0; index < 8; ++index) { + from_sequence = (from_sequence << 8) | bytes[index]; + } + last_replay_from_sequence_.store(from_sequence); + replay_requests_.fetch_add(1); + + const std::string empty; + const uint64_t max_sequence = replay_max_sequence_.load(); + for (uint64_t sequence = from_sequence; sequence <= max_sequence; + ++sequence) { + std::array sequence_bytes{}; + uint64_t value = sequence; + for (int index = 7; index >= 0; --index) { + sequence_bytes[index] = + static_cast(value & 0xFF); + value >>= 8; + } + const auto payload = PackVllmStoredBatch(sequence); + // Match vLLM/SGLang: DEALER receives + // [empty, sequence, payload] after ROUTER removes identity. + std::array<::zmq::const_buffer, 4> reply = { + ::zmq::buffer(frames[0].data(), frames[0].size()), + ::zmq::buffer(empty), + ::zmq::buffer(sequence_bytes), + ::zmq::buffer(payload), + }; + ::zmq::send_multipart(router_, reply); + replay_events_.fetch_add(1); + } + + const auto delay = replay_end_delay_ms_.load(); + if (delay > 0) { + std::this_thread::sleep_for(std::chrono::milliseconds(delay)); + } + std::array end_sequence{}; + end_sequence.fill(0xFF); + std::array<::zmq::const_buffer, 4> end = { + ::zmq::buffer(frames[0].data(), frames[0].size()), + ::zmq::buffer(empty), + ::zmq::buffer(end_sequence), + ::zmq::buffer(empty), + }; + ::zmq::send_multipart(router_, end); + } + } + + ::zmq::context_t ctx_; + ::zmq::socket_t pub_; + ::zmq::socket_t router_; + std::atomic closed_{false}; + std::atomic replay_requests_{0}; + std::atomic replay_events_{0}; + std::atomic replay_end_delay_ms_{0}; + std::atomic replay_max_sequence_{15}; + std::atomic last_replay_from_sequence_{0}; + std::thread replay_thread_; +}; + +ZMQClientConfig TestConfig(MockPublisher& publisher) { + ZMQClientConfig config; + config.cache_pool_key = "test-pod"; + config.endpoint = publisher.PubEndpoint(); + config.replay_endpoint = publisher.RouterEndpoint(); + config.model_name = "test-model"; + config.publisher_kind = PublisherKind::kVllm; + config.poll_timeout = std::chrono::milliseconds(100); + config.replay_timeout = std::chrono::milliseconds(1000); + config.reconnect_delay = std::chrono::milliseconds(100); + return config; +} + +template +bool PublishUntilHandled(MockEventHandler& handler, int64_t sequence, + const std::string& endpoint, Publish publish) { + for (int attempt = 0; attempt < 20; ++attempt) { + publish(); + if (handler.WaitForBatch(sequence, endpoint, + std::chrono::milliseconds(200))) { + return true; + } + } + return false; +} + +bool WaitForStale(ZMQClient& client, std::chrono::milliseconds timeout) { + const auto deadline = std::chrono::steady_clock::now() + timeout; + while (!client.IsStale() && std::chrono::steady_clock::now() < deadline) { + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + } + return client.IsStale(); +} + +const VllmStoredEvent* GetVllmStored(const HandledBatch& handled) { + const auto* batch = std::get_if(&handled.batch); + if (batch == nullptr || batch->events.size() != 1 || + !batch->events[0].event.has_value()) { + return nullptr; + } + return std::get_if(&*batch->events[0].event); +} + +const MooncakeStoredEvent* GetMooncakeStored(const HandledBatch& handled) { + const auto* batch = std::get_if(&handled.batch); + if (batch == nullptr || batch->events.size() != 1 || + !batch->events[0].event.has_value()) { + return nullptr; + } + return std::get_if(&*batch->events[0].event); +} + +TEST(ValidateConfig, RequiresOnlyLiveEndpoint) { + ZMQClientConfig config; + EXPECT_FALSE(ValidateConfig(config).empty()); + config.endpoint = "tcp://127.0.0.1:5557"; + config.replay_endpoint.clear(); + EXPECT_TRUE(ValidateConfig(config).empty()); +} + +TEST(ZMQClient, ConnectSuccessWithReplaySocket) { + MockPublisher publisher; + auto handler = std::make_shared(); + ZMQClient client(TestConfig(publisher), handler); + EXPECT_EQ(client.Connect(), ""); + EXPECT_TRUE(ZMQClientTestPeer::HasReplaySocket(client)); + client.Stop(); +} + +TEST(ZMQClient, MooncakeIgnoresVllmReplayEndpoint) { + MockPublisher publisher; + auto handler = std::make_shared(); + auto config = TestConfig(publisher); + config.publisher_kind = PublisherKind::kMooncake; + ZMQClient client(config, handler); + ASSERT_EQ(client.Connect(), ""); + EXPECT_FALSE(ZMQClientTestPeer::HasReplaySocket(client)); + client.Stop(); +} + +TEST(ZMQClient, SglangEnablesReplaySocket) { + MockPublisher publisher; + auto handler = std::make_shared(); + auto config = TestConfig(publisher); + config.publisher_kind = PublisherKind::kSglang; + ZMQClient client(config, handler); + ASSERT_EQ(client.Connect(), ""); + EXPECT_TRUE(ZMQClientTestPeer::HasReplaySocket(client)); + client.Stop(); +} + +TEST(ZMQClient, SglangRoutesNativeAndMooncakeFallbackEnvelopes) { + MockPublisher publisher; + auto handler = std::make_shared(); + auto config = TestConfig(publisher); + config.publisher_kind = PublisherKind::kSglang; + const std::string endpoint = config.endpoint; + ZMQClient client(config, handler); + ASSERT_EQ(client.Start(), ""); + + ASSERT_TRUE(PublishUntilHandled(*handler, 10, endpoint, [&] { + publisher.Publish("", PackSglangStoredBatch(-42), 10); + })); + const auto native = handler->FindBatch(10, endpoint); + ASSERT_TRUE(native.has_value()); + EXPECT_TRUE( + std::holds_alternative( + native->batch)); + EXPECT_EQ(native->metadata.publisher_kind, PublisherKind::kSglang); + + ASSERT_TRUE(PublishUntilHandled(*handler, 11, endpoint, [&] { + publisher.Publish("", PackMooncakeStoredBatch(9001, 42), 11); + })); + const auto fallback = handler->FindBatch(11, endpoint); + ASSERT_TRUE(fallback.has_value()); + const auto* stored = GetMooncakeStored(*fallback); + ASSERT_NE(stored, nullptr); + EXPECT_EQ(stored->fields.event_id, 9001u); + EXPECT_EQ(fallback->metadata.publisher_kind, PublisherKind::kSglang); + client.Stop(); +} + +TEST(ZMQClient, ConnectAlreadyConnectedIsNoop) { + MockPublisher publisher; + auto handler = std::make_shared(); + ZMQClient client(TestConfig(publisher), handler); + EXPECT_EQ(client.Connect(), ""); + EXPECT_EQ(client.Connect(), ""); + EXPECT_TRUE(ZMQClientTestPeer::HasReplaySocket(client)); + client.Stop(); +} + +TEST(ZMQClient, StartStopGracefulWithReplayConfigured) { + MockPublisher publisher; + auto handler = std::make_shared(); + ZMQClient client(TestConfig(publisher), handler); + EXPECT_EQ(client.Start(), ""); + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + + const auto start = std::chrono::steady_clock::now(); + client.Stop(); + EXPECT_LT(std::chrono::steady_clock::now() - start, + std::chrono::seconds(2)); +} + +TEST(ZMQClient, StopIsIdempotentWithReplayConfigured) { + MockPublisher publisher; + auto handler = std::make_shared(); + ZMQClient client(TestConfig(publisher), handler); + ASSERT_EQ(client.Start(), ""); + client.Stop(); + client.Stop(); +} + +TEST(ZMQClient, TruncatedMultipartDoesNotBlockStop) { + MockPublisher publisher; + auto handler = std::make_shared(); + ZMQClient client(TestConfig(publisher), handler); + ASSERT_EQ(client.Start(), ""); + + ASSERT_TRUE(PublishUntilHandled(*handler, 76, publisher.PubEndpoint(), [&] { + publisher.Publish("", PackVllmStoredBatch(76), 76); + })); + + publisher.PublishFrames({"truncated"}); + std::this_thread::sleep_for(std::chrono::milliseconds(250)); + + auto stopped = std::async(std::launch::async, [&] { client.Stop(); }); + const auto status = stopped.wait_for(std::chrono::milliseconds(500)); + if (status != std::future_status::ready) { + // Release an old three-recv implementation so a regression reports a + // failure instead of hanging the entire test process. + publisher.PublishFrames({"12345678", "rescue"}); + } + EXPECT_EQ(status, std::future_status::ready); + stopped.get(); +} + +TEST(ZMQClient, ExtraMultipartFramesAreDrainedBeforeNextMessage) { + MockPublisher publisher; + auto handler = std::make_shared(); + ZMQClient client(TestConfig(publisher), handler); + ASSERT_EQ(client.Start(), ""); + + for (int attempt = 0; attempt < 10; ++attempt) { + publisher.PublishFrames({"topic", "12345678", "payload", "extra"}); + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + } + ASSERT_TRUE(PublishUntilHandled(*handler, 77, publisher.PubEndpoint(), [&] { + publisher.Publish("", PackVllmStoredBatch(77), 77); + })); + + EXPECT_TRUE(handler->FindBatch(77, publisher.PubEndpoint()).has_value()); + client.Stop(); +} + +TEST(ZMQClient, VllmKindRoutesEmptyAndMisleadingTopics) { + MockPublisher publisher; + auto handler = std::make_shared(); + const auto config = TestConfig(publisher); + const std::string endpoint = config.endpoint; + ZMQClient client(config, handler); + ASSERT_EQ(client.Start(), ""); + + const auto empty_topic_payload = PackVllmStoredBatch(42); + ASSERT_TRUE(PublishUntilHandled(*handler, 41, endpoint, [&] { + publisher.Publish("", empty_topic_payload, 41); + })); + const auto misleading_topic_payload = PackVllmStoredBatch(43); + ASSERT_TRUE(PublishUntilHandled(*handler, 42, endpoint, [&] { + publisher.Publish("mooncake", misleading_topic_payload, 42); + })); + + const auto empty_topic = handler->FindBatch(41, endpoint); + ASSERT_TRUE(empty_topic.has_value()); + EXPECT_EQ(empty_topic->metadata.publisher_kind, PublisherKind::kVllm); + EXPECT_EQ(empty_topic->metadata.endpoint, endpoint); + EXPECT_EQ(empty_topic->metadata.topic, ""); + EXPECT_EQ(empty_topic->metadata.sequence, 41); + const auto* empty_batch = std::get_if(&empty_topic->batch); + ASSERT_NE(empty_batch, nullptr); + EXPECT_DOUBLE_EQ(empty_batch->timestamp_seconds, 1.25); + EXPECT_EQ(empty_batch->data_parallel_rank, std::optional(3)); + const auto* empty_stored = GetVllmStored(*empty_topic); + ASSERT_NE(empty_stored, nullptr); + ASSERT_EQ(empty_stored->block_hashes.size(), 1u); + EXPECT_EQ(std::get(empty_stored->block_hashes[0]), 42u); + + const auto misleading_topic = handler->FindBatch(42, endpoint); + ASSERT_TRUE(misleading_topic.has_value()); + EXPECT_EQ(misleading_topic->metadata.publisher_kind, PublisherKind::kVllm); + EXPECT_EQ(misleading_topic->metadata.endpoint, endpoint); + EXPECT_EQ(misleading_topic->metadata.topic, "mooncake"); + EXPECT_EQ(misleading_topic->metadata.sequence, 42); + const auto* misleading_stored = GetVllmStored(*misleading_topic); + ASSERT_NE(misleading_stored, nullptr); + ASSERT_EQ(misleading_stored->block_hashes.size(), 1u); + EXPECT_EQ(std::get(misleading_stored->block_hashes[0]), 43u); + client.Stop(); +} + +TEST(ZMQClient, EqualTopicsOnDistinctEndpointsPreserveProvenance) { + MockPublisher publisher_a; + MockPublisher publisher_b; + auto handler = std::make_shared(); + auto config_a = TestConfig(publisher_a); + auto config_b = TestConfig(publisher_b); + config_a.cache_pool_key = "instance-a"; + config_b.cache_pool_key = "instance-b"; + const std::string endpoint_a = config_a.endpoint; + const std::string endpoint_b = config_b.endpoint; + ASSERT_NE(endpoint_a, endpoint_b); + ZMQClient client_a(config_a, handler); + ZMQClient client_b(config_b, handler); + ASSERT_EQ(client_a.Start(), ""); + ASSERT_EQ(client_b.Start(), ""); + + const auto payload_a = PackVllmStoredBatch(101); + const auto payload_b = PackVllmStoredBatch(202); + ASSERT_TRUE(PublishUntilHandled(*handler, 77, endpoint_a, [&] { + publisher_a.Publish("shared-topic", payload_a, 77); + })); + ASSERT_TRUE(PublishUntilHandled(*handler, 77, endpoint_b, [&] { + publisher_b.Publish("shared-topic", payload_b, 77); + })); + + const auto handled_a = handler->FindBatch(77, endpoint_a); + const auto handled_b = handler->FindBatch(77, endpoint_b); + ASSERT_TRUE(handled_a.has_value()); + ASSERT_TRUE(handled_b.has_value()); + EXPECT_EQ(handled_a->metadata.topic, "shared-topic"); + EXPECT_EQ(handled_b->metadata.topic, "shared-topic"); + EXPECT_EQ(handled_a->metadata.publisher_kind, PublisherKind::kVllm); + EXPECT_EQ(handled_b->metadata.publisher_kind, PublisherKind::kVllm); + const auto* stored_a = GetVllmStored(*handled_a); + const auto* stored_b = GetVllmStored(*handled_b); + ASSERT_NE(stored_a, nullptr); + ASSERT_NE(stored_b, nullptr); + EXPECT_EQ(std::get(stored_a->block_hashes[0]), 101u); + EXPECT_EQ(std::get(stored_b->block_hashes[0]), 202u); + client_a.Stop(); + client_b.Stop(); +} + +TEST(ZMQClient, MooncakeLiveOnlyConsumesEmptyAndMisleadingTopics) { + MockPublisher publisher; + auto handler = std::make_shared(); + auto config = TestConfig(publisher); + config.publisher_kind = PublisherKind::kMooncake; + config.replay_endpoint.clear(); + const std::string endpoint = config.endpoint; + ZMQClient client(config, handler); + ASSERT_EQ(client.Start(), ""); + EXPECT_FALSE(ZMQClientTestPeer::HasReplaySocket(client)); + + const auto empty_topic_payload = PackMooncakeStoredBatch(9001, 42); + ASSERT_TRUE(PublishUntilHandled(*handler, 101, endpoint, [&] { + publisher.Publish("", empty_topic_payload, 101); + })); + const auto misleading_topic_payload = PackMooncakeStoredBatch(9002, 43); + ASSERT_TRUE(PublishUntilHandled(*handler, 102, endpoint, [&] { + publisher.Publish("vllm", misleading_topic_payload, 102); + })); + + const auto empty_topic = handler->FindBatch(101, endpoint); + ASSERT_TRUE(empty_topic.has_value()); + EXPECT_EQ(empty_topic->metadata.publisher_kind, PublisherKind::kMooncake); + EXPECT_EQ(empty_topic->metadata.endpoint, endpoint); + EXPECT_EQ(empty_topic->metadata.topic, ""); + EXPECT_EQ(empty_topic->metadata.sequence, 101); + const auto* empty_batch = + std::get_if(&empty_topic->batch); + ASSERT_NE(empty_batch, nullptr); + EXPECT_EQ(empty_batch->timestamp_milliseconds, 1700000000123); + EXPECT_EQ(empty_batch->data_parallel_rank, std::optional(7)); + const auto* empty_stored = GetMooncakeStored(*empty_topic); + ASSERT_NE(empty_stored, nullptr); + EXPECT_EQ(empty_stored->fields.event_id, 9001u); + EXPECT_EQ(empty_stored->fields.backend_id, "backend-a"); + EXPECT_EQ(empty_stored->object.object_key, + std::optional("object-42")); + EXPECT_EQ(empty_stored->object.seq_hashes, (std::vector{42})); + EXPECT_FALSE(empty_stored->parent_hash.has_value()); + EXPECT_FALSE(empty_stored->token_ids.has_value()); + + const auto misleading_topic = handler->FindBatch(102, endpoint); + ASSERT_TRUE(misleading_topic.has_value()); + EXPECT_EQ(misleading_topic->metadata.publisher_kind, + PublisherKind::kMooncake); + EXPECT_EQ(misleading_topic->metadata.endpoint, endpoint); + EXPECT_EQ(misleading_topic->metadata.topic, "vllm"); + EXPECT_EQ(misleading_topic->metadata.sequence, 102); + const auto* misleading_stored = GetMooncakeStored(*misleading_topic); + ASSERT_NE(misleading_stored, nullptr); + EXPECT_EQ(misleading_stored->fields.event_id, 9002u); + EXPECT_EQ(misleading_stored->object.seq_hashes, + (std::vector{43})); + EXPECT_EQ(publisher.ReplayRequestCount(), 0u); + + const auto start = std::chrono::steady_clock::now(); + client.Stop(); + EXPECT_LT(std::chrono::steady_clock::now() - start, + std::chrono::seconds(2)); + EXPECT_FALSE(ZMQClientTestPeer::HasReplaySocket(client)); +} + +TEST(ZMQClient, SequenceTrackingWithReplayConfigured) { + MockPublisher publisher; + auto handler = std::make_shared(); + const auto config = TestConfig(publisher); + const std::string endpoint = config.endpoint; + ZMQClient client(config, handler); + ASSERT_EQ(client.Start(), ""); + EXPECT_EQ(client.GetLastSequence(), -1); + + const auto first_payload = PackVllmStoredBatch(1); + ASSERT_TRUE(PublishUntilHandled(*handler, 10, endpoint, [&] { + publisher.Publish("", first_payload, 10); + })); + for (uint64_t sequence = 11; sequence <= 14; ++sequence) { + publisher.Publish("", PackVllmStoredBatch(sequence), sequence); + } + ASSERT_TRUE(handler->WaitForBatch(14, endpoint, std::chrono::seconds(2))); + EXPECT_EQ(client.GetLastSequence(), 14); + EXPECT_EQ(publisher.ReplayRequestCount(), 0u); + client.Stop(); +} + +TEST(ZMQClient, EventGapReplaysMissingMessagesBeforeLiveMessage) { + MockPublisher publisher; + auto handler = std::make_shared(); + const auto config = TestConfig(publisher); + const std::string endpoint = config.endpoint; + ZMQClient client(config, handler); + ASSERT_EQ(client.Start(), ""); + + const auto first_payload = PackVllmStoredBatch(1); + ASSERT_TRUE(PublishUntilHandled(*handler, 10, endpoint, [&] { + publisher.Publish("", first_payload, 10); + })); + EXPECT_EQ(client.GetDroppedEvents(), 0); + EXPECT_EQ(client.GetGapCount(), 0); + + publisher.Publish("", PackVllmStoredBatch(2), 15); + for (int64_t sequence = 11; sequence <= 14; ++sequence) { + ASSERT_TRUE( + handler->WaitForBatch(sequence, endpoint, std::chrono::seconds(2))); + } + ASSERT_TRUE(handler->WaitForBatch(15, endpoint, std::chrono::seconds(2))); + EXPECT_EQ(client.GetLastSequence(), 15); + EXPECT_EQ(publisher.ReplayRequestCount(), 1u); + EXPECT_EQ(publisher.ReplayEventCount(), 5u); + EXPECT_EQ(handler->CountBatches(15, endpoint), 1u); + EXPECT_EQ(handler->Sequences(endpoint, 11), + (std::vector{11, 12, 13, 14, 15})); + EXPECT_EQ(client.GetDroppedEvents(), 4); + EXPECT_EQ(client.GetGapCount(), 1); + client.Stop(); +} + +TEST(ZMQClient, ReplayTimeoutBuffersLiveBoundaryAndPreservesOrder) { + MockPublisher publisher; + auto handler = std::make_shared(); + auto config = TestConfig(publisher); + config.replay_timeout = std::chrono::milliseconds(100); + config.replay_recovery_timeout = std::chrono::seconds(2); + const std::string endpoint = config.endpoint; + ZMQClient client(config, handler); + ASSERT_EQ(client.Start(), ""); + + ASSERT_TRUE(PublishUntilHandled(*handler, 10, endpoint, [&] { + publisher.Publish("", PackVllmStoredBatch(1), 10); + })); + + publisher.SetReplayEndDelay(std::chrono::milliseconds(150)); + publisher.Publish("", PackVllmRemovedBatch(11), 12); + ASSERT_TRUE(publisher.WaitForReplayRequests(1, std::chrono::seconds(1))); + EXPECT_FALSE( + handler->WaitForBatch(12, endpoint, std::chrono::milliseconds(50))); + EXPECT_EQ(client.GetLastSequence(), 10); + + publisher.SetReplayEndDelay(std::chrono::milliseconds(0)); + ASSERT_TRUE(handler->WaitForBatch(12, endpoint, std::chrono::seconds(2))); + EXPECT_EQ(handler->Sequences(endpoint, 11), (std::vector{11, 12})); + EXPECT_EQ(handler->CountBatches(11, endpoint), 1u); + EXPECT_EQ(handler->CountBatches(12, endpoint), 1u); + EXPECT_FALSE(handler->ContainsHash(11)); + EXPECT_GE(publisher.ReplayRequestCount(), 2u); + EXPECT_EQ(client.GetLastSequence(), 12); + EXPECT_FALSE(client.IsStale()); + client.Stop(); +} + +TEST(ZMQClient, PendingLiveBoundaryIsRecoveredAcrossReconnect) { + MockPublisher publisher; + auto handler = std::make_shared(); + auto config = TestConfig(publisher); + config.replay_timeout = std::chrono::milliseconds(100); + config.replay_recovery_timeout = std::chrono::seconds(2); + const std::string endpoint = config.endpoint; + ZMQClient client(config, handler); + ASSERT_EQ(client.Start(), ""); + + ASSERT_TRUE(PublishUntilHandled(*handler, 10, endpoint, [&] { + publisher.Publish("", PackVllmStoredBatch(1), 10); + })); + + publisher.SetReplayEndDelay(std::chrono::milliseconds(150)); + publisher.Publish("", PackVllmStoredBatch(2), 15); + ASSERT_TRUE(publisher.WaitForReplayRequests(1, std::chrono::seconds(1))); + EXPECT_FALSE( + handler->WaitForBatch(15, endpoint, std::chrono::milliseconds(50))); + + publisher.SetReplayEndDelay(std::chrono::milliseconds(0)); + publisher.SetReplayMaxSequence(15); + ZMQClientTestPeer::MarkDisconnected(client); + ASSERT_TRUE(handler->WaitForBatch(15, endpoint, std::chrono::seconds(3))); + + for (int64_t sequence = 11; sequence <= 14; ++sequence) { + ASSERT_TRUE( + handler->WaitForBatch(sequence, endpoint, std::chrono::seconds(2))); + } + EXPECT_EQ(handler->CountBatches(15, endpoint), 1u); + EXPECT_EQ(handler->Sequences(endpoint, 11), + (std::vector{11, 12, 13, 14, 15})); + EXPECT_EQ(client.GetLastSequence(), 15); + EXPECT_GE(publisher.ReplayRequestCount(), 2u); + EXPECT_EQ(publisher.LastReplayFromSequence(), 11u); + EXPECT_FALSE(client.IsStale()); + client.Stop(); +} + +TEST(ZMQClient, UnrecoverableGapMarksSourceStaleWithoutApplyingBoundary) { + MockPublisher publisher; + auto handler = std::make_shared(); + auto config = TestConfig(publisher); + const std::string endpoint = config.endpoint; + ZMQClient client(config, handler); + ASSERT_EQ(client.Start(), ""); + + ASSERT_TRUE(PublishUntilHandled(*handler, 10, endpoint, [&] { + publisher.Publish("", PackVllmStoredBatch(1), 10); + })); + publisher.SetReplayMaxSequence(10); + publisher.Publish("", PackVllmStoredBatch(2), 15); + + const auto deadline = + std::chrono::steady_clock::now() + std::chrono::seconds(2); + while (!client.IsStale() && std::chrono::steady_clock::now() < deadline) { + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + } + EXPECT_TRUE(client.IsStale()); + EXPECT_FALSE(client.GetStaleReason().empty()); + EXPECT_EQ(handler->StaleNotificationCount(), 1u); + EXPECT_TRUE(handler->WasSourceMarkedStale("test-pod", endpoint, 10)); + EXPECT_FALSE(handler->FindBatch(15, endpoint).has_value()); + EXPECT_EQ(client.GetLastSequence(), 10); + client.Stop(); +} + +TEST(ZMQClient, RecoveryDeadlineMarksSourceStale) { + MockPublisher publisher; + auto handler = std::make_shared(); + auto config = TestConfig(publisher); + config.replay_timeout = std::chrono::milliseconds(50); + config.replay_recovery_timeout = std::chrono::milliseconds(150); + const std::string endpoint = config.endpoint; + ZMQClient client(config, handler); + ASSERT_EQ(client.Start(), ""); + + ASSERT_TRUE(PublishUntilHandled(*handler, 10, endpoint, [&] { + publisher.Publish("", PackVllmStoredBatch(1), 10); + })); + publisher.SetReplayEndDelay(std::chrono::milliseconds(500)); + publisher.Publish("", PackVllmStoredBatch(2), 15); + + const auto deadline = + std::chrono::steady_clock::now() + std::chrono::seconds(2); + while (!client.IsStale() && std::chrono::steady_clock::now() < deadline) { + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + } + EXPECT_TRUE(client.IsStale()); + EXPECT_EQ(handler->StaleNotificationCount(), 1u); + EXPECT_FALSE(handler->FindBatch(15, endpoint).has_value()); + EXPECT_EQ(client.GetLastSequence(), 10); + client.Stop(); +} + +TEST(ZMQClient, RecoveryBufferLimitMarksSourceStale) { + MockPublisher publisher; + auto handler = std::make_shared(); + auto config = TestConfig(publisher); + config.max_recovery_buffered_messages = 3; + const std::string endpoint = config.endpoint; + ZMQClient client(config, handler); + ASSERT_EQ(client.Start(), ""); + + ASSERT_TRUE(PublishUntilHandled(*handler, 10, endpoint, [&] { + publisher.Publish("", PackVllmStoredBatch(1), 10); + })); + publisher.Publish("", PackVllmStoredBatch(2), 15); + + const auto deadline = + std::chrono::steady_clock::now() + std::chrono::seconds(2); + while (!client.IsStale() && std::chrono::steady_clock::now() < deadline) { + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + } + EXPECT_TRUE(client.IsStale()); + EXPECT_EQ(handler->StaleNotificationCount(), 1u); + EXPECT_FALSE(handler->FindBatch(15, endpoint).has_value()); + EXPECT_EQ(client.GetLastSequence(), 10); + client.Stop(); +} + +TEST(ZMQClient, EventGapMarksSourceStaleWhenReplayUnavailable) { + MockPublisher publisher; + auto handler = std::make_shared(); + auto config = TestConfig(publisher); + config.replay_endpoint.clear(); + const std::string endpoint = config.endpoint; + ZMQClient client(config, handler); + ASSERT_EQ(client.Start(), ""); + + const auto first_payload = PackVllmStoredBatch(1); + ASSERT_TRUE(PublishUntilHandled(*handler, 10, endpoint, [&] { + publisher.Publish("", first_payload, 10); + })); + publisher.Publish("", PackVllmStoredBatch(2), 13); + const auto deadline = + std::chrono::steady_clock::now() + std::chrono::seconds(2); + while (!client.IsStale() && std::chrono::steady_clock::now() < deadline) { + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + } + EXPECT_TRUE(client.IsStale()); + EXPECT_FALSE(handler->FindBatch(13, endpoint).has_value()); + EXPECT_EQ(handler->StaleNotificationCount(), 1u); + EXPECT_EQ(client.GetLastSequence(), 10); + EXPECT_EQ(publisher.ReplayRequestCount(), 0u); + EXPECT_EQ(client.GetDroppedEvents(), 2); + EXPECT_EQ(client.GetGapCount(), 1); + client.Stop(); +} + +TEST(ZMQClient, HandlerFailureMarksLiveOnlySourceStaleWithoutAdvancing) { + MockPublisher publisher; + auto handler = std::make_shared(); + handler->SetHandleError("index write failed"); + auto config = TestConfig(publisher); + config.replay_endpoint.clear(); + const std::string endpoint = config.endpoint; + ZMQClient client(config, handler); + ASSERT_EQ(client.Start(), ""); + + for (int attempt = 0; attempt < 20 && !client.IsStale(); ++attempt) { + publisher.Publish("", PackVllmStoredBatch(1), 10); + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + } + + ASSERT_TRUE(WaitForStale(client, std::chrono::seconds(1))); + EXPECT_EQ(client.GetLastSequence(), -1); + EXPECT_EQ(handler->StaleNotificationCount(), 1u); + EXPECT_TRUE(handler->WasSourceMarkedStale("test-pod", endpoint, -1)); + EXPECT_NE(client.GetStaleReason().find( + "event handler failed: index write failed"), + std::string::npos); + EXPECT_FALSE(handler->FindBatch(10, endpoint).has_value()); + client.Stop(); +} + +TEST(ZMQClient, HandlerFailureMarksReplaySourceStaleWithoutAdvancing) { + MockPublisher publisher; + auto handler = std::make_shared(); + handler->SetHandleError("index write failed"); + auto config = TestConfig(publisher); + const std::string endpoint = config.endpoint; + ZMQClient client(config, handler); + ASSERT_EQ(client.Start(), ""); + + for (int attempt = 0; attempt < 20 && !client.IsStale(); ++attempt) { + publisher.Publish("", PackVllmStoredBatch(1), 10); + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + } + + ASSERT_TRUE(WaitForStale(client, std::chrono::seconds(1))); + EXPECT_EQ(client.GetLastSequence(), -1); + EXPECT_EQ(handler->StaleNotificationCount(), 1u); + EXPECT_TRUE(handler->WasSourceMarkedStale("test-pod", endpoint, -1)); + EXPECT_NE(client.GetStaleReason().find( + "event handler failed: index write failed"), + std::string::npos); + EXPECT_FALSE(handler->FindBatch(10, endpoint).has_value()); + client.Stop(); +} + +TEST(ZMQClient, ReconnectRequestsReplayFromNextSequence) { + MockPublisher publisher; + auto handler = std::make_shared(); + ZMQClient client(TestConfig(publisher), handler); + ASSERT_EQ(client.Connect(), ""); + ASSERT_TRUE(ZMQClientTestPeer::HasReplaySocket(client)); + ZMQClientTestPeer::SetLastSequence(client, 10); + + ZMQClientTestPeer::MarkDisconnected(client); + ZMQClientTestPeer::HandleReconnect(client); + + EXPECT_TRUE(ZMQClientTestPeer::IsConnected(client)); + ASSERT_TRUE(publisher.WaitForReplayRequests(1, std::chrono::seconds(2))); + for (int64_t sequence = 11; sequence <= 15; ++sequence) { + ASSERT_TRUE(handler->WaitForBatch(sequence, publisher.PubEndpoint(), + std::chrono::seconds(2))); + } + EXPECT_EQ(publisher.ReplayRequestCount(), 1u); + EXPECT_EQ(publisher.LastReplayFromSequence(), 11u); + EXPECT_EQ(publisher.ReplayEventCount(), 5u); + EXPECT_EQ(client.GetLastSequence(), 15); + + publisher.Publish("", PackVllmStoredBatch(15), 15); + std::this_thread::sleep_for(std::chrono::milliseconds(200)); + EXPECT_EQ(handler->CountBatches(15, publisher.PubEndpoint()), 1u); + client.Stop(); +} + +TEST(ZMQClient, FailedReconnectDoesNotRequestReplayUntilSuccess) { + MockPublisher publisher; + auto handler = std::make_shared(); + ZMQClient client(TestConfig(publisher), handler); + ZMQClientTestPeer::SetLastSequence(client, 10); + + ZMQClientTestPeer::SetEndpoint(client, "not-a-valid-zmq-endpoint"); + ZMQClientTestPeer::HandleReconnect(client); + EXPECT_FALSE(ZMQClientTestPeer::IsConnected(client)); + EXPECT_FALSE(ZMQClientTestPeer::HasReplaySocket(client)); + EXPECT_EQ(publisher.ReplayRequestCount(), 0u); + + ZMQClientTestPeer::SetEndpoint(client, publisher.PubEndpoint()); + ZMQClientTestPeer::HandleReconnect(client); + EXPECT_TRUE(ZMQClientTestPeer::IsConnected(client)); + EXPECT_TRUE(ZMQClientTestPeer::HasReplaySocket(client)); + ASSERT_TRUE(publisher.WaitForReplayRequests(1, std::chrono::seconds(2))); + EXPECT_EQ(publisher.ReplayRequestCount(), 1u); + EXPECT_EQ(publisher.LastReplayFromSequence(), 11u); + client.Stop(); +} + +} // namespace diff --git a/mooncake-ep/BuildEpExt.cmake b/mooncake-ep/BuildEpExt.cmake deleted file mode 100644 index 4a5a661ed4..0000000000 --- a/mooncake-ep/BuildEpExt.cmake +++ /dev/null @@ -1,110 +0,0 @@ -# BuildEpExt.cmake - Build the Mooncake EP Python extension. -# -# Invoked at build time via cmake -P from the root CMakeLists.txt when -# WITH_EP=ON. Variables are passed with -D from the custom target: -# -# SOURCE_DIR - mooncake-ep source directory -# EP_CUDA_MAJOR - CUDA major version (integer) -# EP_TORCH_VERSIONS - pipe-separated (|) PyTorch versions to build for -# (empty = use the currently-installed torch) -# TORCH_CUDA_ARCH_LIST - pipe-separated CUDA arch list forwarded to torch -# STAGING_DIR - destination directory for the built .so files -# ENGINE_SO_PATH - absolute path to the built engine.cpython-XYZ.so -# EP_USE_MUSA - set to "1" when building for MUSA (MTLink path) -# EP_USE_MACA - set to "1" when building for MACA (MTLink path) - -cmake_minimum_required(VERSION 3.16) - -# Include common build utilities. -include("${SOURCE_DIR}/../mooncake-common/SetupPyTorchEnv.cmake") - -# Restore pipe-separated strings back to CMake semicolon-separated lists. -if(EP_TORCH_VERSIONS) - string(REPLACE "|" ";" EP_TORCH_VERSIONS "${EP_TORCH_VERSIONS}") -endif() -if(TORCH_CUDA_ARCH_LIST) - string(REPLACE "|" ";" TORCH_CUDA_ARCH_LIST "${TORCH_CUDA_ARCH_LIST}") -endif() - -# --------------------------------------------------------------------------- -# 1. Set up the build environment. -# --------------------------------------------------------------------------- -# Clear jobserver variables so that sub-processes started by setup.py do not -# try to connect to the parent ninja's jobserver pipe FDs, which are not -# inherited and cause: "ninja: error: Could not initialize jobserver: Invalid -# file descriptors". -set(ENV{MAKEFLAGS} "") -set(ENV{MFLAGS} "") -set(ENV{TORCH_CUDA_ARCH_LIST} "${TORCH_CUDA_ARCH_LIST}") -if(EP_USE_MUSA) - set(ENV{MOONCAKE_EP_USE_MUSA} "1") -else() - unset(ENV{MOONCAKE_EP_USE_MUSA}) -endif() -if(EP_USE_MACA) - set(ENV{MOONCAKE_EP_USE_MACA} "1") - if(DEFINED ENV{MACA_PATH}) - set(ENV{MACA_HOME} "$ENV{MACA_PATH}") - elseif(DEFINED ENV{MACA_HOME}) - set(ENV{MACA_PATH} "$ENV{MACA_HOME}") - endif() -else() - unset(ENV{MOONCAKE_EP_USE_MACA}) -endif() - -# --------------------------------------------------------------------------- -# 2. Ensure engine.so exists in mooncake-wheel/mooncake/ for setup.py linking. -# --------------------------------------------------------------------------- -# setup.py links against -l:engine.so in ../mooncake-wheel/mooncake/. -# During the make phase only the versioned engine.cpython-XYZ.so exists in -# the build tree; create a bare engine.so symlink so the linker can find it. -set(_wheel_mooncake_dir "${SOURCE_DIR}/../mooncake-wheel/mooncake") -set(_engine_symlink "${_wheel_mooncake_dir}/engine.so") -if(ENGINE_SO_PATH AND NOT EXISTS "${_engine_symlink}") - message(STATUS "[EP] Creating engine.so symlink -> ${ENGINE_SO_PATH}") - execute_process( - COMMAND ${CMAKE_COMMAND} -E create_symlink "${ENGINE_SO_PATH}" "${_engine_symlink}" - ) -endif() - -# --------------------------------------------------------------------------- -# 3. Build the EP Python extension. -# --------------------------------------------------------------------------- -if("${EP_TORCH_VERSIONS}" STREQUAL "") - message(STATUS "[EP] Building with currently-installed PyTorch") - execute_process( - COMMAND ${Python3_EXECUTABLE} setup.py build_ext --build-lib . - WORKING_DIRECTORY "${SOURCE_DIR}" - RESULT_VARIABLE _ret - ) - if(NOT _ret EQUAL 0) - message(FATAL_ERROR "[EP] Extension build failed (exit code: ${_ret})") - endif() -else() - message(STATUS "[EP] Building for PyTorch versions: ${EP_TORCH_VERSIONS}") - foreach(_version IN LISTS EP_TORCH_VERSIONS) - install_pytorch_wheel("${_version}" "${EP_CUDA_MAJOR}" "${EP_CUDA_MINOR}" "[EP]") - - execute_process( - COMMAND ${Python3_EXECUTABLE} setup.py build_ext --build-lib . --force - WORKING_DIRECTORY "${SOURCE_DIR}" - RESULT_VARIABLE _ret - ) - if(NOT _ret EQUAL 0) - message(FATAL_ERROR "[EP] Extension build failed for PyTorch ${_version}") - endif() - endforeach() -endif() - -# --------------------------------------------------------------------------- -# 4. Copy the built .so files to the staging directory. -# --------------------------------------------------------------------------- -file(MAKE_DIRECTORY "${STAGING_DIR}") -file(GLOB _so_files "${SOURCE_DIR}/mooncake/*.so") -foreach(_so IN LISTS _so_files) - get_filename_component(_fname "${_so}" NAME) - message(STATUS "[EP] Staging ${_fname} -> ${STAGING_DIR}") - file(COPY "${_so}" DESTINATION "${STAGING_DIR}" NO_SOURCE_PERMISSIONS) -endforeach() - -message(STATUS "[EP] Mooncake EP extension build complete") diff --git a/mooncake-ep/CMakeLists.txt b/mooncake-ep/CMakeLists.txt index 11cf38c1f7..573748c127 100644 --- a/mooncake-ep/CMakeLists.txt +++ b/mooncake-ep/CMakeLists.txt @@ -1,33 +1,15 @@ cmake_minimum_required(VERSION 3.16) project(mooncake-ep) -# Find PyTorch's CMake prefix path -execute_process( - COMMAND ${PYTHON_EXECUTABLE} -c "import torch; print(torch.utils.cmake_prefix_path)" - OUTPUT_VARIABLE PYTORCH_CMAKE_PATH - OUTPUT_STRIP_TRAILING_WHITESPACE -) -if(NOT PYTORCH_CMAKE_PATH) - message(WARNING "Could not find PyTorch CMake path! Please set Torch_DIR.") -else () - message(STATUS "Found PyTorch CMake path: ${PYTORCH_CMAKE_PATH}") - list(APPEND CMAKE_PREFIX_PATH "${PYTORCH_CMAKE_PATH}/Torch") +find_package( + Python3 + COMPONENTS Interpreter Development.Module + REQUIRED) + +if(USE_CUDA) + enable_language(CUDA) + find_package(CUDAToolkit REQUIRED) endif() -set(TORCH_CUDA_ARCH_LIST "8.0;9.0") - -find_package(CUDAToolkit REQUIRED) -# https://discuss.pytorch.org/t/failed-to-find-nvtoolsext/179635/13 -if(NOT TARGET CUDA::nvToolsExt AND TARGET CUDA::nvtx3) - add_library(CUDA::nvToolsExt INTERFACE IMPORTED) - target_compile_definitions( - CUDA::nvToolsExt INTERFACE - TORCH_CUDA_USE_NVTX3 - ) - target_link_libraries(CUDA::nvToolsExt INTERFACE CUDA::nvtx3) -endif() -find_package(Torch REQUIRED) -include_directories(${TORCH_INCLUDE_DIRS}) - include_directories(include) add_subdirectory(src) diff --git a/mooncake-ep/benchmarks/elastic_buffer_perf.py b/mooncake-ep/benchmarks/elastic_buffer_perf.py index 0bd2d10d13..edac6da8f6 100644 --- a/mooncake-ep/benchmarks/elastic_buffer_perf.py +++ b/mooncake-ep/benchmarks/elastic_buffer_perf.py @@ -44,6 +44,12 @@ def parse_args() -> argparse.Namespace: parser.add_argument("--num-sms", type=int, default=24) parser.add_argument("--warmup", type=int, default=5) parser.add_argument("--iters", type=int, default=20) + parser.add_argument( + "--transport", + choices=("auto", "ibgda", "nccl"), + default=os.getenv("MOONCAKE_EP_TRANSPORT", "auto"), + help="Device transport; auto prefers NCCL and falls back to IPC + IBGDA.", + ) parser.add_argument( "--route", choices=("alltoall", "local", "cross"), @@ -96,7 +102,9 @@ def make_route_plan( local_experts = num_experts // world_size if local_experts <= 0: raise ValueError("num_experts must be at least world_size") - expert_offsets = torch.arange(num_topk, device="cuda", dtype=torch.long) % local_experts + expert_offsets = ( + torch.arange(num_topk, device="cuda", dtype=torch.long) % local_experts + ) if route == "cross" and buffer.num_scaleout_ranks > 1: dst_scaleout = (buffer.scaleout_rank_idx + 1) % buffer.num_scaleout_ranks @@ -116,7 +124,9 @@ def make_route_plan( 1, ) - dst_ranks = (rank + torch.arange(num_topk, device="cuda", dtype=torch.long)) % world_size + dst_ranks = ( + rank + torch.arange(num_topk, device="cuda", dtype=torch.long) + ) % world_size choices = dst_ranks * local_experts + expert_offsets unique_dst_ranks = int(torch.unique(dst_ranks).numel()) return RoutePlan( @@ -162,6 +172,7 @@ def main() -> None: if num_experts % world_size != 0: raise ValueError("num_experts must be divisible by world_size") + transport_kwargs = {} if args.transport == "auto" else {"transport": args.transport} buffer = ElasticBuffer( dist.group.WORLD, num_max_tokens_per_rank=max_tokens, @@ -172,6 +183,7 @@ def main() -> None: allow_hybrid_mode=True, allow_multiple_reduction=True, num_gpu_timeout_secs=10, + **transport_kwargs, ) route_plan = make_route_plan( rank=rank, @@ -182,7 +194,9 @@ def main() -> None: num_experts=num_experts, route=args.route, ) - weights = torch.ones((args.num_tokens, args.num_topk), device="cuda", dtype=torch.float32) + weights = torch.ones( + (args.num_tokens, args.num_topk), device="cuda", dtype=torch.float32 + ) def run_one(iteration: int, cached_handle): x = make_input(rank, iteration, args.num_tokens, args.hidden) @@ -208,7 +222,9 @@ def run_one(iteration: int, cached_handle): actual_recv_tokens = route_plan.expected_recv_tokens if args.sync_actual_count: - actual_recv_tokens = int(handle.psum_num_recv_tokens_per_scaleup_rank[-1].item()) + actual_recv_tokens = int( + handle.psum_num_recv_tokens_per_scaleup_rank[-1].item() + ) if actual_recv_tokens != route_plan.expected_recv_tokens: raise AssertionError( f"rank={rank}: got {actual_recv_tokens} received tokens, " @@ -230,8 +246,12 @@ def run_one(iteration: int, cached_handle): torch.cuda.synchronize() if args.check_correctness: - expected = (x.float() * route_plan.expected_combine_factor).to(torch.bfloat16) - check_output(rank=rank, route=args.route, combined=combined, expected=expected) + expected = (x.float() * route_plan.expected_combine_factor).to( + torch.bfloat16 + ) + check_output( + rank=rank, route=args.route, combined=combined, expected=expected + ) return ( handle, @@ -259,32 +279,45 @@ def run_one(iteration: int, cached_handle): dist.barrier() wall_seconds = time.time() - wall_start - stats = torch.tensor( - [ - sum(dispatch_ms) / len(dispatch_ms), - sum(combine_ms) / len(combine_ms), - min(dispatch_ms), - max(dispatch_ms), - min(combine_ms), - max(combine_ms), - sum(recv_tokens) / len(recv_tokens), - wall_seconds, - ], + samples = ( + torch.tensor( + [dispatch_ms, combine_ms, recv_tokens], + device="cuda", + dtype=torch.float64, + ) + .transpose(0, 1) + .contiguous() + ) + gathered = [torch.empty_like(samples) for _ in range(world_size)] + dist.all_gather(gathered, samples) + + max_wall_seconds = torch.tensor( + wall_seconds, device="cuda", dtype=torch.float64, ) - gathered = [torch.empty_like(stats) for _ in range(world_size)] - dist.all_gather(gathered, stats) + dist.all_reduce(max_wall_seconds, op=dist.ReduceOp.MAX) if rank == 0: table = torch.stack(gathered).cpu() - payload_bytes = table[:, 6].mean().item() * args.hidden * 2 - dispatch_avg_ms = table[:, 0].mean().item() - combine_avg_ms = table[:, 1].mean().item() + dispatch_samples = table[:, :, 0] + combine_samples = table[:, :, 1] + e2e_samples = dispatch_samples + combine_samples + payload_bytes = table[:, :, 2].mean().item() * args.hidden * 2 + dispatch_avg_ms = dispatch_samples.mean().item() + combine_avg_ms = combine_samples.mean().item() + e2e_avg_ms = e2e_samples.mean().item() + dispatch_critical_samples = dispatch_samples.max(dim=0).values + combine_critical_samples = combine_samples.max(dim=0).values + dispatch_critical_ms = dispatch_critical_samples.mean().item() + combine_critical_ms = combine_critical_samples.mean().item() + e2e_critical_ms = e2e_samples.max(dim=0).values.mean().item() + wall_ms_per_iter = max_wall_seconds.item() * 1000 / args.iters print( "MOONCAKE_ELASTIC_PERF_OK", f"world={world_size}", f"route={args.route}", + f"transport={buffer.transport}", f"reuse_handle={int(args.reuse_handle)}", f"tokens={args.num_tokens}", f"hidden={args.hidden}", @@ -293,13 +326,23 @@ def run_one(iteration: int, cached_handle): f"scaleup={buffer.num_scaleup_ranks}", f"dispatch_avg_ms={dispatch_avg_ms:.3f}", f"combine_avg_ms={combine_avg_ms:.3f}", - f"recv_tokens_avg={table[:, 6].mean().item():.1f}", + f"e2e_avg_ms={e2e_avg_ms:.3f}", + f"dispatch_critical_ms={dispatch_critical_ms:.3f}", + f"combine_critical_ms={combine_critical_ms:.3f}", + f"e2e_critical_ms={e2e_critical_ms:.3f}", + f"wall_ms_per_iter={wall_ms_per_iter:.3f}", + f"recv_tokens_avg={table[:, :, 2].mean().item():.1f}", f"effective_payload_MB_per_rank={payload_bytes / 1e6:.1f}", f"dispatch_effective_GBps={payload_bytes / dispatch_avg_ms / 1e6:.2f}", f"combine_effective_GBps={payload_bytes / combine_avg_ms / 1e6:.2f}", + "dispatch_critical_effective_GBps=" + f"{payload_bytes / dispatch_critical_ms / 1e6:.2f}", + "combine_critical_effective_GBps=" + f"{payload_bytes / combine_critical_ms / 1e6:.2f}", flush=True, ) + buffer.destroy() dist.destroy_process_group() diff --git a/mooncake-ep/benchmarks/legacy_buffer_perf.cpp b/mooncake-ep/benchmarks/legacy_buffer_perf.cpp new file mode 100644 index 0000000000..aa7b5776d3 --- /dev/null +++ b/mooncake-ep/benchmarks/legacy_buffer_perf.cpp @@ -0,0 +1,199 @@ +#include + +#include +#include +#include +#include +#include + +#include +#include +#include + +namespace py = pybind11; + +namespace mooncake { +namespace { + +struct LegacyBufferPerfTensors { + uint64_t x_ptr = 0; + uint64_t topk_idx_ptr = 0; + uint64_t topk_weights_ptr = 0; + uint64_t active_ranks_ptr = 0; + uint64_t expert_x_ptr = 0; + uint64_t packed_recv_x_ptr = 0; + uint64_t packed_recv_count_ptr = 0; + uint64_t packed_recv_src_info_ptr = 0; + uint64_t packed_recv_layout_range_ptr = 0; + uint64_t combined_x_ptr = 0; +}; + +struct LegacyBufferPerfConfig { + int num_tokens = 0; + int hidden = 0; + int num_topk = 0; + int num_local_experts = 0; + int num_max_dispatch_tokens_per_rank = 0; + int num_experts = 0; + int timeout_us = -1; + int warmups = 20; + int iterations = 30; + uint64_t compute_stream_ptr = 0; +}; + +struct LegacyBufferPerfResult { + double average_us = 0.0; + double min_us = 0.0; + double max_us = 0.0; +}; + +void cuda_check(cudaError_t status, const char* operation) { + if (status != cudaSuccess) { + throw std::runtime_error(std::string(operation) + ": " + + cudaGetErrorString(status)); + } +} + +void validate(MooncakeEpBuffer& buffer, const LegacyBufferPerfTensors& tensors, + const LegacyBufferPerfConfig& config) { + if (config.num_tokens <= 0 || config.hidden <= 0 || config.num_topk <= 0 || + config.num_max_dispatch_tokens_per_rank < config.num_tokens || + config.num_experts <= 0 || config.num_local_experts <= 0 || + config.warmups < 0 || config.iterations <= 1) { + throw std::invalid_argument( + "invalid legacy EP benchmark configuration"); + } + if (buffer.ibgda_disabled() && !buffer.use_fast_path()) { + throw std::runtime_error( + "legacy EP benchmark requires the native fast path"); + } + if (!tensors.x_ptr || !tensors.topk_idx_ptr || !tensors.topk_weights_ptr || + !tensors.active_ranks_ptr || !tensors.expert_x_ptr || + !tensors.packed_recv_x_ptr || !tensors.packed_recv_count_ptr || + !tensors.packed_recv_src_info_ptr || + !tensors.packed_recv_layout_range_ptr || !tensors.combined_x_ptr) { + throw std::invalid_argument( + "legacy EP benchmark received a null tensor pointer"); + } +} + +LegacyBufferPerfResult run_legacy_buffer_perf( + MooncakeEpBuffer& buffer, const LegacyBufferPerfTensors& tensors, + const LegacyBufferPerfConfig& config) { + validate(buffer, tensors, config); + + const int num_local_experts = config.num_local_experts; + const auto compute_stream = + reinterpret_cast(config.compute_stream_ptr); + const size_t recv_count_bytes = + static_cast(num_local_experts) * sizeof(int); + + auto run_once = [&] { + cuda_check(cudaMemsetAsync( + reinterpret_cast(tensors.packed_recv_count_ptr), + 0, recv_count_bytes, compute_stream), + "cudaMemsetAsync(packed_recv_count)"); + buffer.dispatch( + tensors.x_ptr, tensors.topk_idx_ptr, tensors.active_ranks_ptr, + config.num_tokens, config.hidden, config.num_topk, + config.num_max_dispatch_tokens_per_rank, config.num_experts, + config.timeout_us, false, tensors.packed_recv_x_ptr, 0, + tensors.packed_recv_count_ptr, tensors.packed_recv_src_info_ptr, + tensors.packed_recv_layout_range_ptr, false, false, + config.compute_stream_ptr); + buffer.combine( + tensors.expert_x_ptr, tensors.topk_idx_ptr, + tensors.topk_weights_ptr, tensors.packed_recv_src_info_ptr, + tensors.packed_recv_layout_range_ptr, tensors.active_ranks_ptr, + num_local_experts, config.num_tokens, config.hidden, + config.num_topk, config.num_max_dispatch_tokens_per_rank, + config.num_experts, config.timeout_us, false, + tensors.combined_x_ptr, false, false, config.compute_stream_ptr); + }; + + for (int i = 0; i < config.warmups; ++i) run_once(); + cuda_check(cudaStreamSynchronize(compute_stream), + "cudaStreamSynchronize(warmup)"); + + std::vector starts(config.iterations); + std::vector ends(config.iterations); + for (int i = 0; i < config.iterations; ++i) { + cuda_check(cudaEventCreate(&starts[i]), "cudaEventCreate(start)"); + cuda_check(cudaEventCreate(&ends[i]), "cudaEventCreate(end)"); + cuda_check(cudaEventRecord(starts[i], compute_stream), + "cudaEventRecord(start)"); + run_once(); + cuda_check(cudaEventRecord(ends[i], compute_stream), + "cudaEventRecord(end)"); + } + cuda_check(cudaEventSynchronize(ends.back()), "cudaEventSynchronize(end)"); + + std::vector timings_us; + timings_us.reserve(config.iterations - 1); + for (int i = 1; i < config.iterations; ++i) { + float elapsed_ms = 0.0f; + cuda_check(cudaEventElapsedTime(&elapsed_ms, starts[i], ends[i]), + "cudaEventElapsedTime"); + timings_us.push_back(elapsed_ms * 1000.0f); + } + for (auto event : starts) cudaEventDestroy(event); + for (auto event : ends) cudaEventDestroy(event); + + double total_us = 0.0; + for (float value : timings_us) total_us += value; + const auto [min_it, max_it] = + std::minmax_element(timings_us.begin(), timings_us.end()); + return {total_us / timings_us.size(), *min_it, *max_it}; +} + +} // namespace + +void bind_legacy_buffer_perf(py::module_& module) { + py::class_(module, "_LegacyBufferPerfTensors") + .def(py::init<>()) + .def_readwrite("x_ptr", &LegacyBufferPerfTensors::x_ptr) + .def_readwrite("topk_idx_ptr", &LegacyBufferPerfTensors::topk_idx_ptr) + .def_readwrite("topk_weights_ptr", + &LegacyBufferPerfTensors::topk_weights_ptr) + .def_readwrite("active_ranks_ptr", + &LegacyBufferPerfTensors::active_ranks_ptr) + .def_readwrite("expert_x_ptr", &LegacyBufferPerfTensors::expert_x_ptr) + .def_readwrite("packed_recv_x_ptr", + &LegacyBufferPerfTensors::packed_recv_x_ptr) + .def_readwrite("packed_recv_count_ptr", + &LegacyBufferPerfTensors::packed_recv_count_ptr) + .def_readwrite("packed_recv_src_info_ptr", + &LegacyBufferPerfTensors::packed_recv_src_info_ptr) + .def_readwrite("packed_recv_layout_range_ptr", + &LegacyBufferPerfTensors::packed_recv_layout_range_ptr) + .def_readwrite("combined_x_ptr", + &LegacyBufferPerfTensors::combined_x_ptr); + + py::class_(module, "_LegacyBufferPerfConfig") + .def(py::init<>()) + .def_readwrite("num_tokens", &LegacyBufferPerfConfig::num_tokens) + .def_readwrite("hidden", &LegacyBufferPerfConfig::hidden) + .def_readwrite("num_topk", &LegacyBufferPerfConfig::num_topk) + .def_readwrite("num_local_experts", + &LegacyBufferPerfConfig::num_local_experts) + .def_readwrite( + "num_max_dispatch_tokens_per_rank", + &LegacyBufferPerfConfig::num_max_dispatch_tokens_per_rank) + .def_readwrite("num_experts", &LegacyBufferPerfConfig::num_experts) + .def_readwrite("timeout_us", &LegacyBufferPerfConfig::timeout_us) + .def_readwrite("warmups", &LegacyBufferPerfConfig::warmups) + .def_readwrite("iterations", &LegacyBufferPerfConfig::iterations) + .def_readwrite("compute_stream_ptr", + &LegacyBufferPerfConfig::compute_stream_ptr); + + py::class_(module, "_LegacyBufferPerfResult") + .def_readonly("average_us", &LegacyBufferPerfResult::average_us) + .def_readonly("min_us", &LegacyBufferPerfResult::min_us) + .def_readonly("max_us", &LegacyBufferPerfResult::max_us); + + module.def("_benchmark_legacy_buffer", &run_legacy_buffer_perf, + py::arg("buffer"), py::arg("tensors"), py::arg("config"), + py::call_guard()); +} + +} // namespace mooncake diff --git a/mooncake-ep/benchmarks/legacy_buffer_perf.py b/mooncake-ep/benchmarks/legacy_buffer_perf.py new file mode 100644 index 0000000000..3a8aac09e4 --- /dev/null +++ b/mooncake-ep/benchmarks/legacy_buffer_perf.py @@ -0,0 +1,123 @@ +#!/usr/bin/env python3 +"""Native-core legacy EP benchmark with Python distributed bootstrap. + +Launch with torchrun. Python initializes the Mooncake process group and owns +the fixed tensor storage; the timed dispatch/combine loop executes entirely in +the torch-free C++ EP module. +""" + +import argparse +import os + +import torch +import torch.distributed as dist + +import mooncake._ep as native_ep +import mooncake.pg # Registers the Mooncake process-group backend for bootstrap. +from mooncake.mooncake_ep_buffer import Buffer + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser() + parser.add_argument("--tokens", type=int, default=128) + parser.add_argument("--hidden", type=int, default=7168) + parser.add_argument("--experts", type=int, default=288) + parser.add_argument("--topk", type=int, default=8) + parser.add_argument("--warmups", type=int, default=20) + parser.add_argument("--iterations", type=int, default=30) + return parser.parse_args() + + +def main() -> None: + args = parse_args() + local_rank = int(os.environ["LOCAL_RANK"]) + torch.cuda.set_device(local_rank) + dist.init_process_group("mooncake") + world_size = dist.get_world_size() + rank = dist.get_rank() + group = dist.new_group(list(range(world_size))) + + if args.experts % world_size: + raise ValueError("--experts must be divisible by world size") + if args.hidden % 128: + raise ValueError("--hidden must be divisible by 128") + + torch.manual_seed(rank) + num_local_experts = args.experts // world_size + buffer_bytes = Buffer.get_ep_buffer_size_hint( + args.tokens, args.hidden, world_size, args.experts + ) + buffer = Buffer(group, num_ep_buffer_bytes=buffer_bytes) + if buffer._use_fallback: + raise RuntimeError("native-core benchmark requires the EP fast path") + + x = torch.randn( + (args.tokens, args.hidden), dtype=torch.bfloat16, device="cuda" + ) + scores = torch.randn( + (args.tokens, args.experts), dtype=torch.float32, device="cuda" + ) + topk_idx = torch.topk(scores, args.topk, dim=-1).indices.contiguous() + topk_weights = torch.rand( + (args.tokens, args.topk), dtype=torch.float32, device="cuda" + ) + active_ranks = torch.ones(world_size, dtype=torch.int32, device="cuda") + recv_tokens = world_size * args.tokens + expert_x = torch.randn( + (num_local_experts, recv_tokens, args.hidden), + dtype=torch.bfloat16, + device="cuda", + ) + packed_recv_x = torch.empty_like(expert_x) + packed_recv_count = torch.empty( + num_local_experts, dtype=torch.int32, device="cuda" + ) + packed_recv_src_info = torch.empty( + (num_local_experts, recv_tokens), dtype=torch.int32, device="cuda" + ) + packed_recv_layout_range = torch.empty( + (num_local_experts, world_size), dtype=torch.int64, device="cuda" + ) + combined_x = torch.empty_like(x) + cache_flush = torch.empty(int(256e6 // 4), dtype=torch.int32, device="cuda") + + tensors = native_ep._LegacyBufferPerfTensors() + tensors.x_ptr = x.data_ptr() + tensors.topk_idx_ptr = topk_idx.data_ptr() + tensors.topk_weights_ptr = topk_weights.data_ptr() + tensors.active_ranks_ptr = active_ranks.data_ptr() + tensors.expert_x_ptr = expert_x.data_ptr() + tensors.packed_recv_x_ptr = packed_recv_x.data_ptr() + tensors.packed_recv_count_ptr = packed_recv_count.data_ptr() + tensors.packed_recv_src_info_ptr = packed_recv_src_info.data_ptr() + tensors.packed_recv_layout_range_ptr = packed_recv_layout_range.data_ptr() + tensors.combined_x_ptr = combined_x.data_ptr() + + config = native_ep._LegacyBufferPerfConfig() + config.num_tokens = args.tokens + config.hidden = args.hidden + config.num_topk = args.topk + config.num_local_experts = num_local_experts + config.num_max_dispatch_tokens_per_rank = args.tokens + config.num_experts = args.experts + config.warmups = args.warmups + config.iterations = args.iterations + config.compute_stream_ptr = torch.cuda.current_stream().cuda_stream + + dist.barrier(group=group) + cache_flush.zero_() + result = native_ep._benchmark_legacy_buffer(buffer.runtime, tensors, config) + selections = topk_idx.numel() + payload_bytes = selections * (args.hidden * 4) + bandwidth = payload_bytes / result.average_us / 1e3 + print( + f"[rank {rank}] Native dispatch + combine: {bandwidth:.2f} GB/s, " + f"avg_t={result.average_us:.2f} us, min_t={result.min_us:.2f} us, " + f"max_t={result.max_us:.2f} us", + flush=True, + ) + dist.destroy_process_group() + + +if __name__ == "__main__": + main() diff --git a/mooncake-ep/include/elastic/mooncake_ep_elastic_buffer.h b/mooncake-ep/include/elastic/mooncake_ep_elastic_buffer.h index 6b5dacdd07..ea3781e9d4 100644 --- a/mooncake-ep/include/elastic/mooncake_ep_elastic_buffer.h +++ b/mooncake-ep/include/elastic/mooncake_ep_elastic_buffer.h @@ -5,6 +5,7 @@ #include #include #include +#include #include #include @@ -13,6 +14,9 @@ namespace mooncake { struct ElasticLaunchContext; +struct NcclElasticState; + +std::vector create_elastic_nccl_unique_id(); struct ElasticTopology { int rank_idx = 0; @@ -24,6 +28,7 @@ struct ElasticTopology { int scaleout_rank_idx = 0; int scaleup_rank_idx = 0; bool hybrid_enabled = false; + bool scaleup_lsa = false; }; struct ElasticConfig { @@ -41,38 +46,6 @@ struct ElasticConfig { int num_gpu_timeout_secs = 100; }; -struct ElasticNativeHandle { - bool do_expand = false; - int num_experts = 0; - int expert_alignment = 1; - int num_max_tokens_per_rank = 0; - int num_sms = 0; - torch::Tensor topk_idx; - torch::Tensor psum_num_recv_tokens_per_scaleup_rank; - torch::Tensor psum_num_recv_tokens_per_expert; - torch::Tensor recv_src_metadata; - torch::Tensor recv_layout_range; - torch::Tensor dst_buffer_slot_idx; - std::optional token_metadata_at_forward; - std::optional channel_linked_list; - std::vector num_recv_tokens_per_expert_list; -}; - -struct ElasticDispatchOutput { - torch::Tensor recv_x; - std::optional recv_x_scales; - std::optional recv_topk_idx; - std::optional recv_topk_weights; - ElasticNativeHandle handle; - std::optional event; -}; - -struct ElasticCombineOutput { - torch::Tensor combined_x; - std::optional combined_topk_weights; - std::optional event; -}; - class MooncakeElasticBuffer { public: MooncakeElasticBuffer(int rank, int num_ranks, int64_t num_buffer_bytes, @@ -84,8 +57,30 @@ class MooncakeElasticBuffer { int num_allocated_qps, int num_cpu_timeout_secs, int num_gpu_timeout_secs); + MooncakeElasticBuffer(int rank, int num_ranks, int64_t num_buffer_bytes, + int64_t num_max_tokens_per_rank, int64_t hidden, + int64_t num_topk, bool use_fp8_dispatch, + bool deterministic, bool allow_hybrid_mode, + bool allow_multiple_reduction, + bool prefer_overlap_with_compute, int sl_idx, + int num_allocated_qps, int num_cpu_timeout_secs, + int num_gpu_timeout_secs, + const std::string& transport, + const std::vector& nccl_unique_id); + ~MooncakeElasticBuffer(); + // For NCCL, the caller must first quiesce CUDA work and coordinate every + // communicator rank. The Python wrapper provides that collective protocol. + void destroy(); + bool using_nccl() const { return nccl_state_ != nullptr; } + + // Replace the NCCL communicator-owned resources while preserving this + // buffer's fixed logical rank and configuration. Every rank must join the + // matching NCCL setup sequence at a quiescent EP boundary with the same + // fresh unique ID; replacement ranks enter it through construction. + void reconfigure_nccl(const std::vector& nccl_unique_id); + static int64_t calculate_buffer_size(int num_ranks, int64_t num_max_tokens_per_rank, int64_t hidden, int64_t num_topk, @@ -97,72 +92,76 @@ class MooncakeElasticBuffer { std::tuple get_logical_domain_size() const; int get_theoretical_num_sms(int num_experts, int num_topk) const; - ElasticDispatchOutput dispatch( - const torch::Tensor& x, const std::optional& sf, - const torch::Tensor& topk_idx, - const std::optional& topk_weights, - torch::Tensor& active_ranks, int num_experts, - int num_max_tokens_per_rank, int expert_alignment, int num_sms, - bool do_expand, bool do_cpu_sync, bool async_with_compute_stream, - const std::optional& cached_handle = std::nullopt); - - ElasticCombineOutput combine( - const torch::Tensor& x, const ElasticNativeHandle& handle, - const std::optional& topk_weights, - torch::Tensor& active_ranks, int num_sms, - bool async_with_compute_stream, - const std::optional& out); - - MooncakeEpBuffer& native_buffer() { return *native_buffer_; } - - bool ibgda_disabled() const { return native_buffer_->ibgda_disabled(); } - bool use_fast_path() { return native_buffer_->use_fast_path(); } - void update_local_qpns() { native_buffer_->update_local_qpns(); } - bool is_roce() const { return native_buffer_->is_roce(); } + std::optional dispatch( + uint64_t x_ptr, int x_element_size, uint64_t sf_ptr, int num_tokens, + int hidden, int num_sf_packs, int sf_token_stride, int sf_hidden_stride, + uint64_t topk_idx_ptr, int num_topk, uint64_t topk_weights_ptr, + uint64_t active_ranks_ptr, int num_experts, int num_max_tokens_per_rank, + int expert_alignment, int num_sms, bool do_expand, + bool async_with_compute_stream, uint64_t compute_stream_ptr, + bool cached_mode, int num_recv_tokens, + uint64_t psum_num_recv_tokens_per_scaleup_rank_ptr, + uint64_t psum_num_recv_tokens_per_expert_ptr, + uint64_t dst_buffer_slot_idx_ptr, + uint64_t token_metadata_at_forward_ptr, + uint64_t channel_linked_list_ptr, uint64_t recv_x_ptr, + uint64_t recv_x_scales_ptr, uint64_t recv_topk_idx_ptr, + uint64_t recv_topk_weights_ptr, uint64_t recv_src_metadata_ptr); + + std::optional combine( + uint64_t x_ptr, int num_input_tokens, int hidden, uint64_t topk_idx_ptr, + int num_combined_tokens, int num_topk, uint64_t topk_weights_ptr, + uint64_t psum_num_recv_tokens_per_scaleup_rank_ptr, + uint64_t recv_src_metadata_ptr, uint64_t token_metadata_at_forward_ptr, + uint64_t channel_linked_list_ptr, uint64_t active_ranks_ptr, + int num_experts, int num_max_tokens_per_rank, bool do_expand, + int num_sms, bool async_with_compute_stream, + uint64_t compute_stream_ptr, uint64_t combined_x_ptr); + + MooncakeEpBuffer& native_buffer(); + + bool ibgda_disabled() const; + bool use_fast_path(); + void update_local_qpns(); + bool is_roce() const; void sync_ibgda_peers(const std::vector& remote_addrs, const std::vector& remote_keys, const std::vector>& peer_qpns, const std::vector>& peer_lids, const std::vector& subnet_prefixes, const std::vector& interface_ids, - const std::vector& active_ranks_mask) { - native_buffer_->sync_ibgda_peers(remote_addrs, remote_keys, peer_qpns, - peer_lids, subnet_prefixes, - interface_ids, active_ranks_mask); - } - std::tuple get_mr_info() { - return native_buffer_->get_mr_info(); - } - std::tuple get_gid() { return native_buffer_->get_gid(); } - std::vector get_local_qpns() { - return native_buffer_->get_local_qpns(); - } - std::vector get_local_lids() { - return native_buffer_->get_local_lids(); - } - std::vector get_ipc_handle() { - return native_buffer_->get_ipc_handle(); - } + const std::vector& active_ranks_mask); + std::tuple get_mr_info(); + std::tuple get_gid(); + std::vector get_local_qpns(); + std::vector get_local_lids(); + std::vector get_ipc_handle(); void sync_nvlink_ipc_handles( const std::vector>& remote_handles, - const std::vector& active_ranks_mask) { - native_buffer_->sync_nvlink_ipc_handles(remote_handles, - active_ranks_mask); - } + const std::vector& active_ranks_mask); private: ElasticConfig config_; ElasticTopology topology_; + std::string transport_; std::unique_ptr native_buffer_; + std::unique_ptr nccl_state_; int64_t host_workspace_bytes_ = 0; void* host_workspace_ = nullptr; void* mapped_host_workspace_ = nullptr; - - static ElasticLaunchContext make_launch_context( - MooncakeEpBuffer& buffer, const ElasticTopology& topology, - void* mapped_host_workspace, int64_t timeout_cycles); + std::shared_ptr deterministic_rank_count_buffer_; + int64_t deterministic_rank_count_buffer_bytes_ = 0; + int device_id_ = -1; + int physical_num_sms_ = 0; + int device_smem_bytes_ = 0; + bool destroyed_ = false; + + ElasticLaunchContext make_launch_context(int64_t timeout_cycles) const; + cudaStream_t communication_stream() const; + int clock_rate_khz() const; static ElasticTopology discover_topology(int rank, int num_ranks, bool allow_hybrid_mode); + std::shared_ptr ensure_deterministic_rank_count_buffer(int num_sms); }; } // namespace mooncake diff --git a/mooncake-ep/include/elastic/mooncake_ep_elastic_combine_official.cuh b/mooncake-ep/include/elastic/mooncake_ep_elastic_combine_official.cuh index 9e36b344f7..44c605fd59 100644 --- a/mooncake-ep/include/elastic/mooncake_ep_elastic_combine_official.cuh +++ b/mooncake-ep/include/elastic/mooncake_ep_elastic_combine_official.cuh @@ -14,7 +14,7 @@ namespace mooncake::elastic { -template (blockIdx.x); const auto thread_idx = static_cast(threadIdx.x); @@ -73,13 +73,13 @@ __global__ void __launch_bounds__(kNumThreads, 1) // We treat each warp as a "channel" const auto [qp_idx, sharing_mode] = comm::get_qp_mode(sm_idx, warp_idx); - const auto gin = transport::MooncakeGin(comm_ctx, qp_idx, sharing_mode, - kNumQPs, 0, 0, 0, kNumRanks); + const auto gin = + Ops(comm_ctx, qp_idx, sharing_mode, kNumQPs, 0, 0, 0, kNumRanks); // Full barrier to ensure the remote buffer is available const auto workspace_layout = layout::WorkspaceLayout(workspace, 1, kNumRanks, kNumExperts); - comm::gpu_barrier(gin, workspace_layout, 0, rank_idx, sm_idx, thread_idx); @@ -102,7 +102,7 @@ __global__ void __launch_bounds__(kNumThreads, 1) // Directly to the remote or via RDMA const bool nvlink_bypass = - gin.is_nvlink_accessible(src_rank_idx); + gin.template is_nvlink_accessible(src_rank_idx); layout::TokenLayout master_token_buffer = [=]() { // NVLink bypass if (nvlink_bypass) { @@ -111,7 +111,7 @@ __global__ void __launch_bounds__(kNumThreads, 1) .get_rank_buffer(kUseRankLayout ? rank_idx : src_topk_idx) .get_token_buffer(src_token_idx); - token_buffer.set_base_ptr(gin.get_sym_ptr( + token_buffer.set_base_ptr(gin.template get_sym_ptr( token_buffer.get_base_ptr(), src_rank_idx)); return token_buffer; } @@ -251,8 +251,8 @@ __global__ void __launch_bounds__(kNumThreads, 1) src_token_idx); #ifdef MOONCAKE_EP_USE_MUSA if (nvlink_bypass) { - auto* dst_ptr = - static_cast(gin.get_sym_ptr( + auto* dst_ptr = static_cast( + gin.template get_sym_ptr( token_buffer.get_base_ptr(), src_rank_idx)); #pragma unroll 1 for (int vec_idx = lane_idx; vec_idx < kHiddenVec; @@ -274,9 +274,10 @@ __global__ void __launch_bounds__(kNumThreads, 1) } __syncwarp(); if (ptx::elect_one_sync()) { - gin.put(token_buffer.get_base_ptr(), - send_token_buffer.get_base_ptr(), - kNumHiddenBytes, src_rank_idx); + gin.template put( + token_buffer.get_base_ptr(), + send_token_buffer.get_base_ptr(), + kNumHiddenBytes, src_rank_idx); } } __syncwarp(); @@ -295,7 +296,7 @@ __global__ void __launch_bounds__(kNumThreads, 1) if (nvlink_bypass) { // Write into the same position ptx::tma_store_1d( - gin.get_sym_ptr( + gin.template get_sym_ptr( token_buffer.get_base_ptr(), src_rank_idx), tma_buffer.get_base_ptr(), kNumHiddenBytes); ptx::tma_store_commit(); @@ -312,9 +313,10 @@ __global__ void __launch_bounds__(kNumThreads, 1) ptx::tma_store_wait(); // Issue RDMA - gin.put(token_buffer.get_base_ptr(), - send_token_buffer.get_base_ptr(), - kNumHiddenBytes, src_rank_idx); + gin.template put( + token_buffer.get_base_ptr(), + send_token_buffer.get_base_ptr(), + kNumHiddenBytes, src_rank_idx); } } __syncwarp(); @@ -349,14 +351,14 @@ __global__ void __launch_bounds__(kNumThreads, 1) .get_rank_buffer(kUseRankLayout ? rank_idx : src_topk_idx) .get_token_buffer(src_token_idx) .get_base_ptr(); - gin.put(dst_ptr, master_token_buffer.get_base_ptr(), - master_token_buffer.get_num_bytes(), - src_rank_idx); + gin.template put( + dst_ptr, master_token_buffer.get_base_ptr(), + master_token_buffer.get_num_bytes(), src_rank_idx); } } // Final barrier to ensure data arrival - comm::gpu_barrier(gin, workspace_layout, 0, rank_idx, sm_idx, thread_idx); diff --git a/mooncake-ep/include/elastic/mooncake_ep_elastic_combine_reduce_epilogue.cuh b/mooncake-ep/include/elastic/mooncake_ep_elastic_combine_reduce_epilogue.cuh index 6e41aebab8..7829e7ecfc 100644 --- a/mooncake-ep/include/elastic/mooncake_ep_elastic_combine_reduce_epilogue.cuh +++ b/mooncake-ep/include/elastic/mooncake_ep_elastic_combine_reduce_epilogue.cuh @@ -42,8 +42,9 @@ __global__ void __launch_bounds__(kNumThreads, 1) // Utils const auto sm_idx = static_cast(blockIdx.x); const auto warp_idx = ptx::get_warp_idx(), lane_idx = ptx::get_lane_idx(); + const auto num_sms = kNumSMs == 0 ? static_cast(gridDim.x) : kNumSMs; const auto global_warp_idx = - warp_idx * kNumSMs + + warp_idx * num_sms + sm_idx; // NOTES: Here we prioritize distributing tasks to different // SMs to ensure that the last wave is evenly concentrated on // each SM. @@ -81,7 +82,7 @@ __global__ void __launch_bounds__(kNumThreads, 1) // Read from buffers and do reduction for (int token_idx = global_warp_idx; token_idx < num_combined_tokens; - token_idx += kNumWarps * kNumSMs) { + token_idx += kNumWarps * num_sms) { // Preprocess all indices int stored_dst_rank_idx = -1, stored_dst_expert_idx = -1; EP_STATIC_ASSERT(kNumTopk <= 32, "Too many top-k selections"); diff --git a/mooncake-ep/include/elastic/mooncake_ep_elastic_combine_utils.cuh b/mooncake-ep/include/elastic/mooncake_ep_elastic_combine_utils.cuh index b2e3623f2a..2201050ce3 100644 --- a/mooncake-ep/include/elastic/mooncake_ep_elastic_combine_utils.cuh +++ b/mooncake-ep/include/elastic/mooncake_ep_elastic_combine_utils.cuh @@ -37,11 +37,10 @@ template struct CombineVecTraits { #if !defined(MOONCAKE_EP_USE_MUSA) && defined(__CUDA_ARCH__) && \ (__CUDA_ARCH__ >= 1000) - // On SM100+, use longlong4_t (32 bytes) if hidden is aligned, otherwise - // fall back to int4 (16 bytes) - static constexpr bool kUseLonglong4 = - (kHiddenBytes % sizeof(longlong4_t) == 0) and - ((kHiddenBytes / sizeof(longlong4_t)) % 32 == 0); + // 256-bit longlong4_t accesses regress this reduction on the validated + // SM100+ kernel shapes. Keep the faster 128-bit int4 path until a workload + // demonstrates that the wider vector is beneficial. + static constexpr bool kUseLonglong4 = false; using vec_t = std::conditional_t; #else using vec_t = int4; diff --git a/mooncake-ep/include/elastic/mooncake_ep_elastic_comm.cuh b/mooncake-ep/include/elastic/mooncake_ep_elastic_comm.cuh index 886c2f12d8..63872c3d3d 100644 --- a/mooncake-ep/include/elastic/mooncake_ep_elastic_comm.cuh +++ b/mooncake-ep/include/elastic/mooncake_ep_elastic_comm.cuh @@ -98,10 +98,11 @@ __device__ __forceinline__ std::pair get_qp_mode( } } -template +template __forceinline__ __device__ void mooncake_barrier_wo_local_sync( - const transport::MooncakeGin& gin, const layout::WorkspaceLayout& workspace, + const Ops& gin, const layout::WorkspaceLayout& workspace, const int& rank_idx, const int& sm_idx, const int& thread_idx) { if (kNumSMs > 1 && sm_idx > 0) return; @@ -111,9 +112,16 @@ __forceinline__ __device__ void mooncake_barrier_wo_local_sync( const int sign = status >> 1; const int* base_signal = workspace.get_nvl_barrier_signal_ptr(kTag, phase); + // NCCL LSA atomics can safely converge on one symmetric counter, letting + // the waiter issue one acquire load. IBGDA retains one slot per source rank + // because its portable remote-atomic protocol must not rely on a shared + // remote word. + constexpr bool kUseAggregateSignal = + Ops::kIsNccl && std::is_same_v; if (thread_idx < kNumRanks) { - auto* dst_ptr = const_cast(base_signal) + rank_idx; - gin.red_add_rel(dst_ptr, sign ? -1 : 1, thread_idx); + auto* dst_ptr = const_cast(base_signal) + + (kUseAggregateSignal ? 0 : rank_idx); + gin.template red_add_rel(dst_ptr, sign ? -1 : 1, thread_idx); } __syncthreads(); @@ -122,63 +130,181 @@ __forceinline__ __device__ void mooncake_barrier_wo_local_sync( timeout_while( thread_idx == 0, [=](const bool& is_last_check) { - int sum = 0; + int observed = 0; + if constexpr (kUseAggregateSignal) { + observed = + ptx::ld_acquire_sys(const_cast(base_signal)); + } else { #pragma unroll - for (int i = 0; i < kNumRanks; ++i) { - sum += - ptx::ld_acquire_sys(const_cast(base_signal) + i); + for (int i = 0; i < kNumRanks; ++i) { + observed += ptx::ld_acquire_sys( + const_cast(base_signal) + i); + } } - // Mooncake's portable barrier uses one additive slot per source - // rank. Each positive phase adds +1 into a zeroed phase slot; the - // matching negative phase later adds -1 into the same phase slot. - // This matches RDMA atomic-add semantics and avoids relying on a - // remote store primitive for non-P2P peers. const auto target = sign ? 0 : kNumRanks; - if (sum == target) return true; + if (observed == target) return true; if (is_last_check) { printf( "Mooncake elastic barrier timeout, tag: %d, rank: %d, " - "signal-sum: %d, target: %d\n", - kTag, rank_idx, sum, target); + "signal: %d, target: %d\n", + kTag, rank_idx, observed, target); } return false; }); } -template +#ifdef USE_NCCL_DEVICE + +// Use one communicator-owned signal per source rank on GIN context 0. Payload +// contexts are flushed before this function; a single control context is enough +// to publish barrier arrival and avoids O(team_size * context_count) signals. +template +__forceinline__ __device__ void nccl_gin_barrier_wo_local_sync( + const transport::NcclOps& gin, const layout::WorkspaceLayout& workspace, + const int& team_rank_idx, const int& barrier_sm_idx, + const int& thread_idx) { + if (barrier_sm_idx != 0) return; + (void)workspace; + + for (int dst_team_rank = thread_idx; dst_team_rank < kNumTeamRanks; + dst_team_rank += kNumThreads) { + gin.gin_barrier_signal_inc(dst_team_rank, team_rank_idx); + } + + for (int src_team_rank = thread_idx; src_team_rank < kNumTeamRanks; + src_team_rank += kNumThreads) { + const uint64_t target = gin.gin_barrier_advance_shadow(src_team_rank); + timeout_while([=](const bool& is_last_check) { + const uint64_t observed = + gin.gin_barrier_read_signal(src_team_rank); + if (observed >= target) return true; + if (is_last_check) { + printf( + "Mooncake NCCL GIN barrier timeout, tag: %d, rank: %d, " + "source: %d, signal: %llu, target: %llu\n", + kTag, team_rank_idx, src_team_rank, + static_cast(observed), + static_cast(target)); + } + return false; + }); + } +} + +#endif // USE_NCCL_DEVICE + +template __forceinline__ __device__ void gpu_barrier( - const transport::MooncakeGin& gin, const layout::WorkspaceLayout& workspace, + const Ops& gin, const layout::WorkspaceLayout& workspace, const int& scaleout_rank_idx, const int& scaleup_rank_idx, const int& sm_idx, const int& thread_idx, bool do_scaleout = true, bool do_scaleup = true) { - if constexpr (kFlushStores) gin.flush(); - if constexpr (kSyncAtStart) { - local_grid_sync(workspace, - thread_idx); + // Complete TMA stores before publishing any remote arrival. This is also + // required for LSA writes because TMA uses a separate async proxy. + if constexpr (Ops::kIsNccl && kFlushStores) { + ptx::tma_store_commit(); + ptx::tma_store_wait(); + __syncwarp(); + } + + if constexpr (!Ops::kIsNccl) { + // Preserve IBGDA's per-thread system fence before the leading grid + // synchronization. Every producer must publish before block 0 can + // announce the remote barrier. + if constexpr (kFlushStores) gin.flush(); + if constexpr (kSyncAtStart) { + local_grid_sync( + workspace, thread_idx); + } + } else { + // Quiesce every block before one block flushes the shared NCCL GIN + // contexts. Without this ordering, block 0 could flush while another + // block is still posting puts and source buffers could be reused early. + if constexpr (kSyncAtStart) { + local_grid_sync( + workspace, thread_idx); + } else { + static_assert(!kFlushStores || kNumScaleoutRanks == 1, + "GIN flush requires a leading grid synchronization"); + } + if constexpr (kFlushStores && kNumScaleoutRanks > 1) { + // Only a participating scale-out barrier needs to flush GIN. A + // scale-up-only epilogue has already consumed/acknowledged every + // rail put through its strong completion tails. + if (do_scaleout) { + gin.flush(); + local_grid_sync( + workspace, thread_idx); + } + } } do_scaleout &= kNumScaleoutRanks > 1; do_scaleup &= kNumScaleupRanks > 1; - if (do_scaleup && !do_scaleout) { - mooncake_barrier_wo_local_sync(gin, workspace, scaleup_rank_idx, - sm_idx, thread_idx); - } else if (do_scaleout && !do_scaleup) { - mooncake_barrier_wo_local_sync( - gin, workspace, scaleout_rank_idx, sm_idx, thread_idx); - } else { - const int global_rank = - scaleout_rank_idx * kNumScaleupRanks + scaleup_rank_idx; - mooncake_barrier_wo_local_sync< - transport::WorldTeam, kNumScaleoutRanks * kNumScaleupRanks, kNumSMs, - kNumThreads, kNumTimeoutCycles, kTag>(gin, workspace, global_rank, - sm_idx, thread_idx); +#ifdef USE_NCCL_DEVICE + if constexpr (Ops::kIsNccl) { + if (do_scaleup && !do_scaleout) { + mooncake_barrier_wo_local_sync< + Ops, transport::ScaleupTeam, kNumScaleupRanks, kNumSMs, + kNumThreads, kNumTimeoutCycles, kTag>( + gin, workspace, scaleup_rank_idx, sm_idx, thread_idx); + } else if (do_scaleout && !do_scaleup) { + nccl_gin_barrier_wo_local_sync( + gin, workspace, scaleout_rank_idx, sm_idx, thread_idx); + } else if (do_scaleup && do_scaleout) { + if constexpr (kNumSMs > 1) { + // Separate blocks let LSA and GIN progress concurrently. They + // use disjoint signal addresses. + mooncake_barrier_wo_local_sync< + Ops, transport::ScaleupTeam, kNumScaleupRanks, kNumSMs, + kNumThreads, kNumTimeoutCycles, kTag>( + gin, workspace, scaleup_rank_idx, sm_idx, thread_idx); + nccl_gin_barrier_wo_local_sync( + gin, workspace, scaleout_rank_idx, sm_idx - 1, thread_idx); + } else { + // A one-block launch cannot overlap teams safely. Complete LSA + // first and then the GIN rail barrier. + mooncake_barrier_wo_local_sync( + gin, workspace, scaleup_rank_idx, 0, thread_idx); + nccl_gin_barrier_wo_local_sync( + gin, workspace, scaleout_rank_idx, 0, thread_idx); + } + } + } else +#endif + { + // Keep the established IBGDA barrier byte-for-byte in behavior. + if (do_scaleup && !do_scaleout) { + mooncake_barrier_wo_local_sync< + Ops, transport::ScaleupTeam, kNumScaleupRanks, kNumSMs, + kNumThreads, kNumTimeoutCycles, kTag>( + gin, workspace, scaleup_rank_idx, sm_idx, thread_idx); + } else if (do_scaleout && !do_scaleup) { + mooncake_barrier_wo_local_sync< + Ops, transport::ScaleoutTeam, kNumScaleoutRanks, kNumSMs, + kNumThreads, kNumTimeoutCycles, kTag>( + gin, workspace, scaleout_rank_idx, sm_idx, thread_idx); + } else { + const int global_rank = + scaleout_rank_idx * kNumScaleupRanks + scaleup_rank_idx; + mooncake_barrier_wo_local_sync< + Ops, transport::WorldTeam, kNumScaleoutRanks * kNumScaleupRanks, + kNumSMs, kNumThreads, kNumTimeoutCycles, kTag>( + gin, workspace, global_rank, sm_idx, thread_idx); + } } if constexpr (kSyncAtEnd) { diff --git a/mooncake-ep/include/elastic/mooncake_ep_elastic_compiled.cuh b/mooncake-ep/include/elastic/mooncake_ep_elastic_compiled.cuh index 1850361106..e5b277f8f6 100644 --- a/mooncake-ep/include/elastic/mooncake_ep_elastic_compiled.cuh +++ b/mooncake-ep/include/elastic/mooncake_ep_elastic_compiled.cuh @@ -28,8 +28,7 @@ #endif #include -#include -#include +#include #if defined(MOONCAKE_EP_USE_MUSA) && defined(__MCC__) && \ !defined(MOONCAKE_EP_MUSA_LDG_DEFINED) @@ -40,7 +39,7 @@ __device__ __forceinline__ dtype_t __ldg(const dtype_t* ptr) { } #endif -#ifndef DISABLE_SM90_FEATURES +#if !defined(MOONCAKE_EP_USE_MUSA) && !defined(DISABLE_SM90_FEATURES) #include #elif !defined(MOONCAKE_EP_USE_MUSA) // Ampere does not support FP8 features diff --git a/mooncake-ep/include/elastic/mooncake_ep_elastic_dispatch_copy_epilogue.cuh b/mooncake-ep/include/elastic/mooncake_ep_elastic_dispatch_copy_epilogue.cuh index 47cfca8401..8c9955d90c 100644 --- a/mooncake-ep/include/elastic/mooncake_ep_elastic_dispatch_copy_epilogue.cuh +++ b/mooncake-ep/include/elastic/mooncake_ep_elastic_dispatch_copy_epilogue.cuh @@ -32,7 +32,8 @@ __global__ void __launch_bounds__(kNumThreads, 1) dispatch_copy_epilogue_impl( const auto sm_idx = static_cast(blockIdx.x), thread_idx = static_cast(threadIdx.x); const auto warp_idx = ptx::get_warp_idx(), lane_idx = ptx::get_lane_idx(); - const auto global_warp_idx = warp_idx * kNumSMs + sm_idx; + const auto num_sms = kNumSMs == 0 ? static_cast(gridDim.x) : kNumSMs; + const auto global_warp_idx = warp_idx * num_sms + sm_idx; // For top-k index transformations constexpr int kNumExpertsPerRank = kNumExperts / kNumRanks; @@ -77,7 +78,7 @@ __global__ void __launch_bounds__(kNumThreads, 1) dispatch_copy_epilogue_impl( int current_rank_start = 0, current_rank_end = 0; #pragma unroll for (int i = global_warp_idx; i < num_recv_tokens; - i += kNumWarps * kNumSMs) { + i += kNumWarps * num_sms) { // Calculate token index in the buffer while (i >= current_rank_end) { current_rank_idx += 1; @@ -256,7 +257,7 @@ __global__ void __launch_bounds__(kNumThreads, 1) dispatch_copy_epilogue_impl( const auto workspace_layout = layout::WorkspaceLayout( workspace, kNumScaleoutRanks, kNumScaleupRanks, kNumExperts); for (int i = global_warp_idx; i < kNumChannels; - i += kNumSMs * kNumWarps) { + i += num_sms * kNumWarps) { #pragma unroll for (int j = 0; j < kNumScaleupRanksPerLane; ++j) { if (const auto k = j * 32 + lane_idx; diff --git a/mooncake-ep/include/elastic/mooncake_ep_elastic_dispatch_official.cuh b/mooncake-ep/include/elastic/mooncake_ep_elastic_dispatch_official.cuh index 761392dde4..98be7026c3 100644 --- a/mooncake-ep/include/elastic/mooncake_ep_elastic_dispatch_official.cuh +++ b/mooncake-ep/include/elastic/mooncake_ep_elastic_dispatch_official.cuh @@ -15,11 +15,12 @@ namespace mooncake::elastic { -template 0)>( sm_idx, warp_idx - kNumNotifyWarps, warp_idx < kNumNotifyWarps); - const auto gin = transport::MooncakeGin(comm_ctx, qp_idx, sharing_mode, - kNumQPs, 0, 0, 0, kNumRanks); + const auto gin = + Ops(comm_ctx, qp_idx, sharing_mode, kNumQPs, 0, 0, 0, kNumRanks); // Barrier without TMA store flush, without prologue grid sync - comm::gpu_barrier(gin, workspace_layout, 0, rank_idx, sm_idx, thread_idx); @@ -178,9 +180,9 @@ __global__ void __launch_bounds__(kNumThreads, 1) const auto dst_rank_counter = workspace_layout.get_scaleup_rank_count_ptr() + rank_idx; - gin.put_value(dst_rank_counter, - static_cast(rank_count[i]), i, - 0); + gin.template put_value( + dst_rank_counter, static_cast(rank_count[i]), i, + 0); } __syncwarp(); @@ -193,7 +195,7 @@ __global__ void __launch_bounds__(kNumThreads, 1) i += kNumNotifyThreads) { const auto idx = kNumExpertsPerRank * rank_idx + (i % kNumExpertsPerRank); - gin.put_value( + gin.template put_value( workspace_layout.get_scaleup_expert_count_ptr() + idx, static_cast(expert_count[i]), @@ -209,8 +211,9 @@ __global__ void __launch_bounds__(kNumThreads, 1) const auto dst_ptr = workspace_layout.get_scaleup_expert_count_ptr() + kNumExpertsPerRank * rank_idx; - gin.put(dst_ptr, src_ptr, - kNumExpertsPerRank * sizeof(int64_t), i); + gin.template put( + dst_ptr, src_ptr, kNumExpertsPerRank * sizeof(int64_t), + i); } } @@ -461,7 +464,7 @@ __global__ void __launch_bounds__(kNumThreads, 1) EP_STATIC_ASSERT(kNumTopk <= 32, "Invalid top-k selection"); const auto dst_ptr = stored_dst_slot_idx >= 0 - ? gin.get_sym_ptr( + ? gin.template get_sym_ptr( recv_buffer.get_token_buffer(stored_dst_slot_idx) .get_base_ptr(), stored_dst_rank_idx) @@ -480,7 +483,7 @@ __global__ void __launch_bounds__(kNumThreads, 1) // NOTES: we should skip the NVLink accessible ranks if (stored_dst_slot_idx >= 0 and dst_ptr == nullptr) { - gin.put( + gin.template put( recv_buffer.get_token_buffer(stored_dst_slot_idx) .get_base_ptr(), send_buffer_ptr, tma_buffer.get_num_bytes(), @@ -492,7 +495,7 @@ __global__ void __launch_bounds__(kNumThreads, 1) } // Barrier to ensure data arrival - comm::gpu_barrier(gin, workspace_layout, 0, rank_idx, sm_idx, thread_idx); diff --git a/mooncake-ep/include/elastic/mooncake_ep_elastic_exception.cuh b/mooncake-ep/include/elastic/mooncake_ep_elastic_exception.cuh index 26e1c8b984..371f16ee8f 100644 --- a/mooncake-ep/include/elastic/mooncake_ep_elastic_exception.cuh +++ b/mooncake-ep/include/elastic/mooncake_ep_elastic_exception.cuh @@ -39,7 +39,7 @@ #endif #ifndef EP_UNIFIED_ASSERT -#ifdef __CUDA_ARCH__ +#if defined(__CUDA_ARCH__) || defined(__MUSA_ARCH__) #define EP_UNIFIED_ASSERT(cond) EP_DEVICE_ASSERT(cond) #else #define EP_UNIFIED_ASSERT(cond) EP_HOST_ASSERT(cond) diff --git a/mooncake-ep/include/elastic/mooncake_ep_elastic_hybrid_combine_official.cuh b/mooncake-ep/include/elastic/mooncake_ep_elastic_hybrid_combine_official.cuh index 1689e8f01f..61c3a1bf40 100644 --- a/mooncake-ep/include/elastic/mooncake_ep_elastic_hybrid_combine_official.cuh +++ b/mooncake-ep/include/elastic/mooncake_ep_elastic_hybrid_combine_official.cuh @@ -14,10 +14,11 @@ namespace mooncake::elastic { template < - bool kUseExpandedLayout, bool kAllowMultipleReduction, int kNumSMs, - int kNumScaleupWarps, int kNumForwardWarps, int kNumScaleoutRanks, - int kNumScaleupRanks, int kHidden, int kNumMaxTokensPerRank, - int kNumExperts, int kNumTopk, int kNumQPs, int64_t kNumTimeoutCycles, + typename Ops, bool kUseExpandedLayout, bool kAllowMultipleReduction, + int kNumSMs, int kNumScaleupWarps, int kNumForwardWarps, + int kNumScaleoutRanks, int kNumScaleupRanks, int kHidden, + int kNumMaxTokensPerRank, int kNumExperts, int kNumTopk, int kNumQPs, + int64_t kNumTimeoutCycles, int kNumScaleupRanksPerLane = math::constexpr_ceil_div(kNumScaleupRanks, 32), int kNumScaleupUpdateInterval = 3, int kNumChannelsPerSM = kNumForwardWarps, @@ -41,7 +42,7 @@ __global__ void __launch_bounds__(kNumThreads, 1) int* psum_num_recv_tokens_per_scaleup_rank, int* token_metadata_at_forward, int* channel_linked_list, - const device::CommCtx comm_ctx, void* buffer, + const typename Ops::Context comm_ctx, void* buffer, void* workspace, const int scaleout_rank_idx, const int scaleup_rank_idx, int num_reduced_tokens) { // Utils @@ -99,14 +100,14 @@ __global__ void __launch_bounds__(kNumThreads, 1) const auto [qp_idx, sharing_mode] = comm::get_qp_mode( sm_idx, warp_idx % kNumChannelsPerSM); - const auto gin = transport::MooncakeGin( - comm_ctx, qp_idx, sharing_mode, kNumQPs, scaleout_rank_idx, - scaleup_rank_idx, kNumScaleupRanks, kNumRanks); + const auto gin = + Ops(comm_ctx, qp_idx, sharing_mode, kNumQPs, scaleout_rank_idx, + scaleup_rank_idx, kNumScaleupRanks, kNumRanks); // Global parallel barriers for scale-out subteam and scale-up subteam // NOTES: this barrier needs a grid sync, as there are channel scale-up tail // cleaning before - comm::gpu_barrier( gin, workspace_layout, scaleout_rank_idx, scaleup_rank_idx, sm_idx, @@ -114,10 +115,17 @@ __global__ void __launch_bounds__(kNumThreads, 1) // Adjust register count at certain cases // TODO: support more cases, or try to make channel count more aligned - // DeepEP's register redistribution uses setmaxnreg, which is not accepted - // by ptxas for the SM90 target used by current Mooncake NV validation. - // Keep the official role split but disable this SM100-only optimization. + // setmaxnreg is rejected by the SM90 build used in Mooncake validation, + // but it materially improves the producer/forwarder split on Blackwell. +#if defined(__CUDA_ARCH__) && __CUDA_ARCH__ >= 1000 && \ + (defined(__CUDA_ARCH_SPECIFIC__) || \ + defined(__CUDA_ARCH_FAMILY_SPECIFIC__)) + constexpr bool kAdjustRegisters = + (kNumChannelsPerSM == 4 || kNumChannelsPerSM == 8) && + !kUseExpandedLayout; +#else constexpr bool kAdjustRegisters = false; +#endif constexpr int kNumRegistersForScaleupWarps = 40; constexpr int kNumRegistersForForwardWarps = 256 - kNumRegistersForScaleupWarps; @@ -164,8 +172,8 @@ __global__ void __launch_bounds__(kNumThreads, 1) if (stored_num_tokens_sent[i] != stored_old_num_tokens_sent[i]) ptx::st_release_sys( - gin.get_sym_ptr( - tail_ptr, j), + gin.template get_sym_ptr< + transport::ScaleupTeam>(tail_ptr, j), stored_num_tokens_sent[i]); stored_old_num_tokens_sent[i] = stored_num_tokens_sent[i]; @@ -271,7 +279,7 @@ __global__ void __launch_bounds__(kNumThreads, 1) } }(); token_buffer.set_base_ptr( - gin.get_sym_ptr( + gin.template get_sym_ptr( token_buffer.get_base_ptr(), dst_scaleup_rank_idx)); // Some checks @@ -377,7 +385,8 @@ __global__ void __launch_bounds__(kNumThreads, 1) kNumMaxTokensPerRank + src_token_idx); ptx::tma_store_1d( - gin.get_sym_ptr( + gin.template get_sym_ptr< + transport::ScaleupTeam>( dst_token_buffer.get_base_ptr(), dst_scaleup_rank_idx), tma_buffer.get_base_ptr(), @@ -464,11 +473,12 @@ __global__ void __launch_bounds__(kNumThreads, 1) // Issue only if not local rank if (last_src_scaleout_rank_idx != scaleout_rank_idx) { - gin.put( + gin.template put( last_recv_token_buffer_ptr, last_send_token_buffer_ptr, token_layout.get_num_bytes(), last_src_scaleout_rank_idx, - last_is_token_last_in_chunk ? 0 : 0); + last_is_token_last_in_chunk ? 0 + : Ops::kAggregateRequests); } } __syncwarp(); @@ -615,13 +625,13 @@ __global__ void __launch_bounds__(kNumThreads, 1) // Issue IBGDA topk_valid_mask ^= 1u << k; if (src_scaleout_rank_idx != scaleout_rank_idx) { - gin.put( + gin.template put( recv_buffer_ptr, send_buffer_ptr, token_layout.get_num_bytes(), src_scaleout_rank_idx, topk_valid_mask == 0 and is_token_last_in_chunk ? 0 - : 0); + : Ops::kAggregateRequests); } } } @@ -743,26 +753,55 @@ __global__ void __launch_bounds__(kNumThreads, 1) } __syncwarp(); + // Settle this channel's source buffers before publishing completion. + // The matching remote tail signals below acknowledge every preceding + // put on this context, so no additional grid-wide flush is needed. + if constexpr (Ops::kIsNccl) gin.flush_channel(); + // Update, wait and clean EP_STATIC_ASSERT(kNumScaleoutRanks <= 32, "Invalid ranks"); + const auto expected_signal = math::pack2(1, 0); if (lane_idx < kNumScaleoutRanks) { // Update remote tails - const auto expected_signal = math::pack2(1, 0); - gin.red_add_rel( + auto* remote_tail = workspace_layout.get_scaleout_channel_signaled_tail_ptr( - channel_idx, scaleout_rank_idx), - expected_signal, lane_idx); + channel_idx, scaleout_rank_idx); + if constexpr (Ops::kIsNccl) { + gin.template red_add_rel( + remote_tail, expected_signal, lane_idx); + } else { + gin.template publish_tail( + remote_tail, channel_idx, expected_signal, expected_signal, + lane_idx); + } + } + __syncwarp(); - // Wait tail arrival + // Wait tail arrival. Keep the NCCL poll independent of `gin` so the + // timeout lambda does not capture and spill the full GIN handle. + if (lane_idx < kNumScaleoutRanks) { const auto wait_ptr = workspace_layout.get_scaleout_channel_signaled_tail_ptr( channel_idx, lane_idx); comm::timeout_while([=](const bool& is_last_check) { - const auto signal = ptx::ld_acquire_sys(wait_ptr); + int64_t signal; + if constexpr (Ops::kIsNccl) { + signal = ptx::ld_acquire_sys(wait_ptr); + } else { + signal = gin.template read_completion_tail< + transport::ScaleoutTeam>(wait_ptr, channel_idx, + lane_idx); + } if (signal == expected_signal) { // Clean for next usages - *wait_ptr = 0; + if constexpr (Ops::kIsNccl) { + *wait_ptr = 0; + } else { + gin.template clear_completion_tail< + transport::ScaleoutTeam>(wait_ptr, channel_idx, + lane_idx); + } return true; } @@ -780,8 +819,6 @@ __global__ void __launch_bounds__(kNumThreads, 1) } __syncwarp(); } - - // No barrier at epilogue } } // namespace mooncake::elastic diff --git a/mooncake-ep/include/elastic/mooncake_ep_elastic_hybrid_dispatch_official.cuh b/mooncake-ep/include/elastic/mooncake_ep_elastic_hybrid_dispatch_official.cuh index 9211911d87..99f83f7e01 100644 --- a/mooncake-ep/include/elastic/mooncake_ep_elastic_hybrid_dispatch_official.cuh +++ b/mooncake-ep/include/elastic/mooncake_ep_elastic_hybrid_dispatch_official.cuh @@ -15,10 +15,10 @@ namespace mooncake::elastic { template < - bool kDoCPUSync, bool kReuseSlotIndices, int kNumSMs, int kNumNotifyWarps, - int kNumScaleoutWarps, int kNumForwardWarps, int kNumScaleoutRanks, - int kNumScaleupRanks, int kNumHiddenBytes, int kNumSFPacks, - int kNumMaxTokensPerRank, int kNumExperts, int kNumTopk, + typename Ops, bool kDoCPUSync, bool kReuseSlotIndices, int kNumSMs, + int kNumNotifyWarps, int kNumScaleoutWarps, int kNumForwardWarps, + int kNumScaleoutRanks, int kNumScaleupRanks, int kNumHiddenBytes, + int kNumSFPacks, int kNumMaxTokensPerRank, int kNumExperts, int kNumTopk, int kExpertAlignment, int kNumQPs, int64_t kNumTimeoutCycles, int kNumScaleupRanksPerLane = math::constexpr_ceil_div(kNumScaleupRanks, 32), @@ -26,7 +26,7 @@ template < int kNumChannels = kNumScaleoutWarps * kNumSMs, int kNumMaxTokensPerChannel = math::constexpr_ceil_div(kNumMaxTokensPerRank, kNumChannels), - int kScaleoutUpdateInterval = 3, + int kScaleoutUpdateInterval = Ops::kScaleoutUpdateInterval, int kNumSlotsPerForwardChunk = kScaleoutUpdateInterval, int kNumRanks = kNumScaleoutRanks * kNumScaleupRanks, int kNumNotifyThreads = kNumNotifyWarps * 32, @@ -44,7 +44,7 @@ __global__ void __launch_bounds__(kNumThreads, 1) int* token_metadata_at_forward, const int num_tokens, const int sf_token_stride, const int sf_hidden_stride, // TODO(NCCL): so many params, plans to optimize? - const device::CommCtx comm_ctx, void* buffer, + const typename Ops::Context comm_ctx, void* buffer, void* workspace, void* mapped_host_workspace, const int scaleout_rank_idx, const int scaleup_rank_idx) { @@ -92,12 +92,12 @@ __global__ void __launch_bounds__(kNumThreads, 1) (kNumNotifyWarps > 0)>( sm_idx, (warp_idx - kNumNotifyWarps) % kNumChannelsPerSM, warp_idx < kNumNotifyWarps); - const auto gin = transport::MooncakeGin( - comm_ctx, qp_idx, sharing_mode, kNumQPs, scaleout_rank_idx, - scaleup_rank_idx, kNumScaleupRanks, kNumRanks); + const auto gin = + Ops(comm_ctx, qp_idx, sharing_mode, kNumQPs, scaleout_rank_idx, + scaleup_rank_idx, kNumScaleupRanks, kNumRanks); // Global parallel barriers for scale-out subteam and scale-up subteam - comm::gpu_barrier( gin, workspace_layout, scaleout_rank_idx, scaleup_rank_idx, sm_idx, @@ -226,13 +226,14 @@ __global__ void __launch_bounds__(kNumThreads, 1) "kNumScaleoutRanks must be less than kNumNotifyThreads"); if (thread_idx < kNumScaleoutRanks) { const auto dst_scaleout_rank_idx = thread_idx; - gin.put( + gin.template put( workspace_layout.get_scaleout_rank_count_ptr( scaleout_rank_idx), workspace_layout.get_scaleout_rank_count_ptr( dst_scaleout_rank_idx), - kNumScaleupRanks * sizeof(int), dst_scaleout_rank_idx, 0); - gin.put( + kNumScaleupRanks * sizeof(int), dst_scaleout_rank_idx, + Ops::kAggregateRequests); + gin.template put( workspace_layout.get_scaleout_expert_count_ptr( scaleout_rank_idx), workspace_layout.get_scaleout_expert_count_ptr( @@ -294,7 +295,7 @@ __global__ void __launch_bounds__(kNumThreads, 1) // Write into the remote scale-up peer const int64_t counter = (static_cast(kNumScaleupRanks) << 32ll) | count; - gin.put_value( + gin.template put_value( workspace_layout.get_scaleup_rank_count_ptr() + scaleup_rank_idx, counter, i); @@ -318,7 +319,7 @@ __global__ void __launch_bounds__(kNumThreads, 1) const int64_t counter = (1ll << 32ll) | count; const auto dst_scaleup_rank_idx = i / kNumExpertsPerRank; const auto expert_idx_in_dst_rank = i % kNumExpertsPerRank; - gin.red_add_rel( + gin.template red_add_rel( workspace_layout.get_scaleup_expert_count_ptr() + expert_idx_in_dst_rank, counter, dst_scaleup_rank_idx); @@ -450,8 +451,9 @@ __global__ void __launch_bounds__(kNumThreads, 1) // NOTES: the "release" scope will be `sys` for the local rank // (we may involve NVLink so not `gpu`) For RDMA requests, // "release" is ensured by "atomic" - gin.red_add_rel( - ptr, signaled_tail - old_signaled_tail, lane_idx, + gin.template publish_tail( + ptr, channel_idx, signaled_tail, + signaled_tail - old_signaled_tail, lane_idx, transport::kRedAddReleaseLowWordLast); stored_old_scaleout_tail = stored_scaleout_tail; } @@ -585,13 +587,13 @@ __global__ void __launch_bounds__(kNumThreads, 1) // Issue IBGDA requests if (stored_dst_slot_idx >= 0 and stored_dst_scaleout_rank_idx != scaleout_rank_idx) { - gin.put( + gin.template put( scaleout_recv_buffer.get_token_buffer(stored_dst_slot_idx) .get_base_ptr(), scaleout_send_buffer.get_token_buffer(token_idx) .get_base_ptr(), tma_buffer.get_num_bytes(), - stored_dst_scaleout_rank_idx, 0); + stored_dst_scaleout_rank_idx, Ops::kAggregateRequests); } __syncwarp(); @@ -677,9 +679,13 @@ __global__ void __launch_bounds__(kNumThreads, 1) // Read new signaled tails if (lane_idx < kNumScaleoutRanks) { - const auto signaled_tail = ptx::ld_acquire_sys( - workspace_layout.get_scaleout_channel_signaled_tail_ptr( - channel_idx, lane_idx)); + const auto signaled_tail = + gin.template read_completion_tail< + transport::ScaleoutTeam>( + workspace_layout + .get_scaleout_channel_signaled_tail_ptr( + channel_idx, lane_idx), + channel_idx, lane_idx); math::unpack2(signaled_tail, stored_finish_flag, stored_scaleout_tail_idx); @@ -779,7 +785,7 @@ __global__ void __launch_bounds__(kNumThreads, 1) // Issue TMAs if (stored_dst_slot_idx >= 0) { const auto dst_ptr = - gin.get_sym_ptr( + gin.template get_sym_ptr( scaleup_buffer.get_token_buffer(stored_dst_slot_idx) .get_base_ptr(), stored_dst_scaleup_rank_idx); @@ -848,7 +854,8 @@ __global__ void __launch_bounds__(kNumThreads, 1) if (const auto j = i * 32 + lane_idx; i < (kNumScaleupRanksPerLane - 1) or j < kNumScaleupRanks) { ptx::st_relaxed_sys( - gin.get_sym_ptr(tail_ptr, j), + gin.template get_sym_ptr( + tail_ptr, j), transform_linked_list_idx( stored_scaleup_send_counters[i])); } @@ -857,16 +864,19 @@ __global__ void __launch_bounds__(kNumThreads, 1) __syncwarp(); // Clean tails for next usages - if (lane_idx < kNumScaleoutRanks) - *workspace_layout.get_scaleout_channel_signaled_tail_ptr( - channel_idx, lane_idx) = 0; + if (lane_idx < kNumScaleoutRanks) { + gin.template clear_completion_tail( + workspace_layout.get_scaleout_channel_signaled_tail_ptr( + channel_idx, lane_idx), + channel_idx, lane_idx); + } __syncwarp(); } // Scale-up barrier to ensure data arrival // As scale-out tokens have already been consumed by forwarders, no need to // do scale-out barrier again - comm::gpu_barrier( gin, workspace_layout, scaleout_rank_idx, scaleup_rank_idx, sm_idx, diff --git a/mooncake-ep/include/elastic/mooncake_ep_elastic_launch.cuh b/mooncake-ep/include/elastic/mooncake_ep_elastic_launch.cuh index e21c86a170..7b396e42b5 100644 --- a/mooncake-ep/include/elastic/mooncake_ep_elastic_launch.cuh +++ b/mooncake-ep/include/elastic/mooncake_ep_elastic_launch.cuh @@ -2,12 +2,39 @@ #include -#include -#include +#include + +#ifdef USE_NCCL_DEVICE +#include +#endif namespace mooncake { +enum class ElasticTransportBackend : uint8_t { + kIbgda = 0, + kNccl = 1, +}; + +#ifdef USE_NCCL_DEVICE +namespace elastic::transport { + +// Everything the NCCL kernel adapter needs in addition to NCCL's opaque +// device context. The pointer fields identify offsets in the symmetric NCCL +// registration; rank/team metadata is kept explicit so hot device paths do +// not need capability queries. +struct NcclContext { + device::NcclDeviceContext device; +}; + +} // namespace elastic::transport +#endif + struct ElasticLaunchContext { + ElasticTransportBackend backend = ElasticTransportBackend::kIbgda; + int device_id = -1; +#ifdef USE_NCCL_DEVICE + elastic::transport::NcclContext nccl; +#endif void* gdr_buffer = nullptr; const int32_t* nvlink_available = nullptr; void* const* ipc_peer_ptrs = nullptr; @@ -53,8 +80,8 @@ void launch_mooncake_elastic_dispatch_copy_epilogue( float* recv_topk_weights, int* recv_src_metadata, int* channel_linked_list, int num_recv_tokens, int num_max_tokens_per_rank, int hidden, int elem_size, int num_sf_packs, int recv_sf_token_stride, int recv_sf_hidden_stride, - int num_experts, int num_topk, int num_sms, int num_smem_bytes, - int num_channels, bool do_expand, bool cached_mode, + int num_experts, int num_topk, int num_sms, int num_epilogue_sms, + int num_smem_bytes, int num_channels, bool do_expand, bool cached_mode, const ElasticLaunchContext& ctx, int* psum_num_recv_tokens_per_scaleup_rank, int* psum_num_recv_tokens_per_expert, cudaStream_t stream); @@ -71,8 +98,8 @@ void launch_mooncake_elastic_combine_reduce_epilogue( void* combined_x, float* combined_topk_weights, int64_t* combined_topk_idx, int num_combined_tokens, int num_max_tokens_per_rank, int hidden, int num_experts, int num_topk, void* reduce_buffer, void* bias_0, - void* bias_1, int num_sms, int num_smem_bytes, bool use_expanded_layout, - bool allow_multiple_reduction, const ElasticLaunchContext& ctx, - cudaStream_t stream); + void* bias_1, int num_sms, int num_epilogue_sms, int num_smem_bytes, + bool use_expanded_layout, bool allow_multiple_reduction, + const ElasticLaunchContext& ctx, cudaStream_t stream); } // namespace mooncake diff --git a/mooncake-ep/include/elastic/mooncake_ep_elastic_layout.cuh b/mooncake-ep/include/elastic/mooncake_ep_elastic_layout.cuh index f03d5943bb..809c84a81b 100644 --- a/mooncake-ep/include/elastic/mooncake_ep_elastic_layout.cuh +++ b/mooncake-ep/include/elastic/mooncake_ep_elastic_layout.cuh @@ -3,6 +3,7 @@ // transport references are replaced with Mooncake Device API adapters. #pragma once +#include #include #include #include @@ -24,13 +25,10 @@ struct WorkspaceLayout { static constexpr int kNumMaxExpertsPerRank = 256; static constexpr int kNumMaxInflightAGRS = 32; - // Mooncake Device API does not rely on NCCL GIN remote RED on a single - // symmetric signal word. Use per-source-rank signal slots for both phases: - // each sender atomically updates its own slot with release semantics and - // receivers poll the full slot vector. Keep an independent counter/slot - // vector for each logical barrier tag, as hybrid kernels mix world and - // scale-up-only barriers in the same workspace and therefore must not share - // phase/sign state across tags. + // Reserve per-source-rank signal slots for the portable IBGDA barrier. + // NCCL LSA barriers aggregate arrivals in the first slot of each phase. + // Each logical barrier tag needs independent phase/sign state because + // hybrid kernels mix world and scale-up-only barriers in one workspace. static constexpr int kNumBarrierTags = 16; static constexpr int64_t kNumBarrierBytesPerTag = sizeof(unsigned long long) + 2 * kNumMaxRanks * sizeof(int); @@ -52,8 +50,8 @@ struct WorkspaceLayout { EP_UNIFIED_ASSERT(num_experts_per_rank <= kNumMaxExpertsPerRank); } - static int64_t get_num_bytes() { - // Pure NVLink scaleup barrier signals + __forceinline__ __device__ __host__ static int64_t get_num_bytes() { + // Pure NVLink scaleup barrier signals. int64_t num_bytes = 0; num_bytes += kNumBarrierSignalBytes; @@ -85,7 +83,7 @@ struct WorkspaceLayout { // Rank send/recv count, for PP prev/next ranks num_bytes += 2 * 2 * sizeof(int64_t); - // AGRS signals + // All-gather/reduce-scatter signals num_bytes += (kNumMaxInflightAGRS + 1) * kNumMaxRanks * sizeof(int); // Ensure LDG.256 work diff --git a/mooncake-ep/include/elastic/mooncake_ep_elastic_ptx.cuh b/mooncake-ep/include/elastic/mooncake_ep_elastic_ptx.cuh index f1fa20650e..c9cfff26ac 100644 --- a/mooncake-ep/include/elastic/mooncake_ep_elastic_ptx.cuh +++ b/mooncake-ep/include/elastic/mooncake_ep_elastic_ptx.cuh @@ -3,7 +3,7 @@ // transport references are replaced with Mooncake Device API adapters. #pragma once -#include +#include #include #include @@ -22,7 +22,7 @@ using arrival_phase = uint32_t; // More than TMA, `longlong4` requires 32 bytes aligned static constexpr int kNumTMAAlignBytes = 32; -#ifdef __CUDACC__ +#if defined(__CUDACC__) || defined(__MUSACC__) /// Exceptions __forceinline__ __device__ void trap() { @@ -521,6 +521,30 @@ __forceinline__ __device__ void red_add_rel_sys(const int64_t* ptr, #endif } +__forceinline__ __device__ void red_add_rel_gpu(const int* ptr, + const int& value) { +#ifdef MOONCAKE_EP_USE_MUSA + atomicAdd(const_cast(ptr), value); + __threadfence(); +#else + asm volatile("red.release.gpu.global.add.s32 [%0], %1;" ::"l"(ptr), + "r"(value)); +#endif +} + +__forceinline__ __device__ void red_add_rel_gpu(const int64_t* ptr, + const int64_t& value) { +#ifdef MOONCAKE_EP_USE_MUSA + atomicAdd(const_cast( + reinterpret_cast(ptr)), + static_cast(value)); + __threadfence(); +#else + asm volatile("red.release.gpu.global.add.u64 [%0], %1;" ::"l"(ptr), + "l"(value)); +#endif +} + template __forceinline__ __device__ dtype_t ld_acquire_sys(const dtype_t* ptr) { #ifdef MOONCAKE_EP_USE_MUSA diff --git a/mooncake-ep/include/elastic/mooncake_ep_elastic_transport.cuh b/mooncake-ep/include/elastic/mooncake_ep_elastic_transport.cuh index 7855ce3a63..58df45b2fc 100644 --- a/mooncake-ep/include/elastic/mooncake_ep_elastic_transport.cuh +++ b/mooncake-ep/include/elastic/mooncake_ep_elastic_transport.cuh @@ -4,8 +4,13 @@ #include #include +#include +#include #include #include +#ifdef USE_NCCL_DEVICE +#include +#endif namespace mooncake::elastic::transport { @@ -31,7 +36,27 @@ constexpr int kRedAddReleaseLowWordLast = 1 << 0; // // Team tags are kept as types so official DeepEP template code can remain close // to the source while the actual routing is decided by Mooncake CommCtx. -struct MooncakeGin { +struct IbgdaOps { + using Context = device::CommCtx; + static constexpr bool kIsNccl = false; + static constexpr int kNumQPs = MAX_QP_COUNT; + static constexpr int kAggregateRequests = 0; + // Preserve the established IPC + IBGDA tail-publication granularity. + static constexpr int kScaleoutUpdateInterval = 3; +#ifdef MOONCAKE_EP_USE_MUSA + static constexpr int kNumDispatchWarps = 4; + static constexpr int kNumDispatchEpilogueWarps = 4; + static constexpr int kNumCombineWarps = 4; + static constexpr int kNumCombineEpilogueWarps = 4; +#else + static constexpr int kNumDispatchWarps = 8; + static constexpr int kNumDispatchEpilogueWarps = 8; + static constexpr int kNumCombineWarps = 8; + static constexpr int kNumCombineEpilogueWarps = 8; +#endif + static constexpr int kNumHybridScaleoutWarps = 4; + static constexpr int kNumHybridForwardWarps = 4; + static constexpr int kNumHybridScaleupWarps = 4; device::CommCtx ctx; int qp_idx = 0; int sharing_mode = 0; @@ -40,10 +65,12 @@ struct MooncakeGin { int scaleup_rank_idx = 0; int num_scaleup_ranks = 0; - __device__ __forceinline__ MooncakeGin( - const device::CommCtx& ctx, int qp_idx, int sharing_mode, int num_qps, - int scaleout_rank_idx = 0, int scaleup_rank_idx = 0, - int num_scaleup_ranks = 0, int num_ranks = 1) + __device__ __forceinline__ IbgdaOps(const device::CommCtx& ctx, int qp_idx, + int sharing_mode, int num_qps, + int scaleout_rank_idx = 0, + int scaleup_rank_idx = 0, + int num_scaleup_ranks = 0, + int num_ranks = 1) : ctx(ctx), qp_idx(qp_idx), sharing_mode(sharing_mode), @@ -255,7 +282,279 @@ struct MooncakeGin { } } + template + __device__ __forceinline__ void reset_completion_tail( + int /*channel_idx*/, int /*sender_rank*/) const {} + + template + __device__ __forceinline__ value_t read_completion_tail( + value_t* ptr, int /*channel_idx*/, int /*sender_rank*/) const { + return ptx::ld_acquire_sys(ptr); + } + + template + __device__ __forceinline__ void clear_completion_tail( + value_t* ptr, int /*channel_idx*/, int /*sender_rank*/) const { + *ptr = 0; + } + + template + __device__ __forceinline__ void publish_tail(value_t* dst_ptr, + int channel_idx, + value_t absolute_value, + value_t delta, int dst_rank, + int flags = 0) const { + (void)channel_idx; + (void)absolute_value; + red_add_rel(dst_ptr, delta, dst_rank, flags); + } + __device__ __forceinline__ void flush() const { __threadfence_system(); } }; +#ifdef USE_NCCL_DEVICE + +// NCCL implementation of the same compile-time kernel surface as IbgdaOps. +// LSA peers use direct symmetric pointers. Hybrid scale-out uses NCCL's rail +// team, while non-hybrid GIN operations use the full world team. +struct NcclOps { + using Context = NcclContext; + static constexpr bool kIsNccl = true; + static constexpr int kNumQPs = 65; + static constexpr int kAggregateRequests = ncclGinOptFlagsAggregateRequests; + // Match current DeepEP v2 NCCL rail tail batching. + static constexpr int kScaleoutUpdateInterval = 6; + static constexpr int kNumDispatchWarps = 27; + static constexpr int kNumDispatchEpilogueWarps = 27; + static constexpr int kNumCombineWarps = 28; + static constexpr int kNumCombineEpilogueWarps = 28; + static constexpr int kNumHybridScaleoutWarps = 8; + static constexpr int kNumHybridForwardWarps = 8; + static constexpr int kNumHybridScaleupWarps = 8; + + // Keep this adapter compact: it is captured by several kernel lambdas. + // Team/rank/window state already lives in the pre-bound handle, so + // retaining a second NcclContext here would create per-thread local-memory + // spills. + device::NcclGinHandle gin; + + __device__ __forceinline__ NcclOps(const Context& ctx, int qp_idx, + int sharing_mode, int /*num_qps*/, + int /*scaleout_rank_idx*/ = 0, + int /*scaleup_rank_idx*/ = 0, + int /*num_scaleup_ranks*/ = 0, + int /*num_ranks*/ = 1) + : gin(ctx.device, static_cast(qp_idx), + sharing_mode == 0 ? device::NcclGinResourceSharing::kCta + : device::NcclGinResourceSharing::kGpu) {} + + template + __device__ __forceinline__ bool is_nvlink_accessible(int dst_rank) const { + if constexpr (std::is_same_v) { + return true; + } else if constexpr (std::is_same_v) { + return gin.railRank() == dst_rank; + } else { + return gin.worldRankInLsa(dst_rank); + } + } + + template + __device__ __forceinline__ bool is_gin_peer(int dst_rank) const { + return !is_nvlink_accessible(dst_rank); + } + + template + __device__ __forceinline__ ptr_t* get_sym_ptr_impl(ptr_t* ptr, + int dst_rank) const { + if constexpr (std::is_same_v) { + return static_cast(gin.lsaPeerPointer(dst_rank, ptr)); + } else if constexpr (std::is_same_v) { + return gin.railRank() == dst_rank ? ptr : nullptr; + } else { + if (!gin.worldRankInLsa(dst_rank)) return nullptr; + return static_cast(gin.worldPeerPointer(dst_rank, ptr)); + } + } + + template + __device__ __forceinline__ void* get_sym_ptr(void* ptr, + int dst_rank) const { + return get_sym_ptr_impl(static_cast(ptr), dst_rank); + } + + template + __device__ __forceinline__ const void* get_sym_ptr(const void* ptr, + int dst_rank) const { + return get_sym_ptr_impl(static_cast(ptr), + dst_rank); + } + + template + __device__ __forceinline__ void put(void* dst_ptr, const void* src_ptr, + int num_bytes, int dst_rank, + int flags = 0) const { + if constexpr (std::is_same_v) { + auto* routed = + static_cast(get_sym_ptr(dst_ptr, dst_rank)); + const auto src_addr = reinterpret_cast(src_ptr); + const auto dst_addr = reinterpret_cast(routed); + if (((src_addr | dst_addr | static_cast(num_bytes)) & + (sizeof(int4) - 1)) == 0) { + const auto* src = reinterpret_cast(src_ptr); + auto* dst = reinterpret_cast(routed); + const int count = num_bytes / static_cast(sizeof(int4)); + for (int i = 0; i < count; ++i) + dst[i] = device::mc_ld_nc(src + i); + } else { + const auto* src = static_cast(src_ptr); + for (int i = 0; i < num_bytes; ++i) routed[i] = src[i]; + } + __threadfence_system(); + } else if constexpr (std::is_same_v) { + // Match NCCL's native GIN contract: world/rail puts always use the + // selected network context. Callers use get_sym_ptr explicitly + // when they want an LSA/local bypass. + gin.put( + dst_rank, src_ptr, dst_ptr, static_cast(num_bytes), + static_cast(flags)); + } else { + gin.put( + dst_rank, src_ptr, dst_ptr, static_cast(num_bytes), + static_cast(flags)); + } + } + + template + __device__ __forceinline__ void put_value(value_t* dst_ptr, value_t value, + int dst_rank, + int flags = 0) const { + static_assert( + std::is_scalar_v && std::is_trivially_copyable_v, + "NCCL EP put_value requires a trivially copyable scalar"); + static_assert(sizeof(value_t) == sizeof(uint32_t) || + sizeof(value_t) == sizeof(uint64_t), + "NCCL EP put_value supports only 4- or 8-byte values"); + auto* routed = get_sym_ptr_impl(dst_ptr, dst_rank); + if (routed != nullptr) { + ptx::st_relaxed_sys(routed, value); + } else if constexpr (std::is_same_v) { + gin.putValue( + dst_rank, dst_ptr, value, static_cast(flags)); + } else { + gin.putValue( + dst_rank, dst_ptr, value, static_cast(flags)); + } + } + + template + __device__ __forceinline__ void red_add_rel(value_t* dst_ptr, value_t value, + int dst_rank, + int /*flags*/ = 0) const { + auto* routed = get_sym_ptr_impl(dst_ptr, dst_rank); + if (routed != nullptr) { + const bool use_gpu_scope = + std::is_same_v || routed == dst_ptr; + if constexpr (sizeof(value_t) == sizeof(int32_t)) { + if (use_gpu_scope) { + ptx::red_add_rel_gpu(reinterpret_cast(routed), + static_cast(value)); + } else { + ptx::red_add_rel_sys(reinterpret_cast(routed), + static_cast(value)); + } + } else if constexpr (sizeof(value_t) == sizeof(uint64_t) || + sizeof(value_t) == sizeof(int64_t)) { + if (use_gpu_scope) { + ptx::red_add_rel_gpu(reinterpret_cast(routed), + static_cast(value)); + } else { + ptx::red_add_rel_sys(reinterpret_cast(routed), + static_cast(value)); + } + } + } else if constexpr (sizeof(value_t) == sizeof(uint64_t) || + sizeof(value_t) == sizeof(int64_t)) { + if constexpr (std::is_same_v) { + gin.signalAdd( + dst_rank, reinterpret_cast(dst_ptr), + static_cast(value)); + } else { + gin.signalAdd( + dst_rank, reinterpret_cast(dst_ptr), + static_cast(value)); + } + } + } + + template + __device__ __forceinline__ value_t read_completion_tail( + value_t* direct_ptr, int /*channel_idx*/, int /*sender_rank*/) const { + static_assert(sizeof(value_t) == sizeof(uint64_t), + "NCCL completion tails must be 64-bit"); + // NCCL's VA-signal read path resolves this same local window pointer + // and performs an acquire atomic load. Keep that operation explicit so + // the compiler does not instantiate unrelated runtime backend paths. + return ptx::ld_acquire_sys(direct_ptr); + } + + template + __device__ __forceinline__ void clear_completion_tail( + value_t* direct_ptr, int /*channel_idx*/, int /*sender_rank*/) const { + // Every NCCL backend resets a VA signal by storing zero to its local + // window address. The tail protocol guarantees that remote writers are + // quiescent before this cleanup. + *direct_ptr = 0; + } + + template + __device__ __forceinline__ void publish_tail(value_t* dst_ptr, + int /*channel_idx*/, + value_t absolute_value, + value_t delta, int dst_rank, + int flags = 0) const { + static_assert(sizeof(value_t) == sizeof(uint64_t), + "NCCL completion tails must be 64-bit"); + (void)absolute_value; + red_add_rel(dst_ptr, delta, dst_rank, flags); + } + + template + __device__ __forceinline__ void gin_barrier_signal_inc( + int dst_team_rank, int signal_id) const { + if constexpr (std::is_same_v) { + gin.signalIncContext0(dst_team_rank, + signal_id); + } else { + gin.signalIncContext0(dst_team_rank, + signal_id); + } + } + + __device__ __forceinline__ uint64_t + gin_barrier_advance_shadow(int signal_id) const { + return gin.advanceSignalShadowContext0(signal_id); + } + + __device__ __forceinline__ uint64_t + gin_barrier_read_signal(int signal_id) const { + return gin.readSignalContext0(signal_id); + } + + __device__ __forceinline__ void flush_channel() const { gin.flushWarp(); } + + __device__ __forceinline__ void flush() const { + const int warps_per_block = static_cast(blockDim.x) / warpSize; + const int global_warp = static_cast(blockIdx.x) * warps_per_block + + static_cast(threadIdx.x) / warpSize; + const int num_warps = static_cast(gridDim.x) * warps_per_block; + for (int context_idx = global_warp; context_idx < gin.contextCount(); + context_idx += num_warps) { + gin.flushContextWarp(context_idx); + } + } +}; + +#endif // USE_NCCL_DEVICE + } // namespace mooncake::elastic::transport diff --git a/mooncake-ep/include/mooncake_ep_api.cuh b/mooncake-ep/include/mooncake_ep_api.cuh index 1a560d1b90..bc747c2f22 100644 --- a/mooncake-ep/include/mooncake_ep_api.cuh +++ b/mooncake-ep/include/mooncake_ep_api.cuh @@ -1,6 +1,6 @@ #pragma once -#include +#include namespace mooncake { diff --git a/mooncake-ep/include/mooncake_ep_buffer.h b/mooncake-ep/include/mooncake_ep_buffer.h index 9307936c80..d2bfa38237 100644 --- a/mooncake-ep/include/mooncake_ep_buffer.h +++ b/mooncake-ep/include/mooncake_ep_buffer.h @@ -1,16 +1,17 @@ #ifndef MOONCAKE_EP_BUFFER_H #define MOONCAKE_EP_BUFFER_H -#include -#include -#include -#include +#include +#include #include +#include +#include +#include #include #include #include #include -#include +#include #include namespace mooncake { @@ -72,9 +73,15 @@ struct MooncakeEpBuffer { int rank, num_ranks; int clock_rate_khz; - // GDR buffer — owned by p2p_transport_ + // GDR buffer — allocated by p2p_transport_; peer mappings are optional. int buffer_idx{}; int phase_epochs[2]{}; + // TBO owns multiple logical dispatchers but shares one native Buffer. + // Keep a private top-k snapshot slot per dispatch metadata tensor so + // interleaved subbatches cannot overwrite one another. + std::mutex buffer_mutex; + std::unordered_map dispatch_shadow_slots; + int next_shadow_slot = 0; int64_t num_ep_buffer_bytes; void* gdr_buffer = nullptr; @@ -90,16 +97,16 @@ struct MooncakeEpBuffer { std::unique_ptr owned_rdma_transport_; bool ibgda_disabled_ = false; + bool p2p_enabled_ = true; int USE_QP_COUNT = MAX_QP_COUNT; - // Cap on active RoCE QPs per peer: spreading small EP messages across too - // many QP/doorbell/progress streams hurts when GPUs share an HCA. Default - // 8; override at runtime with MOONCAKE_EP_ACTIVE_QPS_PER_RANK (>= per-rank - // QP count disables). - int active_qps_cap_ = 8; + // Active RoCE QPs per peer. The platform-specific default is selected in + // active_qps_per_rank_for_ep(); a positive + // MOONCAKE_EP_ACTIVE_QPS_PER_RANK value forces an explicit count. + int active_qps_cap_ = 0; // Stream for communication - at::cuda::CUDAStream comm_stream; + cudaStream_t comm_stream = nullptr; // Workspace void* workspace = nullptr; @@ -109,31 +116,32 @@ struct MooncakeEpBuffer { // (engine owns the transports). Otherwise EP creates its own via the // factory functions (EP owns them via owned_p2p_transport_ etc.). MooncakeEpBuffer(int rank, int num_ranks, int64_t num_ep_buffer_bytes, + bool disable_p2p = false, TransferEngine* engine = nullptr); ~MooncakeEpBuffer() noexcept(false); - std::tuple, torch::Tensor, - torch::Tensor, torch::Tensor, std::optional, - std::optional>> - dispatch(const torch::Tensor& x, const torch::Tensor& topk_idx, - torch::Tensor& active_ranks, int num_max_dispatch_tokens_per_rank, - int num_experts, int timeout_us, bool use_fp8, bool async, - bool return_recv_hook); - - std::tuple, - std::optional>> - combine(const torch::Tensor& x, const torch::Tensor& topk_idx, - const torch::Tensor& topk_weights, const torch::Tensor& src_info, - const torch::Tensor& layout_range, torch::Tensor& active_ranks, + std::tuple, std::optional>> + dispatch(uint64_t x_ptr, uint64_t topk_idx_ptr, uint64_t active_ranks_ptr, + int num_tokens, int hidden, int num_topk, + int num_max_dispatch_tokens_per_rank, int num_experts, + int timeout_us, bool use_fp8, uint64_t packed_recv_x_ptr, + uint64_t packed_recv_x_scales_ptr, uint64_t packed_recv_count_ptr, + uint64_t packed_recv_src_info_ptr, + uint64_t packed_recv_layout_range_ptr, bool async, + bool return_recv_hook, uint64_t compute_stream_ptr); + + std::tuple, std::optional>> + combine(uint64_t x_ptr, uint64_t topk_idx_ptr, uint64_t topk_weights_ptr, + uint64_t src_info_ptr, uint64_t layout_range_ptr, + uint64_t active_ranks_ptr, int num_local_experts, + int num_combined_tokens, int hidden, int num_topk, int num_max_dispatch_tokens_per_rank, int num_experts, - int timeout_us, bool zero_copy, bool async, bool return_recv_hook, - const std::optional& out); - - torch::Tensor get_next_combine_buffer(int num_max_dispatch_tokens_per_rank, - int hidden, int num_experts); + int timeout_us, bool zero_copy, uint64_t combined_x_ptr, bool async, + bool return_recv_hook, uint64_t compute_stream_ptr); bool ibgda_disabled() const { return ibgda_disabled_; } + bool p2p_enabled() const { return p2p_enabled_; } bool is_roce() const { return rdma_transport_ && rdma_transport_->isRoce(); diff --git a/mooncake-ep/include/mooncake_ep_configs.cuh b/mooncake-ep/include/mooncake_ep_configs.cuh index 1e7f0c2149..5d05908f36 100644 --- a/mooncake-ep/include/mooncake_ep_configs.cuh +++ b/mooncake-ep/include/mooncake_ep_configs.cuh @@ -39,17 +39,23 @@ #undef __CUDA_NO_BFLOAT162_OPERATORS__ #endif +#include +#if !defined(MOONCAKE_EP_USE_MUSA) && !defined(MOONCAKE_EP_USE_MACA) #include -#ifndef MOONCAKE_EP_USE_MACA #include +#endif +#ifndef MOONCAKE_EP_USE_MACA #include #endif -#include #if defined(MOONCAKE_EP_USE_MUSA) || defined(MOONCAKE_EP_USE_MACA) #define MOONCAKE_EP_SPLIT_SEND_RECV 1 #endif +#if defined(MOONCAKE_EP_USE_MACA) +#define MOONCAKE_EP_PHASE_ACK 1 +#endif + // torchada maps nv_bfloat16 → __mt_bfloat16 which is an incomplete type on // MUSA, so sizeof(__mt_bfloat16) fails. mt_bfloat16 (the complete typedef in // musa_bf16.hpp) requires the MUSA device compiler (mcc) and cannot be diff --git a/mooncake-ep/include/mooncake_ep_device.h b/mooncake-ep/include/mooncake_ep_device.h index e88417ff7f..0ab7b58f1d 100644 --- a/mooncake-ep/include/mooncake_ep_device.h +++ b/mooncake-ep/include/mooncake_ep_device.h @@ -13,14 +13,14 @@ #include using ep_fp8_storage_t = __mt_fp8_storage_t; using ep_fp8x2_storage_t = __mt_fp8x2_storage_t; -#if defined(__CUDACC__) || defined(__MCC__) +#if defined(__CUDACC__) || defined(__MCC__) || defined(__MUSACC__) __device__ __forceinline__ ep_fp8x2_storage_t ep_cvt_float2_to_fp8x2(float2 x) { return __musa_cvt_float2_to_fp8x2(x, __MT_SATFINITE, __MT_E4M3); } #endif // -- Device intrinsics (MUSA doesn't have __ldg / __activemask) -------------- -#if (defined(__CUDACC__) || defined(__MCC__)) && \ +#if (defined(__CUDACC__) || defined(__MCC__) || defined(__MUSACC__)) && \ !defined(MOONCAKE_EP_MUSA_LDG_DEFINED) #define MOONCAKE_EP_MUSA_LDG_DEFINED template @@ -32,7 +32,7 @@ __device__ __forceinline__ dtype_t __ldg(const dtype_t* ptr) { #define __activemask() (0xffffffff) #endif -#if defined(__CUDACC__) || defined(__MCC__) +#if defined(__CUDACC__) || defined(__MCC__) || defined(__MUSACC__) __forceinline__ __device__ int get_lane_id() { return threadIdx.x % 32; } #endif @@ -44,15 +44,11 @@ __forceinline__ __device__ int get_lane_id() { return threadIdx.x % 32; } dim3 _block(num_threads); \ cudaStream_t _stream = stream -#define LAUNCH_KERNEL(config, kernel, ...) \ - kernel<<<_grid, _block, 0, _stream>>>(__VA_ARGS__); \ - { \ - auto _err = cudaGetLastError(); \ - if (_err != cudaSuccess) { \ - fprintf(stderr, "[EP] kernel launch failed: %s\n", \ - cudaGetErrorString(_err)); \ - } \ - } +#define LAUNCH_KERNEL(config, kernel, ...) \ + do { \ + kernel<<<_grid, _block, 0, _stream>>>(__VA_ARGS__); \ + CUDA_CHECK(cudaGetLastError()); \ + } while (false) #elif defined(MOONCAKE_EP_USE_MACA) @@ -63,7 +59,7 @@ __forceinline__ __device__ int get_lane_id() { return threadIdx.x % 32; } #include using ep_fp8_storage_t = uint8_t; using ep_fp8x2_storage_t = uint16_t; -#if defined(__CUDACC__) || defined(__MCC__) +#if defined(__CUDACC__) || defined(__MCC__) || defined(__MUSACC__) __device__ __forceinline__ ep_fp8x2_storage_t ep_cvt_float2_to_fp8x2(float2) { return 0; } @@ -74,7 +70,7 @@ __device__ __forceinline__ ep_fp8x2_storage_t ep_cvt_float2_to_fp8x2(float2) { #define __activemask() (0xffffffff) #endif -#if defined(__CUDACC__) || defined(__MCC__) +#if defined(__CUDACC__) || defined(__MCC__) || defined(__MUSACC__) __forceinline__ __device__ int get_lane_id() { return threadIdx.x % 32; } #endif @@ -86,15 +82,11 @@ __forceinline__ __device__ int get_lane_id() { return threadIdx.x % 32; } dim3 _block(num_threads); \ cudaStream_t _stream = stream -#define LAUNCH_KERNEL(config, kernel, ...) \ - kernel<<<_grid, _block, 0, _stream>>>(__VA_ARGS__); \ - { \ - auto _err = cudaGetLastError(); \ - if (_err != cudaSuccess) { \ - fprintf(stderr, "[EP] kernel launch failed: %s\n", \ - cudaGetErrorString(_err)); \ - } \ - } +#define LAUNCH_KERNEL(config, kernel, ...) \ + do { \ + kernel<<<_grid, _block, 0, _stream>>>(__VA_ARGS__); \ + CUDA_CHECK(cudaGetLastError()); \ + } while (false) #else // !MOONCAKE_EP_USE_MUSA && !MOONCAKE_EP_USE_MACA diff --git a/mooncake-ep/include/mooncake_ep_event.h b/mooncake-ep/include/mooncake_ep_event.h index 4809704983..c0e8a055e4 100644 --- a/mooncake-ep/include/mooncake_ep_event.h +++ b/mooncake-ep/include/mooncake_ep_event.h @@ -1,49 +1,62 @@ #pragma once -#include +#include #include #include -#include namespace mooncake { struct EventHandle { - std::shared_ptr event; + std::shared_ptr event; + std::shared_ptr keepalive; EventHandle() { - event = std::make_shared(torch::kCUDA); - event->record(at::cuda::getCurrentCUDAStream()); + event = std::shared_ptr(new cudaEvent_t(nullptr), + [](cudaEvent_t* p) { + if (p != nullptr) { + if (*p != nullptr) + cudaEventDestroy(*p); + delete p; + } + }); + CUDA_CHECK( + cudaEventCreateWithFlags(event.get(), cudaEventDisableTiming)); } - explicit EventHandle(const at::cuda::CUDAStream& stream) { - event = std::make_shared(torch::kCUDA); - event->record(stream); + explicit EventHandle(uint64_t stream_ptr, + std::shared_ptr keepalive = nullptr) + : EventHandle() { + this->keepalive = std::move(keepalive); + auto stream = reinterpret_cast(stream_ptr); + CUDA_CHECK(cudaEventRecord(*event, stream)); } EventHandle(const EventHandle& other) = default; - void current_stream_wait() const { - at::cuda::getCurrentCUDAStream().unwrap().wait(*event); + void current_stream_wait(uint64_t stream_ptr) const { + auto stream = reinterpret_cast(stream_ptr); + CUDA_CHECK(cudaStreamWaitEvent(stream, *event, 0)); } - void synchronize() const { event->synchronize(); } + void synchronize() const { CUDA_CHECK(cudaEventSynchronize(*event)); } }; -inline torch::Event create_event(const at::cuda::CUDAStream& s) { - auto event = torch::Event(torch::kCUDA); - event.record(s); +inline cudaEvent_t create_event(cudaStream_t stream) { + cudaEvent_t event = nullptr; + CUDA_CHECK(cudaEventCreateWithFlags(&event, cudaEventDisableTiming)); + CUDA_CHECK(cudaEventRecord(event, stream)); return event; } -inline void stream_wait(const at::cuda::CUDAStream& s_0, - const at::cuda::CUDAStream& s_1) { - EP_HOST_ASSERT(s_0.id() != s_1.id()); - s_0.unwrap().wait(create_event(s_1)); +inline void stream_wait(cudaStream_t dst_stream, cudaStream_t src_stream) { + EP_HOST_ASSERT(dst_stream != src_stream); + auto event = create_event(src_stream); + CUDA_CHECK(cudaStreamWaitEvent(dst_stream, event, 0)); + CUDA_CHECK(cudaEventDestroy(event)); } -inline void stream_wait(const at::cuda::CUDAStream& s, - const EventHandle& event) { - s.unwrap().wait(*event.event); +inline void stream_wait(cudaStream_t s, const EventHandle& event) { + CUDA_CHECK(cudaStreamWaitEvent(s, *event.event, 0)); } } // namespace mooncake diff --git a/mooncake-ep/include/mooncake_ep_launch.cuh b/mooncake-ep/include/mooncake_ep_launch.cuh index c146c6d72b..7afbd5ec34 100644 --- a/mooncake-ep/include/mooncake_ep_launch.cuh +++ b/mooncake-ep/include/mooncake_ep_launch.cuh @@ -71,6 +71,8 @@ case_macro(2560); \ case 3072: \ case_macro(3072); /* for gpt-oss */ \ + case 3584: \ + case_macro(3584); /* for kimi k3 */ \ case 4096: \ case_macro(4096); \ case 5120: \ diff --git a/mooncake-ep/setup.py b/mooncake-ep/setup.py deleted file mode 100644 index 2a4f388e1c..0000000000 --- a/mooncake-ep/setup.py +++ /dev/null @@ -1,138 +0,0 @@ -import os -import re - -from setuptools import setup -import torch - -use_musa = os.getenv("MOONCAKE_EP_USE_MUSA", "").upper() in {"1", "ON", "TRUE", "YES"} -use_maca = ( - os.getenv("MOONCAKE_EP_USE_MACA", "").upper() in {"1", "ON", "TRUE", "YES"} - or (hasattr(torch.version, "maca") and torch.version.maca is not None) -) -if use_musa: - try: - import importlib - - importlib.import_module("torchada") - except ImportError as e: - raise ImportError( - "torchada is required to build the MUSA EP extension. " - "Please install it first using 'pip install torchada'." - ) from e - - -from torch.utils.cpp_extension import ( # noqa: E402 - BuildExtension, - CUDAExtension, - CUDA_HOME, -) - - -torch_version = re.match(r"\d+(?:\.\d+)*", torch.__version__).group() -version_suffix = "_" + torch_version.replace(".", "_") -module_name = "mooncake.ep" + version_suffix - -abi_flag = int(torch._C._GLIBCXX_USE_CXX11_ABI) -current_dir = os.path.abspath(os.path.dirname(__file__)) -repo_dir = os.path.abspath(os.path.join(current_dir, os.pardir)) -sysroot_dir = os.path.join(repo_dir, ".deps", "sysroot", "usr") - - -def existing_dirs(*paths): - return [path for path in paths if os.path.isdir(path)] - - -sysroot_include_dirs = existing_dirs( - os.path.join(sysroot_dir, "include"), - os.path.join(sysroot_dir, "include", "jsoncpp"), - os.path.join(sysroot_dir, "include", "libnl3"), -) -sysroot_library_dirs = existing_dirs( - os.path.join(sysroot_dir, "lib", "x86_64-linux-gnu"), - os.path.join(sysroot_dir, "lib"), -) - -abi_define = f"-D_GLIBCXX_USE_CXX11_ABI={abi_flag}" -cxx_args = [abi_define, "-std=c++20", "-O3", "-g0"] - -cuda_libraries = ["ibverbs", "mlx5"] -cuda_library_dirs = [] -include_dirs = [ - os.path.join(current_dir, "include"), - os.path.join(current_dir, "../mooncake-transfer-engine/include"), -] - -if use_musa: - cuda_libraries = [] - musa_defines = [ - "-DUSE_MUSA", - "-DMOONCAKE_EP_USE_MUSA=1", - ] - cxx_args += musa_defines - # torchada maps the "nvcc" key to "mcc". - device_args = [ - abi_define, - *musa_defines, - "-std=c++20", - "--cuda-gpu-arch=mp_21", - "--cuda-gpu-arch=mp_31", - "-O3", - ] -elif use_maca: - cuda_libraries = [] - cuda_library_dirs = sysroot_library_dirs.copy() - include_dirs += sysroot_include_dirs - maca_defines = ["-DUSE_MACA", "-DMOONCAKE_EP_USE_MACA=1"] - cxx_args += maca_defines - device_args = [ - abi_define, - *maca_defines, - "-std=c++20", - "-O3", - ] -else: - cxx_args.append("-DUSE_CUDA") - device_args = [ - abi_define, - "-std=c++20", - "-DUSE_CUDA", - "-Xcompiler", - "-O3", - "-Xcompiler", - "-g0", - ] - # Link against the CUDA driver stub library if available. - if CUDA_HOME is not None: - cuda_stub_dir = os.path.join(CUDA_HOME, "lib64", "stubs") - cuda_stub_lib = os.path.join(cuda_stub_dir, "libcuda.so") - if os.path.exists(cuda_stub_lib): - cuda_libraries.insert(0, "cuda") - cuda_library_dirs.append(cuda_stub_dir) - -setup( - name=module_name, - ext_modules=[ - CUDAExtension( - name=module_name, - include_dirs=include_dirs, - sources=[ - "src/ep_py.cpp", - "src/mooncake_ep_buffer.cpp", - "src/mooncake_ep_elastic_buffer.cpp", - "src/mooncake_ep_kernel.cu", - "src/mooncake_ep_elastic_kernel.cu", - ], - extra_compile_args={"cxx": cxx_args, "nvcc": device_args}, - libraries=cuda_libraries, - library_dirs=cuda_library_dirs, - extra_link_args=[ - "-Wl,-rpath,$ORIGIN", - "-L" + os.path.join(current_dir, "../mooncake-wheel/mooncake"), - "-Wl,--push-state,--no-as-needed", - "-l:engine.so", - "-Wl,--pop-state", - ], - ) - ], - cmdclass={"build_ext": BuildExtension}, -) diff --git a/mooncake-ep/src/CMakeLists.txt b/mooncake-ep/src/CMakeLists.txt index a102f5011e..dee0b61dd1 100644 --- a/mooncake-ep/src/CMakeLists.txt +++ b/mooncake-ep/src/CMakeLists.txt @@ -1,4 +1,127 @@ -add_library(mooncake_ep ep_py.cpp mooncake_ep_buffer.cpp mooncake_ep_elastic_buffer.cpp mooncake_ep_kernel.cu mooncake_ep_elastic_kernel.cu) +set(MOONCAKE_EP_HOST_SOURCES + ep_py.cpp + ../benchmarks/legacy_buffer_perf.cpp + mooncake_ep_buffer.cpp + mooncake_ep_elastic_buffer.cpp) -set_target_properties(mooncake_ep PROPERTIES POSITION_INDEPENDENT_CODE ON) -target_link_libraries(mooncake_ep PUBLIC ${TORCH_LIBRARIES} transfer_engine ibverbs mlx5) +set(MOONCAKE_EP_DEVICE_SOURCES + "${CMAKE_CURRENT_SOURCE_DIR}/mooncake_ep_kernel.cu" + "${CMAKE_CURRENT_SOURCE_DIR}/mooncake_ep_elastic_kernel.cu") + +if(USE_CUDA) + enable_language(CUDA) + find_package(CUDAToolkit REQUIRED) + + # Keep CUDA fatbins in a separate library. The Python host extension can + # then pass through auditwheel while this library is injected afterwards. + add_library(mooncake_ep_device SHARED ${MOONCAKE_EP_DEVICE_SOURCES}) + set_target_properties(mooncake_ep_device PROPERTIES + POSITION_INDEPENDENT_CODE ON) + if(CUDAToolkit_VERSION VERSION_GREATER_EQUAL "13.0") + set_target_properties(mooncake_ep_device PROPERTIES + CUDA_ARCHITECTURES "80;90;103") + else() + set_target_properties(mooncake_ep_device PROPERTIES + CUDA_ARCHITECTURES "80;90") + endif() + target_include_directories( + mooncake_ep_device PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/../include + ${CMAKE_CURRENT_SOURCE_DIR}/../../mooncake-transfer-engine/include) + target_compile_options( + mooncake_ep_device PRIVATE + $<$:-Xcompiler=-O3> + $<$:-Xcompiler=-g0> + $<$:--expt-relaxed-constexpr>) + target_link_libraries(mooncake_ep_device PRIVATE CUDA::cudart) + if(USE_NCCL_DEVICE) + target_link_libraries(mooncake_ep_device PRIVATE NCCL::nccl) + endif() +elseif(USE_MUSA) + set(MOONCAKE_EP_SOURCES ${MOONCAKE_EP_HOST_SOURCES}) + if(DEFINED ENV{MUSA_HOME} AND NOT "$ENV{MUSA_HOME}" STREQUAL "") + set(_ep_musa_compiler_hint "$ENV{MUSA_HOME}/bin") + else() + set(_ep_musa_compiler_hint /usr/local/musa/bin) + endif() + find_program(_ep_musa_compiler NAMES mcc HINTS "${_ep_musa_compiler_hint}") + if(NOT _ep_musa_compiler) + message(FATAL_ERROR "USE_MUSA=ON requires the MUSA compiler (mcc)") + endif() + set(_ep_musa_depfile_supported FALSE) + if(CMAKE_GENERATOR MATCHES "^Ninja" OR + (CMAKE_GENERATOR MATCHES "Makefiles" AND + CMAKE_VERSION VERSION_GREATER_EQUAL 3.20)) + set(_ep_musa_depfile_supported TRUE) + endif() + + foreach(_ep_device_source IN LISTS MOONCAKE_EP_DEVICE_SOURCES) + get_filename_component(_ep_device_name "${_ep_device_source}" NAME_WE) + set(_ep_device_object + "${CMAKE_CURRENT_BINARY_DIR}/${_ep_device_name}_musa.o") + set(_ep_device_depfile + "${CMAKE_CURRENT_BINARY_DIR}/${_ep_device_name}_musa.d") + set(_ep_musa_depfile_argument) + if(_ep_musa_depfile_supported) + set(_ep_musa_depfile_argument DEPFILE "${_ep_device_depfile}") + endif() + add_custom_command( + OUTPUT "${_ep_device_object}" + COMMAND "${_ep_musa_compiler}" + -x musa + -std=c++20 + -O3 + -fPIC + -DUSE_MUSA + -DMOONCAKE_EP_USE_MUSA=1 + -MMD + -MT "${_ep_device_object}" + -MF "${_ep_device_depfile}" + --cuda-gpu-arch=mp_21 + --cuda-gpu-arch=mp_31 + "-I${CMAKE_CURRENT_SOURCE_DIR}/../include" + "-I${CMAKE_CURRENT_SOURCE_DIR}/../../mooncake-transfer-engine/include" + -c "${_ep_device_source}" -o "${_ep_device_object}" + DEPENDS "${_ep_device_source}" + ${_ep_musa_depfile_argument} + COMMENT "Compiling Mooncake EP MUSA device source ${_ep_device_name}" + VERBATIM) + set_source_files_properties("${_ep_device_object}" + PROPERTIES GENERATED TRUE EXTERNAL_OBJECT TRUE) + list(APPEND MOONCAKE_EP_SOURCES "${_ep_device_object}") + endforeach() +else() + set(MOONCAKE_EP_SOURCES ${MOONCAKE_EP_HOST_SOURCES}) +endif() + +if(USE_CUDA) + pybind11_add_module(_ep MODULE ${MOONCAKE_EP_HOST_SOURCES}) +else() + pybind11_add_module(_ep MODULE ${MOONCAKE_EP_SOURCES}) +endif() +set_target_properties(_ep PROPERTIES POSITION_INDEPENDENT_CODE ON) +set_target_properties(_ep PROPERTIES INSTALL_RPATH "$ORIGIN") +if(USE_MUSA) + target_compile_definitions(_ep PRIVATE MOONCAKE_EP_USE_MUSA=1) +endif() +if(USE_MACA) + target_compile_definitions(_ep PRIVATE MOONCAKE_EP_USE_MACA=1) +endif() + +target_include_directories(_ep PRIVATE ${Python3_INCLUDE_DIRS}) +if(USE_CUDA) + target_link_libraries(_ep PRIVATE transfer_engine ibverbs mlx5 glog::glog + gflags::gflags mooncake_ep_device + CUDA::cudart) +else() + target_link_libraries(_ep PRIVATE transfer_engine ibverbs mlx5 glog::glog + gflags::gflags) +endif() + +if(USE_CUDA) + # The host binding contains no CUDA fatbin; only the device library above + # is kept out of auditwheel and injected into the wheel afterwards. +elseif(USE_MUSA) + set_target_properties(_ep PROPERTIES LINKER_LANGUAGE CXX) + target_link_libraries(_ep PRIVATE musa musart rt) +endif() diff --git a/mooncake-ep/src/ep_py.cpp b/mooncake-ep/src/ep_py.cpp index 02307c0caf..54951b1a07 100644 --- a/mooncake-ep/src/ep_py.cpp +++ b/mooncake-ep/src/ep_py.cpp @@ -4,72 +4,41 @@ #include #include #include -#include -#include -#include namespace py = pybind11; namespace mooncake { -PYBIND11_MODULE(TORCH_EXTENSION_NAME, m) { +void bind_legacy_buffer_perf(py::module_& module); + +PYBIND11_MODULE(_ep, m) { m.def("get_ep_buffer_size_hint", &get_ep_buffer_size_hint); m.def("calculate_elastic_buffer_size", &MooncakeElasticBuffer::calculate_buffer_size); + m.def("create_nccl_unique_id", &create_elastic_nccl_unique_id); + m.def("has_nccl_device_support", []() { +#ifdef USE_NCCL_DEVICE + return true; +#else + return false; +#endif + }); py::class_(m, "EventHandle") - .def(py::init<>()) - .def("current_stream_wait", &EventHandle::current_stream_wait) + .def(py::init(), py::arg("stream_ptr") = 0) + .def("current_stream_wait", &EventHandle::current_stream_wait, + py::arg("stream_ptr")) .def("synchronize", &EventHandle::synchronize); - py::class_(m, "ElasticNativeHandle") - .def(py::init<>()) - .def_readwrite("do_expand", &ElasticNativeHandle::do_expand) - .def_readwrite("num_experts", &ElasticNativeHandle::num_experts) - .def_readwrite("expert_alignment", - &ElasticNativeHandle::expert_alignment) - .def_readwrite("num_max_tokens_per_rank", - &ElasticNativeHandle::num_max_tokens_per_rank) - .def_readwrite("num_sms", &ElasticNativeHandle::num_sms) - .def_readwrite("topk_idx", &ElasticNativeHandle::topk_idx) - .def_readwrite( - "psum_num_recv_tokens_per_scaleup_rank", - &ElasticNativeHandle::psum_num_recv_tokens_per_scaleup_rank) - .def_readwrite("psum_num_recv_tokens_per_expert", - &ElasticNativeHandle::psum_num_recv_tokens_per_expert) - .def_readwrite("recv_src_metadata", - &ElasticNativeHandle::recv_src_metadata) - .def_readwrite("recv_layout_range", - &ElasticNativeHandle::recv_layout_range) - .def_readwrite("dst_buffer_slot_idx", - &ElasticNativeHandle::dst_buffer_slot_idx) - .def_readwrite("token_metadata_at_forward", - &ElasticNativeHandle::token_metadata_at_forward) - .def_readwrite("channel_linked_list", - &ElasticNativeHandle::channel_linked_list) - .def_readwrite("num_recv_tokens_per_expert_list", - &ElasticNativeHandle::num_recv_tokens_per_expert_list); - - py::class_(m, "ElasticDispatchOutput") - .def_readonly("recv_x", &ElasticDispatchOutput::recv_x) - .def_readonly("recv_x_scales", &ElasticDispatchOutput::recv_x_scales) - .def_readonly("recv_topk_idx", &ElasticDispatchOutput::recv_topk_idx) - .def_readonly("recv_topk_weights", - &ElasticDispatchOutput::recv_topk_weights) - .def_readonly("handle", &ElasticDispatchOutput::handle) - .def_readonly("event", &ElasticDispatchOutput::event); - - py::class_(m, "ElasticCombineOutput") - .def_readonly("combined_x", &ElasticCombineOutput::combined_x) - .def_readonly("combined_topk_weights", - &ElasticCombineOutput::combined_topk_weights) - .def_readonly("event", &ElasticCombineOutput::event); - m.attr("MAX_QP_COUNT") = pybind11::int_(MAX_QP_COUNT); + bind_legacy_buffer_perf(m); py::class_(m, "Buffer") - .def(py::init()) + .def(py::init(), py::arg("rank"), + py::arg("num_ranks"), py::arg("num_ep_buffer_bytes"), + py::arg("disable_p2p") = false) .def("ibgda_disabled", &MooncakeEpBuffer::ibgda_disabled) + .def("p2p_enabled", &MooncakeEpBuffer::p2p_enabled) .def("use_fast_path", &MooncakeEpBuffer::use_fast_path) .def("update_local_qpns", &MooncakeEpBuffer::update_local_qpns) .def("is_roce", &MooncakeEpBuffer::is_roce) @@ -82,13 +51,12 @@ PYBIND11_MODULE(TORCH_EXTENSION_NAME, m) { .def("sync_nvlink_ipc_handles", &MooncakeEpBuffer::sync_nvlink_ipc_handles) .def("dispatch", &MooncakeEpBuffer::dispatch) - .def("combine", &MooncakeEpBuffer::combine) - .def("get_next_combine_buffer", - &MooncakeEpBuffer::get_next_combine_buffer); + .def("combine", &MooncakeEpBuffer::combine); py::class_(m, "ElasticBuffer") .def(py::init(), + bool, bool, bool, int, int, int, int, std::string, + std::vector>(), py::arg("rank"), py::arg("num_ranks"), py::arg("num_buffer_bytes"), py::arg("num_max_tokens_per_rank"), py::arg("hidden"), py::arg("num_topk"), py::arg("use_fp8_dispatch"), @@ -96,7 +64,10 @@ PYBIND11_MODULE(TORCH_EXTENSION_NAME, m) { py::arg("allow_multiple_reduction"), py::arg("prefer_overlap_with_compute"), py::arg("sl_idx"), py::arg("num_allocated_qps"), py::arg("num_cpu_timeout_secs"), - py::arg("num_gpu_timeout_secs")) + py::arg("num_gpu_timeout_secs"), py::arg("transport") = "ibgda", + py::arg("nccl_unique_id") = std::vector{}) + .def("destroy", &MooncakeElasticBuffer::destroy) + .def("using_nccl", &MooncakeElasticBuffer::using_nccl) .def_static("calculate_buffer_size", &MooncakeElasticBuffer::calculate_buffer_size) .def("get_physical_domain_size", @@ -107,6 +78,7 @@ PYBIND11_MODULE(TORCH_EXTENSION_NAME, m) { &MooncakeElasticBuffer::get_theoretical_num_sms) .def("ibgda_disabled", &MooncakeElasticBuffer::ibgda_disabled) .def("use_fast_path", &MooncakeElasticBuffer::use_fast_path) + .def("reconfigure_nccl", &MooncakeElasticBuffer::reconfigure_nccl) .def("update_local_qpns", &MooncakeElasticBuffer::update_local_qpns) .def("is_roce", &MooncakeElasticBuffer::is_roce) .def("sync_ibgda_peers", &MooncakeElasticBuffer::sync_ibgda_peers) @@ -117,18 +89,8 @@ PYBIND11_MODULE(TORCH_EXTENSION_NAME, m) { .def("get_ipc_handle", &MooncakeElasticBuffer::get_ipc_handle) .def("sync_nvlink_ipc_handles", &MooncakeElasticBuffer::sync_nvlink_ipc_handles) - .def("dispatch", &MooncakeElasticBuffer::dispatch, py::arg("x"), - py::arg("sf"), py::arg("topk_idx"), py::arg("topk_weights"), - py::arg("active_ranks"), py::arg("num_experts"), - py::arg("num_max_tokens_per_rank"), py::arg("expert_alignment"), - py::arg("num_sms"), py::arg("do_expand"), py::arg("do_cpu_sync"), - py::arg("async_with_compute_stream"), - py::arg("cached_handle") = std::nullopt) - .def("combine", &MooncakeElasticBuffer::combine, py::arg("x"), - py::arg("handle"), py::arg("topk_weights"), - py::arg("active_ranks"), py::arg("num_sms"), - py::arg("async_with_compute_stream"), - py::arg("out") = std::nullopt); + .def("dispatch", &MooncakeElasticBuffer::dispatch) + .def("combine", &MooncakeElasticBuffer::combine); } } // namespace mooncake diff --git a/mooncake-ep/src/mooncake_ep_buffer.cpp b/mooncake-ep/src/mooncake_ep_buffer.cpp index b587e29103..56dda8132a 100644 --- a/mooncake-ep/src/mooncake_ep_buffer.cpp +++ b/mooncake-ep/src/mooncake_ep_buffer.cpp @@ -9,9 +9,60 @@ namespace mooncake { namespace { -int active_qps_per_rank_for_ep(int qps_per_rank, bool is_roce, int cap) { +int active_qps_per_rank_for_ep(int qps_per_rank, bool is_roce, int cap, + int num_local_experts) { if (!is_roce) return qps_per_rank; - return std::min(qps_per_rank, cap); + // CUDA RoCE shares HCAs across local GPUs; spreading small EP messages + // over all expert QPs adds doorbell/progress overhead. MUSA keeps the + // existing expert-scaled policy until its high-QP path is fully tuned. + int target = cap; + if (target <= 0) { +#ifdef MOONCAKE_EP_USE_MUSA + target = std::max(8, num_local_experts); +#else + target = 8; +#endif + } + return std::min(qps_per_rank, target); +} + +cudaStream_t create_comm_stream() { + int least_priority = 0; + int greatest_priority = 0; + auto status = + cudaDeviceGetStreamPriorityRange(&least_priority, &greatest_priority); + if (status != cudaSuccess) { + cudaGetLastError(); + least_priority = 0; + greatest_priority = 0; + } + + cudaStream_t stream = nullptr; + CUDA_CHECK(cudaStreamCreateWithPriority(&stream, cudaStreamNonBlocking, + greatest_priority)); + return stream; +} + +bool stream_is_capturing(cudaStream_t stream) { + cudaStreamCaptureStatus status = cudaStreamCaptureStatusNone; + auto error = cudaStreamIsCapturing(stream, &status); + if (error != cudaSuccess) { + cudaGetLastError(); + return false; + } + return status != cudaStreamCaptureStatusNone; +} + +constexpr int kTopkShadowMaxTokens = 1024; +constexpr int kTopkShadowMaxTopk = 32; +constexpr int kTopkShadowSlots = 8; +constexpr size_t kTopkShadowBytes = static_cast(kTopkShadowMaxTokens) * + kTopkShadowMaxTopk * sizeof(int64_t); + +int64_t* topk_shadow(void* workspace, int shadow_slot, int num_experts) { + auto* base = reinterpret_cast(workspace) + + 2 * static_cast(num_experts) * sizeof(int); + return reinterpret_cast(base + shadow_slot * kTopkShadowBytes); } } // namespace @@ -44,16 +95,17 @@ static bool macaHostPhaseFenceCoversPeers() { MooncakeEpBuffer::MooncakeEpBuffer(int rank, int num_ranks, int64_t num_ep_buffer_bytes, - TransferEngine* engine) + bool disable_p2p, TransferEngine* engine) : rank(rank), num_ranks(num_ranks), num_ep_buffer_bytes(num_ep_buffer_bytes), - comm_stream(at::cuda::getStreamFromPool(true)) { + p2p_enabled_(!disable_p2p), + comm_stream(create_comm_stream()) { USE_QP_COUNT = MAX_QP_COUNT / num_ranks * num_ranks; - // Optional runtime override for the RoCE active-QP cap (default 8). - // Set MOONCAKE_EP_ACTIVE_QPS_PER_RANK to a value >= the per-rank QP count - // (e.g. 256) to effectively disable the cap. + // Optional runtime override for the RoCE active-QP count. Without an + // override, CUDA uses eight QPs and MUSA scales up to local experts. + active_qps_cap_ = 0; if (const char* env = std::getenv("MOONCAKE_EP_ACTIVE_QPS_PER_RANK")) { char* end = nullptr; long v = std::strtol(env, &end, 10); @@ -65,14 +117,17 @@ MooncakeEpBuffer::MooncakeEpBuffer(int rank, int num_ranks, << env << "'"; } } - LOG(INFO) << "[EP] RoCE active QPs/rank cap = " << active_qps_cap_; + LOG(INFO) << "[EP] RoCE active QPs/rank override = " + << (active_qps_cap_ > 0 ? std::to_string(active_qps_cap_) + : "auto"); // Get ranks CUDA_CHECK(cudaGetDevice(&device_id)); CUDA_CHECK(cudaDeviceGetAttribute(&clock_rate_khz, cudaDevAttrClockRate, device_id)); - // P2P transport — owns GDR buffer allocation and IPC handle exchange. + // P2P transport owns GDR buffer allocation. Peer mappings remain disabled + // when the EP caller selects RDMA-only operation. if (engine) { p2p_transport_ = engine->getOrCreateP2pTransport(num_ranks); } else { @@ -92,7 +147,7 @@ MooncakeEpBuffer::MooncakeEpBuffer(int rank, int num_ranks, if (rdma_transport_) { if (!initRdmaTransport(rdma_transport_, gdr_buffer, num_ep_buffer_bytes, num_ranks, USE_QP_COUNT, - comm_stream.stream())) { + comm_stream)) { rdma_transport_ = nullptr; ibgda_disabled_ = true; LOG(INFO) << "[EP] IBGDA unavailable, using P2P-only path"; @@ -114,7 +169,7 @@ MooncakeEpBuffer::MooncakeEpBuffer(int rank, int num_ranks, } auto t = device::createIbgdaDeviceTransport(device_filter); if (initRdmaTransport(t.get(), gdr_buffer, num_ep_buffer_bytes, - num_ranks, USE_QP_COUNT, comm_stream.stream())) { + num_ranks, USE_QP_COUNT, comm_stream)) { owned_rdma_transport_ = std::move(t); rdma_transport_ = owned_rdma_transport_.get(); } else { @@ -151,35 +206,34 @@ MooncakeEpBuffer::~MooncakeEpBuffer() noexcept(false) { p2p_transport_ = nullptr; if (workspace) cudaFree(workspace); + if (comm_stream) { + cudaStreamDestroy(comm_stream); + comm_stream = nullptr; + } } -std::tuple, torch::Tensor, - torch::Tensor, torch::Tensor, std::optional, - std::optional>> -MooncakeEpBuffer::dispatch(const torch::Tensor& x, - const torch::Tensor& topk_idx, - torch::Tensor& active_ranks, - int num_max_dispatch_tokens_per_rank, - int num_experts, int timeout_us, bool use_fp8, - bool async, bool return_recv_hook) { - // Tensor checks - // By default using `ptp128c` FP8 cast - EP_HOST_ASSERT(x.dim() == 2 and x.is_contiguous() and - x.scalar_type() == torch::kBFloat16); - EP_HOST_ASSERT(x.size(1) % sizeof(int4) == 0 and x.size(1) % 128 == 0); - EP_HOST_ASSERT(topk_idx.dim() == 2 and topk_idx.is_contiguous()); - EP_HOST_ASSERT(x.size(0) == topk_idx.size(0) and - x.size(0) <= num_max_dispatch_tokens_per_rank); - EP_HOST_ASSERT(topk_idx.scalar_type() == torch::kInt64); +std::tuple, std::optional>> +MooncakeEpBuffer::dispatch( + uint64_t x_ptr, uint64_t topk_idx_ptr, uint64_t active_ranks_ptr, + int num_tokens, int hidden, int num_topk, + int num_max_dispatch_tokens_per_rank, int num_experts, int timeout_us, + bool use_fp8, uint64_t packed_recv_x_ptr, uint64_t packed_recv_x_scales_ptr, + uint64_t packed_recv_count_ptr, uint64_t packed_recv_src_info_ptr, + uint64_t packed_recv_layout_range_ptr, bool async, bool return_recv_hook, + uint64_t compute_stream_ptr) { EP_HOST_ASSERT(num_experts % num_ranks == 0); EP_HOST_ASSERT(USE_QP_COUNT % num_ranks == 0); + EP_HOST_ASSERT(hidden % static_cast(sizeof(int4)) == 0 && + hidden % 128 == 0); + EP_HOST_ASSERT(num_tokens <= num_max_dispatch_tokens_per_rank); - auto num_tokens = static_cast(x.size(0)), - hidden = static_cast(x.size(1)); - auto num_scales = hidden / 128, - num_topk = static_cast(topk_idx.size(1)); + auto num_scales = hidden / 128; int num_local_experts = num_experts / num_ranks; + auto compute_stream_raw = + reinterpret_cast(compute_stream_ptr); + const bool graph_capture = stream_is_capturing(compute_stream_raw); + // Buffer control BufferPair layout(gdr_buffer, num_max_dispatch_tokens_per_rank, hidden, num_ranks, num_experts); @@ -188,42 +242,54 @@ MooncakeEpBuffer::dispatch(const torch::Tensor& x, auto buffer = layout.buffers[current_buffer_idx]; auto next_buffer = layout.buffers[buffer_idx ^= 1]; int phase_epoch = ++phase_epochs[current_buffer_idx]; + int shadow_slot; + { + std::lock_guard lock(buffer_mutex); + auto [it, inserted] = dispatch_shadow_slots.emplace( + packed_recv_src_info_ptr, next_shadow_slot); + if (inserted) + next_shadow_slot = (next_shadow_slot + 1) % kTopkShadowSlots; + shadow_slot = it->second; + } // Wait previous tasks to be finished - // NOTES: the hook mode will always use the default stream - auto compute_stream = at::cuda::getCurrentCUDAStream(); - auto launch_stream = return_recv_hook ? compute_stream : comm_stream; + // NOTES: the hook mode will always use the default stream, whose native + // handle is allowed to be nullptr in CUDA/PyTorch. + auto launch_stream = + (return_recv_hook || graph_capture) ? compute_stream_raw : comm_stream; EP_HOST_ASSERT(not(async and return_recv_hook)); - if (not return_recv_hook) stream_wait(launch_stream, compute_stream); + if (not return_recv_hook and not graph_capture) + stream_wait(launch_stream, compute_stream_raw); // Allocate packed tensors - auto packed_recv_x = torch::empty( - {num_local_experts, num_ranks * num_max_dispatch_tokens_per_rank, - hidden}, - x.options().dtype(use_fp8 ? torch::kFloat8_e4m3fn : torch::kBFloat16)); - auto packed_recv_src_info = torch::empty( - {num_local_experts, num_ranks * num_max_dispatch_tokens_per_rank}, - torch::dtype(torch::kInt32).device(x.device())); - auto packed_recv_layout_range = - torch::empty({num_local_experts, num_ranks}, - torch::dtype(torch::kInt64).device(x.device())); - auto packed_recv_count = torch::zeros( - {num_local_experts}, torch::dtype(torch::kInt32).device(x.device())); - - // Allocate column-majored scales - auto packed_recv_x_scales = std::optional(); - float* packed_recv_x_scales_ptr = nullptr; + void* x = reinterpret_cast(x_ptr); + auto* topk_idx = reinterpret_cast(topk_idx_ptr); + auto* active_ranks = reinterpret_cast(active_ranks_ptr); + void* packed_recv_x = reinterpret_cast(packed_recv_x_ptr); + auto* packed_recv_x_scales = + reinterpret_cast(packed_recv_x_scales_ptr); + auto* packed_recv_count = reinterpret_cast(packed_recv_count_ptr); + auto* packed_recv_src_info = + reinterpret_cast(packed_recv_src_info_ptr); + auto* packed_recv_layout_range = + reinterpret_cast(packed_recv_layout_range_ptr); + EP_HOST_ASSERT(active_ranks != nullptr); + EP_HOST_ASSERT(num_tokens == 0 || (x != nullptr && topk_idx != nullptr)); + EP_HOST_ASSERT(packed_recv_x != nullptr && packed_recv_count != nullptr); + EP_HOST_ASSERT(packed_recv_src_info != nullptr && + packed_recv_layout_range != nullptr); if (use_fp8) { EP_HOST_ASSERT((num_ranks * num_max_dispatch_tokens_per_rank) % 4 == 0 and "TMA requires the number of tokens to be multiple of 4"); - packed_recv_x_scales = - torch::empty({num_local_experts, num_scales, - num_ranks * num_max_dispatch_tokens_per_rank}, - torch::dtype(torch::kFloat32).device(x.device())); - packed_recv_x_scales = - torch::transpose(packed_recv_x_scales.value(), 1, 2); - packed_recv_x_scales_ptr = packed_recv_x_scales->data_ptr(); + EP_HOST_ASSERT(packed_recv_x_scales != nullptr); + } + EP_HOST_ASSERT(num_topk <= kTopkShadowMaxTopk); + if (num_tokens > 0) { + CUDA_CHECK(cudaMemcpyAsync( + topk_shadow(workspace, shadow_slot, num_experts), topk_idx, + static_cast(num_tokens) * num_topk * sizeof(int64_t), + cudaMemcpyDeviceToDevice, launch_stream)); } int64_t timeout_ticks = @@ -238,10 +304,9 @@ MooncakeEpBuffer::dispatch(const torch::Tensor& x, void** ipc_ptrs = p2p_transport_->peerPtrsTablePtr(); int active_qps_per_rank = active_qps_per_rank_for_ep( USE_QP_COUNT / num_ranks, rdma_transport_ && rdma_transport_->isRoce(), - active_qps_cap_); - + active_qps_cap_, num_experts / num_ranks); auto mark_send_done = [=]() { -#ifdef MOONCAKE_EP_SPLIT_SEND_RECV +#ifdef MOONCAKE_EP_PHASE_ACK mooncake::mark_phase_ack(gdr_buffer, nvlink_avail, ipc_ptrs, buffer.rdma_send_signal_buffer, rank, num_ranks, phase_epoch, launch_stream); @@ -249,7 +314,7 @@ MooncakeEpBuffer::dispatch(const torch::Tensor& x, }; auto wait_peer_send_done = [=]() { -#ifdef MOONCAKE_EP_SPLIT_SEND_RECV +#ifdef MOONCAKE_EP_PHASE_ACK mooncake::wait_phase_ack(buffer.rdma_send_signal_buffer, rank, num_ranks, phase_epoch, launch_stream, timeout_ticks); @@ -257,7 +322,7 @@ MooncakeEpBuffer::dispatch(const torch::Tensor& x, }; auto mark_and_wait_peer_send_done = [=]() { -#ifdef MOONCAKE_EP_SPLIT_SEND_RECV +#ifdef MOONCAKE_EP_PHASE_ACK mooncake::mark_and_wait_phase_ack( gdr_buffer, nvlink_avail, ipc_ptrs, buffer.rdma_send_signal_buffer, rank, num_ranks, phase_epoch, launch_stream, timeout_ticks); @@ -266,22 +331,23 @@ MooncakeEpBuffer::dispatch(const torch::Tensor& x, auto launcher = [=](int phases) { mooncake::dispatch( - packed_recv_x.data_ptr(), packed_recv_x_scales_ptr, - packed_recv_src_info.data_ptr(), - packed_recv_layout_range.data_ptr(), - packed_recv_count.data_ptr(), active_ranks.data_ptr(), + packed_recv_x, packed_recv_x_scales, packed_recv_src_info, + packed_recv_layout_range, packed_recv_count, active_ranks, gdr_buffer, buffer.rdma_send_signal_buffer, buffer.rdma_recv_signal_buffer, buffer.rdma_send_data_buffer, buffer.rdma_recv_data_buffer, nullptr, nullptr, raddrs_ptr, - rkeys_ptr, qp_devctxs_ptr, nvlink_avail, ipc_ptrs, x.data_ptr(), - topk_idx.data_ptr(), next_buffer.rdma_recv_signal_buffer, - num_tokens, hidden, num_max_dispatch_tokens_per_rank, num_topk, - num_experts, rank, num_ranks, use_fp8, workspace, launch_stream, - timeout_ticks, phases, active_qps_per_rank); + rkeys_ptr, qp_devctxs_ptr, nvlink_avail, ipc_ptrs, x, topk_idx, + next_buffer.rdma_recv_signal_buffer, num_tokens, hidden, + num_max_dispatch_tokens_per_rank, num_topk, num_experts, rank, + num_ranks, use_fp8, workspace, launch_stream, timeout_ticks, phases, + active_qps_per_rank); }; - if (return_recv_hook) { + if (return_recv_hook && + (!graph_capture || !macaHostPhaseFenceCoversPeers())) { launcher(LOW_LATENCY_SEND_PHASE); mark_send_done(); + } else if (graph_capture) { + launcher(LOW_LATENCY_SEND_PHASE | LOW_LATENCY_RECV_PHASE); } else { #ifdef MOONCAKE_EP_SPLIT_SEND_RECV launcher(LOW_LATENCY_SEND_PHASE); @@ -298,94 +364,84 @@ MooncakeEpBuffer::dispatch(const torch::Tensor& x, // NOTES: we must ensure the all tensors will not be deallocated // before the stream-wait happens, so in Python API, we must wrap // all tensors into the event handle. - event = EventHandle(launch_stream); + event = EventHandle(reinterpret_cast(launch_stream)); } else if (return_recv_hook && macaHostPhaseFenceCoversPeers()) { - event = EventHandle(launch_stream); - } else if (not return_recv_hook) { - stream_wait(compute_stream, launch_stream); + event = EventHandle(reinterpret_cast(launch_stream)); + } else if (not return_recv_hook and not graph_capture) { + stream_wait(compute_stream_raw, launch_stream); } // Receiver callback std::optional> recv_hook = std::nullopt; if (return_recv_hook) recv_hook = [=]() { + if (graph_capture && macaHostPhaseFenceCoversPeers()) return; if (!macaHostPhaseFenceCoversPeers()) wait_peer_send_done(); launcher(LOW_LATENCY_RECV_PHASE); }; // Return values - return {packed_recv_x, - packed_recv_x_scales, - packed_recv_count, - packed_recv_src_info, - packed_recv_layout_range, - event, - recv_hook}; + return {event, recv_hook}; } -std::tuple, - std::optional>> -MooncakeEpBuffer::combine(const torch::Tensor& x, const torch::Tensor& topk_idx, - const torch::Tensor& topk_weights, - const torch::Tensor& src_info, - const torch::Tensor& layout_range, - torch::Tensor& active_ranks, +std::tuple, std::optional>> +MooncakeEpBuffer::combine(uint64_t x_ptr, uint64_t topk_idx_ptr, + uint64_t topk_weights_ptr, uint64_t src_info_ptr, + uint64_t layout_range_ptr, uint64_t active_ranks_ptr, + int num_local_experts, int num_combined_tokens, + int hidden, int num_topk, int num_max_dispatch_tokens_per_rank, int num_experts, - int timeout_us, bool zero_copy, bool async, - bool return_recv_hook, - const std::optional& out) { - // Tensor checks - EP_HOST_ASSERT(x.dim() == 3 and x.is_contiguous() and - x.scalar_type() == torch::kBFloat16); - EP_HOST_ASSERT(x.size(0) == num_experts / num_ranks); - EP_HOST_ASSERT(x.size(1) == num_ranks * num_max_dispatch_tokens_per_rank); - EP_HOST_ASSERT(x.size(2) % sizeof(int4) == 0 and x.size(2) % 128 == 0); - EP_HOST_ASSERT(topk_idx.dim() == 2 and topk_idx.is_contiguous()); - EP_HOST_ASSERT(topk_idx.size(0) == topk_weights.size(0) and - topk_idx.size(1) == topk_weights.size(1)); - EP_HOST_ASSERT(topk_idx.scalar_type() == torch::kInt64); - EP_HOST_ASSERT(topk_weights.dim() == 2 and topk_weights.is_contiguous()); - EP_HOST_ASSERT(topk_weights.size(0) <= num_max_dispatch_tokens_per_rank); - EP_HOST_ASSERT(topk_weights.scalar_type() == torch::kFloat32); - EP_HOST_ASSERT(src_info.dim() == 2 and src_info.is_contiguous()); - EP_HOST_ASSERT(src_info.scalar_type() == torch::kInt32 and - x.size(0) == src_info.size(0)); - EP_HOST_ASSERT(layout_range.dim() == 2 and layout_range.is_contiguous()); - EP_HOST_ASSERT(layout_range.scalar_type() == torch::kInt64); - EP_HOST_ASSERT(layout_range.size(0) == num_experts / num_ranks and - layout_range.size(1) == num_ranks); - auto hidden = static_cast(x.size(2)); - auto num_local_experts = num_experts / num_ranks, - num_topk = static_cast(topk_weights.size(1)); - auto num_combined_tokens = static_cast(topk_weights.size(0)); - - // Buffer control + int timeout_us, bool zero_copy, + uint64_t combined_x_ptr, bool async, + bool return_recv_hook, uint64_t compute_stream_ptr) { + EP_HOST_ASSERT(num_local_experts == num_experts / num_ranks); + EP_HOST_ASSERT(hidden % static_cast(sizeof(int4)) == 0 && + hidden % 128 == 0); + EP_HOST_ASSERT(num_combined_tokens <= num_max_dispatch_tokens_per_rank); + void* x = reinterpret_cast(x_ptr); + auto* topk_idx = reinterpret_cast(topk_idx_ptr); + auto* topk_weights = reinterpret_cast(topk_weights_ptr); + auto* src_info = reinterpret_cast(src_info_ptr); + auto* layout_range = reinterpret_cast(layout_range_ptr); + auto* active_ranks = reinterpret_cast(active_ranks_ptr); + void* combined_x = reinterpret_cast(combined_x_ptr); + EP_HOST_ASSERT( + num_combined_tokens == 0 || + (x != nullptr && topk_idx != nullptr && topk_weights != nullptr)); + EP_HOST_ASSERT(src_info != nullptr && layout_range != nullptr); + EP_HOST_ASSERT(active_ranks != nullptr); + EP_HOST_ASSERT(num_combined_tokens == 0 || combined_x != nullptr); + + auto compute_stream_raw = + reinterpret_cast(compute_stream_ptr); + const bool graph_capture = stream_is_capturing(compute_stream_raw); + + // Buffer control. The metadata tensor is the opaque handle returned by + // dispatch through the Python wrapper, so use it to pair this combine + // with the dispatch that produced it. A process-wide flip is incorrect + // when TBO interleaves its two logical dispatchers on one native Buffer. BufferPair layout(gdr_buffer, num_max_dispatch_tokens_per_rank, hidden, num_ranks, num_experts); EP_HOST_ASSERT(layout.total_bytes <= num_ep_buffer_bytes); + int shadow_slot = 0; + { + std::lock_guard lock(buffer_mutex); + auto it = dispatch_shadow_slots.find(src_info_ptr); + if (it != dispatch_shadow_slots.end()) shadow_slot = it->second; + } int current_buffer_idx = buffer_idx; auto buffer = layout.buffers[current_buffer_idx]; auto next_buffer = layout.buffers[buffer_idx ^= 1]; int phase_epoch = ++phase_epochs[current_buffer_idx]; // Wait previous tasks to be finished - // NOTES: the hook mode will always use the default stream - auto compute_stream = at::cuda::getCurrentCUDAStream(); - auto launch_stream = return_recv_hook ? compute_stream : comm_stream; + // NOTES: the hook mode will always use the default stream, whose native + // handle is allowed to be nullptr in CUDA/PyTorch. + auto launch_stream = + (return_recv_hook || graph_capture) ? compute_stream_raw : comm_stream; EP_HOST_ASSERT(not(async and return_recv_hook)); - if (not return_recv_hook) stream_wait(launch_stream, compute_stream); - - // Allocate output tensor - torch::Tensor combined_x; - if (out.has_value()) { - EP_HOST_ASSERT(out->dim() == 2 and out->is_contiguous()); - EP_HOST_ASSERT(out->size(0) == num_combined_tokens and - out->size(1) == hidden); - EP_HOST_ASSERT(out->scalar_type() == x.scalar_type()); - combined_x = out.value(); - } else { - combined_x = torch::empty({num_combined_tokens, hidden}, x.options()); - } + if (not return_recv_hook and not graph_capture) + stream_wait(launch_stream, compute_stream_raw); int64_t timeout_ticks = timeout_us == -1 ? -1 @@ -399,10 +455,10 @@ MooncakeEpBuffer::combine(const torch::Tensor& x, const torch::Tensor& topk_idx, void** ipc_ptrs = p2p_transport_->peerPtrsTablePtr(); int active_qps_per_rank = active_qps_per_rank_for_ep( USE_QP_COUNT / num_ranks, rdma_transport_ && rdma_transport_->isRoce(), - active_qps_cap_); + active_qps_cap_, num_experts / num_ranks); auto mark_send_done = [=]() { -#ifdef MOONCAKE_EP_SPLIT_SEND_RECV +#ifdef MOONCAKE_EP_PHASE_ACK mooncake::mark_phase_ack(gdr_buffer, nvlink_avail, ipc_ptrs, buffer.rdma_send_signal_buffer, rank, num_ranks, phase_epoch, launch_stream); @@ -410,7 +466,7 @@ MooncakeEpBuffer::combine(const torch::Tensor& x, const torch::Tensor& topk_idx, }; auto wait_peer_send_done = [=]() { -#ifdef MOONCAKE_EP_SPLIT_SEND_RECV +#ifdef MOONCAKE_EP_PHASE_ACK mooncake::wait_phase_ack(buffer.rdma_send_signal_buffer, rank, num_ranks, phase_epoch, launch_stream, timeout_ticks); @@ -418,7 +474,7 @@ MooncakeEpBuffer::combine(const torch::Tensor& x, const torch::Tensor& topk_idx, }; auto mark_and_wait_peer_send_done = [=]() { -#ifdef MOONCAKE_EP_SPLIT_SEND_RECV +#ifdef MOONCAKE_EP_PHASE_ACK mooncake::mark_and_wait_phase_ack( gdr_buffer, nvlink_avail, ipc_ptrs, buffer.rdma_send_signal_buffer, rank, num_ranks, phase_epoch, launch_stream, timeout_ticks); @@ -427,22 +483,25 @@ MooncakeEpBuffer::combine(const torch::Tensor& x, const torch::Tensor& topk_idx, // Kernel launch auto launcher = [=](int phases) { + auto* kernel_topk_idx = + topk_shadow(workspace, shadow_slot, num_experts); mooncake::combine( - combined_x.data_ptr(), active_ranks.data_ptr(), gdr_buffer, + combined_x, active_ranks, gdr_buffer, buffer.rdma_send_signal_buffer, buffer.rdma_recv_signal_buffer, buffer.rdma_send_data_buffer, buffer.rdma_recv_data_buffer, nullptr, nullptr, raddrs_ptr, rkeys_ptr, qp_devctxs_ptr, nvlink_avail, - ipc_ptrs, x.data_ptr(), topk_idx.data_ptr(), - topk_weights.data_ptr(), src_info.data_ptr(), - layout_range.data_ptr(), + ipc_ptrs, x, kernel_topk_idx, topk_weights, src_info, layout_range, next_buffer.rdma_recv_signal_buffer, num_combined_tokens, hidden, num_max_dispatch_tokens_per_rank, num_topk, num_experts, rank, num_ranks, workspace, launch_stream, timeout_ticks, phases, zero_copy, active_qps_per_rank); }; - if (return_recv_hook) { + if (return_recv_hook && + (!graph_capture || !macaHostPhaseFenceCoversPeers())) { launcher(LOW_LATENCY_SEND_PHASE); mark_send_done(); + } else if (graph_capture) { + launcher(LOW_LATENCY_SEND_PHASE | LOW_LATENCY_RECV_PHASE); } else { #ifdef MOONCAKE_EP_SPLIT_SEND_RECV launcher(LOW_LATENCY_SEND_PHASE); @@ -459,49 +518,29 @@ MooncakeEpBuffer::combine(const torch::Tensor& x, const torch::Tensor& topk_idx, // NOTES: we must ensure the all tensors will not be deallocated // before the stream-wait happens, so in Python API, we must wrap // all tensors into the event handle. - event = EventHandle(launch_stream); + event = EventHandle(reinterpret_cast(launch_stream)); } else if (return_recv_hook && macaHostPhaseFenceCoversPeers()) { - event = EventHandle(launch_stream); - } else if (not return_recv_hook) { - stream_wait(compute_stream, launch_stream); + event = EventHandle(reinterpret_cast(launch_stream)); + } else if (not return_recv_hook and not graph_capture) { + stream_wait(compute_stream_raw, launch_stream); } // Receiver callback std::optional> recv_hook = std::nullopt; if (return_recv_hook) recv_hook = [=]() { + if (graph_capture && macaHostPhaseFenceCoversPeers()) return; if (!macaHostPhaseFenceCoversPeers()) wait_peer_send_done(); launcher(LOW_LATENCY_RECV_PHASE); }; // Return values - return {combined_x, event, recv_hook}; -} - -torch::Tensor MooncakeEpBuffer::get_next_combine_buffer( - int num_max_dispatch_tokens_per_rank, int hidden, int num_experts) { - BufferPair layout(gdr_buffer, num_max_dispatch_tokens_per_rank, hidden, - num_ranks, num_experts); - - auto buffer = layout.buffers[buffer_idx]; - auto dtype = torch::kBFloat16; - size_t num_bytes_per_combine_msg = hidden * EP_BF16_SIZE; - auto num_msg_elems = - static_cast(num_bytes_per_combine_msg / elementSize(dtype)); - - EP_HOST_ASSERT(num_bytes_per_combine_msg % elementSize(dtype) == 0); - return torch::from_blob( - buffer.rdma_send_data_buffer, - {num_experts / num_ranks, num_ranks * num_max_dispatch_tokens_per_rank, - hidden}, - {num_ranks * num_max_dispatch_tokens_per_rank * num_msg_elems, - num_msg_elems, 1}, - torch::TensorOptions().dtype(dtype).device(torch::kCUDA)); + return {event, recv_hook}; } void MooncakeEpBuffer::update_local_qpns() { if (!rdma_transport_) return; - int ret = rdma_transport_->recreateQueuePairs(comm_stream.stream()); + int ret = rdma_transport_->recreateQueuePairs(comm_stream); if (ret != 0) { ibgda_disabled_ = true; LOG(ERROR) << "[EP] Failed to recreate QPs"; @@ -542,12 +581,14 @@ void MooncakeEpBuffer::sync_ibgda_peers( } std::vector MooncakeEpBuffer::get_ipc_handle() { + if (!p2p_enabled_) return {}; return p2p_transport_->exportIpcHandle(gdr_buffer); } void MooncakeEpBuffer::sync_nvlink_ipc_handles( const std::vector>& remote_handles, const std::vector& active_ranks_mask) { + if (!p2p_enabled_) return; p2p_transport_->importPeerHandles(gdr_buffer, rank, num_ranks, remote_handles, active_ranks_mask); } diff --git a/mooncake-ep/src/mooncake_ep_elastic_buffer.cpp b/mooncake-ep/src/mooncake_ep_elastic_buffer.cpp index 7d3d0052cf..38c58524fa 100644 --- a/mooncake-ep/src/mooncake_ep_elastic_buffer.cpp +++ b/mooncake-ep/src/mooncake_ep_elastic_buffer.cpp @@ -1,19 +1,26 @@ #include #include +#include #include #include #include +#include #include -#include +#include +#include +#ifdef USE_NCCL_DEVICE +#include +#endif namespace mooncake { namespace { int64_t ceil_div_i64(int64_t x, int64_t y) { return (x + y - 1) / y; } -constexpr int kElasticHybridChannelsPerSm = 4; +constexpr int kIbgdaElasticHybridChannelsPerSm = 4; +constexpr int kNcclElasticHybridChannelsPerSm = 8; int64_t align_i64(int64_t x, int64_t alignment) { return ceil_div_i64(x, alignment) * alignment; @@ -25,17 +32,19 @@ int getenv_int(const char* name, int default_value) { return std::max(1, std::atoi(value)); } -int hybrid_num_channels(int num_sms) { - return std::max(1, num_sms) * kElasticHybridChannelsPerSm; +int hybrid_num_channels(int num_sms, int channels_per_sm) { + return std::max(1, num_sms) * channels_per_sm; } -int hybrid_num_max_tokens_per_channel(int num_max_tokens_per_rank, - int num_sms) { +int hybrid_num_max_tokens_per_channel(int num_max_tokens_per_rank, int num_sms, + int channels_per_sm) { return static_cast( - ceil_div_i64(num_max_tokens_per_rank, hybrid_num_channels(num_sms))); + ceil_div_i64(num_max_tokens_per_rank, + hybrid_num_channels(num_sms, channels_per_sm))); } int64_t elastic_workspace_num_bytes() { + // Preserve the established host reservation and payload offset. constexpr int64_t kNumMaxRanks = 1024; constexpr int64_t kNumMaxExperts = 2048; constexpr int64_t kNumMaxChannels = 8 * 160; @@ -62,68 +71,401 @@ int64_t elastic_atomic_scratch_num_bytes() { return elastic_workspace_num_bytes(); } -int device_smem_bytes() { -#ifdef MOONCAKE_EP_USE_MUSA - return 0; -#else - int device = 0; - cudaGetDevice(&device); - int value = 0; - cudaDeviceGetAttribute(&value, cudaDevAttrMaxSharedMemoryPerBlockOptin, - device); - return value > 0 ? value : 98304; -#endif +#ifdef USE_NCCL_DEVICE +class ScopedCudaDevice { + public: + explicit ScopedCudaDevice(int device) { + CUDA_CHECK(cudaGetDevice(&previous_device_)); + if (previous_device_ != device) { + CUDA_CHECK(cudaSetDevice(device)); + restore_ = true; + } + } + + ScopedCudaDevice(const ScopedCudaDevice&) = delete; + ScopedCudaDevice& operator=(const ScopedCudaDevice&) = delete; + + ~ScopedCudaDevice() { + if (!restore_) return; + const auto status = cudaSetDevice(previous_device_); + if (status != cudaSuccess) { + LOG(ERROR) + << "failed to restore CUDA device after NCCL ElasticBuffer " + "reconfiguration: " + << cudaGetErrorString(status); + } + } + + private: + int previous_device_ = -1; + bool restore_ = false; +}; + +class NcclCommStream { + public: + NcclCommStream() { + int least_priority = 0; + int greatest_priority = 0; + auto status = cudaDeviceGetStreamPriorityRange(&least_priority, + &greatest_priority); + if (status != cudaSuccess) { + cudaGetLastError(); + greatest_priority = 0; + } + CUDA_CHECK(cudaStreamCreateWithPriority(&stream_, cudaStreamNonBlocking, + greatest_priority)); + } + + NcclCommStream(const NcclCommStream&) = delete; + NcclCommStream& operator=(const NcclCommStream&) = delete; + + ~NcclCommStream() { reset(); } + + cudaStream_t stream() const { return stream_; } + + cudaError_t reset() { + if (stream_ == nullptr) return cudaSuccess; + const auto status = cudaStreamDestroy(stream_); + stream_ = nullptr; + return status; + } + + private: + cudaStream_t stream_ = nullptr; +}; + +int nccl_gin_context_count(int requested_count, bool allow_hybrid_mode) { + // Match upstream DeepEP v2: hybrid mode reserves one notify context plus + // 64 data contexts; direct mode reserves one notify plus 16 data contexts. + const int default_count = allow_hybrid_mode ? 65 : 17; + constexpr int kMaxGinContexts = MAX_QP_COUNT; + const int count = + requested_count > 0 + ? requested_count + : getenv_int("MOONCAKE_EP_NCCL_GIN_CONTEXTS", default_count); + if (count > kMaxGinContexts) { + throw std::invalid_argument( + "NCCL ElasticBuffer GIN context count exceeds MAX_QP_COUNT"); + } + return count; } +#endif +} // namespace + +struct NcclElasticState { +#ifdef USE_NCCL_DEVICE + std::unique_ptr transport; + device::NcclBufferRegistration registration; + device::NcclDeviceContext device_context; + device::NcclTransportProperties properties; + device::NcclLsaTopology lsa_topology; + void* allocation = nullptr; + size_t allocation_bytes = 0; + int device_id = -1; + int gin_context_count = 0; + int clock_rate_khz = 0; + NcclCommStream comm_stream; + + NcclElasticState(int rank, int num_ranks, size_t bytes, + int requested_gin_context_count, bool use_rail_gin, + int gin_traffic_class, + const std::vector& nccl_unique_id) + : transport(device::createNcclDeviceTransport()), + allocation_bytes(bytes), + gin_context_count(requested_gin_context_count) { + if (!transport) { + throw std::runtime_error( + "failed to create the NCCL device transport"); + } + + CUDA_CHECK(cudaGetDevice(&device_id)); + CUDA_CHECK(cudaDeviceGetAttribute(&clock_rate_khz, cudaDevAttrClockRate, + device_id)); + + device::NcclTransportConfig config; + config.rank = rank; + config.num_ranks = num_ranks; + config.enable_gin = num_ranks > 1; + config.gin_connection_type = use_rail_gin + ? device::NcclGinConnectionType::kRail + : device::NcclGinConnectionType::kFull; + config.gin_context_count = + config.enable_gin ? requested_gin_context_count : 0; + config.gin_exclusive_contexts = config.enable_gin; + config.gin_queue_depth = config.enable_gin ? 1024 : 0; + config.gin_signal_count = config.enable_gin ? num_ranks + 4 : 0; + config.gin_traffic_class = gin_traffic_class; + config.lsa_barrier_count = 0; + config.require_lsa_multimem = false; + if (transport->initialize(config, nccl_unique_id) != 0) { + throw std::runtime_error( + "failed to initialize the NCCL ElasticBuffer transport"); + } + + properties = transport->properties(); + lsa_topology = transport->lsaTopology(); + if (transport->allocateAndRegisterBuffer(allocation_bytes, &allocation, + ®istration) != 0) { + throw std::runtime_error( + "failed to allocate and register the NCCL ElasticBuffer"); + } + device_context = transport->deviceContext(registration); + if (!transport->allRanksSucceeded(device_context.valid())) { + throw std::runtime_error( + "failed to create the NCCL ElasticBuffer device context on " + "one or more ranks"); + } + } + + int release() noexcept { + if (!transport) return 0; + + int status = 0; + int previous_device = -1; + if (cudaGetDevice(&previous_device) != cudaSuccess || + cudaSetDevice(device_id) != cudaSuccess) { + status = -1; + } + if (cudaStreamSynchronize(comm_stream.stream()) != cudaSuccess) { + status = -1; + } + if (registration.valid() && + transport->deregisterBuffer(®istration) != 0) { + status = -1; + } + if (allocation != nullptr && !registration.valid()) { + if (transport->freeBuffer(allocation) != 0) status = -1; + } + if (transport->shutdown() != 0) status = -1; + if (comm_stream.reset() != cudaSuccess) status = -1; + + allocation = nullptr; + device_context = {}; + transport.reset(); + if (previous_device >= 0 && previous_device != device_id && + cudaSetDevice(previous_device) != cudaSuccess) { + status = -1; + } + return status; + } + + int abort() noexcept { + if (!transport) return 0; + + int status = 0; + int previous_device = -1; + if (cudaGetDevice(&previous_device) != cudaSuccess || + cudaSetDevice(device_id) != cudaSuccess) { + status = -1; + } + if (cudaStreamSynchronize(comm_stream.stream()) != cudaSuccess) { + status = -1; + } + if (transport->abort() != 0) status = -1; + if (comm_stream.reset() != cudaSuccess) status = -1; + + allocation = nullptr; + registration = {}; + device_context = {}; + transport.reset(); + if (previous_device >= 0 && previous_device != device_id && + cudaSetDevice(previous_device) != cudaSuccess) { + status = -1; + } + return status; + } +#endif + + ~NcclElasticState() { +#ifdef USE_NCCL_DEVICE + release(); +#endif + } +}; + +#ifdef USE_NCCL_DEVICE +namespace { +ElasticTopology make_nccl_topology(NcclElasticState& state, int rank, + int num_ranks, bool allow_hybrid_mode) { + const auto& properties = state.properties; + const auto& lsa = state.lsa_topology; + const bool local_lsa_topology_valid = + properties.rank == rank && properties.num_ranks == num_ranks && + lsa.rank >= 0 && lsa.size > 0 && lsa.first_rank >= 0 && + lsa.first_rank + lsa.rank == rank && + lsa.first_rank + lsa.size <= num_ranks && num_ranks % lsa.size == 0 && + lsa.first_rank == (rank / lsa.size) * lsa.size; + if (!state.transport->allRanksSucceeded(local_lsa_topology_valid)) { + throw std::runtime_error( + "NCCL LSA membership on one or more ranks is not a " + "contiguous, equal-sized EP local team; reorder " + "process-group ranks by node/device"); + } + ElasticTopology topology; + topology.rank_idx = rank; + topology.num_ranks = num_ranks; + topology.num_rdma_ranks = num_ranks / lsa.size; + topology.num_nvlink_ranks = lsa.size; + const bool local_mode_supported = + topology.num_rdma_ranks <= 1 || allow_hybrid_mode; + if (!state.transport->allRanksSucceeded(local_mode_supported)) { + throw std::runtime_error( + "multi-node NCCL ElasticBuffer requires " + "allow_hybrid_mode=true; full-world GIN kernels are not part " + "of the initial backend"); + } + const bool local_topology_supported = + topology.num_rdma_ranks == 1 + ? (lsa.size == 2 || lsa.size == 8) + : ((topology.num_rdma_ranks == 2 && + (lsa.size == 4 || lsa.size == 8)) || + (topology.num_rdma_ranks == 4 && lsa.size == 4)); + if (!state.transport->allRanksSucceeded(local_topology_supported)) { + throw std::runtime_error( + "NCCL ElasticBuffer currently supports one LSA team of 2 or 8 " + "GPUs, two LSA teams of 4 or 8 GPUs, or four LSA teams of 4 " + "GPUs"); + } + if (allow_hybrid_mode && topology.num_rdma_ranks > 1) { + topology.num_scaleout_ranks = topology.num_rdma_ranks; + topology.num_scaleup_ranks = topology.num_nvlink_ranks; + topology.scaleout_rank_idx = lsa.first_rank / lsa.size; + topology.scaleup_rank_idx = lsa.rank; + topology.hybrid_enabled = true; + topology.scaleup_lsa = true; + } else { + topology.num_scaleout_ranks = 1; + topology.num_scaleup_ranks = num_ranks; + topology.scaleout_rank_idx = 0; + topology.scaleup_rank_idx = rank; + topology.hybrid_enabled = false; + topology.scaleup_lsa = topology.num_rdma_ranks == 1; + } + return topology; +} } // namespace +#endif ElasticLaunchContext MooncakeElasticBuffer::make_launch_context( - MooncakeEpBuffer& buffer, const ElasticTopology& topology, - void* mapped_host_workspace, int64_t timeout_cycles) { + int64_t timeout_cycles) const { ElasticLaunchContext ctx; - auto* rdma = buffer.rdma_transport_; - auto* gdr_base = static_cast(buffer.gdr_buffer); - // Mooncake P2P/RDMA Device API translates remote pointers as offsets from - // the registered GDR buffer base. DeepEP elastic writes both `buffer` and - // `workspace` pointers to peer ranks through GIN, so both regions must live - // inside the same peer-visible registered allocation. The elastic buffer - // size reserves `elastic_workspace_num_bytes()` first; use that prefix as - // the workspace. RDMA atomics also need a separate local response area: - // mlx5 atomics write the fetched old value to the WQE local address, so - // reusing the remote signal workspace as `local_atomic_base` can corrupt - // the barrier/signal slots. Reserve an equal-sized scratch prefix after - // the workspace, then place the communication buffer after both prefixes. + ctx.device_id = device_id_; const auto workspace_bytes = elastic_workspace_num_bytes(); - const auto atomic_scratch_bytes = elastic_atomic_scratch_num_bytes(); - ctx.gdr_buffer = gdr_base; - ctx.nvlink_available = buffer.p2p_transport_->availableTablePtr(); - ctx.ipc_peer_ptrs = buffer.p2p_transport_->peerPtrsTablePtr(); - ctx.raddrs = rdma ? rdma->raddrsPtr() : nullptr; - ctx.rkeys = rdma ? rdma->rkeysPtr() : nullptr; - ctx.qp_devctxs = rdma ? rdma->qpDevCtxsPtr() : nullptr; - ctx.rdma_send_signal_buffer = gdr_base + workspace_bytes; - ctx.rdma_recv_signal_buffer = gdr_base; - ctx.workspace = gdr_base; - ctx.buffer = gdr_base + workspace_bytes + atomic_scratch_bytes; - ctx.mapped_host_workspace = mapped_host_workspace; - ctx.rank = topology.rank_idx; - ctx.num_ranks = topology.num_ranks; - ctx.scaleout_rank_idx = topology.scaleout_rank_idx; - ctx.scaleup_rank_idx = topology.scaleup_rank_idx; - ctx.num_scaleout_ranks = topology.num_scaleout_ranks; - ctx.num_scaleup_ranks = topology.num_scaleup_ranks; - ctx.is_scaleup_nvlink = true; - ctx.num_qps = buffer.USE_QP_COUNT; + const auto scratch_bytes = elastic_atomic_scratch_num_bytes(); + + char* local_base = nullptr; + if (using_nccl()) { +#ifdef USE_NCCL_DEVICE + local_base = static_cast(nccl_state_->allocation); + ctx.backend = ElasticTransportBackend::kNccl; + ctx.nccl.device = nccl_state_->device_context; + ctx.num_qps = std::max(1, nccl_state_->properties.gin_context_count); +#else + throw std::logic_error( + "NCCL ElasticBuffer state exists in a non-NCCL build"); +#endif + } else { + if (!native_buffer_) { + throw std::logic_error( + "ElasticBuffer transport has already been destroyed"); + } + auto& buffer = *native_buffer_; + auto* rdma = buffer.rdma_transport_; + local_base = static_cast(buffer.gdr_buffer); + ctx.backend = ElasticTransportBackend::kIbgda; + ctx.nvlink_available = buffer.p2p_transport_->availableTablePtr(); + ctx.ipc_peer_ptrs = buffer.p2p_transport_->peerPtrsTablePtr(); + ctx.raddrs = rdma ? rdma->raddrsPtr() : nullptr; + ctx.rkeys = rdma ? rdma->rkeysPtr() : nullptr; + ctx.qp_devctxs = rdma ? rdma->qpDevCtxsPtr() : nullptr; + ctx.rdma_send_signal_buffer = local_base + workspace_bytes; + ctx.rdma_recv_signal_buffer = local_base; + ctx.num_qps = buffer.USE_QP_COUNT; + } + + // Both backends expose one registered allocation. Keep the established + // workspace and IBGDA atomic-response prefix sizes for a common payload + // offset; NCCL does not access the second prefix. + ctx.gdr_buffer = local_base; + ctx.workspace = local_base; + ctx.buffer = local_base + workspace_bytes + scratch_bytes; + ctx.mapped_host_workspace = mapped_host_workspace_; + ctx.rank = topology_.rank_idx; + ctx.num_ranks = topology_.num_ranks; + ctx.scaleout_rank_idx = topology_.scaleout_rank_idx; + ctx.scaleup_rank_idx = topology_.scaleup_rank_idx; + ctx.num_scaleout_ranks = topology_.num_scaleout_ranks; + ctx.num_scaleup_ranks = topology_.num_scaleup_ranks; + ctx.is_scaleup_nvlink = topology_.scaleup_lsa; ctx.timeout_cycles = timeout_cycles; return ctx; } +std::vector create_elastic_nccl_unique_id() { +#ifdef USE_NCCL_DEVICE + auto transport = device::createNcclDeviceTransport(); + if (!transport) { + throw std::runtime_error("failed to create the NCCL device transport"); + } + auto unique_id = transport->createUniqueId(); + if (unique_id.empty()) { + throw std::runtime_error("failed to create an NCCL unique ID"); + } + return unique_id; +#else + throw std::runtime_error( + "Mooncake EP was built without NCCL Device API support; rebuild with " + "-DUSE_NCCL_DEVICE=ON"); +#endif +} + +MooncakeElasticBuffer::MooncakeElasticBuffer( + int rank, int num_ranks, int64_t num_buffer_bytes, + int64_t num_max_tokens_per_rank, int64_t hidden, int64_t num_topk, + bool use_fp8_dispatch, bool deterministic, bool allow_hybrid_mode, + bool allow_multiple_reduction, bool prefer_overlap_with_compute, int sl_idx, + int num_allocated_qps, int num_cpu_timeout_secs, int num_gpu_timeout_secs) + : MooncakeElasticBuffer( + rank, num_ranks, num_buffer_bytes, num_max_tokens_per_rank, hidden, + num_topk, use_fp8_dispatch, deterministic, allow_hybrid_mode, + allow_multiple_reduction, prefer_overlap_with_compute, sl_idx, + num_allocated_qps, num_cpu_timeout_secs, num_gpu_timeout_secs, + "ibgda", {}) {} + MooncakeElasticBuffer::MooncakeElasticBuffer( int rank, int num_ranks, int64_t num_buffer_bytes, int64_t num_max_tokens_per_rank, int64_t hidden, int64_t num_topk, bool use_fp8_dispatch, bool deterministic, bool allow_hybrid_mode, bool allow_multiple_reduction, bool prefer_overlap_with_compute, int sl_idx, - int num_allocated_qps, int num_cpu_timeout_secs, int num_gpu_timeout_secs) { + int num_allocated_qps, int num_cpu_timeout_secs, int num_gpu_timeout_secs, + const std::string& transport, const std::vector& nccl_unique_id) + : transport_(transport) { + if (rank < 0 || num_ranks <= 0 || rank >= num_ranks) { + throw std::invalid_argument("invalid ElasticBuffer rank or world size"); + } + if (!allow_multiple_reduction) { + throw std::runtime_error( + "Mooncake ElasticBuffer currently supports only " + "allow_multiple_reduction=true"); + } + if (transport_ != "ibgda" && transport_ != "nccl") { + throw std::invalid_argument( + "ElasticBuffer transport must be either 'ibgda' or 'nccl'"); + } + + CUDA_CHECK(cudaGetDevice(&device_id_)); + CUDA_CHECK(cudaDeviceGetAttribute( + &physical_num_sms_, cudaDevAttrMultiProcessorCount, device_id_)); +#ifdef MOONCAKE_EP_USE_MUSA + device_smem_bytes_ = 0; +#else + CUDA_CHECK(cudaDeviceGetAttribute(&device_smem_bytes_, + cudaDevAttrMaxSharedMemoryPerBlockOptin, + device_id_)); + if (device_smem_bytes_ <= 0) device_smem_bytes_ = 98304; +#endif + config_.num_max_tokens_per_rank = num_max_tokens_per_rank; config_.hidden = hidden; config_.num_topk = num_topk; @@ -137,35 +479,293 @@ MooncakeElasticBuffer::MooncakeElasticBuffer( config_.num_cpu_timeout_secs = num_cpu_timeout_secs; config_.num_gpu_timeout_secs = num_gpu_timeout_secs; - topology_ = discover_topology(rank, num_ranks, allow_hybrid_mode); - if (!allow_multiple_reduction) { - throw std::runtime_error( - "Mooncake ElasticBuffer currently supports only " - "allow_multiple_reduction=true"); - } if (num_buffer_bytes == 0) { num_buffer_bytes = calculate_buffer_size( num_ranks, num_max_tokens_per_rank, hidden, num_topk, use_fp8_dispatch, allow_hybrid_mode, allow_multiple_reduction); } - native_buffer_ = - std::make_unique(rank, num_ranks, num_buffer_bytes); + if (num_buffer_bytes <= 0) { + throw std::invalid_argument("ElasticBuffer size must be positive"); + } + + if (transport_ == "nccl") { +#ifdef USE_NCCL_DEVICE + const int context_count = + nccl_gin_context_count(num_allocated_qps, allow_hybrid_mode); + nccl_state_ = std::make_unique( + rank, num_ranks, static_cast(num_buffer_bytes), + context_count, allow_hybrid_mode, sl_idx, nccl_unique_id); + topology_ = make_nccl_topology(*nccl_state_, rank, num_ranks, + allow_hybrid_mode); +#else + (void)nccl_unique_id; + throw std::runtime_error( + "transport='nccl' requires a Mooncake EP build with " + "-DUSE_NCCL_DEVICE=ON"); +#endif + } else { + if (!nccl_unique_id.empty()) { + throw std::invalid_argument( + "nccl_unique_id must be empty for transport='ibgda'"); + } + topology_ = discover_topology(rank, num_ranks, allow_hybrid_mode); + native_buffer_ = std::make_unique(rank, num_ranks, + num_buffer_bytes); + } + host_workspace_bytes_ = elastic_workspace_num_bytes(); - CUDA_CHECK(cudaHostAlloc(&host_workspace_, host_workspace_bytes_, - cudaHostAllocMapped)); - CUDA_CHECK( - cudaHostGetDevicePointer(&mapped_host_workspace_, host_workspace_, 0)); - std::memset(host_workspace_, 0, host_workspace_bytes_); + if (using_nccl()) { +#ifdef USE_NCCL_DEVICE + const cudaError_t allocation_status = cudaHostAlloc( + &host_workspace_, host_workspace_bytes_, cudaHostAllocMapped); + cudaError_t mapping_status = cudaSuccess; + if (allocation_status == cudaSuccess) { + mapping_status = cudaHostGetDevicePointer(&mapped_host_workspace_, + host_workspace_, 0); + } + const bool local_workspace_valid = + allocation_status == cudaSuccess && mapping_status == cudaSuccess; + if (!nccl_state_->transport->allRanksSucceeded(local_workspace_valid)) { + if (host_workspace_ != nullptr) cudaFreeHost(host_workspace_); + host_workspace_ = nullptr; + mapped_host_workspace_ = nullptr; + throw std::runtime_error( + "failed to allocate and map the NCCL ElasticBuffer host " + "workspace on one or more ranks"); + } + std::memset(host_workspace_, 0, host_workspace_bytes_); +#else + throw std::logic_error( + "NCCL ElasticBuffer state exists in a non-NCCL build"); +#endif + } else { + try { + CUDA_CHECK(cudaHostAlloc(&host_workspace_, host_workspace_bytes_, + cudaHostAllocMapped)); + CUDA_CHECK(cudaHostGetDevicePointer(&mapped_host_workspace_, + host_workspace_, 0)); + std::memset(host_workspace_, 0, host_workspace_bytes_); + } catch (...) { + if (host_workspace_ != nullptr) cudaFreeHost(host_workspace_); + host_workspace_ = nullptr; + mapped_host_workspace_ = nullptr; + throw; + } + } } MooncakeElasticBuffer::~MooncakeElasticBuffer() { + try { + destroy(); + } catch (const std::exception& error) { + LOG(ERROR) << "ElasticBuffer cleanup failed: " << error.what(); + } catch (...) { + LOG(ERROR) << "ElasticBuffer cleanup failed with an unknown error"; + } +} + +void MooncakeElasticBuffer::destroy() { + if (destroyed_) return; + destroyed_ = true; + + std::exception_ptr cleanup_error; +#ifdef USE_NCCL_DEVICE + if (nccl_state_) { + if (nccl_state_->release() != 0) { + cleanup_error = std::make_exception_ptr( + std::runtime_error("NCCL ElasticBuffer cleanup failed")); + } + nccl_state_.reset(); + } +#endif + if (native_buffer_) { + auto* buffer = native_buffer_.release(); + if (cudaStreamSynchronize(buffer->comm_stream) != cudaSuccess && + !cleanup_error) { + cleanup_error = std::make_exception_ptr(std::runtime_error( + "failed to synchronize the IBGDA ElasticBuffer stream")); + } + try { + delete buffer; + } catch (...) { + if (!cleanup_error) cleanup_error = std::current_exception(); + } + } if (host_workspace_ != nullptr) { - cudaFreeHost(host_workspace_); + if (cudaFreeHost(host_workspace_) != cudaSuccess && !cleanup_error) { + cleanup_error = std::make_exception_ptr( + std::runtime_error("failed to free ElasticBuffer workspace")); + } host_workspace_ = nullptr; mapped_host_workspace_ = nullptr; } + if (cleanup_error) std::rethrow_exception(cleanup_error); +} + +void MooncakeElasticBuffer::reconfigure_nccl( + const std::vector& nccl_unique_id) { + if (destroyed_ || !nccl_state_) { + throw std::runtime_error( + "NCCL ElasticBuffer reconfiguration requires a live NCCL buffer"); + } +#ifdef USE_NCCL_DEVICE + const ScopedCudaDevice device_guard(device_id_); + // update_ep_member() is a quiescent-boundary operation. Synchronizing the + // communication stream makes the lifetime boundary explicit even for + // direct native callers that do not use the Python wrapper. + CUDA_CHECK(cudaStreamSynchronize(nccl_state_->comm_stream.stream())); + + const int rank = topology_.rank_idx; + const int num_ranks = topology_.num_ranks; + const int context_count = nccl_state_->gin_context_count; + + // Build the complete replacement before publishing it. This intentionally + // uses a second allocation: GIN VA-signal storage cannot be cleared with + // ordinary CUDA stores while its old NCCL window remains registered. + auto candidate = std::make_unique( + rank, num_ranks, nccl_state_->allocation_bytes, context_count, + config_.allow_hybrid_mode, config_.sl_idx, nccl_unique_id); + const auto candidate_topology = make_nccl_topology( + *candidate, rank, num_ranks, config_.allow_hybrid_mode); + + // Initial construction performs this same status collective after + // creating its host workspace. Reconfiguration participates as well so a + // replacement rank constructing a new buffer and surviving ranks updating + // an old one execute an identical NCCL setup sequence. + const bool local_workspace_valid = + host_workspace_ != nullptr && mapped_host_workspace_ != nullptr; + if (!candidate->transport->allRanksSucceeded(local_workspace_valid)) { + throw std::runtime_error( + "NCCL ElasticBuffer host workspace is unavailable on one or more " + "recovered ranks"); + } + + auto previous = std::move(nccl_state_); + nccl_state_ = std::move(candidate); + topology_ = candidate_topology; + std::memset(host_workspace_, 0, host_workspace_bytes_); + deterministic_rank_count_buffer_.reset(); + deterministic_rank_count_buffer_bytes_ = 0; + + // A replacement process never owned the previous communicator, so normal + // collective destruction cannot be required here. Retire it locally after + // the new generation is already usable. + if (previous->abort() != 0) { + LOG(WARNING) << "failed to completely release an obsolete NCCL " + "ElasticBuffer generation"; + } +#else + (void)nccl_unique_id; + throw std::runtime_error( + "Mooncake EP was built without NCCL Device API support"); +#endif +} + +MooncakeEpBuffer& MooncakeElasticBuffer::native_buffer() { + if (!native_buffer_) { + throw std::runtime_error( + "this ElasticBuffer uses NCCL or has already been destroyed"); + } + return *native_buffer_; +} + +bool MooncakeElasticBuffer::ibgda_disabled() const { + if (using_nccl()) return true; + if (!native_buffer_) { + throw std::runtime_error("ElasticBuffer has already been destroyed"); + } + return native_buffer_->ibgda_disabled(); +} + +bool MooncakeElasticBuffer::use_fast_path() { + return using_nccl() ? true : native_buffer().use_fast_path(); +} + +void MooncakeElasticBuffer::update_local_qpns() { + if (using_nccl()) { + throw std::runtime_error( + "NCCL ElasticBuffer membership is fixed; recreate the buffer " + "instead of updating QPs"); + } + native_buffer().update_local_qpns(); } +bool MooncakeElasticBuffer::is_roce() const { + if (using_nccl()) return false; + if (!native_buffer_) { + throw std::runtime_error("ElasticBuffer has already been destroyed"); + } + return native_buffer_->is_roce(); +} + +void MooncakeElasticBuffer::sync_ibgda_peers( + const std::vector& remote_addrs, + const std::vector& remote_keys, + const std::vector>& peer_qpns, + const std::vector>& peer_lids, + const std::vector& subnet_prefixes, + const std::vector& interface_ids, + const std::vector& active_ranks_mask) { + native_buffer().sync_ibgda_peers(remote_addrs, remote_keys, peer_qpns, + peer_lids, subnet_prefixes, interface_ids, + active_ranks_mask); +} + +std::tuple MooncakeElasticBuffer::get_mr_info() { + return native_buffer().get_mr_info(); +} + +std::tuple MooncakeElasticBuffer::get_gid() { + return native_buffer().get_gid(); +} + +std::vector MooncakeElasticBuffer::get_local_qpns() { + return native_buffer().get_local_qpns(); +} + +std::vector MooncakeElasticBuffer::get_local_lids() { + return native_buffer().get_local_lids(); +} + +std::vector MooncakeElasticBuffer::get_ipc_handle() { + return native_buffer().get_ipc_handle(); +} + +void MooncakeElasticBuffer::sync_nvlink_ipc_handles( + const std::vector>& remote_handles, + const std::vector& active_ranks_mask) { + native_buffer().sync_nvlink_ipc_handles(remote_handles, active_ranks_mask); +} + +cudaStream_t MooncakeElasticBuffer::communication_stream() const { + if (using_nccl()) { +#ifdef USE_NCCL_DEVICE + return nccl_state_->comm_stream.stream(); +#else + throw std::logic_error( + "NCCL ElasticBuffer state exists in a non-NCCL build"); +#endif + } + if (!native_buffer_) { + throw std::runtime_error("ElasticBuffer has already been destroyed"); + } + return native_buffer_->comm_stream; +} + +int MooncakeElasticBuffer::clock_rate_khz() const { + if (using_nccl()) { +#ifdef USE_NCCL_DEVICE + return nccl_state_->clock_rate_khz; +#else + throw std::logic_error( + "NCCL ElasticBuffer state exists in a non-NCCL build"); +#endif + } + if (!native_buffer_) { + throw std::runtime_error("ElasticBuffer has already been destroyed"); + } + return native_buffer_->clock_rate_khz; +} int64_t MooncakeElasticBuffer::calculate_buffer_size( int num_ranks, int64_t num_max_tokens_per_rank, int64_t hidden, int64_t num_topk, bool use_fp8_dispatch, bool allow_hybrid_mode, @@ -197,6 +797,23 @@ std::tuple MooncakeElasticBuffer::get_logical_domain_size() const { return {topology_.num_scaleout_ranks, topology_.num_scaleup_ranks}; } +std::shared_ptr +MooncakeElasticBuffer::ensure_deterministic_rank_count_buffer(int num_sms) { + const int64_t required_bytes = static_cast(sizeof(int)) * num_sms * + topology_.num_scaleup_ranks; + if (deterministic_rank_count_buffer_ != nullptr && + deterministic_rank_count_buffer_bytes_ >= required_bytes) { + return deterministic_rank_count_buffer_; + } + + void* buffer_ptr = nullptr; + CUDA_CHECK(cudaMalloc(&buffer_ptr, required_bytes)); + deterministic_rank_count_buffer_ = + std::shared_ptr(buffer_ptr, [](void* p) { cudaFree(p); }); + deterministic_rank_count_buffer_bytes_ = required_bytes; + return deterministic_rank_count_buffer_; +} + int MooncakeElasticBuffer::get_theoretical_num_sms(int num_experts, int num_topk) const { int device = 0; @@ -210,142 +827,93 @@ int MooncakeElasticBuffer::get_theoretical_num_sms(int num_experts, std::max(1, num_experts * num_topk)})); } -ElasticDispatchOutput MooncakeElasticBuffer::dispatch( - const torch::Tensor& x, const std::optional& sf, - const torch::Tensor& topk_idx, - const std::optional& topk_weights, - torch::Tensor& active_ranks, int num_experts, int num_max_tokens_per_rank, - int expert_alignment, int num_sms, bool do_expand, bool do_cpu_sync, - bool async_with_compute_stream, - const std::optional& cached_handle) { - EP_HOST_ASSERT(x.dim() == 2 && x.is_contiguous()); - const bool use_sf = sf.has_value(); - if (use_sf) { - EP_HOST_ASSERT(x.element_size() == 1); - EP_HOST_ASSERT(sf->dim() == 2 && sf->is_cuda()); - EP_HOST_ASSERT(sf->scalar_type() == torch::kFloat32 || - sf->scalar_type() == torch::kInt32); - EP_HOST_ASSERT(sf->size(0) == x.size(0)); - } else { - EP_HOST_ASSERT(!config_.use_fp8_dispatch); - EP_HOST_ASSERT(x.scalar_type() == torch::kBFloat16); - } - EP_HOST_ASSERT(topk_idx.dim() == 2 && topk_idx.is_contiguous()); - EP_HOST_ASSERT(topk_idx.scalar_type() == torch::kInt64); - EP_HOST_ASSERT(x.size(0) == topk_idx.size(0)); +std::optional MooncakeElasticBuffer::dispatch( + uint64_t x_ptr, int x_element_size, uint64_t sf_ptr, int num_tokens, + int hidden, int num_sf_packs, int sf_token_stride, int sf_hidden_stride, + uint64_t topk_idx_ptr, int num_topk, uint64_t topk_weights_ptr, + uint64_t active_ranks_ptr, int num_experts, int num_max_tokens_per_rank, + int expert_alignment, int num_sms, bool do_expand, + bool async_with_compute_stream, uint64_t compute_stream_ptr, + bool cached_mode, int num_recv_tokens, + uint64_t psum_num_recv_tokens_per_scaleup_rank_ptr, + uint64_t psum_num_recv_tokens_per_expert_ptr, + uint64_t dst_buffer_slot_idx_ptr, uint64_t token_metadata_at_forward_ptr, + uint64_t channel_linked_list_ptr, uint64_t recv_x_ptr, + uint64_t recv_x_scales_ptr, uint64_t recv_topk_idx_ptr, + uint64_t recv_topk_weights_ptr, uint64_t recv_src_metadata_ptr) { + const bool use_sf = sf_ptr != 0; EP_HOST_ASSERT(num_experts % topology_.num_ranks == 0); - const int num_tokens = static_cast(x.size(0)); - const int hidden = static_cast(x.size(1)); - const int num_topk = static_cast(topk_idx.size(1)); - const int num_sf_packs = use_sf ? static_cast(sf->size(1)) : 0; - const int sf_token_stride = use_sf ? static_cast(sf->stride(0)) : 0; - const int sf_hidden_stride = use_sf ? static_cast(sf->stride(1)) : 0; const int num_local_experts = num_experts / topology_.num_ranks; // The copy epilogue uses `kNumMaxTokensPerRank * kNumRanks` as the // no-CPU-sync sentinel and then reads the real local receive count from the - // GPU prefix-sum tensor. In hybrid mode each scale-up peer may receive + // GPU prefix-sum tensor. In hybrid mode each scale-up peer may receive // tokens forwarded from every scale-out rank, so the conservative output // capacity and sentinel must cover the full logical world, not just the // intra-node scale-up domain. - const int num_recv_tokens = num_max_tokens_per_rank * topology_.num_ranks; - const int num_smem_bytes = device_smem_bytes(); + const int max_num_recv_tokens = + num_max_tokens_per_rank * topology_.num_ranks; + EP_HOST_ASSERT(num_recv_tokens >= 0 && + num_recv_tokens <= max_num_recv_tokens); + EP_HOST_ASSERT(cached_mode || num_recv_tokens == max_num_recv_tokens); + const int num_smem_bytes = device_smem_bytes_; const int num_channels_per_sm = 1; const int num_channels = num_sms * num_channels_per_sm; - const bool cached_mode = cached_handle.has_value(); const bool use_hybrid = topology_.num_scaleout_ranks != 1; - const int hybrid_channels = use_hybrid ? hybrid_num_channels(num_sms) : 0; + const int hybrid_channels_per_sm = using_nccl() + ? kNcclElasticHybridChannelsPerSm + : kIbgdaElasticHybridChannelsPerSm; + const int hybrid_channels = + use_hybrid ? hybrid_num_channels(num_sms, hybrid_channels_per_sm) : 0; const int hybrid_max_tokens_per_channel = - use_hybrid ? hybrid_num_max_tokens_per_channel(num_max_tokens_per_rank, - num_sms) - : 0; - if (cached_mode) { - const auto& handle = cached_handle.value(); - EP_HOST_ASSERT(!handle.do_expand && !do_expand); - EP_HOST_ASSERT(handle.num_experts == num_experts); - EP_HOST_ASSERT(handle.expert_alignment == expert_alignment); - EP_HOST_ASSERT(handle.num_max_tokens_per_rank == - num_max_tokens_per_rank); - EP_HOST_ASSERT(handle.num_sms == num_sms); - if (use_hybrid) { - EP_HOST_ASSERT(handle.dst_buffer_slot_idx.dim() == 4); - EP_HOST_ASSERT(handle.dst_buffer_slot_idx.size(0) == - hybrid_channels); - EP_HOST_ASSERT(handle.dst_buffer_slot_idx.size(1) == - topology_.num_scaleout_ranks); - EP_HOST_ASSERT(handle.dst_buffer_slot_idx.size(2) == - hybrid_max_tokens_per_channel); - EP_HOST_ASSERT(handle.dst_buffer_slot_idx.size(3) == num_topk); - EP_HOST_ASSERT(handle.token_metadata_at_forward.has_value()); - EP_HOST_ASSERT(handle.channel_linked_list.has_value()); - } else { - EP_HOST_ASSERT(handle.dst_buffer_slot_idx.dim() == 2); - EP_HOST_ASSERT(handle.dst_buffer_slot_idx.size(0) == num_tokens); - EP_HOST_ASSERT(handle.dst_buffer_slot_idx.size(1) == num_topk); - } + use_hybrid + ? hybrid_num_max_tokens_per_channel(num_max_tokens_per_rank, + num_sms, hybrid_channels_per_sm) + : 0; + + EP_HOST_ASSERT(x_ptr != 0 && topk_idx_ptr != 0 && active_ranks_ptr != 0); + EP_HOST_ASSERT(psum_num_recv_tokens_per_scaleup_rank_ptr != 0); + EP_HOST_ASSERT(psum_num_recv_tokens_per_expert_ptr != 0); + EP_HOST_ASSERT(dst_buffer_slot_idx_ptr != 0); + EP_HOST_ASSERT(recv_x_ptr != 0 && recv_topk_idx_ptr != 0 && + recv_src_metadata_ptr != 0); + if (use_hybrid) { + EP_HOST_ASSERT(token_metadata_at_forward_ptr != 0); + EP_HOST_ASSERT(channel_linked_list_ptr != 0); } - auto compute_stream = at::cuda::getCurrentCUDAStream(); - auto launch_stream = native_buffer_->comm_stream; - stream_wait(launch_stream, compute_stream); + void* x = reinterpret_cast(x_ptr); + void* sf = reinterpret_cast(sf_ptr); + auto* topk_idx = reinterpret_cast(topk_idx_ptr); + auto* topk_weights = reinterpret_cast(topk_weights_ptr); + auto* active_ranks = reinterpret_cast(active_ranks_ptr); + auto* psum_num_recv_tokens_per_scaleup_rank = + reinterpret_cast(psum_num_recv_tokens_per_scaleup_rank_ptr); + auto* psum_num_recv_tokens_per_expert = + reinterpret_cast(psum_num_recv_tokens_per_expert_ptr); + auto* dst_buffer_slot_idx = reinterpret_cast(dst_buffer_slot_idx_ptr); + auto* token_metadata_at_forward = + reinterpret_cast(token_metadata_at_forward_ptr); + auto* channel_linked_list = reinterpret_cast(channel_linked_list_ptr); + void* recv_x = reinterpret_cast(recv_x_ptr); + void* recv_x_scales = reinterpret_cast(recv_x_scales_ptr); + auto* recv_topk_idx = reinterpret_cast(recv_topk_idx_ptr); + auto* recv_topk_weights = reinterpret_cast(recv_topk_weights_ptr); + auto* recv_src_metadata = reinterpret_cast(recv_src_metadata_ptr); + + auto compute_stream_raw = + reinterpret_cast(compute_stream_ptr); + auto launch_stream = communication_stream(); + stream_wait(launch_stream, compute_stream_raw); const int64_t timeout_cycles = config_.num_gpu_timeout_secs < 0 ? -1 - : static_cast(native_buffer_->clock_rate_khz) * + : static_cast(clock_rate_khz()) * static_cast(config_.num_gpu_timeout_secs) * 1000; - auto launch_ctx = make_launch_context( - *native_buffer_, topology_, mapped_host_workspace_, timeout_cycles); - - auto psum_num_recv_tokens_per_scaleup_rank = - cached_mode ? cached_handle->psum_num_recv_tokens_per_scaleup_rank - : torch::empty({topology_.num_scaleup_ranks}, - torch::TensorOptions() - .dtype(torch::kInt32) - .device(x.device())); - auto psum_num_recv_tokens_per_expert = - cached_mode - ? cached_handle->psum_num_recv_tokens_per_expert - : torch::empty({num_local_experts + 1}, torch::TensorOptions() - .dtype(torch::kInt32) - .device(x.device())); - auto dst_buffer_slot_idx = - cached_mode - ? cached_handle->dst_buffer_slot_idx - : (use_hybrid ? torch::empty( - {hybrid_channels, topology_.num_scaleout_ranks, - hybrid_max_tokens_per_channel, num_topk}, - torch::TensorOptions() - .dtype(torch::kInt32) - .device(x.device())) - : torch::empty({num_tokens, num_topk}, - torch::TensorOptions() - .dtype(torch::kInt32) - .device(x.device()))); - std::optional token_metadata_at_forward = std::nullopt; - std::optional channel_linked_list = std::nullopt; - if (use_hybrid) { - if (cached_mode) { - token_metadata_at_forward = - cached_handle->token_metadata_at_forward; - channel_linked_list = cached_handle->channel_linked_list; - } else { - const int forward_metadata_dims = 2 + num_topk * 2; - token_metadata_at_forward = torch::empty( - {hybrid_channels, - topology_.num_scaleout_ranks * hybrid_max_tokens_per_channel + - 1, - forward_metadata_dims}, - torch::TensorOptions().dtype(torch::kInt32).device(x.device())); - channel_linked_list = torch::empty( - {hybrid_channels, - topology_.num_scaleout_ranks * hybrid_max_tokens_per_channel + - 1, - topology_.num_scaleup_ranks}, - torch::TensorOptions().dtype(torch::kInt32).device(x.device())); - } - } - std::optional deterministic_rank_count_buffer = std::nullopt; + auto launch_ctx = make_launch_context(timeout_cycles); + + std::shared_ptr deterministic_rank_count_buffer; #ifdef MOONCAKE_EP_USE_MUSA // MUSA non-hybrid dispatch always runs // launch_musa_elastic_prepare_dispatch(), which assigns slots and publishes @@ -356,248 +924,117 @@ ElasticDispatchOutput MooncakeElasticBuffer::dispatch( config_.deterministic && !cached_mode && !use_hybrid; #endif if (run_deterministic_prologue) { - deterministic_rank_count_buffer = torch::empty( - {num_sms, topology_.num_scaleup_ranks}, - torch::TensorOptions().dtype(torch::kInt32).device(x.device())); + deterministic_rank_count_buffer = + ensure_deterministic_rank_count_buffer(num_sms); launch_elastic_dispatch_deterministic_prologue( - topk_idx.data_ptr(), - deterministic_rank_count_buffer.value().data_ptr(), - dst_buffer_slot_idx.data_ptr(), num_tokens, - num_max_tokens_per_rank, num_experts, num_topk, - topology_.scaleup_rank_idx, topology_.num_scaleup_ranks, num_sms, - num_smem_bytes, launch_stream.stream()); + topk_idx, static_cast(deterministic_rank_count_buffer.get()), + dst_buffer_slot_idx, num_tokens, num_max_tokens_per_rank, + num_experts, num_topk, topology_.scaleup_rank_idx, + topology_.num_scaleup_ranks, num_sms, num_smem_bytes, + launch_stream); } launch_mooncake_elastic_dispatch( - x.data_ptr(), use_sf ? const_cast(sf->data_ptr()) : nullptr, - const_cast(topk_idx.data_ptr()), - topk_weights.has_value() - ? const_cast(topk_weights->data_ptr()) - : nullptr, - nullptr, nullptr, psum_num_recv_tokens_per_scaleup_rank.data_ptr(), - psum_num_recv_tokens_per_expert.data_ptr(), - dst_buffer_slot_idx.data_ptr(), - token_metadata_at_forward.has_value() - ? token_metadata_at_forward->data_ptr() - : nullptr, - num_tokens, num_max_tokens_per_rank, hidden, - static_cast(x.element_size()), num_sf_packs, sf_token_stride, - sf_hidden_stride, num_experts, num_topk, expert_alignment, num_sms, - use_hybrid ? kElasticHybridChannelsPerSm : num_channels_per_sm, + x, sf, topk_idx, topk_weights, nullptr, nullptr, + psum_num_recv_tokens_per_scaleup_rank, psum_num_recv_tokens_per_expert, + dst_buffer_slot_idx, token_metadata_at_forward, num_tokens, + num_max_tokens_per_rank, hidden, x_element_size, num_sf_packs, + sf_token_stride, sf_hidden_stride, num_experts, num_topk, + expert_alignment, num_sms, + use_hybrid ? hybrid_channels_per_sm : num_channels_per_sm, num_smem_bytes, cached_mode, config_.deterministic, false, launch_ctx, - launch_stream.stream()); - - const int num_recv_output_capacity = - do_expand ? num_recv_tokens * num_topk : num_recv_tokens; - auto recv_x = torch::empty({num_recv_output_capacity, hidden}, x.options()); - auto recv_x_scales = std::optional(); - void* recv_x_scales_ptr = nullptr; - int recv_sf_token_stride = 0; - int recv_sf_hidden_stride = 0; - if (use_sf) { - recv_x_scales = torch::empty({num_recv_output_capacity, num_sf_packs}, - sf->options()); - recv_x_scales_ptr = recv_x_scales->data_ptr(); - recv_sf_token_stride = static_cast(recv_x_scales->stride(0)); - recv_sf_hidden_stride = static_cast(recv_x_scales->stride(1)); - } - auto recv_topk_idx = - torch::empty({num_recv_tokens, num_topk}, topk_idx.options()); - auto recv_topk_weights = std::optional(); - float* recv_topk_weights_ptr = nullptr; - if (topk_weights.has_value()) { - recv_topk_weights = do_expand - ? torch::empty({num_recv_output_capacity}, - topk_weights->options()) - : torch::empty({num_recv_tokens, num_topk}, - topk_weights->options()); - recv_topk_weights_ptr = recv_topk_weights->data_ptr(); - } - auto recv_src_metadata = torch::empty( - {num_recv_tokens, num_topk + 2}, - torch::TensorOptions().dtype(torch::kInt32).device(x.device())); - auto handle_psum_num_recv_tokens_per_expert = - do_expand - ? psum_num_recv_tokens_per_expert.slice(0, 0, num_local_experts) - : psum_num_recv_tokens_per_expert.slice(0, 1, - num_local_experts + 1); - auto epilogue_psum_num_recv_tokens_per_expert = + launch_stream); + + const int recv_sf_token_stride = num_sf_packs; + const int recv_sf_hidden_stride = 1; + auto* epilogue_psum_num_recv_tokens_per_expert = do_expand ? psum_num_recv_tokens_per_expert - : handle_psum_num_recv_tokens_per_expert; + : psum_num_recv_tokens_per_expert + 1; launch_mooncake_elastic_dispatch_copy_epilogue( - recv_x.data_ptr(), recv_x_scales_ptr, recv_topk_idx.data_ptr(), - recv_topk_weights_ptr, recv_src_metadata.data_ptr(), - channel_linked_list.has_value() ? channel_linked_list->data_ptr() - : nullptr, - num_recv_tokens, num_max_tokens_per_rank, hidden, - static_cast(x.element_size()), num_sf_packs, recv_sf_token_stride, - recv_sf_hidden_stride, num_experts, num_topk, num_sms, num_smem_bytes, + recv_x, recv_x_scales, recv_topk_idx, recv_topk_weights, + recv_src_metadata, channel_linked_list, num_recv_tokens, + num_max_tokens_per_rank, hidden, x_element_size, num_sf_packs, + recv_sf_token_stride, recv_sf_hidden_stride, num_experts, num_topk, + num_sms, physical_num_sms_, num_smem_bytes, use_hybrid ? hybrid_channels : num_channels, do_expand, cached_mode, - launch_ctx, psum_num_recv_tokens_per_scaleup_rank.data_ptr(), - epilogue_psum_num_recv_tokens_per_expert.data_ptr(), - launch_stream.stream()); - - if (do_cpu_sync || !async_with_compute_stream) { - stream_wait(compute_stream, launch_stream); - } - std::optional event = std::nullopt; - if (async_with_compute_stream) { - event = EventHandle(launch_stream); - } - - std::vector num_recv_tokens_per_expert_list; - int actual_num_recv_tokens = num_recv_tokens; - int actual_num_output_tokens = num_recv_tokens; - if (do_cpu_sync) { - auto scaleup_psum_cpu = psum_num_recv_tokens_per_scaleup_rank.cpu(); - auto expert_psum_cpu = psum_num_recv_tokens_per_expert.cpu(); - const auto* scaleup_psum = scaleup_psum_cpu.data_ptr(); - const auto* expert_psum = expert_psum_cpu.data_ptr(); - actual_num_recv_tokens = scaleup_psum[topology_.num_scaleup_ranks - 1]; - EP_HOST_ASSERT(actual_num_recv_tokens >= 0 && - actual_num_recv_tokens <= num_recv_tokens); - actual_num_output_tokens = actual_num_recv_tokens; - - num_recv_tokens_per_expert_list.reserve(num_local_experts); - const auto align_count = [expert_alignment](int value) { - return ((value + expert_alignment - 1) / expert_alignment) * - expert_alignment; - }; - if (do_expand) { - int previous_psum = 0; - for (int i = 0; i < num_local_experts; ++i) { - const int count = expert_psum[i] - align_count(previous_psum); - EP_HOST_ASSERT(count >= 0); - num_recv_tokens_per_expert_list.push_back(count); - previous_psum = expert_psum[i]; - } - actual_num_output_tokens = - num_local_experts == 0 ? 0 : expert_psum[num_local_experts - 1]; - } else { - for (int i = 0; i < num_local_experts; ++i) { - const int count = expert_psum[i + 1] - expert_psum[i]; - EP_HOST_ASSERT(count >= 0); - num_recv_tokens_per_expert_list.push_back(count); - } - } - EP_HOST_ASSERT(actual_num_output_tokens >= 0 && - actual_num_output_tokens <= recv_x.size(0)); + launch_ctx, psum_num_recv_tokens_per_scaleup_rank, + epilogue_psum_num_recv_tokens_per_expert, launch_stream); - recv_x = recv_x.slice(0, 0, actual_num_output_tokens); - if (recv_x_scales.has_value()) { - recv_x_scales = - recv_x_scales->slice(0, 0, actual_num_output_tokens); - } - recv_topk_idx = recv_topk_idx.slice(0, 0, actual_num_recv_tokens); - if (recv_topk_weights.has_value()) { - recv_topk_weights = - recv_topk_weights->slice(0, 0, actual_num_output_tokens); - } - recv_src_metadata = - recv_src_metadata.slice(0, 0, actual_num_recv_tokens); - } - - ElasticNativeHandle handle; - handle.do_expand = do_expand; - handle.num_experts = num_experts; - handle.expert_alignment = expert_alignment; - handle.num_max_tokens_per_rank = num_max_tokens_per_rank; - handle.num_sms = num_sms; - handle.topk_idx = cached_mode ? cached_handle->topk_idx : topk_idx.clone(); - handle.psum_num_recv_tokens_per_expert = - handle_psum_num_recv_tokens_per_expert; - handle.psum_num_recv_tokens_per_scaleup_rank = - psum_num_recv_tokens_per_scaleup_rank; - handle.recv_src_metadata = recv_src_metadata; - handle.recv_layout_range = torch::empty( - {0}, torch::TensorOptions().dtype(torch::kInt64).device(x.device())); - handle.dst_buffer_slot_idx = dst_buffer_slot_idx; - handle.token_metadata_at_forward = token_metadata_at_forward; - handle.channel_linked_list = channel_linked_list; - handle.num_recv_tokens_per_expert_list = num_recv_tokens_per_expert_list; - - ElasticDispatchOutput output; - output.recv_x = recv_x; - output.recv_x_scales = recv_x_scales; - output.recv_topk_idx = recv_topk_idx; - output.recv_topk_weights = recv_topk_weights; - output.handle = handle; - output.event = event; - return output; -} - -ElasticCombineOutput MooncakeElasticBuffer::combine( - const torch::Tensor& x, const ElasticNativeHandle& handle, - const std::optional& topk_weights, - torch::Tensor& active_ranks, int num_sms, bool async_with_compute_stream, - const std::optional& out) { - EP_HOST_ASSERT(x.dim() == 2 && x.is_contiguous()); - EP_HOST_ASSERT(x.scalar_type() == torch::kBFloat16); - torch::Tensor weights = topk_weights.value_or(torch::Tensor()); - if (!weights.defined()) { - weights = torch::ones( - handle.topk_idx.sizes(), - torch::TensorOptions().dtype(torch::kFloat32).device(x.device())); - } - const int hidden = static_cast(x.size(1)); - const int num_topk = static_cast(handle.topk_idx.size(1)); - const int num_combined_tokens = static_cast(handle.topk_idx.size(0)); - const int num_smem_bytes = device_smem_bytes(); + (void)active_ranks; + (void)num_local_experts; + (void)hybrid_max_tokens_per_channel; + if (!async_with_compute_stream) { + stream_wait(compute_stream_raw, launch_stream); + return std::nullopt; + } + return EventHandle(reinterpret_cast(launch_stream), + deterministic_rank_count_buffer); +} + +std::optional MooncakeElasticBuffer::combine( + uint64_t x_ptr, int num_input_tokens, int hidden, uint64_t topk_idx_ptr, + int num_combined_tokens, int num_topk, uint64_t topk_weights_ptr, + uint64_t psum_num_recv_tokens_per_scaleup_rank_ptr, + uint64_t recv_src_metadata_ptr, uint64_t token_metadata_at_forward_ptr, + uint64_t channel_linked_list_ptr, uint64_t active_ranks_ptr, + int num_experts, int num_max_tokens_per_rank, bool do_expand, int num_sms, + bool async_with_compute_stream, uint64_t compute_stream_ptr, + uint64_t combined_x_ptr) { + EP_HOST_ASSERT(x_ptr != 0 && topk_idx_ptr != 0 && topk_weights_ptr != 0); + EP_HOST_ASSERT(psum_num_recv_tokens_per_scaleup_rank_ptr != 0); + EP_HOST_ASSERT(recv_src_metadata_ptr != 0 && active_ranks_ptr != 0); + EP_HOST_ASSERT(combined_x_ptr != 0); + void* x = reinterpret_cast(x_ptr); + auto* topk_idx = reinterpret_cast(topk_idx_ptr); + auto* topk_weights = reinterpret_cast(topk_weights_ptr); + auto* psum_num_recv_tokens_per_scaleup_rank = + reinterpret_cast(psum_num_recv_tokens_per_scaleup_rank_ptr); + auto* recv_src_metadata = reinterpret_cast(recv_src_metadata_ptr); + auto* token_metadata_at_forward = + reinterpret_cast(token_metadata_at_forward_ptr); + auto* channel_linked_list = reinterpret_cast(channel_linked_list_ptr); + auto* active_ranks = reinterpret_cast(active_ranks_ptr); + void* combined_x = reinterpret_cast(combined_x_ptr); + + const int num_smem_bytes = device_smem_bytes_; const int num_channels = std::max(1, num_sms); const bool use_hybrid = topology_.num_scaleout_ranks != 1; - const int hybrid_channels = use_hybrid ? hybrid_num_channels(num_sms) : 0; - auto compute_stream = at::cuda::getCurrentCUDAStream(); - auto launch_stream = native_buffer_->comm_stream; - stream_wait(launch_stream, compute_stream); + const int hybrid_channels_per_sm = using_nccl() + ? kNcclElasticHybridChannelsPerSm + : kIbgdaElasticHybridChannelsPerSm; + const int hybrid_channels = + use_hybrid ? hybrid_num_channels(num_sms, hybrid_channels_per_sm) : 0; + auto compute_stream_raw = + reinterpret_cast(compute_stream_ptr); + auto launch_stream = communication_stream(); + stream_wait(launch_stream, compute_stream_raw); const int64_t timeout_cycles = config_.num_gpu_timeout_secs < 0 ? -1 - : static_cast(native_buffer_->clock_rate_khz) * + : static_cast(clock_rate_khz()) * static_cast(config_.num_gpu_timeout_secs) * 1000; - auto launch_ctx = make_launch_context( - *native_buffer_, topology_, mapped_host_workspace_, timeout_cycles); - auto psum_num_recv_tokens_per_scaleup_rank = - handle.psum_num_recv_tokens_per_scaleup_rank; + auto launch_ctx = make_launch_context(timeout_cycles); void* reduce_buffer = launch_mooncake_elastic_combine( - x.data_ptr(), weights.data_ptr(), - const_cast(handle.recv_src_metadata.data_ptr()), - psum_num_recv_tokens_per_scaleup_rank.data_ptr(), - handle.token_metadata_at_forward.has_value() - ? handle.token_metadata_at_forward->data_ptr() - : nullptr, - handle.channel_linked_list.has_value() - ? handle.channel_linked_list->data_ptr() - : nullptr, - static_cast(x.size(0)), handle.num_max_tokens_per_rank, hidden, - handle.num_experts, num_topk, num_sms, num_smem_bytes, - use_hybrid ? hybrid_channels : num_channels, handle.do_expand, - config_.allow_multiple_reduction, launch_ctx, launch_stream.stream()); - - torch::Tensor combined_x = - out.has_value() - ? out.value() - : torch::empty({num_combined_tokens, hidden}, x.options()); + x, topk_weights, recv_src_metadata, + psum_num_recv_tokens_per_scaleup_rank, token_metadata_at_forward, + channel_linked_list, num_input_tokens, num_max_tokens_per_rank, hidden, + num_experts, num_topk, num_sms, num_smem_bytes, + use_hybrid ? hybrid_channels : num_channels, do_expand, + config_.allow_multiple_reduction, launch_ctx, launch_stream); + launch_mooncake_elastic_combine_reduce_epilogue( - combined_x.data_ptr(), weights.data_ptr(), - const_cast(handle.topk_idx.data_ptr()), - num_combined_tokens, handle.num_max_tokens_per_rank, hidden, - handle.num_experts, num_topk, reduce_buffer, nullptr, nullptr, num_sms, - num_smem_bytes, handle.do_expand, config_.allow_multiple_reduction, - launch_ctx, launch_stream.stream()); + combined_x, topk_weights, topk_idx, num_combined_tokens, + num_max_tokens_per_rank, hidden, num_experts, num_topk, reduce_buffer, + nullptr, nullptr, num_sms, physical_num_sms_, num_smem_bytes, do_expand, + config_.allow_multiple_reduction, launch_ctx, launch_stream); + (void)active_ranks; if (!async_with_compute_stream) { - stream_wait(compute_stream, launch_stream); + stream_wait(compute_stream_raw, launch_stream); + return std::nullopt; } - std::optional event = std::nullopt; - if (async_with_compute_stream) event = EventHandle(launch_stream); - (void)active_ranks; - - ElasticCombineOutput output; - output.combined_x = combined_x; - output.combined_topk_weights = std::nullopt; - output.event = event; - return output; + return EventHandle(reinterpret_cast(launch_stream)); } ElasticTopology MooncakeElasticBuffer::discover_topology( @@ -626,6 +1063,8 @@ ElasticTopology MooncakeElasticBuffer::discover_topology( } topology.scaleout_rank_idx = rank / topology.num_scaleup_ranks; topology.scaleup_rank_idx = rank % topology.num_scaleup_ranks; + topology.scaleup_lsa = + topology.hybrid_enabled || topology.num_rdma_ranks == 1; return topology; } diff --git a/mooncake-ep/src/mooncake_ep_elastic_kernel.cu b/mooncake-ep/src/mooncake_ep_elastic_kernel.cu index a5d3e383ff..5f9c795bc9 100644 --- a/mooncake-ep/src/mooncake_ep_elastic_kernel.cu +++ b/mooncake-ep/src/mooncake_ep_elastic_kernel.cu @@ -1,8 +1,10 @@ // clang-format off #include +#include #include #include +#include #include #include @@ -24,13 +26,13 @@ constexpr int kElasticNumEpilogueWarps = 8; constexpr int kElasticNumHybridScaleoutWarps = 4; constexpr int kElasticNumHybridForwardWarps = 4; constexpr int kElasticNumHybridScaleupWarps = 4; -constexpr int kElasticNumQPs = MAX_QP_COUNT; constexpr int64_t kElasticTimeoutCycles = NUM_TIMEOUT_CYCLES; inline int ceil_div(int x, int y) { return (x + y - 1) / y; } +template inline int hybrid_num_channels(int num_sms) { - return num_sms * kElasticNumHybridForwardWarps; + return num_sms * Ops::kNumHybridForwardWarps; } inline void* hybrid_combine_reduce_buffer_ptr(void* buffer, int hidden, @@ -136,7 +138,7 @@ __global__ void musa_elastic_prepare_clear_barrier_kernel( int num_scaleup_ranks, int num_experts, int64_t timeout_cycles) { const auto layout = elastic::layout::WorkspaceLayout( workspace, 1, num_scaleup_ranks, num_experts); - const auto gin = elastic::transport::MooncakeGin( + const auto gin = elastic::transport::IbgdaOps( comm_ctx, 0, 0, 1, 0, rank_idx, num_scaleup_ranks, num_scaleup_ranks); constexpr int kTag = elastic::comm::kDeviceBarrierTag; const int status = @@ -223,7 +225,7 @@ __global__ void musa_elastic_publish_counts_kernel( const auto layout = elastic::layout::WorkspaceLayout( workspace, 1, num_scaleup_ranks, num_experts); const int num_experts_per_rank = num_experts / num_scaleup_ranks; - const auto gin = elastic::transport::MooncakeGin( + const auto gin = elastic::transport::IbgdaOps( comm_ctx, 0, 0, 1, 0, rank_idx, num_scaleup_ranks, num_scaleup_ranks); const int tid = blockIdx.x * blockDim.x + threadIdx.x; const int stride = blockDim.x * gridDim.x; @@ -362,12 +364,46 @@ void launch_musa_elastic_prepare_dispatch( #endif -template -void launch_cooperative(Kernel kernel, int num_sms, int num_threads, - int smem_bytes, cudaStream_t stream, Args... args) { #ifndef MOONCAKE_EP_USE_MUSA +struct ConfiguredKernelAttribute { + const void* kernel = nullptr; + int device_id = -1; + int max_dynamic_smem_bytes = 0; +}; + +std::mutex configured_kernel_attributes_mutex; +std::vector configured_kernel_attributes; + +template +void configure_kernel_dynamic_smem_once(Kernel kernel, int device_id, + int smem_bytes) { + const auto* kernel_ptr = reinterpret_cast(kernel); + std::lock_guard lock(configured_kernel_attributes_mutex); + for (auto& entry : configured_kernel_attributes) { + if (entry.kernel == kernel_ptr && entry.device_id == device_id) { + if (entry.max_dynamic_smem_bytes >= smem_bytes) return; + CUDA_CHECK(cudaFuncSetAttribute( + kernel, cudaFuncAttributeMaxDynamicSharedMemorySize, + smem_bytes)); + entry.max_dynamic_smem_bytes = smem_bytes; + return; + } + } CUDA_CHECK(cudaFuncSetAttribute( kernel, cudaFuncAttributeMaxDynamicSharedMemorySize, smem_bytes)); + configured_kernel_attributes.push_back( + {kernel_ptr, device_id, smem_bytes}); +} +#endif + +template +void launch_cooperative(Kernel kernel, int device_id, int num_sms, + int num_threads, int smem_bytes, cudaStream_t stream, + Args... args) { +#ifndef MOONCAKE_EP_USE_MUSA + configure_kernel_dynamic_smem_once(kernel, device_id, smem_bytes); +#else + (void)device_id; #endif #ifdef MOONCAKE_EP_USE_MUSA kernel<<>>(args...); @@ -411,12 +447,18 @@ void launch_elastic_dispatch_deterministic_prologue( constexpr int kNumThreads = kNumWarps * 32; const int smem_bytes = (1 + 2 * kNumWarps) * num_scaleup_ranks * sizeof(int); (void)num_smem_bytes; +#ifdef MOONCAKE_EP_USE_MUSA + constexpr int device_id = 0; +#else + int device_id = -1; + CUDA_CHECK(cudaGetDevice(&device_id)); +#endif #define LAUNCH_PROLOGUE(HIDDEN, EXPERTS, TOPK, MAXTOK, SMS, RANKS) \ do { \ auto kernel = elastic::dispatch_deterministic_prologue_impl< \ SMS, kNumWarps, RANKS, MAXTOK, EXPERTS, TOPK>; \ - launch_cooperative(kernel, SMS, kNumThreads, smem_bytes, stream, \ + launch_cooperative(kernel, device_id, SMS, kNumThreads, smem_bytes, stream, \ const_cast(topk_idx), rank_count_buffer, \ dst_buffer_slot_idx, num_tokens, \ scaleup_rank_idx); \ @@ -451,7 +493,8 @@ void launch_elastic_dispatch_deterministic_prologue( num_scaleup_ranks); } -void launch_mooncake_elastic_dispatch( +template +void launch_mooncake_elastic_dispatch_backend( void* x, void* sf, int64_t* topk_idx, float* topk_weights, int64_t* copied_topk_idx, int* cumulative_local_expert_recv_stats, int* psum_num_recv_tokens_per_scaleup_rank, @@ -461,7 +504,8 @@ void launch_mooncake_elastic_dispatch( int sf_token_stride, int sf_hidden_stride, int num_experts, int num_topk, int expert_alignment, int num_sms, int num_channels_per_sm, int num_smem_bytes, bool cached_mode, bool deterministic, - bool do_cpu_sync, const ElasticLaunchContext& ctx, cudaStream_t stream) { + bool do_cpu_sync, const ElasticLaunchContext& ctx, + const typename Ops::Context& comm_ctx, cudaStream_t stream) { #ifdef MOONCAKE_EP_USE_MUSA const bool musa_use_prepared_slots = !cached_mode && ctx.num_scaleout_ranks == 1; #else @@ -469,7 +513,7 @@ void launch_mooncake_elastic_dispatch( #endif const bool effective_cached_mode = cached_mode || musa_use_prepared_slots; const int num_notify_warps = effective_cached_mode ? 0 : kElasticNumNotifyWarps; - const int num_dispatch_warps = kElasticNumDispatchWarps; + const int num_dispatch_warps = Ops::kNumDispatchWarps; const int num_threads = (num_notify_warps + num_dispatch_warps) * 32; const int smem_bytes = std::max( num_smem_bytes, @@ -477,7 +521,6 @@ void launch_mooncake_elastic_dispatch( ctx.num_scaleup_ranks, num_experts, num_notify_warps, num_dispatch_warps)); const bool reuse_slot_indices = effective_cached_mode || deterministic; - const auto comm_ctx = make_comm_ctx(ctx); (void)num_channels_per_sm; #ifdef MOONCAKE_EP_USE_MUSA @@ -494,7 +537,7 @@ void launch_mooncake_elastic_dispatch( if (ctx.num_scaleout_ranks != 1) { const bool hybrid_reuse_slot_indices = cached_mode; const int hybrid_dispatch_warps = - kElasticNumHybridScaleoutWarps + kElasticNumHybridForwardWarps; + Ops::kNumHybridScaleoutWarps + Ops::kNumHybridForwardWarps; const int hybrid_threads = (num_notify_warps + hybrid_dispatch_warps) * 32; const int hybrid_smem_bytes = std::max( @@ -509,12 +552,12 @@ void launch_mooncake_elastic_dispatch( constexpr int kHiddenBytes = (HB); \ constexpr int kNumSFPacks = (SFP); \ if (cached_mode) { \ - auto kernel = elastic::hybrid_dispatch_impl< \ - false, true, S, 0, kElasticNumHybridScaleoutWarps, \ - kElasticNumHybridForwardWarps, SO, SU, kHiddenBytes, \ - kNumSFPacks, M, E, K, 1, kElasticNumQPs, \ + auto kernel = elastic::hybrid_dispatch_impl; \ - launch_cooperative(kernel, S, hybrid_threads, \ + launch_cooperative(kernel, ctx.device_id, S, hybrid_threads, \ hybrid_smem_bytes, stream, x, \ static_cast(sf), topk_idx, \ topk_weights, copied_topk_idx, \ @@ -529,13 +572,13 @@ void launch_mooncake_elastic_dispatch( ctx.scaleout_rank_idx, \ ctx.scaleup_rank_idx); \ } else if (hybrid_reuse_slot_indices) { \ - auto kernel = elastic::hybrid_dispatch_impl< \ + auto kernel = elastic::hybrid_dispatch_impl; \ - launch_cooperative(kernel, S, hybrid_threads, \ + launch_cooperative(kernel, ctx.device_id, S, hybrid_threads, \ hybrid_smem_bytes, stream, x, \ static_cast(sf), topk_idx, \ topk_weights, copied_topk_idx, \ @@ -550,13 +593,13 @@ void launch_mooncake_elastic_dispatch( ctx.scaleout_rank_idx, \ ctx.scaleup_rank_idx); \ } else { \ - auto kernel = elastic::hybrid_dispatch_impl< \ + auto kernel = elastic::hybrid_dispatch_impl; \ - launch_cooperative(kernel, S, hybrid_threads, \ + launch_cooperative(kernel, ctx.device_id, S, hybrid_threads, \ hybrid_smem_bytes, stream, x, \ static_cast(sf), topk_idx, \ topk_weights, copied_topk_idx, \ @@ -590,7 +633,8 @@ void launch_mooncake_elastic_dispatch( #define TRY_HYBRID_DISPATCH_SHAPE(H, E, K, M, S) \ TRY_HYBRID_DISPATCH(H, E, K, M, S, 2, 4); \ - TRY_HYBRID_DISPATCH(H, E, K, M, S, 2, 8) + TRY_HYBRID_DISPATCH(H, E, K, M, S, 2, 8); \ + TRY_HYBRID_DISPATCH(H, E, K, M, S, 4, 4) TRY_HYBRID_DISPATCH_SHAPE(4096, 256, 8, 128, 24); @@ -606,11 +650,11 @@ void launch_mooncake_elastic_dispatch( constexpr int kHiddenBytes = (HB); \ constexpr int kNumSFPacks = (SFP); \ if (effective_cached_mode) { \ - auto kernel = elastic::dispatch_impl< \ - true, false, true, S, 0, kElasticNumDispatchWarps, R, \ - kHiddenBytes, kNumSFPacks, M, E, K, 1, kElasticNumQPs, \ + auto kernel = elastic::dispatch_impl; \ - launch_cooperative(kernel, S, num_threads, smem_bytes, stream, x, \ + launch_cooperative(kernel, ctx.device_id, S, num_threads, smem_bytes, stream, x, \ static_cast(sf), topk_idx, \ topk_weights, copied_topk_idx, \ cumulative_local_expert_recv_stats, \ @@ -621,11 +665,11 @@ void launch_mooncake_elastic_dispatch( ctx.workspace, ctx.mapped_host_workspace, \ ctx.scaleup_rank_idx); \ } else if (reuse_slot_indices) { \ - auto kernel = elastic::dispatch_impl< \ + auto kernel = elastic::dispatch_impl; \ - launch_cooperative(kernel, S, num_threads, smem_bytes, stream, x, \ + Ops::kNumDispatchWarps, R, kHiddenBytes, kNumSFPacks, M, E, K, 1, \ + Ops::kNumQPs, kElasticTimeoutCycles>; \ + launch_cooperative(kernel, ctx.device_id, S, num_threads, smem_bytes, stream, x, \ static_cast(sf), topk_idx, \ topk_weights, copied_topk_idx, \ cumulative_local_expert_recv_stats, \ @@ -636,11 +680,11 @@ void launch_mooncake_elastic_dispatch( ctx.workspace, ctx.mapped_host_workspace, \ ctx.scaleup_rank_idx); \ } else { \ - auto kernel = elastic::dispatch_impl< \ + auto kernel = elastic::dispatch_impl; \ - launch_cooperative(kernel, S, num_threads, smem_bytes, stream, x, \ + Ops::kNumDispatchWarps, R, kHiddenBytes, kNumSFPacks, M, E, K, 1, \ + Ops::kNumQPs, kElasticTimeoutCycles>; \ + launch_cooperative(kernel, ctx.device_id, S, num_threads, smem_bytes, stream, x, \ static_cast(sf), topk_idx, \ topk_weights, copied_topk_idx, \ cumulative_local_expert_recv_stats, \ @@ -684,20 +728,64 @@ void launch_mooncake_elastic_dispatch( ctx.num_scaleup_ranks); } -void launch_mooncake_elastic_dispatch_copy_epilogue( +void launch_mooncake_elastic_dispatch( + void* x, void* sf, int64_t* topk_idx, float* topk_weights, + int64_t* copied_topk_idx, int* cumulative_local_expert_recv_stats, + int* psum_num_recv_tokens_per_scaleup_rank, + int* psum_num_recv_tokens_per_expert, int* dst_buffer_slot_idx, + int* token_metadata_at_forward, int num_tokens, + int num_max_tokens_per_rank, int hidden, int elem_size, int num_sf_packs, + int sf_token_stride, int sf_hidden_stride, int num_experts, int num_topk, + int expert_alignment, int num_sms, int num_channels_per_sm, + int num_smem_bytes, bool cached_mode, bool deterministic, + bool do_cpu_sync, const ElasticLaunchContext& ctx, cudaStream_t stream) { +#ifdef USE_NCCL_DEVICE + if (ctx.backend == ElasticTransportBackend::kNccl) { + launch_mooncake_elastic_dispatch_backend( + x, sf, topk_idx, topk_weights, copied_topk_idx, + cumulative_local_expert_recv_stats, + psum_num_recv_tokens_per_scaleup_rank, + psum_num_recv_tokens_per_expert, dst_buffer_slot_idx, + token_metadata_at_forward, num_tokens, num_max_tokens_per_rank, + hidden, elem_size, num_sf_packs, sf_token_stride, sf_hidden_stride, + num_experts, num_topk, expert_alignment, num_sms, + num_channels_per_sm, num_smem_bytes, cached_mode, deterministic, + do_cpu_sync, ctx, ctx.nccl, stream); + return; + } +#endif + if (ctx.backend != ElasticTransportBackend::kIbgda) + throw std::invalid_argument( + "Mooncake EP was built without NCCL device backend support"); + const auto comm_ctx = make_comm_ctx(ctx); + launch_mooncake_elastic_dispatch_backend( + x, sf, topk_idx, topk_weights, copied_topk_idx, + cumulative_local_expert_recv_stats, + psum_num_recv_tokens_per_scaleup_rank, + psum_num_recv_tokens_per_expert, dst_buffer_slot_idx, + token_metadata_at_forward, num_tokens, num_max_tokens_per_rank, hidden, + elem_size, num_sf_packs, sf_token_stride, sf_hidden_stride, + num_experts, num_topk, expert_alignment, num_sms, num_channels_per_sm, + num_smem_bytes, cached_mode, deterministic, do_cpu_sync, ctx, comm_ctx, + stream); +} + +template +void launch_mooncake_elastic_dispatch_copy_epilogue_backend( void* recv_x, void* recv_sf, int64_t* recv_topk_idx, float* recv_topk_weights, int* recv_src_metadata, int* channel_linked_list, int num_recv_tokens, int num_max_tokens_per_rank, int hidden, int elem_size, int num_sf_packs, int recv_sf_token_stride, int recv_sf_hidden_stride, int num_experts, int num_topk, int num_sms, - int num_smem_bytes, int num_channels, bool do_expand, bool cached_mode, + int num_epilogue_sms, int num_smem_bytes, int num_channels, bool do_expand, + bool cached_mode, const ElasticLaunchContext& ctx, int* psum_num_recv_tokens_per_scaleup_rank, int* psum_num_recv_tokens_per_expert, cudaStream_t stream) { - const int num_threads = kElasticNumEpilogueWarps * 32; + const int num_threads = kNumEpilogueWarps * 32; const int smem_bytes = std::max( num_smem_bytes, dispatch_epilogue_smem_bytes(hidden, elem_size, num_sf_packs, num_topk, - kElasticNumEpilogueWarps)); + kNumEpilogueWarps)); #ifndef MOONCAKE_EP_USE_MUSA if (ctx.num_scaleout_ranks != 1) { @@ -707,16 +795,17 @@ void launch_mooncake_elastic_dispatch_copy_epilogue( constexpr int kNumSFPacks = (SFP); \ auto kernel = do_expand ? \ elastic::dispatch_copy_epilogue_impl< \ - true, false, S, C, kElasticNumEpilogueWarps, SO, SU, \ + true, false, 0, C, kNumEpilogueWarps, SO, SU, \ kHiddenBytes, kNumSFPacks, M, E, K> : \ (cached_mode ? \ elastic::dispatch_copy_epilogue_impl< \ - false, true, S, C, kElasticNumEpilogueWarps, SO, SU, \ + false, true, 0, C, kNumEpilogueWarps, SO, SU, \ kHiddenBytes, kNumSFPacks, M, E, K> : \ elastic::dispatch_copy_epilogue_impl< \ - false, false, S, C, kElasticNumEpilogueWarps, SO, SU, \ + false, false, 0, C, kNumEpilogueWarps, SO, SU, \ kHiddenBytes, kNumSFPacks, M, E, K>); \ - launch_cooperative(kernel, S, num_threads, smem_bytes, stream, \ + launch_cooperative(kernel, ctx.device_id, num_epilogue_sms, num_threads, \ + smem_bytes, stream, \ ctx.buffer, ctx.workspace, \ psum_num_recv_tokens_per_scaleup_rank, \ psum_num_recv_tokens_per_expert, recv_x, \ @@ -728,27 +817,31 @@ void launch_mooncake_elastic_dispatch_copy_epilogue( ctx.scaleup_rank_idx); \ } while (false) -#define TRY_HYBRID_DISPATCH_EPILOGUE_TYPED(H, E, K, M, S, SO, SU, EL, SFP) \ +#define TRY_HYBRID_DISPATCH_EPILOGUE_TYPED(H, E, K, M, S, SO, SU, EL, SFP, CPS) \ if (hidden == H && num_experts == E && num_topk == K && \ num_max_tokens_per_rank == M && num_sms == S && \ ctx.num_scaleout_ranks == SO && ctx.num_scaleup_ranks == SU && \ elem_size == EL && num_sf_packs == SFP && \ - num_channels == hybrid_num_channels(S)) { \ + num_channels == (S) * (CPS)) { \ LAUNCH_HYBRID_DISPATCH_EPILOGUE((H) * (EL), SFP, E, K, M, S, SO, SU, \ - (S) * kElasticNumHybridForwardWarps); \ + (S) * (CPS)); \ return; \ } -#define TRY_HYBRID_DISPATCH_EPILOGUE(H, E, K, M, S, SO, SU) \ +#define TRY_HYBRID_DISPATCH_EPILOGUE(H, E, K, M, S, SO, SU, CPS) \ TRY_HYBRID_DISPATCH_EPILOGUE_TYPED(H, E, K, M, S, SO, SU, \ - static_cast(sizeof(nv_bfloat16)), 0); \ - TRY_HYBRID_DISPATCH_EPILOGUE_TYPED(H, E, K, M, S, SO, SU, 1, (H) / 128) + static_cast(sizeof(nv_bfloat16)), 0, CPS); \ + TRY_HYBRID_DISPATCH_EPILOGUE_TYPED(H, E, K, M, S, SO, SU, 1, (H) / 128, CPS) -#define TRY_HYBRID_DISPATCH_EPILOGUE_SHAPE(H, E, K, M, S) \ - TRY_HYBRID_DISPATCH_EPILOGUE(H, E, K, M, S, 2, 4); \ - TRY_HYBRID_DISPATCH_EPILOGUE(H, E, K, M, S, 2, 8) +#define TRY_HYBRID_DISPATCH_EPILOGUE_SHAPE(H, E, K, M, S, CPS) \ + TRY_HYBRID_DISPATCH_EPILOGUE(H, E, K, M, S, 2, 4, CPS); \ + TRY_HYBRID_DISPATCH_EPILOGUE(H, E, K, M, S, 2, 8, CPS); \ + TRY_HYBRID_DISPATCH_EPILOGUE(H, E, K, M, S, 4, 4, CPS) - TRY_HYBRID_DISPATCH_EPILOGUE_SHAPE(4096, 256, 8, 128, 24); +#ifdef USE_NCCL_DEVICE + TRY_HYBRID_DISPATCH_EPILOGUE_SHAPE(4096, 256, 8, 128, 24, 8); +#endif + TRY_HYBRID_DISPATCH_EPILOGUE_SHAPE(4096, 256, 8, 128, 24, 4); #undef TRY_HYBRID_DISPATCH_EPILOGUE_SHAPE #undef TRY_HYBRID_DISPATCH_EPILOGUE @@ -763,16 +856,17 @@ void launch_mooncake_elastic_dispatch_copy_epilogue( constexpr int kNumSFPacks = (SFP); \ auto kernel = do_expand ? \ elastic::dispatch_copy_epilogue_impl< \ - true, false, S, 1, kElasticNumEpilogueWarps, 1, R, \ + true, false, 0, 1, kNumEpilogueWarps, 1, R, \ kHiddenBytes, kNumSFPacks, M, E, K> : \ (cached_mode ? \ elastic::dispatch_copy_epilogue_impl< \ - false, true, S, 1, kElasticNumEpilogueWarps, 1, R, \ + false, true, 0, 1, kNumEpilogueWarps, 1, R, \ kHiddenBytes, kNumSFPacks, M, E, K> : \ elastic::dispatch_copy_epilogue_impl< \ - false, false, S, 1, kElasticNumEpilogueWarps, 1, R, \ + false, false, 0, 1, kNumEpilogueWarps, 1, R, \ kHiddenBytes, kNumSFPacks, M, E, K>); \ - launch_cooperative(kernel, S, num_threads, smem_bytes, stream, \ + launch_cooperative(kernel, ctx.device_id, num_epilogue_sms, num_threads, \ + smem_bytes, stream, \ ctx.buffer, ctx.workspace, \ psum_num_recv_tokens_per_scaleup_rank, \ psum_num_recv_tokens_per_expert, recv_x, \ @@ -814,25 +908,58 @@ void launch_mooncake_elastic_dispatch_copy_epilogue( ctx.num_scaleup_ranks); } -void* launch_mooncake_elastic_combine( + +void launch_mooncake_elastic_dispatch_copy_epilogue( + void* recv_x, void* recv_sf, int64_t* recv_topk_idx, + float* recv_topk_weights, int* recv_src_metadata, + int* channel_linked_list, int num_recv_tokens, int num_max_tokens_per_rank, + int hidden, int elem_size, int num_sf_packs, int recv_sf_token_stride, + int recv_sf_hidden_stride, int num_experts, int num_topk, int num_sms, + int num_epilogue_sms, int num_smem_bytes, int num_channels, bool do_expand, + bool cached_mode, + const ElasticLaunchContext& ctx, int* psum_num_recv_tokens_per_scaleup_rank, + int* psum_num_recv_tokens_per_expert, cudaStream_t stream) { +#define CALL_DISPATCH_EPILOGUE(WARPS) \ + launch_mooncake_elastic_dispatch_copy_epilogue_backend( \ + recv_x, recv_sf, recv_topk_idx, recv_topk_weights, recv_src_metadata, \ + channel_linked_list, num_recv_tokens, num_max_tokens_per_rank, hidden, \ + elem_size, num_sf_packs, recv_sf_token_stride, recv_sf_hidden_stride, \ + num_experts, num_topk, num_sms, num_epilogue_sms, num_smem_bytes, \ + num_channels, \ + do_expand, cached_mode, ctx, psum_num_recv_tokens_per_scaleup_rank, \ + psum_num_recv_tokens_per_expert, stream) +#ifdef USE_NCCL_DEVICE + if (ctx.backend == ElasticTransportBackend::kNccl) { + CALL_DISPATCH_EPILOGUE( + elastic::transport::NcclOps::kNumDispatchEpilogueWarps); + return; + } +#endif + CALL_DISPATCH_EPILOGUE( + elastic::transport::IbgdaOps::kNumDispatchEpilogueWarps); +#undef CALL_DISPATCH_EPILOGUE +} + +template +void* launch_mooncake_elastic_combine_backend( void* x, float* topk_weights, int* src_metadata, int* psum_num_recv_tokens_per_scaleup_rank, int* token_metadata_at_forward, int* channel_linked_list, int num_reduced_tokens, int num_max_tokens_per_rank, int hidden, int num_experts, int num_topk, int num_sms, int num_smem_bytes, int num_channels, bool use_expanded_layout, bool allow_multiple_reduction, - const ElasticLaunchContext& ctx, cudaStream_t stream) { - const int num_threads = kElasticNumEpilogueWarps * 32; + const ElasticLaunchContext& ctx, const typename Ops::Context& comm_ctx, + cudaStream_t stream) { + const int num_threads = Ops::kNumCombineWarps * 32; const int smem_bytes = std::max( - num_smem_bytes, combine_smem_bytes(hidden, num_topk, kElasticNumEpilogueWarps)); - const auto comm_ctx = make_comm_ctx(ctx); + num_smem_bytes, combine_smem_bytes(hidden, num_topk, Ops::kNumCombineWarps)); (void)token_metadata_at_forward; (void)channel_linked_list; #ifndef MOONCAKE_EP_USE_MUSA if (ctx.num_scaleout_ranks != 1) { const int hybrid_combine_warps = - kElasticNumHybridScaleupWarps + kElasticNumHybridForwardWarps; + Ops::kNumHybridScaleupWarps + Ops::kNumHybridForwardWarps; const int hybrid_threads = hybrid_combine_warps * 32; const int hybrid_smem_bytes = std::max( num_smem_bytes, @@ -840,11 +967,11 @@ void* launch_mooncake_elastic_combine( #define LAUNCH_HYBRID_COMBINE(H, E, K, M, S, SO, SU) \ do { \ - auto kernel = elastic::hybrid_combine_impl< \ - false, true, S, kElasticNumHybridScaleupWarps, \ - kElasticNumHybridForwardWarps, SO, SU, H, M, E, K, \ - kElasticNumQPs, kElasticTimeoutCycles>; \ - launch_cooperative(kernel, S, hybrid_threads, hybrid_smem_bytes, \ + auto kernel = elastic::hybrid_combine_impl; \ + launch_cooperative(kernel, ctx.device_id, S, hybrid_threads, hybrid_smem_bytes, \ stream, static_cast(x), \ topk_weights, src_metadata, \ psum_num_recv_tokens_per_scaleup_rank, \ @@ -859,7 +986,7 @@ void* launch_mooncake_elastic_combine( num_max_tokens_per_rank == M && num_sms == S && \ ctx.num_scaleout_ranks == SO && ctx.num_scaleup_ranks == SU && \ allow_multiple_reduction && !use_expanded_layout && \ - num_channels == hybrid_num_channels(S) && \ + num_channels == hybrid_num_channels(S) && \ token_metadata_at_forward != nullptr && channel_linked_list != nullptr) { \ LAUNCH_HYBRID_COMBINE(H, E, K, M, S, SO, SU); \ return hybrid_combine_reduce_buffer_ptr( \ @@ -868,7 +995,8 @@ void* launch_mooncake_elastic_combine( #define TRY_HYBRID_COMBINE_SHAPE(H, E, K, M, S) \ TRY_HYBRID_COMBINE(H, E, K, M, S, 2, 4); \ - TRY_HYBRID_COMBINE(H, E, K, M, S, 2, 8) + TRY_HYBRID_COMBINE(H, E, K, M, S, 2, 8); \ + TRY_HYBRID_COMBINE(H, E, K, M, S, 4, 4) TRY_HYBRID_COMBINE_SHAPE(4096, 256, 8, 128, 24); @@ -882,10 +1010,10 @@ void* launch_mooncake_elastic_combine( #define LAUNCH_COMBINE(H, E, K, M, S, R) \ do { \ - auto kernel = elastic::combine_impl; \ - launch_cooperative(kernel, S, num_threads, smem_bytes, stream, \ + launch_cooperative(kernel, ctx.device_id, S, num_threads, smem_bytes, stream, \ static_cast(x), topk_weights, \ src_metadata, psum_num_recv_tokens_per_scaleup_rank,\ comm_ctx, ctx.buffer, ctx.workspace, \ @@ -916,22 +1044,58 @@ void* launch_mooncake_elastic_combine( ctx.num_scaleup_ranks); } -void launch_mooncake_elastic_combine_reduce_epilogue( +void* launch_mooncake_elastic_combine( + void* x, float* topk_weights, int* src_metadata, + int* psum_num_recv_tokens_per_scaleup_rank, + int* token_metadata_at_forward, int* channel_linked_list, + int num_reduced_tokens, int num_max_tokens_per_rank, int hidden, + int num_experts, int num_topk, int num_sms, int num_smem_bytes, + int num_channels, bool use_expanded_layout, bool allow_multiple_reduction, + const ElasticLaunchContext& ctx, cudaStream_t stream) { +#ifdef USE_NCCL_DEVICE + if (ctx.backend == ElasticTransportBackend::kNccl) { + return launch_mooncake_elastic_combine_backend< + elastic::transport::NcclOps>( + x, topk_weights, src_metadata, + psum_num_recv_tokens_per_scaleup_rank, token_metadata_at_forward, + channel_linked_list, num_reduced_tokens, + num_max_tokens_per_rank, hidden, num_experts, num_topk, num_sms, + num_smem_bytes, num_channels, use_expanded_layout, + allow_multiple_reduction, ctx, ctx.nccl, stream); + } +#endif + if (ctx.backend != ElasticTransportBackend::kIbgda) + throw std::invalid_argument( + "Mooncake EP was built without NCCL device backend support"); + const auto comm_ctx = make_comm_ctx(ctx); + return launch_mooncake_elastic_combine_backend< + elastic::transport::IbgdaOps>( + x, topk_weights, src_metadata, + psum_num_recv_tokens_per_scaleup_rank, token_metadata_at_forward, + channel_linked_list, num_reduced_tokens, num_max_tokens_per_rank, + hidden, num_experts, num_topk, num_sms, num_smem_bytes, num_channels, + use_expanded_layout, allow_multiple_reduction, ctx, comm_ctx, stream); +} + +template +void launch_mooncake_elastic_combine_reduce_epilogue_backend( void* combined_x, float* combined_topk_weights, int64_t* combined_topk_idx, int num_combined_tokens, int num_max_tokens_per_rank, int hidden, int num_experts, int num_topk, void* reduce_buffer, void* bias_0, - void* bias_1, int num_sms, int num_smem_bytes, bool use_expanded_layout, + void* bias_1, int num_sms, int num_epilogue_sms, int num_smem_bytes, + bool use_expanded_layout, bool allow_multiple_reduction, const ElasticLaunchContext& ctx, cudaStream_t stream) { - const int num_threads = kElasticNumEpilogueWarps * 32; + const int num_threads = kNumEpilogueWarps * 32; const int smem_bytes = std::max( - num_smem_bytes, combine_epilogue_smem_bytes(hidden, kElasticNumEpilogueWarps)); + num_smem_bytes, combine_epilogue_smem_bytes(hidden, kNumEpilogueWarps)); #define LAUNCH_COMBINE_EPILOGUE(H, E, K, M, S, SO, SU) \ do { \ auto kernel = elastic::combine_reduce_epilogue_impl< \ - false, true, S, kElasticNumEpilogueWarps, SO, SU, H, M, E, K>; \ - launch_cooperative(kernel, S, num_threads, smem_bytes, stream, \ + false, true, 0, kNumEpilogueWarps, SO, SU, H, M, E, K>; \ + launch_cooperative(kernel, ctx.device_id, num_epilogue_sms, num_threads, \ + smem_bytes, stream, \ static_cast(combined_x), \ combined_topk_weights, combined_topk_idx, \ reduce_buffer, bias_0, bias_1, num_combined_tokens, \ @@ -956,7 +1120,8 @@ void launch_mooncake_elastic_combine_reduce_epilogue( #define TRY_HYBRID_COMBINE_EPILOGUE_SHAPE(H, E, K, M, S) \ TRY_COMBINE_EPILOGUE(H, E, K, M, S, 2, 4); \ - TRY_COMBINE_EPILOGUE(H, E, K, M, S, 2, 8) + TRY_COMBINE_EPILOGUE(H, E, K, M, S, 2, 8); \ + TRY_COMBINE_EPILOGUE(H, E, K, M, S, 4, 4) TRY_HYBRID_COMBINE_EPILOGUE_SHAPE(4096, 256, 8, 128, 24); #endif @@ -970,4 +1135,32 @@ void launch_mooncake_elastic_combine_reduce_epilogue( ctx.num_scaleup_ranks); } + +void launch_mooncake_elastic_combine_reduce_epilogue( + void* combined_x, float* combined_topk_weights, int64_t* combined_topk_idx, + int num_combined_tokens, int num_max_tokens_per_rank, int hidden, + int num_experts, int num_topk, void* reduce_buffer, void* bias_0, + void* bias_1, int num_sms, int num_epilogue_sms, int num_smem_bytes, + bool use_expanded_layout, + bool allow_multiple_reduction, const ElasticLaunchContext& ctx, + cudaStream_t stream) { +#define CALL_COMBINE_EPILOGUE(WARPS) \ + launch_mooncake_elastic_combine_reduce_epilogue_backend( \ + combined_x, combined_topk_weights, combined_topk_idx, \ + num_combined_tokens, num_max_tokens_per_rank, hidden, num_experts, \ + num_topk, reduce_buffer, bias_0, bias_1, num_sms, num_epilogue_sms, \ + num_smem_bytes, \ + use_expanded_layout, allow_multiple_reduction, ctx, stream) +#ifdef USE_NCCL_DEVICE + if (ctx.backend == ElasticTransportBackend::kNccl) { + CALL_COMBINE_EPILOGUE( + elastic::transport::NcclOps::kNumCombineEpilogueWarps); + return; + } +#endif + CALL_COMBINE_EPILOGUE( + elastic::transport::IbgdaOps::kNumCombineEpilogueWarps); +#undef CALL_COMBINE_EPILOGUE +} + } // namespace mooncake diff --git a/mooncake-ep/src/mooncake_ep_kernel.cu b/mooncake-ep/src/mooncake_ep_kernel.cu index bc96441181..328e6b124b 100644 --- a/mooncake-ep/src/mooncake_ep_kernel.cu +++ b/mooncake-ep/src/mooncake_ep_kernel.cu @@ -162,7 +162,7 @@ dispatch(void* packed_recv_x, float* packed_recv_x_scales, const auto warp_group_id = warp_id / kNumWarpsPerGroup; const auto sub_warp_id = warp_id % kNumWarpsPerGroup; const auto responsible_expert_idx = sm_id * kNumWarpGroups + warp_group_id; -#ifdef MOONCAKE_EP_USE_MACA +#if defined(MOONCAKE_EP_USE_MUSA) || defined(MOONCAKE_EP_USE_MACA) // C500 reports 64-thread hardware warps. Do not split the last hardware // warp by assigning only the final 32-thread pseudo-warp to count work. // Reserve one full warp group from the data path, but write counts from a @@ -211,8 +211,8 @@ dispatch(void* packed_recv_x, float* packed_recv_x_scales, // There are 2 kinds of execution lanes in this part: // 1. Data lanes for FP8 cast and sending top-k tokens. // 2. Count lanes for reading `topk_idx` and per-expert token counts. - // MACA reserves a full warp group for the count path; CUDA keeps the - // original final 32-thread warp behavior. + // Non-CUDA backends reserve a full warp group for the count path. This + // keeps the final group out of the data path when MUSA uses five groups. if (is_data_warp) { constexpr int kNumElemsPerRead = sizeof(int4) / EP_BF16_SIZE; EP_DEVICE_ASSERT(kHidden % kNumElemsPerRead == 0); @@ -310,7 +310,8 @@ dispatch(void* packed_recv_x, float* packed_recv_x_scales, // Participate in __syncthreads() barriers from data warps. // Each token iteration in the send loop above calls // __syncthreads() once; the count path must match. - for (int token_idx = sm_id; token_idx < num_tokens; token_idx += num_sms) { + for (int token_idx = sm_id; token_idx < num_tokens; + token_idx += num_sms) { __syncthreads(); } #endif @@ -481,20 +482,26 @@ void dispatch(void* packed_recv_x, float* packed_recv_x_scales, int* next_clean_buffer, int num_tokens, int hidden, int num_max_dispatch_tokens_per_rank, int num_topk, int num_experts, int rank, int num_ranks, bool use_fp8, - void* workspace, cudaStream_t stream, int64_t timeout_ticks, - int phases, int active_qps_per_rank) { - constexpr int kNumMaxTopK = 11; + void* workspace, cudaStream_t stream, + int64_t timeout_ticks, int phases, int active_qps_per_rank) { + constexpr int kNumMaxTopK = 17; constexpr int kNumWarpsPerGroup = 4; + int num_warp_groups = 8; #ifdef MOONCAKE_EP_USE_MUSA - // MT S5000 benefits from slightly more CTAs while keeping enough warps for top-k<=11. - constexpr int kNumWarpGroups = 5; -#else - constexpr int kNumWarpGroups = 8; + cudaDeviceProp device_prop{}; + int device = 0; + CUDA_CHECK(cudaGetDevice(&device)); + CUDA_CHECK(cudaGetDeviceProperties(&device_prop, device)); + num_warp_groups = cell_div(num_experts, device_prop.multiProcessorCount); + // MUSA keeps four 32-thread pseudo-warps per group. The range is also + // constrained by the count group and the maximum supported CTA shape. + num_warp_groups = max(3, min(8, num_warp_groups)); #endif - EP_STATIC_ASSERT(kNumMaxTopK + 1 <= kNumWarpGroups * kNumWarpsPerGroup, "Too many top-k selections"); + EP_HOST_ASSERT(kNumMaxTopK + 1 <= num_warp_groups * kNumWarpsPerGroup && + "Too many top-k selections"); - const auto num_warps = kNumWarpGroups * kNumWarpsPerGroup; - const auto num_sms = cell_div(num_experts, kNumWarpGroups); + const auto num_warps = num_warp_groups * kNumWarpsPerGroup; + const auto num_sms = max(2, cell_div(num_experts, num_warp_groups)); EP_HOST_ASSERT(num_topk <= kNumMaxTopK); // Workspace checks @@ -502,7 +509,8 @@ void dispatch(void* packed_recv_x, float* packed_recv_x_scales, auto atomic_finish_counter_per_expert = atomic_counter_per_expert + num_experts; EP_HOST_ASSERT(num_experts * sizeof(int) * 2 <= NUM_WORKSPACE_BYTES); -#define DISPATCH_LAUNCH_CASE(hidden) { \ +#define DISPATCH_LAUNCH_GROUP(hidden, groups) case groups: { \ +constexpr int kNumWarpGroups = groups; \ auto dispatch_func = use_fp8 ? dispatch