2025, Nov 01 20:32

pybind11 में C++ से Python कॉलबैक पर in-place लिखाई: कारण और समाधान

यह गाइड बताएगा कि pybind11 में Python कॉलबैक पर DenseVec को in-place क्यों अपडेट नहीं होता, ओनरशिप/कॉपी कारण क्या है, और रेफ़रेंस view या shared_ptr से समाधान

C++ ऑप्टिमाइज़ेशन कोर को Python API से जोड़ने में अक्सर ऐसे कॉलबैक्स अहम होते हैं, जो आउटपुट बफ़र को उसी जगह भरते हैं। pybind11 के साथ एक आम उलझन यह होती है कि Python की तरफ़ से लिखना सफल लगता है, फिर भी मूल C++ ऑब्जेक्ट ज्यों का त्यों रहता है। अगर आपका कॉलबैक Vector को non-const reference के रूप में लेता है और कॉल के बाद भी बदलाव नहीं टिकते, तो बहुत संभव है कि आप ओनरशिप और कॉपी की सीमा पर अटक रहे हैं।

समस्या की रूपरेखा

मान लें, एक solver Python कॉलबैक में दो आर्ग्युमेंट पास करता है: इनपुट स्टेट और एक आउटपुट कंटेनर, जिसे इन-प्लेस अपडेट होना चाहिए। मंशा साफ़ है, लेकिन C++ में नतीजा बदलता नहीं।

// C++ पक्ष (pybind11 बाइंडिंग्स और कॉल साइट)
#include "DenseVec.hpp"
#include <pybind11/pybind11.h>
namespace py = pybind11;
void optimize(const std::function<void(const DenseVec&, DenseVec&)>& apply_constraints) {
  const DenseVec xi = ...;
  DenseVec bounds = ...;
  apply_constraints(xi, bounds);
}
PYBIND11_MODULE(corebind, m) {
  py::class_<DenseVec>(m, "DenseVec")
    .def(py::init<size_t>(), "Constructor")
    .def("__getitem__", [](const DenseVec& v, size_t i) {
      return v[i];
    })
    .def("__setitem__", [](DenseVec& v, size_t i, double val) {
      v[i] = val;
    });
  m.def("optimize", &optimize);
}
# Python पक्ष
import corebind
def apply_constraints(xx, out_v):
    out_v[0] = /* xx का फ़ंक्शन */
    out_v[1] = /* xx का फ़ंक्शन */
    ...
corebind.optimize(apply_constraints)

Python ऑब्जेक्ट पर लिखने के बावजूद, कॉलबैक के लौटते ही C++ कंटेनर में वे बदलाव परिलक्षित नहीं होते।

असल में हो क्या रहा है

pybind11 Python ऑब्जेक्ट के अंदर C++ इंस्टेंस को by value रखता है। अवधारणात्मक रूप से Python रैपर एक छोटा-सा struct है: एक हिस्सा Python का कंट्रोल ब्लॉक, दूसरा आपकी C++ क्लास का इंस्टेंस। इस Python ऑब्जेक्ट को बनाने के लिए pybind11 को C++ ऑब्जेक्ट की कॉपी बनानी पड़ती है। नतीजतन, Python रैपर के पास रखी कॉपी को बदलता है, न कि उस मूल ऑब्जेक्ट को जो आपने C++ स्टैक पर बनाया था। py::return_value_policy::reference_internal जैसी रिटर्न वैल्यू पॉलिसी बदलने से भी यह खास स्थिति नहीं सुलझती, क्योंकि रैपर अब भी अपने by-value C++ पेलोड का मालिक होता है।

व्यावहारिक समाधान: रेफ़रेंस जैसा view

एक कारगर तरीका है कि आप एक हल्का-फुल्का view एक्सपोज़ करें, जो सिर्फ़ अंदरूनी C++ डेटा की ओर इशारा करे। वेक्टर को सीधे रैप करने के बजाय, ऐसे रेफ़रेंस-जैसे ऑब्जेक्ट को रैप करें जो पढ़ने-लिखने को मूल इंस्टेंस तक फॉरवर्ड करे। इस रैपर की कॉपी बनना महज़ एक पॉइंटर की कॉपी बनना होता है, इसलिए Python से किया गया संशोधन सीधे मूल स्टोरेज पर लगता है। हाँ, लाइफ़टाइम के प्रति बेहद सावधान रहना होगा: Python समझेगा कि वह ऑब्जेक्ट का मालिक है और पॉइंटी की वैधता से ज़्यादा देर तक उसे थाम सकता है। जहाँ बदलाव की ज़रूरत न हो, वहाँ const-view का विकल्प अनचाही कॉपी से बचाता है।

// C++: एक पतला, रेफ़रेंस जैसा view
template <typename Vec>
struct DenseVecRefT {
  Vec* p = nullptr;
  explicit DenseVecRefT(Vec& vec) : p(&vec) {}
  double get(size_t i) const { return (*p)[i]; }
  void set(size_t i, double val) { (*p)[i] = val; }
};
using DenseVecRef = DenseVecRefT<DenseVec>;
// अपडेटेड solver: callback में view पास करें
void optimize(const std::function<void(const DenseVec&, DenseVecRef&)>& apply_constraints) {
  const DenseVec xi = ...;
  DenseVec bounds = ...;
  DenseVecRef out_view(bounds);
  apply_constraints(xi, out_view);
  // bounds को out_view के ज़रिए अपडेट कर दिया गया है
}
PYBIND11_MODULE(corebind, m) {
  py::class_<DenseVec>(m, "DenseVec")
    .def(py::init<size_t>(), "Constructor")
    .def("__getitem__", [](const DenseVec& v, size_t i) { return v[i]; })
    .def("__setitem__", [](DenseVec& v, size_t i, double val) { v[i] = val; });
  py::class_<DenseVecRef>(m, "DenseVecRef")
    .def("__getitem__", [](const DenseVecRef& ref, size_t i) { return ref.get(i); })
    .def("__setitem__", [](DenseVecRef& ref, size_t i, double val) { ref.set(i, val); });
  m.def("optimize", &optimize);
}
# Python: वही callback, अब ऐसा view मिलता है जो सीधे लिखता है
import corebind
def apply_constraints(xx, out_view):
    out_view[0] = /* xx का फ़ंक्शन */
    out_view[1] = /* xx का फ़ंक्शन */
    ...
corebind.optimize(apply_constraints)

यह पैटर्न span-जैसे व्यवहार को दर्शाता है। अगर आपको const view चाहिए, तो इसी तरह एक read-only विकल्प भी एक्सपोज़ किया जा सकता है। व्यवहार में यह रेफ़रेंस-जैसी रणनीति अच्छी तरह काम करती है।

वैकल्पिक रास्ते और समझौते

आप Python ऑब्जेक्ट के भीतर std::shared_ptr भी रख सकते हैं। अगर आप Python से लाइफ़टाइम मैनेज नहीं कराना चाहते, तो एक खाली deleter लगाया जा सकता है; लेकिन तब भी एक कंट्रोल ब्लॉक अलोकेट होता है और यह असुरक्षित रहता है, क्योंकि यूज़र कोड C++ ऑब्जेक्ट को पकड़े रख सकता है और UB तक ले जा सकता है। सुरक्षा की दृष्टि से make_shared से अलोकेट करके Python को मालिकी देना ज़्यादा साफ़ है—shared ownership के ओवरहेड को स्वीकार करते हुए।

दूसरा रास्ता यह है कि NumPy पर भरोसा करें और अपनी मैट्रिक्स को buffer protocol के ज़रिए एक्सपोज़ करें। आप read-only और read-write दोनों तरह के view दे सकते हैं। हालाँकि, जैसा दिखाया गया है, Python ऑब्जेक्ट से बफ़र बनाते समय C++ ऑब्जेक्ट की कॉपी Python रैपर में करनी पड़ती है, और आप उसे स्टैक पर नहीं रख सकते। कॉपी से बचने के लिए आपको स्टैक मेमोरी की ओर इशारा करती हुई NumPy array मैन्युअल तौर पर बनानी पड़ेगी, जो कोड को कम सुरक्षित बनाती है; या फिर आप एक Python ऑब्जेक्ट (उदाहरण के लिए py::object में कास्ट करके) बनाएँ और उसी कॉपी से पढ़ें-लिखें।

यदि आप पहले से Eigen का उपयोग करते हैं, तो उसका इंटीग्रेशन तुरंत उपलब्ध है। वही सीमा यहाँ भी लागू होती है: रेफ़रेंस से मैट्रिस पास करने के लिए Eigen::Ref चाहिए—यानी वही रेफ़रेंस-जैसा विचार जो ऊपर बताया गया है।

यह क्यों मायने रखता है

जब किसी कॉलबैक को आउटपुट इन-प्लेस भरना हो, तो एक अनदेखी कॉपी चुपचाप शुद्धता और प्रदर्शन दोनों को बिगाड़ देती है। आप अतिरिक्त अलोकेशन का खर्च उठाते हैं, गलत बफ़र में लिखते हैं, और तब तक पता नहीं चलता जब तक डाउनस्ट्रीम चेक्स फेल न होने लगें। pybind11 C++ स्टोरेज को कैसे रैप करता है, यह समझना ऐसे बाइंडिंग्स डिज़ाइन करने में मदद करता है जो सही अर्थ बनाए रखें और सूक्ष्म ओनरशिप बग्स से बचाएँ।

मुख्य बातें

मूल बात यह है कि pybind11 का Python रैपर आपके C++ ऑब्जेक्ट को by value रखता है, इसलिए रैपर बनते समय ऑब्जेक्ट की कॉपी बनती है। Python से C++ में इन-प्लेस अपडेट के लिए, लिखाई को ऐसे ऑब्जेक्ट के ज़रिए रूट करें जो मूल स्टोरेज को संदर्भित करता हो। यदि आप लाइफ़टाइम सावधानी से सँभालते हैं, तो रेफ़रेंस-जैसा view सीधा और कुशल उपाय है। अगर shared ownership बेहतर लगता है, तो डेटा को std::shared_ptr में लपेटें और Python को उसका मालिक बनने दें। मैट्रिक्स-प्रधान कोड में Eigen के साथ Eigen::Ref पर विचार करें, या NumPy बफ़र्स का इस्तेमाल करें—बस यह देखिए कि कॉपी कहाँ होती है और सुरक्षा पर उसका क्या असर है। अपने लाइफ़टाइम मॉडल और परफ़ॉर्मेंस सीमाओं के अनुरूप तरीक़ा चुनें, और ओनरशिप नियमों को बाइंडिंग डिज़ाइन का प्रथम-श्रेणी हिस्सा मानें।

यह लेख StackOverflow पर एक प्रश्न (लेखक: Charlie Vanaret - the Uno guy) और Ahmed AEK के उत्तर पर आधारित है।